Building a material UI input component

Most input fields have a terrible UX. One tends to forget the many states an input field can be in. Material UI offers a great starting point with hover, focus, filled, disabled and error states available. Not the prettiest, but very clear with great UX. Lets reimplement it in vue 3.

We need to solve 3 Problems: Validation, Styling and Error states

Validation

You really don't want to implement validation yourself - there are very good solutions out there. I wanted to try out something new here, so I opted for vuelidate for Vue 3. In contrast to VeeValidate, it is quite bare-bones. It does not use higher-order components to inject the validation but instead defines validation rules next to the data in the setup function. I think it is a very nice approach to not add too much overhead and makes it easy to encapsulate validation rules in compostables.

Styling

I had a tailwind setup running with all the colours configured. But this is a good example where Tailwind just straight up fails in modelling the CSS nesting and state logic.

One problem I needed to solve for example was that when the input element is focused the label transform needs to be applied. Also, we need to do the same when the placeholder is not shown, i.e. when the input field is filled.

input:not(:placeholder-shown) ~ label,
input:focus ~ label {
  cursor: pointer;
  pointer-events: none;
  background-color: white;
  padding: 0px 8px;
  color: v-bind('dynamicLabelColorFocused');
  transform: translate(0.6rem, -2.6rem) scale(0.75);
}

This kind of problem is not solvable with TailwindCSS. It needs specific selectors to target the elements. I decided to mix CSS and Tailiwnd classes in my component- which in hindsight was a bad idea. When iterating the component in the future, I would clearly opt for another solution. I am very interested in the approach to use data attributes as states (through xstate 🤓) and using CSS variables for component external colour configuration.

Error States

The error state is the most important state of almost any input component. I think validation should happen when the field gets blurred (defocused) or the wrapping form element gets submitted. Vuelidate offers a neat dirty and validation management system. I am using the $touch method of the passed Vuelidate element to validate the element on @blur event on the input element.

Thoughts on further improvements

If you need a feature-complete material input do not build it yourself. It is not worth maintaining the complex logic associated with an at first glance simple component. There are very good libraries that provide beautiful implementations of the Material UI inputs, e.g. mui.

That said you then still need to care about everything out of the scope of the input itself. Most apparent is the topic of validation, which is not handled (and should not be handled in my opinion) by any generalistic UI framework.

An interesting approach to the challenge of building forms is Vue-Formulate aka FormKit (its Vue 3 version). It combines Validation, markup, styling, error handling on input and form level, making it a one-stop shop for building simple and very complicated (e.g. interdependent) forms. It is an interesting idea I really want to try out in the future.

(I actually did already for this blog post but FormKit is not feature-complete with its predecessor by now and it made it very tedious to get the markup right for a material UI component. )


Final component

<template>
  <div>
    <div class="flex relative flex-col-reverse mt-3 text-left transition-all">
      <input
        :id="name"
        :type="type"
        :name="name"
        :placeholder="placeholder"
        :required="required"
        :value="modelValue"
        :class="dynamicClassesInput"
        class="px-4 pt-[18px] pb-[16px] w-full text-base leading-none text-teal-dark placeholder:text-teal-light/50 rounded border outline-0 focus:ring-0 transition duration-150 ease-out appearance-none caret-secondary-500"
        autocomplete
        @blur="validation.$touch()"
        @input="$emit('update:modelValue', $event.target.value)"
      />
      <label
        class="absolute text-base leading-5 transition-all duration-200 ease-out translate-x-4 -translate-y-[16px]"
        :for="name"
      >
        {{ label }}
      </label>
    </div>
    <div class="flex flex-col pt-1 pl-0.5 ml-4">
      <span
        v-for="error of validation.$errors"
        :key="error.$uid"
        class="mt-1 text-xs text-red-primary"
      >
        {{ error.$message }}
      </span>
    </div>
  </div>
</template>

