Ver contenido
Introducción
El manejo accesible de errores es crítico para la usabilidad de formularios. Esta guía cubre patrones completos de implementación para identificación de errores, sugerencias de errores y gestión de foco que cumplen con los requisitos de WCAG 3.3.1 y 3.3.3 mientras proporcionan una excelente experiencia de usuario para todos.
Más allá del cumplimiento de accesibilidad, el manejo de errores bien implementado reduce el abandono de formularios y las solicitudes de soporte al ayudar a los usuarios a completar formularios exitosamente en el primer intento.
Patrón Completo de Validación de Formularios
Estructura HTML con Todos los Atributos ARIA
<form id="registration-form" novalidate>
<!-- Resumen de Errores (mostrado después del envío con errores) -->
<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"><!-- icono --></svg>
Por favor corrige los siguientes errores:
</h2>
<ul id="error-list">
<!-- Enlaces de errores insertados dinámicamente -->
</ul>
</div>
<!-- Campos del Formulario -->
<div class="form-group">
<label for="nombre">
Nombre Completo
<span class="required" aria-hidden="true">*</span>
</label>
<input
type="text"
id="nombre"
name="nombre"
required
aria-required="true"
autocomplete="name"
>
<p id="nombre-error" class="error-message" hidden></p>
</div>
<div class="form-group">
<label for="email">
Correo Electrónico
<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">Nunca compartiremos tu correo</p>
<p id="email-error" class="error-message" hidden></p>
</div>
<div class="form-group">
<label for="telefono">Número de Teléfono</label>
<input
type="tel"
id="telefono"
name="telefono"
pattern="[0-9]{10}"
aria-describedby="telefono-hint"
autocomplete="tel"
>
<p id="telefono-hint" class="hint">10 dígitos, solo números</p>
<p id="telefono-error" class="error-message" hidden></p>
</div>
<button type="submit">Crear Cuenta</button>
</form>
Estilos CSS para Estados de Error
/* Contenedor de resumen de errores */
.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;
}
/* Grupo de formulario con 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;
}
/* Estilo del mensaje de error */
.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;
}
/* Indicador de requerido */
.required {
color: #dc2626;
margin-left: 2px;
}
/* Texto de ayuda */
.hint {
color: #6b7280;
font-size: 0.875rem;
margin-top: 4px;
}
/* Estado oculto */
[hidden] {
display: none !important;
}
Implementación de Validación en JavaScript
Clase de Validación Completa
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() {
// Prevenir validación del navegador
this.form.setAttribute('novalidate', '');
// Configurar 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 de validación de input
clearTimeout(e.target.validationTimeout);
e.target.validationTimeout = setTimeout(() => {
this.validateField(e.target);
}, 300);
}
});
}
// Limpiar error cuando el usuario comienza a escribir (después de validación inicial)
this.form.addEventListener('input', (e) => {
if (e.target.matches('input, select, textarea') &&
this.errors.has(e.target.id)) {
this.clearFieldError(e.target);
}
});
}
// Agregar validador personalizado para un campo
addValidator(fieldId, validator, message) {
if (!this.validators.has(fieldId)) {
this.validators.set(fieldId, []);
}
this.validators.get(fieldId).push({ validator, message });
}
validateField(field) {
const errors = [];
// Validación de requerido
if (field.hasAttribute('required') && !field.value.trim()) {
errors.push(this.getRequiredMessage(field));
}
// Validación de tipo (email, url, etc.)
if (field.value && field.type === 'email' && !this.isValidEmail(field.value)) {
errors.push('Por favor ingresa un correo electrónico válido (ej., [email protected])');
}
// Validación de patrón
if (field.value && field.pattern) {
const regex = new RegExp(`^${field.pattern}$`);
if (!regex.test(field.value)) {
errors.push(field.title || 'Por favor usa el formato solicitado');
}
}
// Validación de longitud mínima/máxima
if (field.value && field.minLength > 0 && field.value.length < field.minLength) {
errors.push(`Por favor ingresa al menos ${field.minLength} caracteres`);
}
if (field.value && field.maxLength > 0 && field.value.length > field.maxLength) {
errors.push(`Por favor ingresa no más de ${field.maxLength} caracteres`);
}
// Validadores personalizados
const customValidators = this.validators.get(field.id) || [];
for (const { validator, message } of customValidators) {
if (!validator(field.value, field)) {
errors.push(message);
}
}
// Actualizar estado del campo
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} es requerido`;
}
getFieldLabel(field) {
const label = this.form.querySelector(`label[for="${field.id}"]`);
if (label) {
// Obtener contenido de texto sin el indicador de requerido
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`);
// Actualizar estado de error
this.errors.set(field.id, message);
// Actualizar atributos del campo
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby',
this.buildDescribedBy(field, `${field.id}-error`));
// Actualizar grupo del formulario
formGroup?.classList.add('has-error');
// Mostrar mensaje de error
if (errorElement) {
errorElement.textContent = message;
errorElement.hidden = false;
}
}
clearFieldError(field) {
const formGroup = field.closest('.form-group');
const errorElement = document.getElementById(`${field.id}-error`);
// Limpiar estado de error
this.errors.delete(field.id);
// Actualizar atributos del campo
field.removeAttribute('aria-invalid');
field.setAttribute('aria-describedby',
this.buildDescribedBy(field, null));
// Actualizar grupo del formulario
formGroup?.classList.remove('has-error');
// Ocultar mensaje de error
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();
// Validar todos los campos
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;
}
// Formulario válido - enviar
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;
// Construir lista de errores
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);
});
// Mostrar y enfocar resumen
summary.hidden = false;
summary.focus();
// Desplazar al resumen
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' });
}
}
}
// Uso
const form = document.getElementById('registration-form');
const validator = new FormValidator(form);
// Agregar validador personalizado
validator.addValidator('telefono',
(value) => !value || /^\d{10}$/.test(value),
'El número de teléfono debe tener exactamente 10 dígitos'
);
Implementación en React
Componente de Formulario Accesible
import React, { useState, useRef, useEffect } from 'react';
interface FormErrors {
[key: string]: string;
}
interface FormData {
nombre: string;
email: string;
telefono: string;
}
function FormularioAccesible() {
const [formData, setFormData] = useState<FormData>({
nombre: '',
email: '',
telefono: ''
});
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 'nombre':
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 'Por favor ingresa un correo válido (ej., [email protected])';
}
return null;
case 'telefono':
if (value && !/^\d{10}$/.test(value)) {
return 'El teléfono debe tener 10 dígitos';
}
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 }));
// Limpiar error cuando el usuario comienza a escribir (solo después de validación inicial)
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('Formulario enviado:', formData);
}
};
// Enfocar resumen de errores cuando existen errores después del envío
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>
{/* Resumen de Errores */}
{errorCount > 0 && submitted && (
<div
ref={errorSummaryRef}
className="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>
{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>
)}
{/* Campo de Nombre */}
<div className={`form-group ${errors.nombre ? 'has-error' : ''}`}>
<label htmlFor="nombre">
Nombre Completo <span aria-hidden="true">*</span>
</label>
<input
type="text"
id="nombre"
name="nombre"
value={formData.nombre}
onChange={handleChange}
onBlur={handleBlur}
aria-required="true"
aria-invalid={errors.nombre ? 'true' : undefined}
aria-describedby={errors.nombre ? 'nombre-error' : undefined}
autoComplete="name"
/>
{errors.nombre && (
<p id="nombre-error" className="error-message" role="alert">
{errors.nombre}
</p>
)}
</div>
{/* Campo de Email */}
<div className={`form-group ${errors.email ? 'has-error' : ''}`}>
<label htmlFor="email">
Correo Electrónico <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">Nunca compartiremos tu correo</p>
{errors.email && (
<p id="email-error" className="error-message" role="alert">
{errors.email}
</p>
)}
</div>
{/* Campo de Teléfono */}
<div className={`form-group ${errors.telefono ? 'has-error' : ''}`}>
<label htmlFor="telefono">Número de Teléfono</label>
<input
type="tel"
id="telefono"
name="telefono"
value={formData.telefono}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={errors.telefono ? 'true' : undefined}
aria-describedby={`telefono-hint${errors.telefono ? ' telefono-error' : ''}`}
autoComplete="tel"
/>
<p id="telefono-hint" class="hint">10 dígitos, solo números</p>
{errors.telefono && (
<p id="telefono-error" className="error-message" role="alert">
{errors.telefono}
</p>
)}
</div>
<button type="submit">Crear Cuenta</button>
</form>
);
}
export default FormularioAccesible;
Implementación en Vue 3
Composable para Validación de Formularios
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
interface FormData {
nombre: string
email: string
telefono: string
}
interface FormErrors {
[key: string]: string
}
const formData = ref<FormData>({
nombre: '',
email: '',
telefono: ''
})
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 'nombre':
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 'telefono':
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
// Limpiar error cuando el usuario escribe
if (errors.value[name]) {
const { [name]: removed, ...rest } = errors.value
errors.value = rest
}
}
async function handleSubmit() {
submitted.value = true
if (validateForm()) {
console.log('Formulario enviado:', 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>
<!-- Resumen de Errores -->
<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>
<!-- Campo de Nombre -->
<div :class="['form-group', { 'has-error': errors.nombre }]">
<label for="nombre">
Nombre Completo <span aria-hidden="true">*</span>
</label>
<input
v-model="formData.nombre"
type="text"
id="nombre"
name="nombre"
@blur="handleBlur"
@input="handleInput"
aria-required="true"
:aria-invalid="errors.nombre ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('nombre')"
autocomplete="name"
>
<p v-if="errors.nombre" id="nombre-error" class="error-message" role="alert">
{{ errors.nombre }}
</p>
</div>
<!-- Campo de Email -->
<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>
<!-- Campo de Teléfono -->
<div :class="['form-group', { 'has-error': errors.telefono }]">
<label for="telefono">Número de Teléfono</label>
<input
v-model="formData.telefono"
type="tel"
id="telefono"
name="telefono"
@blur="handleBlur"
@input="handleInput"
:aria-invalid="errors.telefono ? 'true' : undefined"
:aria-describedby="getAriaDescribedBy('telefono', true)"
autocomplete="tel"
>
<p id="telefono-hint" class="hint">10 dígitos, solo números</p>
<p v-if="errors.telefono" id="telefono-error" class="error-message" role="alert">
{{ errors.telefono }}
</p>
</div>
<button type="submit">Crear Cuenta</button>
</form>
</template>
Pruebas Automatizadas
Tests de Playwright para Identificación de Errores
import { test, expect } from '@playwright/test';
test.describe('Identificación de Errores en Formularios', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/formulario');
});
test('muestra resumen de errores con enlaces después de envío inválido', async ({ page }) => {
// Enviar formulario vacío
await page.click('button[type="submit"]');
// El resumen de errores debe aparecer
const errorSummary = page.locator('[role="alert"]');
await expect(errorSummary).toBeVisible();
// Debe tener role="alert" para anuncio de lector de pantalla
await expect(errorSummary).toHaveAttribute('role', 'alert');
// Debe estar enfocado para acceso inmediato del lector de pantalla
await expect(errorSummary).toBeFocused();
// Debe contener enlaces a campos con errores
const errorLinks = errorSummary.locator('a');
const linkCount = await errorLinks.count();
expect(linkCount).toBeGreaterThan(0);
// Cada enlace debe navegar a su campo
const firstLink = errorLinks.first();
await firstLink.click();
const nombreInput = page.locator('#nombre');
await expect(nombreInput).toBeFocused();
});
test('errores en línea tienen atributos ARIA apropiados', async ({ page }) => {
// Enviar formulario vacío para activar errores
await page.click('button[type="submit"]');
// Verificar que campo requerido tiene aria-invalid
const nombreInput = page.locator('#nombre');
await expect(nombreInput).toHaveAttribute('aria-invalid', 'true');
// Verificar que aria-describedby apunta al mensaje de error
const describedBy = await nombreInput.getAttribute('aria-describedby');
expect(describedBy).toContain('nombre-error');
// El mensaje de error debe ser visible
const errorMessage = page.locator('#nombre-error');
await expect(errorMessage).toBeVisible();
await expect(errorMessage).not.toBeEmpty();
});
test('mensajes de error describen problemas específicos', async ({ page }) => {
// Ingresar email inválido
await page.fill('#email', 'no-es-email');
await page.locator('#email').blur();
// El error debe ser específico
const emailError = page.locator('#email-error');
await expect(emailError).toBeVisible();
const errorText = await emailError.textContent();
expect(errorText).toContain('válido');
expect(errorText).toContain('@'); // Debe incluir pista de formato
});
test('errores se limpian cuando el usuario corrige la entrada', async ({ page }) => {
// Enviar formulario vacío
await page.click('button[type="submit"]');
// Verificar que el error existe
const nombreInput = page.locator('#nombre');
await expect(nombreInput).toHaveAttribute('aria-invalid', 'true');
// Ingresar valor válido
await page.fill('#nombre', 'Juan Pérez');
// El error debe limpiarse
await expect(nombreInput).not.toHaveAttribute('aria-invalid');
const nombreError = page.locator('#nombre-error');
await expect(nombreError).toBeHidden();
});
test('sugerencias de error proporcionan orientación accionable', async ({ page }) => {
// Ingresar teléfono con formato incorrecto
await page.fill('#telefono', '12345');
await page.locator('#telefono').blur();
// El error debe sugerir formato correcto
const telefonoError = page.locator('#telefono-error');
await expect(telefonoError).toBeVisible();
const errorText = await telefonoError.textContent();
expect(errorText).toMatch(/10|dígitos/i); // Debe mencionar formato
});
test('lector de pantalla recibe anuncios de errores', async ({ page }) => {
// Enviar formulario vacío
await page.click('button[type="submit"]');
// El resumen de errores debe ser anunciado (role="alert" + aria-live)
const errorSummary = page.locator('[role="alert"]');
await expect(errorSummary).toHaveAttribute('role', 'alert');
// Los errores en línea también deben ser anunciados
const emailError = page.locator('#email-error[role="alert"]');
// Role alert implica aria-live="assertive"
await expect(emailError).toBeVisible();
});
});
Checklist de Resumen
- [ ] El resumen de errores aparece en la parte superior del formulario con role=“alert”
- [ ] El resumen de errores recibe el foco después del envío del formulario con errores
- [ ] El resumen de errores contiene enlaces a cada campo con errores
- [ ] Cada mensaje de error es descriptivo y accionable
- [ ] Los campos con errores tienen aria-invalid=“true”
- [ ] Los campos con errores tienen aria-describedby apuntando al mensaje de error
- [ ] Los mensajes de error aparecen cerca de sus campos asociados
- [ ] Los errores se describen en texto (no solo color o iconos)
- [ ] Las sugerencias de error proporcionan pistas de formato cuando se conocen
- [ ] Los errores se limpian cuando el usuario corrige la entrada
- [ ] La gestión de foco ayuda a los usuarios a navegar a los errores
- [ ] Los lectores de pantalla anuncian los errores cuando aparecen