loading...

Accessible form error auto-focus with Vuelidate in Vue

marinamosti profile image Marina Mosti ・9 min read

Vuelidate makes it very simple for developers to handle even the most complex cases of form validation, but what about accessibility UX? Let's take a look at some very simple practices that you can implement on your Vuelidate powered forms that will make them behave a lot more nicely for accessibility tools like screen reade

The form

Let's first create a standard form, and apply some validation rules to our data.

    <template>
      <div>
        <form @submit.prevent="submit">
          <div>
            <label for="firstName">First Name</label>
            <input
              type="text"
              id="firstName"
              name="firstName"
            >
          </div>

          <div>
            <label for="lastName">Last Name</label>
            <input
              type="text"
              id="lastName"
              name="lastName"
            >
          </div>

          <div>
            <label for="email">Email</label>
            <input
              type="email"
              id="email"
              name="email"
            >
          </div>

          <button type="submit">Submit</button>
        </form>
      </div>
    </template>

Our form has three inputs - the first two are of type text and the last one of type email. Finally, we have a submit type button to trigger the submit event on our form element.

The form element itself has a @submit handler with a prevent modifier so that we can stop default browser behavior and process the form submit ourselves.

  • To learn more about event modifiers, you can check the official docs

Let's now add the code that will handle the validation rules and the submit method.

    <script>
    import { required, email } from "vuelidate/lib/validators";
    export default {
      name: "App",
      data() {
        return {
          firstName: "",
          lastName: "",
          email: ""
        };
      },
      validations: {
        firstName: { required },
        lastName: { required },
        email: { required, email }
      },
      methods: {
        submit() {
          // Submit the form here!
        }
      }
    };
    </script>

First, we import a couple of Vuelidate's built in validators: required and email.

We create a local state with data and set up a property for each of our inputs, and proceed to create a validations object. This object in turn defines rules for each one of our inputs.

Finally, we need to head back into the <template> and connect our inputs to Vuelidate through v-model.

    <div>
      <label for="firstName">First Name</label>
      <input
        type="text"
            id="firstName"
        name="firstName"
        v-model="$v.firstName.$model"
      >
    </div>

    <div>
      <label for="lastName">Last Name</label>
      <input
        type="text"
        id="lastName"
        name="lastName"
        v-model="$v.lastName.$model"
      >
    </div>

    <div>
      <label for="email">Email</label>
      <input
        type="email"
        id="email"
        name="email"
        v-model="email"
        @change="$v.email.$touch"
      >
    </div>

Notice that for firstName and lastName we are v-modeling directly into Vuelidate's internal $model for each property, this allows us to not have to worry about triggering the $dirty state of each input on change/input events.

For the email input, however, I have opted to v-model directly to the data() local state and trigger the $touch event manually. That way the validation won't trigger right away until after the input blur's, and the user won't be faced with an immediate error message when the email condition is not met because they're starting to type it out.

Adding error messages

Let's start by adding descriptive error messages when an input's validation fails. We are going to first add a <p> element directly after the input and output the error for the user.

    <div>
      <label for="firstName">First Name</label>
      <input
        type="text"
        id="firstName"
        name="firstName"
        v-model="$v.firstName.$model"
      >
      <p
        v-if="$v.firstName.$error"
      >This field is required</p>
    </div>

    <div>
      <label for="lastName">Last Name</label>
      <input
        type="text"
        id="lastName"
        name="lastName"
        v-model="$v.lastName.$model"
      >
      <p v-if="$v.lastName.$error">This field is required</p>
    </div>

    <div>
      <label for="email">Email</label>
      <input
        type="email"
        id="email"
        name="email"
        v-model="email"
        @change="$v.email.$touch"
      >
      <p v-if="$v.email.$error">{{ email }} doesn't seem to be a valid email</p>
    </div>

Notice that each p tag is getting conditionally rendered by a v-if statement. This statement is checking inside the Vuelidate object $v, then accessing the state for each input (based on how we defined your validations and state in the previous section), and finally we access the $error state of this element.

Vuelidate has different states it tracks for each element, $error is a boolean property that will check for two conditions - it will check that the input's $dirty state is true, and that ANY of the validation rules is failing.

The $dirty state is a boolean with the value of false by default, when an input is changed by the user and a v-model state to the $v.element.$model is set, it will automatically change to true, indicating that the contents have been modified and the validation is now ready to display errors (otherwise the form would be in default error state when loaded).

In the case of our email input, since we binding the v-model to our local state, we have to trigger the $touch method on the change event - this $touch will set the $dirty state to true.

Now that we have a clear error message for our users when the validation fails, let's go ahead and make it accessible. As it is right now, screen readers will not pick up the change and notify the user of the problem whenever the input is re-focused, which would be super confusing.

Thankfully, we have a handy tool to attach this message to our input - the aria-describedby attribute. This attribute allows to attach one or more element through their id that describe the element. So let's modify our form to reflect this.

    <form @submit.prevent="submit">
        <div>
          <label for="firstName">First Name</label>
          <input
            aria-describedby="firstNameError"
            type="text"
            id="firstName"
            name="firstName"
            v-model="$v.firstName.$model"
          >
          <p
            v-if="$v.firstName.$error"
            id="firstNameError"
          >This field is required</p>
        </div>

        <div>
          <label for="lastName">Last Name</label>
          <input
            aria-describedby="lastNameError"
            type="text"
            id="lastName"
            name="lastName"
            v-model="$v.lastName.$model"
          >
          <p v-if="$v.lastName.$error" id="lastNameError">This field is required</p>
        </div>

        <div>
          <label for="email">Email</label>
          <input
            aria-describedby="emailError"
            type="email"
            id="email"
            name="email"
            v-model="email"
            @change="$v.email.$touch"
          >
          <p v-if="$v.email.$error" id="emailError">{{ email }} doesn't seem to be a valid email</p>
        </div>

        <button type="submit">Submit</button>
    </form>

Great! If you now test out the form with a screen reader such as ChromeVox, you can trigger a validation error and focus the element - the screen reader will now read the error as part of the input's information when focused, making it clearer to the user as to what is going on.

Triggering validations on @submit

Let's take the form one step further, right now when you click the submit button nothing will happen. Let's trigger the validation check for all of the element's on our form when the user tries to submit the form.

Modify the submit method like this.

    methods: {
      submit() {
        this.$v.$touch();
        if (this.$v.$invalid) {
          // Something went wrong 
        } else {
          // Submit the form here
        }
      }
    }

Two things are happening here, first we trigger the validations on every input on our form by calling $v.$touch(). Vuelidate will go over every input that has a validator and trigger the validation functions, so that if there are any errors the states will be updated to show it.

Vuelidate also managed a "global" state for the form which includes its own $invalid state, which we will use to verify if the form is in a valid state to be submitted - if it's not, we are going to help our users out by auto-focusing the first element that has an error state.

Auto-focusing the element with an error

As it is right now, when our users click the submit button and trigger the submit() method, Vuelidate will verify all the inputs. If some of these inputs have errors, the v-if conditions for each of these inputs will be met and the error messages will be displayed.

However, screen readers will not auto-read these error messages unless we tell them to. In order to make our users' experience better, let's auto focus the input that has the problem.

First, we are going to have to go back into our form and add a ref attribute to each of our inputs so that we can reference and target it inside our submit() method.

    <form @submit.prevent="submit">
      <div>
        <label for="firstName">First Name</label>
        <input
          aria-describedby="firstNameError"
          type="text"
          id="firstName"
          name="firstName"
          ref="firstName"
          v-model="$v.firstName.$model"
        >
        <p
          v-if="$v.firstName.$error"
          id="firstNameError"
        >This field is required</p>
      </div>

      <div>
        <label for="lastName">Last Name</label>
        <input
          aria-describedby="lastNameError"
          type="text"
          id="lastName"
          name="lastName"
          ref="lastName"
          v-model="$v.lastName.$model"
        >
        <p v-if="$v.lastName.$error" id="lastNameError">This field is required</p>
      </div>

      <div>
        <label for="email">Email</label>
        <input
          aria-describedby="emailError"
          type="email"
          id="email"
          name="email"
          ref="email"
          v-model="email"
          @change="$v.email.$touch"
        >
        <p v-if="$v.email.$error" id="emailError">{{ email }} doesn't seem to be a valid email</p>
      </div>

      <button type="submit">Submit</button>
    </form>

Notice that I have named all the ref attributes the same as their respective models. This will make the looping easier in the next step.

Now that we can target the inputs, let's modify the submit() method so we can loop through the different inputs and figure out which one has the error.

    submit() {
      this.$v.$touch();
      if (this.$v.$invalid) {
        // 1. Loop the keys
        for (let key in Object.keys(this.$v)) {
          // 2. Extract the input
          const input = Object.keys(this.$v)[key];
          // 3. Remove special properties
          if (input.includes("$")) return false;

          // 4. Check for errors
          if (this.$v[input].$error) {
            // 5. Focus the input with the error
            this.$refs[input].focus();

            // 6. Break out of the loop
            break;
          }
        }
      } else {
        // Submit the form here
      }
    }

