Anuncio de error accesible

El problema

La validación estándar de HTML5 proporciona comentarios visuales (a través de :invalid o :user-invalid), pero no se sincroniza automáticamente con los atributos de accesibilidad, como aria-invalid.

Si usas el diseño estándar de :invalid, es posible que los lectores de pantalla anuncien "Entrada no válida" en el momento en que un usuario presione la tecla Tab para ingresar a un campo obligatorio que esté vacío. Esto crea una experiencia disruptiva para los usuarios que usan tecnologías de accesibilidad, ya que el error se anuncia antes de que se produzca la interacción.

La solución

Queremos que el estado programático (aria-invalid="true") se aplique en el mismo momento en que se aplica el estado visual (:user-invalid). Dado que :user-invalid se basa en el indicador interno "user-interacted" del navegador, podemos usar JavaScript para verificar que este selector coincida durante los eventos de interacción estándar.

Consulta MDN aria-invalid para obtener más detalles.

Estrategia de implementación

  1. Capa visual: Usa CSS :user-invalid para mostrar bordes o íconos.
  2. Capa de accesibilidad: Usa aria-invalid y aria-errormessage para comunicar el estado a la tecnología de accesibilidad (AT).
  3. Crea un puente entre la capa visual y la de accesibilidad: Crea una utilidad de JavaScript ligera que escuche los eventos blur y input, verifique si el elemento coincide con :user-invalid y actualice los atributos ARIA según corresponda.

Guía de implementación

1. Estructura HTML

Vincula tu entrada a su mensaje de error con aria-errormessage (o aria-describedby para obtener una compatibilidad más amplia).

<form>
  <div class="field">
    <label for="email">Email</label>
    <input 
      type="email" 
      id="email" 
      required 
      aria-errormessage="email-error"
    >
    <span id="email-error" class="error-msg">
      Please enter a valid email address.
    </span>
  </div>
</form>

2. CSS

Controla la visibilidad del mensaje de error con la pseudoclase nativa :user-invalid.

.error-msg {
  display: none;
  color: #d93025;
}

/* Show error message when input is user-invalid */
input:user-invalid ~ .error-msg {
  display: block;
}

/* Optional: Visual cues on the input itself */
input:user-invalid {
  border-color: #d93025;
}

3. JavaScript

Como no hay un evento "UserInvalidChanged", conéctate a los eventos de formulario estándar para verificar el estado.

const updateAriaState = (event) => {
  const input = event.target;
  if (!input.matches?.('input, textarea, select')) return;

  // Check if the browser currently considers this input "user-invalid"
  const isUserInvalid = input.matches(':user-invalid');
  
  if (isUserInvalid) {
    input.setAttribute('aria-invalid', 'true');
  } else {
    input.removeAttribute('aria-invalid');
  }
};

// Listen on the document to handle dynamically added fields.
// 'blur' and 'focus' do not bubble, so we must use the capture phase (true).
document.addEventListener('blur', updateAriaState, true);
document.addEventListener('focus', updateAriaState, true);

// Also update on input if we've already shown the error, 
// so the error clears immediately when fixed.
document.addEventListener('input', (event) => {
  const input = event.target;
  if (!input.matches?.('input, textarea, select')) return;

  const hasAriaInvalid = input.hasAttribute('aria-invalid');
  const ariaInvalid = input.getAttribute('aria-invalid');
  if (hasAriaInvalid && ariaInvalid === 'true') {
    updateAriaState(event);
  }
});

Resguardo y compatibilidad con navegadores

Detección de funciones

Puedes verificar la compatibilidad en CSS y JavaScript.

Verificación de JavaScript:

if (!CSS.supports('selector(:user-invalid)')) {
  // Fallback logic here
}

CSS para resguardo

Para garantizar que la lógica de resguardo sea visualmente indistinguible del comportamiento nativo, debes aplicar los estilos de error tanto a la pseudoclase como a la clase de resguardo.

/* Apply error styles to both native selector and fallback class */
input:user-invalid,
input.user-invalid-fallback {
  border-color: #d93025;
  background-color: #fce8e6;
}

/* Show error message for both cases */
input:user-invalid ~ .error-msg,
input.user-invalid-fallback ~ .error-msg {
  display: block;
}

Lógica de resguardo

Si falta :user-invalid, haz un seguimiento manual del estado de interacción con un WeakMap.

const UserInvalidFallback = (() => {
  const dirtyState = new WeakMap();

  const updateState = (input) => {
    const isValid = input.checkValidity();

    // Update both visual and ARIA state
    input.classList.toggle('user-invalid-fallback', !isValid);
    input.classList.toggle('user-valid-fallback', isValid);

    if (!isValid) {
      input.setAttribute('aria-invalid', 'true');
    } else {
      input.removeAttribute('aria-invalid');
    }
  };

  const handleEvent = (event) => {
    const input = event.target;

    if (event.type === 'reset' && input.matches?.('form')) {
      const controls = input.elements || [];
      for (const control of controls) {
        dirtyState.delete(control);
        control.classList.remove('user-invalid-fallback');
        control.classList.remove('user-valid-fallback');
        control.removeAttribute('aria-invalid');
      }
      return;
    }

    if (!input.matches?.('input, textarea, select')) return;

    if (event.type === 'input' || event.type === 'change') {
      const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
      state.hasInteracted = true;
      dirtyState.set(input, state);
      if (state.hasBlurred) {
        updateState(input);
      }
    } else if (event.type === 'blur') {
      const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
      state.hasBlurred = true;
      dirtyState.set(input, state);
      if (state.hasInteracted) {
        updateState(input);
      }
    }
  };

  const init = () => {
    if (CSS.supports('selector(:user-invalid)')) return;

    document.addEventListener('blur', handleEvent, true); // Capture phase required
    document.addEventListener('input', handleEvent, true);
    document.addEventListener('change', handleEvent, true);
    document.addEventListener('reset', handleEvent, true); // Capture resets
  };

  return { init };
})();

// Initialize globally
UserInvalidFallback.init();

Otros factores que considerar

  1. aria-live vs. aria-errormessage:

    • aria-errormessage conecta la entrada al texto, pero es posible que los lectores de pantalla no lo anuncien inmediatamente después de que aparezca (solo cuando se enfoca la entrada).
    • Si necesitas un anuncio inmediato cuando aparece el error (p.ej., cuando se pierde el foco), considera agregar role="alert" o aria-live="polite" al contenedor del mensaje de error, pero realiza pruebas exhaustivas para evitar un "anuncio doble" cuando el usuario se enfoca en el campo para corregirlo.
  2. Internacionalización:

    • Asegúrate de que el contenido de texto de tu mensaje de error (#email-error) esté traducido. La lógica sigue siendo la misma.