<script lang="ts">
  import { computed, defineComponent, PropType } from 'vue'
  import { Validation } from '@vuelidate/core'

  export default defineComponent({
    props: {
      validation: {
        type: Object as PropType<Validation>,
        default: null,
      },
      modelValue: {
        type: String,
        required: true,
      },
      placeholder: {
        type: String,
        required: true,
      },
      label: {
        type: String,
        required: true,
      },
      name: {
        type: String,
        required: true,
      },
      disabled: {
        type: Boolean,
        default: false,
      },
      required: {
        type: Boolean,
        default: false,
      },
      autocomplete: {
        type: Boolean,
        default: false,
      },
      type: {
        type: String,
        default: 'text',
        validator(value: string) {
          return [
            'text',
            'email',
            'password',
            'search',
            'number',
            'tel',
          ].includes(value)
        },
      },
    },
    emits: ['input'],
    setup(props) {
      const dynamicClassesInput = computed(() => {
        if (props.validation?.$invalid) {
          return 'border-red-primary focus:border-red-primary hover:border-red-primary hover:ring-1 focus:ring-1 hover:ring-red-primary/25 focus:ring-red-primary/50'
        }

        return 'border-teal-light focus:border-teal-primary hover:border-teal-primary hover:ring-1 focus:ring-1 hover:ring-teal-light/25 focus:ring-teal-light/50'
      })

      const tealLight = '#6C9595'
      const tealPrimary = '#104F55'
      const redPrimary = '#C91E1E'

      const dynamicLabelColor = computed(() => {
        if (props.validation?.$invalid) {
          return redPrimary
        }

        return tealLight
      })

      const dynamicLabelColorFocused = computed(() => {
        if (props.validation?.$invalid) {
          return redPrimary
        }

        return tealPrimary
      })

      return {
        dynamicClassesInput,
        dynamicLabelColor,
        dynamicLabelColorFocused,
      }
    },
  })
</script>

<style lang="scss" scoped>
  /**
* Add a transition to the label and input.
* I'm not even sure that touch-action: manipulation works on
* inputs, but hey, it's new and cool and could remove the
* pesky delay.
*/
  label,
  input {
    touch-action: manipulation;
  }

  label {
    transform-origin: left top;
    color: v-bind('dynamicLabelColor');
  }

  /**
* Translate down and scale the label up to cover the placeholder,
* when following an input (with placeholder-shown support).
* Also make sure the label is only on one row, at max 2/3rds of the
* field—to make sure it scales properly and doesn't wrap.
*/
  input:placeholder-shown ~ label {
    cursor: text;
    max-width: 66.66%;
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  /**
* By default, the placeholder should be transparent. Also, it should
* inherit the transition.
*/
  ::placeholder {
    opacity: 0;
    transition: inherit;
  }

  /**
* Show the placeholder when the input is focused.
*/
  input:focus::placeholder {
    opacity: 1;
  }

  /**
* When the element is focused, remove the label transform.
* Also, do this when the placeholder is _not_ shown, i.e. when
* there's something in the input at all.
*/
  input:not(:placeholder-shown) ~ label,
  input:focus ~ label {
    cursor: pointer;
    pointer-events: none;
    background-color: white;
    padding: 0px 8px;
    color: v-bind('dynamicLabelColorFocused');
    transform: translate(0.6rem, -2.6rem) scale(0.75);
  }
</style>

Usage

<template>
  <AppCard class="mx-auto mt-12">
    <AppInput
      v-model="state.email"
      label="Email address"
      placeholder="name@example.de"
      name="emailaddress"
      :validation="v$.email"
    />
    <AppInput
      v-model="state.password"
      label="Password"
      name="password"
      type="password"
      placeholder="..."
      :validation="v$.password"
    />
    <AppInput
      v-model="state.name"
      label="Name"
      name="firstName"
      type="text"
      placeholder="Joane Doe"
      :validation="v$.name"
    />
    <AppButton :variant="ButtonVariants.PRIMARY">Anmelden</AppButton>
  </AppCard>
</template>

<script lang="ts">
  import { computed, defineComponent, reactive } from 'vue'
  import AppButton, { ButtonVariants } from '~/components/AppButton.vue'
  import AppCard from '~/components/AppCard.vue'
  import AppInput from '~/components/AppInput.vue'
  import useVuelidate from '@vuelidate/core'
  import { required, email, minLength, helpers } from '@vuelidate/validators'

  export default defineComponent({
    components: { AppInput, AppCard, AppButton },
    setup() {
      const state = reactive({
        email: '',
        password: '',
        name: '',
      })
      const rules = computed(() => ({
        email: {
          required: helpers.withMessage('What was your email again?', required),
          email: helpers.withMessage(
            'Unfortunately, you mistyped. Look again carefully',
            email
          ),
        },
        name: {},
        password: {
          required: helpers.withMessage('Please enter your password', required),
          minLength: helpers.withMessage(
            'Your password must have at least 8 characters',
            minLength(8)
          ),
          mustHaveUppercase: helpers.withMessage(
            'Your password must have 1 capital character',
            helpers.regex(/[A-Z]/)
          ),
          mustHaveLowercase: helpers.withMessage(
            'Your password must have 1 small character',
            helpers.regex(/[a-z]/)
          ),
          mustHaveNumber: helpers.withMessage(
            'Your password must have 1 number',
            helpers.regex(/\d/)
          ),
        },
      }))

      const v$ = useVuelidate(rules, state, { $lazy: true })

      return { state, v$, ButtonVariants }
    },
  })
</script>