Progressively Enhanced Form Validation, Part 3: Validating a checkbox group
At 4/19/2024
Part 1 and Part 2 of this series explore the browser’s built-in HTML and CSS form validation features and how to progressively enhance the experience by layering JavaScript using the Constraint Validation API.
This article extends that exploration by focusing on a form validation use case that is not handled by the browser’s native validation features: a checkbox
group.
Feel free to view the demo for this article as a reference. The source code is available on GitHub.
A checkbox group is a series of checkbox
input types with the same name
attribute value. For example, below is a simplified version of the “interests” checkbox group from the demo:
<fieldset>
<legend>Interests <span>(required)</span></legend>
<input id="coding" type="checkbox" name="interests" value="coding" />
<label for="coding">Coding</label>
<input id="music" type="checkbox" name="interests" value="music" />
<label for="music">Music</label>
<input id="art" type="checkbox" name="interests" value="art" />
<label for="art">Art</label>
<input id="sports" type="checkbox" name="interests" value="sports" />
<label for="sports">Sports</label>
</fieldset>
Code language: HTML, XML (xml)
Notice that a fieldset
element wraps the checkboxes and includes a legend
describing the group. It’s a good practice to wrap a group of checkboxes (or any related form elements) in a fieldset
element as it improves semantics, accessibility, and visual organization.
Unlike a radio group, there isn’t a native HTML validation feature to mark a checkbox group as “required” (where at least one checkbox must be selected). Since we cannot validate using browser built-in features, we’ll need to add custom JavaScript.
For this demo, we’ll focus on the following:
- Implementing the real-time validation pattern: Validate when any checkbox within the group is checked/unchecked
- Implementing the late validation pattern: Validate when the group loses focus when keyboard tabbing through the interface
- Adding the validation logic: At least one interest must be selected from the group
- Toggling the checkbox visual validation state: Toggle
is-invalid
/is-valid
CSS classes appropriately - Adding an accessible custom error message: Show a custom error message when applicable
- Adding a group validation state icon and
aria-invalid
value: Individual icons per checkbox don’t make sense; consider accessibility - Hooking into the existing form submit handler: Use the existing
onSubmit
handler flow - Include the checkbox group in the “first invalid input” focus: Automatically set focus to the group when the form is submitted if it’s the first invalid “input”
- Selectively validate the checkbox group on page load: Handle if a checkbox is checked on page load
There may be room for improvement, but implementing the above requirements provides a solid, enhanced experience when JavaScript is available.
Let’s dive in!
Implementing the real-time validation pattern
Let’s start adding some JavaScript. We’ll begin by adding a function, validateInterestsCheckboxGroup
, that we can call to validate the group of checkboxes (we’ll fill in this function’s logic shortly). The function will accept the form
element as an argument:
/**
* Validates the "interests" checkbox group.
* Custom validation is required because checkbox group validation
* is not supported by the browser's built-in validation features.
* @param {HTMLFormElement} formEl The form element
* @return {boolean} Is the "interests" checkbox group valid?
*/
const validateInterestsCheckboxGroup = (formEl) => {
// Validation logic will go here…
}
Code language: JavaScript (javascript)
Now let’s set up the change
event listener to hook up real-time validation (a live validation pattern).
change
event instead of an input
event for the checkboxes?
Why use a change
event instead of an input
event for the checkboxes?
Input elements of type="checkbox"
or type="radio"
have inconsistent browser behavior when using the input
event. Per MDN Web Docs, the change
event is suggested instead for these input types.
We can add the change
event listener code in the init
function introduced in Part 2 so it can initialize with the rest of the form validation logic:
- Select all inputs with a
name
value of “interests” (the checkboxes) - For each checkbox input, add a
change
event listener withvalidateInterestsCheckboxGroup
as the callback function - Make sure to pass along the
formEl
as the argument
/**
* Initialize validation setup
*/
const init = () => {
const formEl = document.querySelector('#demo-form');
// Existing code from Part 2 here…
// Set up event listeners to validate the "interests" checkbox group.
document
.querySelectorAll('input[name="interests"]')
.forEach((checkboxInputEl) => {
// Updates the UI state for the checkbox group when checked/unchecked
checkboxInputEl.addEventListener('change', () =>
validateInterestsCheckboxGroup(formEl)
);
});
};
Code language: JavaScript (javascript)
Thoughtful consideration when adding real-time validation
As previously noted, carefully consider when adding real-time validation, as not all users appreciate live validation feedback. A group of checkboxes is a more appropriate use case for real-time validation since after a single action (press/click), the user is “done” checking or unchecking the input, unlike a text
input where a single action (typing one character) may not complete the user’s full intent.
To test things are working, we can add a console.log
in the validateInterestsCheckboxGroup
function we created above:
const validateInterestsCheckboxGroup = (formEl) => {
console.log('Validate the "interests" checkbox group');
}
Code language: JavaScript (javascript)
Fantastic, the correct wires are connected! This sets us up to validate the group when any checkboxes are checked or unchecked.
Implementing the late validation pattern
Per our requirements above, when navigating through the interface with a keyboard, the checkbox group should be validated when a user tabs out of the group. This live validation pattern is known as “late validation.”
Setting up the late validation pattern requires a touch of extra logic for a group of checkboxes. For example, if it were a single checkbox input, we’d want the validation to happen immediately when the single checkbox’s blur
event fires. However, we only want the validation to run for a checkbox group when the focus has left the group.
The following logic gets us what we need:
- Add a
blur
event to each of the checkbox inputs in the group - On
blur
, check theFocusEvent.relatedTarget
to see if it is one of the checkboxes - If it is not one of the checkboxes, run the validation
FocusEvent.relatedTarget
?
What is the FocusEvent.relatedTarget
?
For a blur
event, the FocusEvent.relatedTarget
is the element receiving focus (the EventTarget
). In our case, we can use this to tell if the element receiving focus is or is not one of the “interests” checkbox inputs.
We can add the blur
logic alongside the previously added change
event listener:
const init = () => {
const formEl = document.querySelector('#demo-form');
// Existing code from Part 2 here…
// Set up event listeners to validate the "interests" checkbox group.
document
.querySelectorAll('input[name="interests"]')
.forEach((checkboxInputEl) => {
// Updates the UI state for the checkbox group when checked/unchecked
checkboxInputEl.addEventListener('change', () =>
validateInterestsCheckboxGroup(formEl)
);
// Set up late validation for the checkbox group
checkboxInputEl.addEventListener('blur', (event) => {
// FocusEvent.relatedTarget is the element receiving focus.
const activeEl = event.relatedTarget;
// Validate only if the focus is not going to another checkbox.
if (activeEl?.getAttribute('name') !== 'interests') {
validateInterestsCheckboxGroup(formEl);
}
});
});
};
Code language: JavaScript (javascript)
Excellent! That wasn’t terrible to figure out (props to Paul Hebert for pointing me to the FocusEvent.relatedTarget
MDN docs 🙂).
Below, notice the console.log
message prints only when the focus leaves the group of checkboxes (and not when tabbing between the checkbox inputs):
Adding the validation logic
We can now add the code to validate the checkbox group inside the validateInterestsCheckboxGroup
function. The logic will be as follows:
- Use the
getAll
method from theFormData
API to confirm at least one checkbox is checked - Use the result to return a boolean representing the “Is the checkbox group valid?” state
FormData
API?
What is the FormData
API?
The FormData
API provides a concise way to interact with field values from an HTML form. The API feels intuitive and is well-supported. For example, the FormData.getAll
method returns an array of all the values for a given key, an excellent choice for a checkbox group.
Note: Input fields must have a name
attribute to be retrieved by the FormData
API.
Replace the console.log
with the following logic:
const validateInterestsCheckboxGroup = (formEl) => {
// Are any of the "interests" checkboxes checked?
// At least one is required.
const formData = new FormData(formEl);
const isValid = formData.getAll('interests').length > 0;
// Return the validation state.
return isValid;
}
Code language: JavaScript (javascript)
Not too bad! We now have the checkbox group logic to drive the rest of the validation experience.
Toggling the checkbox visual validation state
With the validation logic in place, we can leverage it to provide visual feedback. In the validateInterestsCheckboxGroup
function, we’ll want to:
- Select all of the “interests” checkboxes
- Reference the
isValid
boolean to toggleis-invalid
/is-valid
CSS classes for each checkbox
const validateInterestsCheckboxGroup = (formEl) => {
// Code from above here…
// Get all the "interests" checkboxes.
const interestsCheckboxInputEls = document.querySelectorAll(
'input[name="interests"]'
);
// Update the validation UI state for each checkbox.
interestsCheckboxInputEls.forEach((checkboxInputEl) => {
checkboxInputEl.classList.toggle('is-valid', isValid);
checkboxInputEl.classList.toggle('is-invalid', !isValid);
});
// Return the validation state.
return isValid;
}
Code language: JavaScript (javascript)
With that in place, the checkboxes’ visual validation state now updates. Hooray!
Below you can see the real-time validation pattern in action. Notice the checkbox border color changes between a “valid” (green) and “invalid” (red) state when checked/unchecked:
The late validation pattern is now also updated visually. The border color for all of the checkboxes renders the “invalid” (red) state when the keyboard focus leaves the group if no checkbox was selected:
Adding an accessible custom error message
In Part 2, we created a more accessible experience by adding an aria-describedby
attribute on the individual inputs pointing to the ID of their respective error message elements.
We need to use a different pattern for the checkbox group because we want to associate the error message with the group as a whole.
The pattern we’ll implement includes injecting the error message into the legend
element. When using a screen reader, for example, the validation feedback will be incorporated when the screen reader reads the legend
.
While this pattern is a bit more complicated, it was the most inclusive pattern I could find based on my research which also included reaching out to the web-a11y.slack.com community. Theoretically, the pattern could be simplified by only adding an aria-describedby
attribute to the fieldset
element, but unfortunately, there is an NVDA bug where the aria-describedby
attribute is not respected for a checkbox group. A helpful resource for me was a great article by Tenon, “Accessible validation of checkbox and radiobutton groups,” where they explore various checkbox group validation patterns.
Let’s start with the HTML updates, which include adding some ARIA attributes:
- In the
fieldset
element: Add anaria-required
attribute to provide extra feedback when using assistive technologies like a screen reader - In the
legend
element: Add anaria-hidden
attribute to the “(required)” text- This “required” text provides visual feedback but is redundant when using assistive technology devices
We’ll also be adding two empty error message elements; one that assistive technologies will pick up (visually hidden) and one for users who are sighted (visible but hidden from assistive technologies):
- First empty error message element: Place it inside the
legend
- Visually hide the element via a
visually-hidden
CSS class - Includes a
js-interests-legend-error
CSS class to attach JavaScript logic
- Visually hide the element via a
- Second empty error message element: Place it below the last checkbox input
- Has
hidden
attribute as the default state - Hidden from assistive technologies via
aria-hidden
attribute so duplicate error messages aren’t conveyed - Includes a
js-interests-visual-error
CSS class to attach JavaScript logic
- Has
Why two error message elements?
Adding two error message elements may seem redundant, but there’s a good reason. We add the first empty error message element within the legend
element so that assistive technologies, like screen readers, can associate the error message with the checkbox group. But that doesn’t match our visual design, so we visually hide it. The second empty error message element is added to match the visual design; we hide it from assistive technologies so the error message doesn’t get conveyed twice.
<fieldset aria-required="true">
<legend>
Interests <span aria-hidden="true">(required)</span>
<span class="visually-hidden js-interests-legend-error">
<!-- Text content set by JS -->
</span>
</legend>
<div class="field-wrapper checkbox-field-wrapper">
<!--
Checkboxes and label elements here…
-->
<p hidden aria-hidden="true" class=js-interests-visual-error">
<!-- Text content set by JS -->
</p>
</div>
</fieldset>
Code language: HTML, XML (xml)
In the JavaScript, we can update the validateInterestsCheckboxGroup
function as follows:
- Get both empty error message elements via their
js-*
classes - Set the error message depending on the
isValid
boolean - Toggle the
hidden
attribute on the visible error element accordingly
const validateInterestsCheckboxGroup = (formEl) => {
// Existing code from above here…
// Get both the legend and visual error message elements.
const legendErrorEl = document.querySelector('.js-interests-legend-error');
const visualErrorEl = document.querySelector('.js-interests-visual-error');
// Update the validation error message.
const errorMsg = isValid ? '' : 'Select at least one interest.';
// Set the error message for both the legend and the visual error.
legendErrorEl.textContent = errorMsg;
visualErrorEl.textContent = errorMsg;
// Show/hide the visual error message depending on validity.
visualErrorEl.hidden = isValid;
// Return the validation state.
return isValid;
}
Code language: JavaScript (javascript)
Wonderful! We now have an accessible validation error message. Below you can see the error message visually displayed:
If using a screen reader, in this example, VoiceOver with Safari on macOS, the validation feedback is included with the legend
. Also, notice the “required” feedback provided by the aria-required
attribute on the fieldset
:
aria-invalid
value
Adding a group validation state icon and aria-invalid
value
In Part 2, each individual input had its own “valid”/”invalid” state icon and aria-invalid
attribute. We need to move those out onto the fieldset
for a checkbox group.
Need a refresher on aria-invalid
? See “What does aria-invalid
do?” in my previous article.
Let’s add a js-*
CSS class to the fieldset
so we can reference it in our JavaScript code:
<fieldset
aria-required="true"
class="js-checkbox-fieldset"
>
Code language: HTML, XML (xml)
Then, in the validateInterestsCheckboxGroup
function, we can add the following logic:
- Get the
fieldset
element via thejs-*
CSS class - Depending on the
isValid
boolean, update:- The
is-valid
/is-invalid
state classes to toggle a single “valid”/”invalid” group icon - The
aria-invalid
attribute
- The
const validateInterestsCheckboxGroup = (formEl) => {
// Existing code from above…
// Get the fieldset element for the "interests" checkbox group.
const checkboxFieldsetEl = document.querySelector('.js-checkbox-fieldset');
// Need to place the validation state classes higher up to show
// a validation state icon (one icon for the group of checkboxes).
checkboxFieldsetEl.classList.toggle('is-valid', isValid);
checkboxFieldsetEl.classList.toggle('is-invalid', !isValid);
// Also update aria-invalid on the fieldset (convert to a string)
checkboxFieldsetEl.setAttribute('aria-invalid', String(!isValid));
// Return the validation state.
return isValid;
}
Code language: JavaScript (javascript)
Sweet! We can see a single group validation state icon in action below:
When using VoiceOver with Safari on macOS, the aria-invalid
attribute adds “invalid data” to the validation feedback for the group:
Hooking into the existing form submit handler
In the previous article, we set up the logic for the form submit
event. Adding the checkbox group validation into the existing submit flow will be relatively straightforward.
Just a moment ago, in the validateInterestsCheckboxGroup
function, we added logic that stores and returns the validation state via the isValid
boolean:
const validateInterestsCheckboxGroup = (formEl) => {
// Existing code from above…
// Return the validation state.
return isValid;
}
Code language: JavaScript (javascript)
We can use the returned boolean value to include the checkbox group validation within the existing onSubmit
flow. We can add the following logic to that flow:
- Call the
validateInterestsCheckboxGroup
function and store the returned boolean value - Include the returned boolean value in the “Is the form valid” check
const onSubmit = (event) => {
// Existing onSubmit code from Part 2…
// Fields that cannot be validated with the Constraint Validation API need
// to be validated manually. This includes the "interests" checkbox group.
const isInterestsGroupValid = validateInterestsCheckboxGroup(formEl);
// Prevent form submission if any of the validation checks fail.
if (!isFormValid || !isInterestsGroupValid) {
event.preventDefault();
}
};
Code language: JavaScript (javascript)
And that’s it! Now, the checkbox group will also be validated when the form is submitted:
Include the checkbox group in the “first invalid input” focus
Some of the work in the form submit logic introduced in Part 2 was to set focus on the first invalid input when the form is submitted. We’ll want to ensure the checkbox group is included in this query.
To do so, we’ll need to make another update to the existing onSubmit
handler as follows:
- Add a selector for the
input
in afieldset
that has theis-invalid
state class (e.g.,'fieldset.is-invalid input'
)
const onSubmit = (event) => {
// Existing onSubmit code from Part 2…
// Set the focus to the first invalid input.
const firstInvalidInputEl = formEl.querySelector(
'input:invalid, fieldset.is-invalid input'
);
firstInvalidInputEl?.focus();
};
Code language: JavaScript (javascript)
Note: The order of operations matters here. The validateInterestsCheckboxGroup
function must be called before attempting to query for fieldset.is-invalid input
. Otherwise, the fieldset
won’t have the is-invalid
class to query by.
It’s almost like magic! Below, you can see the group’s first checkbox input receiving focus when no checkbox is selected and the form is submitted:
Selectively validate the checkbox group on page load
We are near the finish line! One last thing to do. Currently, the checkbox group validation will only happen when a checkbox input’s change
, blur
, or form submit
events fire. There is one more case we should handle: What if a checkbox is checked and the browser reloads the page?
Ideally, if the page loads with at least one selection made, it should show the “valid” UI state. At the moment, nothing happens, and the checkbox group ends up in this unresolved state:
We can resolve this by adding the following logic to the existing init
function from Part 2:
- Query for all “interests” inputs (the checkboxes) that have a
:checked
state - If any are checked, run the
validateInterestsCheckboxGroup
function so the “valid” UI state can render
/**
* Initialize validation setup
*/
const init = () => {
// Existing code from Part 2 here…
// On page load, if a checkbox is checked, update the group's UI state
const isInterestsGroupChecked =
document.querySelectorAll('input[name="interests"]:checked').length > 0;
if (isInterestsGroupChecked) {
validateInterestsCheckboxGroup(formEl);
}
};
Code language: JavaScript (javascript)
Easy peasy! Now, when the page loads with at least one checkbox selected, we see the “valid” UI state as expected:
Why not validate the checkbox group on every page load?
We don’t want to run the validateInterestsCheckboxGroup
function every time the validation code is initialized because if no checkboxes are checked, then the “invalid” UI state will render. This pattern, called premature validation, is not helpful and leads to a frustrating user experience.
Future improvement opportunities
Because we can always learn more and keep growing, I wanted to note a few opportunities to improve the user experience.
- Localize the validation error message: Currently, the validation error message is hard-coded in English in JavaScript. Ideally, the message can be localized into different languages.
- Remove or minimize the layout shift when validation error messages are displayed: When the error messages are displayed, there is a visual layout shift. This issue is not specific to the checkbox group, but it becomes more prominent (and annoying) as more fieldsets/inputs are added to the form.
Wrapping up
That was fun! I’ll admit that the perfectionist in me almost gave up on finding an accessible validation solution for a checkbox group, but I’m glad I pushed through. Finding a solution feels good, even if not ideal, especially knowing the native HTML validation features leave us short.
Stick around for the following article, Part 4, where we explore using the Constraint Validation API’s ValidityState
interface to help render custom validation error messages.
Until next time!
A special thank you to Juliette Alexandria, Adrian Roselli, and Joe Schunk, who provided me with feedback, resources, and confirmation for creating a more accessible checkbox group validation experience via the web-a11y.slack.com Slack community. 🙌🏽
More resources
- MDN Web Docs: Handling multiple checkboxes
- MDN Web Docs: Validating forms without a built-in API
- MDN Web Docs: Checkbox input type validation
- Support for Marking Radio Buttons Required, Invalid by Adrian Roselli
- “Use
<fieldset aria-requried="true">
” and “Use<fieldset aria-invalid="true">
” tweets from Adrian Roselli - Axe Rules: Checkbox inputs with the same name attribute value must be part of a group
- HTML.form.guide: HTML Form Checkbox with required validation
Missed an article in the series?
I’ve got you! Listed below are all of the articles from the series: