Documentación de UXR SEO Analyzer

Introducción

Identificación de Errores

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 error
  • aria-describedby vinculando campo con mensaje de error
  • role="alert" o aria-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

  1. Envía un formulario con errores intencionales
  2. Verifica que cada error se describa en texto
  3. Comprueba que los mensajes de error identifiquen qué campo tiene el error
  4. Prueba con lector de pantalla para asegurar que los errores se anuncien
  5. 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


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

Artículos relacionados

Hub de categoría

Hub

Hub de Cumplimiento WCAG 2.2

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

Última actualización: