View contents
Introduction
When users make mistakes filling out forms, they need clear information about what went wrong and how to fix it. WCAG 3.3.1 (Error Identification) requires that when errors are detected, the specific field with the error must be identified and the error described in text. This isn’t just an accessibility requirement—it’s essential for good user experience.
Forms with unclear error messages frustrate everyone. For users with disabilities, particularly those using screen readers or who have cognitive impairments, ambiguous error handling can make forms completely unusable.
What WCAG Requires
3.3.1 Error Identification (Level A)
If an input error is automatically detected, the item that is in error is identified and the error is described to the user in text.
This means:
- Errors must be described in text (not just color or icons)
- The specific field with the error must be identified
- Users must be able to find and understand what went wrong
3.3.3 Error Suggestion (Level AA)
If an input error is automatically detected and suggestions for correction are known, then the suggestions are provided to the user.
This means:
- When possible, provide specific guidance on how to fix the error
- Suggest correct formats (e.g., “Use format: MM/DD/YYYY”)
- Offer alternatives when appropriate
Who Benefits
| User Type | Benefit |
|---|---|
| Screen reader users | Errors announced clearly, not just visual cues |
| Users with low vision | Text descriptions supplement color changes |
| Users with cognitive disabilities | Clear guidance on what needs fixing |
| Users with motor impairments | Can navigate directly to error fields |
| All users | Faster form completion with clear feedback |
Common Error Handling Problems
1. Color-Only Errors
<!-- BAD: Only color indicates error -->
<input type="email" class="error-red-border">
<!-- GOOD: Text description with color -->
<input type="email" aria-describedby="email-error" aria-invalid="true">
<p id="email-error" class="error-text">
Please enter a valid email address (e.g., [email protected])
</p>
2. Generic Error Messages
<!-- BAD: Unhelpful message -->
<p class="error">Invalid input</p>
<!-- GOOD: Specific, actionable message -->
<p id="phone-error" class="error">
Phone number must be 10 digits. You entered 8 digits.
</p>
3. Errors Far from Fields
<!-- BAD: All errors at top with no field association -->
<div class="error-summary">
<p>There were errors in your submission</p>
</div>
<!-- GOOD: Summary with links to fields + inline errors -->
<div class="error-summary" role="alert">
<h2>Please fix 2 errors:</h2>
<ul>
<li><a href="#email">Email address is required</a></li>
<li><a href="#phone">Phone number format is invalid</a></li>
</ul>
</div>
4. Errors Not Announced to Screen Readers
<!-- BAD: Error appears but isn't announced -->
<span class="error">Required field</span>
<!-- GOOD: Live region announces error -->
<span class="error" role="alert" aria-live="assertive">
Required field
</span>
Key Error Handling Patterns
Error Summary Pattern
Provide a summary of all errors at the top of the form, with links to each field:
<div class="error-summary" role="alert">
<h2>There are 2 errors in this form</h2>
<ul>
<li><a href="#name">Name is required</a></li>
<li><a href="#email">Email format is invalid</a></li>
</ul>
</div>
Inline Error Pattern
Place error messages directly next to the field they describe:
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
aria-describedby="email-error"
aria-invalid="true"
>
<p id="email-error" class="error">
Enter a valid email address (e.g., [email protected])
</p>
</div>
Real-Time Validation
Validate as users type or when they leave a field, but avoid validating too aggressively:
- Do validate: On blur (when user leaves field)
- Do validate: On form submission
- Avoid: Validating while user is still typing
- Avoid: Showing errors for empty required fields before user interacts
Accessible Error Message Components
Visual Indicators
- Red border or background (but never color alone)
- Error icon (with alt text or aria-label)
- Clear error text near the field
Programmatic Requirements
aria-invalid="true"on the field with an erroraria-describedbylinking field to error messagerole="alert"oraria-live="assertive"for dynamic errors- Focus management (move focus to error summary or first error field)
Testing Error Identification
Quick Manual Test
- Submit a form with intentional errors
- Verify each error is described in text
- Check that error messages identify which field has the error
- Test with screen reader to ensure errors are announced
- Verify keyboard users can navigate to error fields
What to Verify
- [ ] Errors are described in text (not just color/icons)
- [ ] Each error identifies which field has the problem
- [ ] Error suggestions provide helpful guidance when possible
- [ ] Screen readers announce errors when they appear
- [ ] Users can navigate from error summary to specific fields
- [ ] Focus is managed appropriately after form submission
Tools for Testing
- Screen reader: Test error announcement with NVDA, VoiceOver, or JAWS
- axe DevTools: Checks for missing error associations
- Keyboard testing: Verify focus moves to errors appropriately
Best Practices Summary
| Do | Don’t |
|---|---|
| Describe errors in text | Rely only on color or icons |
| Identify specific field with error | Use vague “form has errors” messages |
| Provide correction suggestions | Just say “invalid input” |
| Use aria-invalid and aria-describedby | Leave errors unlinked to fields |
| Announce errors to screen readers | Show errors silently |
| Place errors near related fields | Put all errors far from fields |
Implementation
Complete Form Validation Pattern
HTML Structure with All ARIA Attributes
<form id="registration-form" novalidate>
<!-- Error Summary (shown after submission with errors) -->
<div
id="error-summary"
class="error-summary"
role="alert"
aria-labelledby="error-summary-title"
tabindex="-1"
hidden
>
<h2 id="error-summary-title">
<svg aria-hidden="true" class="error-icon"><!-- icon --></svg>
Please fix the following errors:
</h2>
<ul id="error-list">
<!-- Error links inserted dynamically -->
</ul>
</div>
<!-- Form Fields -->
<div class="form-group">
<label for="name">
Full Name
<span class="required" aria-hidden="true">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
autocomplete="name"
>
<p id="name-error" class="error-message" hidden></p>
</div>
<div class="form-group">
<label for="email">
Email Address
<span class="required" aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-describedby="email-hint"
autocomplete="email"
>
<p id="email-hint" class="hint">We'll never share your email</p>
<p id="email-error" class="error-message" hidden></p>
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input
type="tel"
id="phone"
name="phone"
pattern="[0-9]{10}"
aria-describedby="phone-hint"
autocomplete="tel"
>
<p id="phone-hint" class="hint">10 digits, numbers only</p>
<p id="phone-error" class="error-message" hidden></p>
</div>
<button type="submit">Create Account</button>
</form>
CSS Styling for Error States
/* Error summary container */
.error-summary {
background-color: #fef2f2;
border: 2px solid #dc2626;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.error-summary:focus {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
.error-summary h2 {
color: #dc2626;
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 12px 0;
display: flex;
align-items: center;
gap: 8px;
}
.error-summary ul {
margin: 0;
padding-left: 24px;
}
.error-summary li {
margin-bottom: 4px;
}
.error-summary a {
color: #dc2626;
text-decoration: underline;
}
.error-summary a:hover,
.error-summary a:focus {
color: #991b1b;
}
/* Form group with error */
.form-group.has-error input,
.form-group.has-error select,
.form-group.has-error textarea {
border-color: #dc2626;
box-shadow: 0 0 0 1px #dc2626;
}
.form-group.has-error input:focus,
.form-group.has-error select:focus,
.form-group.has-error textarea:focus {
outline: 3px solid #2563eb;
outline-offset: 2px;
box-shadow: 0 0 0 1px #dc2626;
}
/* Error message styling */
.error-message {
color: #dc2626;
font-size: 0.875rem;
margin-top: 4px;
display: flex;
align-items: flex-start;
gap: 6px;
}
.error-message::before {
content: "⚠";
flex-shrink: 0;
}
/* Required indicator */
.required {
color: #dc2626;
margin-left: 2px;
}
/* Hint text */
.hint {
color: #6b7280;
font-size: 0.875rem;
margin-top: 4px;
}
/* Hidden state */
[hidden] {
display: none !important;
}
JavaScript Validation Implementation
Complete Validation Class
class FormValidator {
constructor(formElement, options = {}) {
this.form = formElement;
this.options = {
validateOnBlur: true,
validateOnInput: false,
showErrorSummary: true,
focusFirstError: true,
...options
};
this.errors = new Map();
this.validators = new Map();
this.init();
}
init() {
// Prevent browser validation
this.form.setAttribute('novalidate', '');
// Set up event listeners
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
if (this.options.validateOnBlur) {
this.form.addEventListener('blur', (e) => {
if (e.target.matches('input, select, textarea')) {
this.validateField(e.target);
}
}, true);
}
if (this.options.validateOnInput) {
this.form.addEventListener('input', (e) => {
if (e.target.matches('input, select, textarea')) {
// Debounce input validation
clearTimeout(e.target.validationTimeout);
e.target.validationTimeout = setTimeout(() => {
this.validateField(e.target);
}, 300);
}
});
}
// Clear error when user starts typing (after initial validation)
this.form.addEventListener('input', (e) => {
if (e.target.matches('input, select, textarea') &&
this.errors.has(e.target.id)) {
this.clearFieldError(e.target);
}
});
}
// Add custom validator for a field
addValidator(fieldId, validator, message) {
if (!this.validators.has(fieldId)) {
this.validators.set(fieldId, []);
}
this.validators.get(fieldId).push({ validator, message });
}
validateField(field) {
const errors = [];
// Required validation
if (field.hasAttribute('required') && !field.value.trim()) {
errors.push(this.getRequiredMessage(field));
}
// Type validation (email, url, etc.)
if (field.value && field.type === 'email' && !this.isValidEmail(field.value)) {
errors.push('Please enter a valid email address (e.g., [email protected])');
}
// Pattern validation
if (field.value && field.pattern) {
const regex = new RegExp(`^${field.pattern}$`);
if (!regex.test(field.value)) {
errors.push(field.title || 'Please match the requested format');
}
}
// Min/max length validation
if (field.value && field.minLength > 0 && field.value.length < field.minLength) {
errors.push(`Please enter at least ${field.minLength} characters`);
}
if (field.value && field.maxLength > 0 && field.value.length > field.maxLength) {
errors.push(`Please enter no more than ${field.maxLength} characters`);
}
// Custom validators
const customValidators = this.validators.get(field.id) || [];
for (const { validator, message } of customValidators) {
if (!validator(field.value, field)) {
errors.push(message);
}
}
// Update field state
if (errors.length > 0) {
this.showFieldError(field, errors[0]);
return false;
} else {
this.clearFieldError(field);
return true;
}
}
getRequiredMessage(field) {
const label = this.getFieldLabel(field);
return `${label} is required`;
}
getFieldLabel(field) {
const label = this.form.querySelector(`label[for="${field.id}"]`);
if (label) {
// Get text content without the required indicator
return label.textContent.replace('*', '').trim();
}
return field.name || field.id;
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
showFieldError(field, message) {
const formGroup = field.closest('.form-group');
const errorElement = document.getElementById(`${field.id}-error`);
// Update error state
this.errors.set(field.id, message);
// Update field attributes
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby',
this.buildDescribedBy(field, `${field.id}-error`));
// Update form group
formGroup?.classList.add('has-error');
// Show error message
if (errorElement) {
errorElement.textContent = message;
errorElement.hidden = false;
}
}
clearFieldError(field) {
const formGroup = field.closest('.form-group');
const errorElement = document.getElementById(`${field.id}-error`);
// Clear error state
this.errors.delete(field.id);
// Update field attributes
field.removeAttribute('aria-invalid');
field.setAttribute('aria-describedby',
this.buildDescribedBy(field, null));
// Update form group
formGroup?.classList.remove('has-error');
// Hide error message
if (errorElement) {
errorElement.textContent = '';
errorElement.hidden = true;
}
}
buildDescribedBy(field, errorId) {
const hintId = `${field.id}-hint`;
const hintExists = document.getElementById(hintId);
const ids = [];
if (hintExists) ids.push(hintId);
if (errorId && this.errors.has(field.id)) ids.push(errorId);
return ids.length > 0 ? ids.join(' ') : null;
}
handleSubmit(e) {
e.preventDefault();
// Validate all fields
const fields = this.form.querySelectorAll('input, select, textarea');
let isValid = true;
fields.forEach(field => {
if (!this.validateField(field)) {
isValid = false;
}
});
if (!isValid) {
this.showErrorSummary();
return;
}
// Form is valid - submit
this.hideErrorSummary();
this.form.submit();
}
showErrorSummary() {
if (!this.options.showErrorSummary) {
if (this.options.focusFirstError) {
this.focusFirstError();
}
return;
}
const summary = document.getElementById('error-summary');
const errorList = document.getElementById('error-list');
if (!summary || !errorList) return;
// Build error list
errorList.innerHTML = '';
this.errors.forEach((message, fieldId) => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${fieldId}`;
link.textContent = message;
link.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById(fieldId)?.focus();
});
li.appendChild(link);
errorList.appendChild(li);
});
// Show and focus summary
summary.hidden = false;
summary.focus();
// Scroll to summary
summary.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
hideErrorSummary() {
const summary = document.getElementById('error-summary');
if (summary) {
summary.hidden = true;
}
}
focusFirstError() {
const firstErrorField = this.form.querySelector('[aria-invalid="true"]');
if (firstErrorField) {
firstErrorField.focus();
firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
// Usage
const form = document.getElementById('registration-form');
const validator = new FormValidator(form);
// Add custom validator
validator.addValidator('phone',
(value) => !value || /^\d{10}$/.test(value),
'Phone number must be exactly 10 digits'
);
React Implementation
Accessible Form Component
import React, { useState, useRef, useEffect } from 'react';
interface FormErrors {
[key: string]: string;
}
interface FormData {
name: string;
email: string;
phone: string;
}
function AccessibleForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
phone: ''
});
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
const errorSummaryRef = useRef<HTMLDivElement>(null);
const validateField = (name: string, value: string): string | null => {
switch (name) {
case 'name':
if (!value.trim()) return 'Full name is required';
if (value.length < 2) return 'Name must be at least 2 characters';
return null;
case 'email':
if (!value.trim()) return 'Email address is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email (e.g., [email protected])';
}
return null;
case 'phone':
if (value && !/^\d{10}$/.test(value)) {
return 'Phone must be 10 digits';
}
return null;
default:
return null;
}
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
Object.keys(formData).forEach(key => {
const error = validateField(key, formData[key as keyof FormData]);
if (error) {
newErrors[key] = error;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const error = validateField(name, value);
setErrors(prev => {
if (error) {
return { ...prev, [name]: error };
}
const { [name]: removed, ...rest } = prev;
return rest;
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing (only after initial validation)
if (errors[name]) {
setErrors(prev => {
const { [name]: removed, ...rest } = prev;
return rest;
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
if (validateForm()) {
console.log('Form submitted:', formData);
}
};
// Focus error summary when errors exist after submission
useEffect(() => {
if (submitted && Object.keys(errors).length > 0 && errorSummaryRef.current) {
errorSummaryRef.current.focus();
}
}, [errors, submitted]);
const errorCount = Object.keys(errors).length;
return (
<form onSubmit={handleSubmit} noValidate>
{/* Error Summary */}
{errorCount > 0 && submitted && (
<div
ref={errorSummaryRef}
className="error-summary"
role="alert"
tabIndex={-1}
aria-labelledby="error-summary-title"
>
<h2 id="error-summary-title">
Please fix {errorCount} error{errorCount !== 1 ? 's' : ''}:
</h2>
<ul>
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a
href={`#${field}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(field)?.focus();
}}
>
{message}
</a>
</li>
))}
</ul>
</div>
)}
{/* Name Field */}
<div className={`form-group ${errors.name ? 'has-error' : ''}`}>
<label htmlFor="name">
Full Name <span aria-hidden="true">*</span>
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
aria-required="true"
aria-invalid={errors.name ? 'true' : undefined}
aria-describedby={errors.name ? 'name-error' : undefined}
autoComplete="name"
/>
{errors.name && (
<p id="name-error" className="error-message" role="alert">
{errors.name}
</p>
)}
</div>
{/* Email Field */}
<div className={`form-group ${errors.email ? 'has-error' : ''}`}>
<label htmlFor="email">
Email Address <span aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
aria-required="true"
aria-invalid={errors.email ? 'true' : undefined}
aria-describedby={`email-hint${errors.email ? ' email-error' : ''}`}
autoComplete="email"
/>
<p id="email-hint" className="hint">We'll never share your email</p>
{errors.email && (
<p id="email-error" className="error-message" role="alert">
{errors.email}
</p>
)}
</div>
{/* Phone Field */}
<div className={`form-group ${errors.phone ? 'has-error' : ''}`}>
<label htmlFor="phone">Phone Number</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={errors.phone ? 'true' : undefined}
aria-describedby={`phone-hint${errors.phone ? ' phone-error' : ''}`}
autoComplete="tel"
/>
<p id="phone-hint" className="hint">10 digits, numbers only</p>
{errors.phone && (
<p id="phone-error" className="error-message" role="alert">
{errors.phone}
</p>
)}
</div>
<button type="submit">Create Account</button>
</form>
);
}
export default AccessibleForm;
Vue 3 Implementation
Composable for Form Validation
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
interface FormData {
name: string
email: string
phone: string
}
interface FormErrors {
[key: string]: string
}
const formData = ref<FormData>({
name: '',
email: '',
phone: ''
})
const errors = ref<FormErrors>({})
const submitted = ref(false)
const errorSummaryRef = ref<HTMLDivElement | null>(null)
const errorCount = computed(() => Object.keys(errors.value).length)
function validateField(name: keyof FormData, value: string): string | null {
switch (name) {
case 'name':
if (!value.trim()) return 'El nombre completo es requerido'
if (value.length < 2) return 'El nombre debe tener al menos 2 caracteres'
return null
case 'email':
if (!value.trim()) return 'El correo electrónico es requerido'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Ingresa un correo válido (ej., [email protected])'
}
return null
case 'phone':
if (value && !/^\d{10}$/.test(value)) {
return 'El teléfono debe tener 10 dígitos'
}
return null
default:
return null
}
}
function validateForm(): boolean {
const newErrors: FormErrors = {}
for (const key of Object.keys(formData.value) as (keyof FormData)[]) {
const error = validateField(key, formData.value[key])
if (error) {
newErrors[key] = error
}
}
errors.value = newErrors
return Object.keys(newErrors).length === 0
}
function handleBlur(e: FocusEvent) {
const target = e.target as HTMLInputElement
const name = target.name as keyof FormData
const error = validateField(name, target.value)
if (error) {
errors.value = { ...errors.value, [name]: error }
} else {
const { [name]: removed, ...rest } = errors.value
errors.value = rest
}
}
function handleInput(e: Event) {
const target = e.target as HTMLInputElement
const name = target.name as keyof FormData
// Clear error when user types
if (errors.value[name]) {
const { [name]: removed, ...rest } = errors.value
errors.value = rest
}
}
async function handleSubmit() {
submitted.value = true
if (validateForm()) {
console.log('Form submitted:', formData.value)
} else {
await nextTick()
errorSummaryRef.value?.focus()
}
}
function focusField(fieldId: string) {
document.getElementById(fieldId)?.focus()
}
function getAriaDescribedBy(fieldName: string, hasHint: boolean = false): string | undefined {
const ids: string[] = []
if (hasHint) ids.push(`${fieldName}-hint`)
if (errors.value[fieldName]) ids.push(`${fieldName}-error`)
return ids.length > 0 ? ids.join(' ') : undefined
}
</script>
<template>
<form @submit.prevent="handleSubmit" novalidate>
<!-- Error Summary -->
<div
v-if="errorCount > 0 && submitted"
ref="errorSummaryRef"
class="error-summary"
role="alert"
tabindex="-1"
aria-labelledby="error-summary-title"
>
<h2 id="error-summary-title">
Por favor corrige {{ errorCount }} error{{ errorCount !== 1 ? 'es' : '' }}:
</h2>
<ul>
<li v-for="(message, field) in errors" :key="field">
<a
:href="`#${field}`"
@click.prevent="focusField(field as string)"
>
{{ message }}
</a>
</li>
</ul>
</div>
<!-- Name Field -->
<div :class="['form-group', { 'has-error': errors.name }]">
<label for="name">
Nombre Completo <span aria-hidden="true">*</span>
</label>
<input
v-model="formData.name"
type="text"
id="name"
name="name"
@blur="handleBlur"
@input="handleInput"
aria-required="true"
:aria-invalid="errors.name ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('name')"
autocomplete="name"
>
<p v-if="errors.name" id="name-error" class="error-message" role="alert">
{{ errors.name }}
</p>
</div>
<!-- Email Field -->
<div :class="['form-group', { 'has-error': errors.email }]">
<label for="email">
Correo Electrónico <span aria-hidden="true">*</span>
</label>
<input
v-model="formData.email"
type="email"
id="email"
name="email"
@blur="handleBlur"
@input="handleInput"
aria-required="true"
:aria-invalid="errors.email ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('email', true)"
autocomplete="email"
>
<p id="email-hint" class="hint">Nunca compartiremos tu correo</p>
<p v-if="errors.email" id="email-error" class="error-message" role="alert">
{{ errors.email }}
</p>
</div>
<!-- Phone Field -->
<div :class="['form-group', { 'has-error': errors.phone }]">
<label for="phone">Número de Teléfono</label>
<input
v-model="formData.phone"
type="tel"
id="phone"
name="phone"
@blur="handleBlur"
@input="handleInput"
:aria-invalid="errors.phone ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('phone', true)"
autocomplete="tel"
>
<p id="phone-hint" class="hint">10 dígitos, solo números</p>
<p v-if="errors.phone" id="phone-error" class="error-message" role="alert">
{{ errors.phone }}
</p>
</div>
<button type="submit">Crear Cuenta</button>
</form>
</template>
Automated Testing
Playwright Tests for Error Identification
import { test, expect } from '@playwright/test';
test.describe('Form Error Identification', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/form');
});
test('shows error summary with links after invalid submission', async ({ page }) => {
// Submit empty form
await page.click('button[type="submit"]');
// Error summary should appear
const errorSummary = page.locator('[role="alert"]');
await expect(errorSummary).toBeVisible();
// Should have role="alert" for screen reader announcement
await expect(errorSummary).toHaveAttribute('role', 'alert');
// Should be focused for immediate screen reader access
await expect(errorSummary).toBeFocused();
// Should contain links to error fields
const errorLinks = errorSummary.locator('a');
const linkCount = await errorLinks.count();
expect(linkCount).toBeGreaterThan(0);
// Each link should navigate to its field
const firstLink = errorLinks.first();
await firstLink.click();
const nameInput = page.locator('#name');
await expect(nameInput).toBeFocused();
});
test('inline errors have proper ARIA attributes', async ({ page }) => {
// Submit empty form to trigger errors
await page.click('button[type="submit"]');
// Check required field has aria-invalid
const nameInput = page.locator('#name');
await expect(nameInput).toHaveAttribute('aria-invalid', 'true');
// Check aria-describedby points to error message
const describedBy = await nameInput.getAttribute('aria-describedby');
expect(describedBy).toContain('name-error');
// Error message should be visible
const errorMessage = page.locator('#name-error');
await expect(errorMessage).toBeVisible();
await expect(errorMessage).not.toBeEmpty();
});
test('error messages describe specific problems', async ({ page }) => {
// Enter invalid email
await page.fill('#email', 'not-an-email');
await page.locator('#email').blur();
// Error should be specific
const emailError = page.locator('#email-error');
await expect(emailError).toBeVisible();
const errorText = await emailError.textContent();
expect(errorText).toContain('valid email');
expect(errorText).toContain('@'); // Should include format hint
});
test('errors clear when user corrects input', async ({ page }) => {
// Submit empty form
await page.click('button[type="submit"]');
// Verify error exists
const nameInput = page.locator('#name');
await expect(nameInput).toHaveAttribute('aria-invalid', 'true');
// Enter valid value
await page.fill('#name', 'John Doe');
// Error should clear
await expect(nameInput).not.toHaveAttribute('aria-invalid');
const nameError = page.locator('#name-error');
await expect(nameError).toBeHidden();
});
test('error suggestions provide actionable guidance', async ({ page }) => {
// Enter phone with wrong format
await page.fill('#phone', '12345');
await page.locator('#phone').blur();
// Error should suggest correct format
const phoneError = page.locator('#phone-error');
await expect(phoneError).toBeVisible();
const errorText = await phoneError.textContent();
expect(errorText).toMatch(/10|digits/i); // Should mention format
});
test('screen reader receives error announcements', async ({ page }) => {
// Submit empty form
await page.click('button[type="submit"]');
// Error summary should be announced (role="alert" + aria-live)
const errorSummary = page.locator('[role="alert"]');
await expect(errorSummary).toHaveAttribute('role', 'alert');
// Inline errors should also be announced
const emailError = page.locator('#email-error[role="alert"]');
// Role alert implies aria-live="assertive"
await expect(emailError).toBeVisible();
});
});
Summary Checklist
- [ ] Error summary appears at top of form with role=“alert”
- [ ] Error summary receives focus after form submission with errors
- [ ] Error summary contains links to each field with errors
- [ ] Each error message is descriptive and actionable
- [ ] Fields with errors have aria-invalid=“true”
- [ ] Fields with errors have aria-describedby pointing to error message
- [ ] Error messages appear near their associated fields
- [ ] Errors are described in text (not just color or icons)
- [ ] Error suggestions provide format hints when known
- [ ] Errors clear when user corrects input
- [ ] Focus management helps users navigate to errors
- [ ] Screen readers announce errors when they appear
Related Articles
- ARIA Labels Guide - Accessible naming
- WCAG Compliance Hub - All accessibility evaluators