Lots of code! But fear not, we're going to break this down into easy steps.

  1. First we create a for loop to go through each of the properties in the $v object. The $v object contains several properties, between them you will find each of the inputs that are being validated - and also some special state properties like $error and $invalid for the whole form.
  2. We extract the input/property name into a variable for easy access
  3. We check if the input contains the $ character, if it does we skip this one because its a special data property and we don't care for it right now.
  4. We check the $error state, if the $error state is true, it means this particular input has a problem and one of the validations is failing.
  5. Finally, we use the name of the input as a way to access it through the instance $refs, and trigger the element's focus. This is input → ref name relationship is why earlier we went with the same naming for the ref and the v-model state.
  6. We only want to focus the first element, so we call break to stop the loop from continuing to execute.

Try this out, now when the user triggers the form's submit and there is an error the form will automatically focus the first input with an error.

One more slight issue, the screen reader will still not read our custom error message. We need to tell it that this <p> tag describing the input is going to be a "live" area that will display information and that may change.

In this case, we are going to add aria-live="assertive" to our error messages. This way when they appear and our focus goes to the element, the screen reader will notify the users. It will also notify them if this message changes into something else, like from a required validation error to a minLength error.

    <form @submit.prevent="submit">
      <div>
        <label for="firstName">First Name</label>
        <input
          aria-describedby="firstNameError"
          type="text"
          id="firstName"
          name="firstName"
          ref="firstName"
          v-model="$v.firstName.$model"
        >
        <p
          v-if="$v.firstName.$error"
          aria-live="assertive"
          id="firstNameError"
        >This field is required</p>
      </div>

      <div>
        <label for="lastName">Last Name</label>
        <input
          aria-describedby="lastNameError"
          type="text"
          id="lastName"
          name="lastName"
          ref="lastName"
          v-model="$v.lastName.$model"
        >
        <p v-if="$v.lastName.$error" aria-live="assertive" id="lastNameError">This field is required</p>
      </div>

      <div>
        <label for="email">Email</label>
        <input
          aria-describedby="emailError"
          type="email"
          id="email"
          name="email"
          ref="email"
          v-model="email"
          @change="$v.email.$touch"
        >
        <p
          v-if="$v.email.$error"
          aria-live="assertive"
          id="emailError"
        >{{ email }} doesn't seem to be a valid email</p>
      </div>

      <button type="submit">Submit</button>
    </form>

Wrapping up

Auto focusing elements for the user when attempting to submit an invalid form is a very nice form of accessible UX that doesn't take a lot of effort and work on our side as developers.

With the use of attributes like aria-describedby and aria-live we have already enhanced our form into an accessible state that most form out there in the wild wild web don't implement. This can also be enhanced futher of course, but this is a great starting point!

If you want to see this example in action, I have set up a codesandbox here.

As always, thanks for reading and share with me your accessible form experiences on twitter at: @marinamosti

PS. All hail the magical avocado 🥑

PPS. ❤️🔥🐶☠️

Discussion

pic
Editor guide
Collapse
plweil profile image
Peter Weil

Nice article, Marina. I share your interest in both Vue and accessibility. A couple of observations. Live regions are supposed to announce changes only when that the region contains content that is already present in the DOM on page load. If you use v-if, the new content is inserted into the DOM only when there is an error, and the announcement will not work. If you create a live region container, and use v-show instead of v-if; the live region will work. e.,g.,

This field is required.

Since you are already using aria-describedby, one could also take a slightly different, arguably simpler approach. As you point out, focusing on the input will cause the screen reader to read the aria-describedby message. Instead of doing the above, you could make the content of the aria-describedby message dynamic, using a method. The method would return an empty string if there is no error, and the error message if there is an error. This way, then you can dispense with the live region, and let aria-describedby do the work for you.

I wrote an article on this subject a few months ago:

Accessible Form Validation Messages with ARIA and Vue.js

Collapse
marinamosti profile image
Marina Mosti Author

Thanks for the info Peter, v-if is indeed a nice solution once its paired with a method/computed in this scenario

Collapse
acatzk profile image
acatzk

I've been looking for this autofocus... you killed it!

Collapse
blokche profile image
Guillaume DEBLOCK

v-model.lazy would do the job for the email input field I guess?
We definitely need more articles about accessibility like this. Thanks for sharing.

Collapse
marinamosti profile image
Marina Mosti Author

Great catch Guillaume. Sometimes when im writing im trying to find examples that fit the subject best, and I wanted to cover $touch. Thanks for reading!