Guía detallada

Error Identification Implementation Guide

Ver contenido

Guía de Implementación de Identificación de Errores: Patrones Completos para Validación de Formularios Accesible

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

Referencias

  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. W3C - ARIA21 Using aria-invalid
  5. WebAIM - Accessible Form Validation

Artículos relacionados

Versión relacionada

Introducción

Error Identification Explained

Cuando los usuarios cometen errores al completar formularios, necesitan información clara sobre qué salió mal y cómo corregirlo

Hub de categoría

Hub

Wcag Compliance Hub

La accesibilidad web asegura que los sitios web y aplicaciones puedan ser utilizados por todos, incluyendo personas con discapacidades