UXR SEO Analyzer documentation

Introduction

Error Identification

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 error
  • aria-describedby linking field to error message
  • role="alert" or aria-live="assertive" for dynamic errors
  • Focus management (move focus to error summary or first error field)

Testing Error Identification

Quick Manual Test

  1. Submit a form with intentional errors
  2. Verify each error is described in text
  3. Check that error messages identify which field has the error
  4. Test with screen reader to ensure errors are announced
  5. 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

References

  1. W3C - WCAG 2.2 SC 3.3.1 Error Identification
  2. W3C - WCAG 2.2 SC 3.3.3 Error Suggestion
  3. W3C - WAI Forms Tutorial - Validating Input
  4. WebAIM - Accessible Form Validation
  5. W3C - ARIA21 Using aria-invalid

Related articles

Category hub

Hub

WCAG 2.2 Compliance Hub

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities

Last updated: