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.
- 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. - We extract the input/property name into a variable for easy access
- 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. - 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. - Finally, we use the name of the
input
as a way to access it through the instance$refs
, and trigger the element'sfocus
. This is input → ref name relationship is why earlier we went with the same naming for the ref and the v-model state. - 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. ❤️🔥🐶☠️
Top comments (5)
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
Thanks for the info Peter, v-if is indeed a nice solution once its paired with a method/computed in this scenario
I've been looking for this autofocus... you killed it!
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.
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!