Ver contenido
Introducción
Cuando los usuarios cometen errores al completar formularios, necesitan información clara sobre qué salió mal y cómo corregirlo. WCAG 3.3.1 (Identificación de Errores) requiere que cuando se detectan errores, el campo específico con el error debe ser identificado y el error descrito en texto. Esto no es solo un requisito de accesibilidad—es esencial para una buena experiencia de usuario.
Los formularios con mensajes de error poco claros frustran a todos. Para usuarios con discapacidades, particularmente aquellos que usan lectores de pantalla o que tienen discapacidades cognitivas, el manejo ambiguo de errores puede hacer que los formularios sean completamente inutilizables.
Qué Requiere WCAG
3.3.1 Identificación de Errores (Nivel A)
Si se detecta automáticamente un error de entrada, se identifica el elemento que está en error y el error se describe al usuario en texto.
Esto significa:
- Los errores deben describirse en texto (no solo color o iconos)
- El campo específico con el error debe ser identificado
- Los usuarios deben poder encontrar y entender qué salió mal
3.3.3 Sugerencia de Errores (Nivel AA)
Si se detecta automáticamente un error de entrada y se conocen sugerencias para la corrección, entonces las sugerencias se proporcionan al usuario.
Esto significa:
- Cuando sea posible, proporcionar orientación específica sobre cómo corregir el error
- Sugerir formatos correctos (ej., “Usa el formato: DD/MM/AAAA”)
- Ofrecer alternativas cuando sea apropiado
Quién Se Beneficia
| Tipo de Usuario | Beneficio |
|---|---|
| Usuarios de lectores de pantalla | Errores anunciados claramente, no solo señales visuales |
| Usuarios con baja visión | Descripciones de texto complementan cambios de color |
| Usuarios con discapacidades cognitivas | Orientación clara sobre qué necesita corrección |
| Usuarios con discapacidades motoras | Pueden navegar directamente a campos con errores |
| Todos los usuarios | Completado de formularios más rápido con retroalimentación clara |
Problemas Comunes en el Manejo de Errores
1. Errores Solo por Color
<!-- MAL: Solo el color indica error -->
<input type="email" class="error-borde-rojo">
<!-- BIEN: Descripción de texto con color -->
<input type="email" aria-describedby="email-error" aria-invalid="true">
<p id="email-error" class="error-text">
Por favor ingresa un correo electrónico válido (ej., [email protected])
</p>
2. Mensajes de Error Genéricos
<!-- MAL: Mensaje inútil -->
<p class="error">Entrada inválida</p>
<!-- BIEN: Mensaje específico y accionable -->
<p id="telefono-error" class="error">
El número de teléfono debe tener 10 dígitos. Ingresaste 8 dígitos.
</p>
3. Errores Lejos de los Campos
<!-- MAL: Todos los errores arriba sin asociación de campo -->
<div class="error-summary">
<p>Hubo errores en tu envío</p>
</div>
<!-- BIEN: Resumen con enlaces a campos + errores en línea -->
<div class="error-summary" role="alert">
<h2>Por favor corrige 2 errores:</h2>
<ul>
<li><a href="#email">El correo electrónico es requerido</a></li>
<li><a href="#telefono">El formato del teléfono es inválido</a></li>
</ul>
</div>
4. Errores No Anunciados a Lectores de Pantalla
<!-- MAL: El error aparece pero no se anuncia -->
<span class="error">Campo requerido</span>
<!-- BIEN: Región en vivo anuncia el error -->
<span class="error" role="alert" aria-live="assertive">
Campo requerido
</span>
Patrones Clave de Manejo de Errores
Patrón de Resumen de Errores
Proporciona un resumen de todos los errores en la parte superior del formulario, con enlaces a cada campo:
<div class="error-summary" role="alert">
<h2>Hay 2 errores en este formulario</h2>
<ul>
<li><a href="#nombre">El nombre es requerido</a></li>
<li><a href="#email">El formato del correo es inválido</a></li>
</ul>
</div>
Patrón de Error en Línea
Coloca mensajes de error directamente junto al campo que describen:
<div class="form-group">
<label for="email">Correo Electrónico</label>
<input
type="email"
id="email"
aria-describedby="email-error"
aria-invalid="true"
>
<p id="email-error" class="error">
Ingresa un correo electrónico válido (ej., [email protected])
</p>
</div>
Validación en Tiempo Real
Valida mientras los usuarios escriben o cuando abandonan un campo, pero evita validar demasiado agresivamente:
- Sí validar: En blur (cuando el usuario abandona el campo)
- Sí validar: Al enviar el formulario
- Evitar: Validar mientras el usuario aún está escribiendo
- Evitar: Mostrar errores para campos requeridos vacíos antes de que el usuario interactúe
Componentes de Mensajes de Error Accesibles
Indicadores Visuales
- Borde o fondo rojo (pero nunca solo color)
- Icono de error (con texto alt o aria-label)
- Texto de error claro cerca del campo
Requisitos Programáticos
aria-invalid="true"en el campo con erroraria-describedbyvinculando campo con mensaje de errorrole="alert"oaria-live="assertive"para errores dinámicos- Gestión de foco (mover foco al resumen de errores o primer campo con error)
Probando Identificación de Errores
Prueba Manual Rápida
- Envía un formulario con errores intencionales
- Verifica que cada error se describa en texto
- Comprueba que los mensajes de error identifiquen qué campo tiene el error
- Prueba con lector de pantalla para asegurar que los errores se anuncien
- Verifica que los usuarios de teclado puedan navegar a los campos con errores
Qué Verificar
- [ ] Los errores se describen en texto (no solo color/iconos)
- [ ] Cada error identifica qué campo tiene el problema
- [ ] Las sugerencias de error proporcionan orientación útil cuando es posible
- [ ] Los lectores de pantalla anuncian los errores cuando aparecen
- [ ] Los usuarios pueden navegar del resumen de errores a campos específicos
- [ ] El foco se gestiona apropiadamente después del envío del formulario
Herramientas para Pruebas
- Lector de pantalla: Prueba el anuncio de errores con NVDA, VoiceOver o JAWS
- axe DevTools: Verifica asociaciones de errores faltantes
- Prueba de teclado: Verifica que el foco se mueva apropiadamente a los errores
Resumen de Mejores Prácticas
| Hacer | No Hacer |
|---|---|
| Describir errores en texto | Confiar solo en color o iconos |
| Identificar campo específico con error | Usar mensajes vagos “el formulario tiene errores” |
| Proporcionar sugerencias de corrección | Solo decir “entrada inválida” |
| Usar aria-invalid y aria-describedby | Dejar errores sin vincular a campos |
| Anunciar errores a lectores de pantalla | Mostrar errores silenciosamente |
| Colocar errores cerca de campos relacionados | Poner todos los errores lejos de los campos |
Implementación
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
Artículos Relacionados
- Guía de Etiquetas ARIA - Nombres accesibles
- Hub de Cumplimiento WCAG - Todos los evaluadores de accesibilidad