Annonce d'erreur accessible

Fonctionnalités Web : :user-valid et :user-invalid

Le problème

La validation HTML5 standard fournit un retour visuel (via :invalid ou :user-invalid), mais elle ne se synchronise pas automatiquement avec les attributs d'accessibilité tels que aria-invalid.

Si vous utilisez le style :invalid standard, les lecteurs d'écran peuvent annoncer "Entrée non valide" dès qu'un utilisateur accède à un champ obligatoire actuellement vide. Cela crée une expérience perturbante pour les utilisateurs de technologies d'assistance, car l'erreur est annoncée avant que l'interaction n'ait eu lieu.

La solution

Nous voulons que l'état programmatic (aria-invalid="true") soit appliqué exactement au même moment que l'état visual (:user-invalid). Étant donné que :user-invalid s'appuie sur l'indicateur interne "user-interacted" du navigateur, nous pouvons utiliser JavaScript pour vérifier que ce sélecteur correspond lors des événements d'interaction standards.

Pour en savoir plus, consultez MDN aria-invalid.

Stratégie d'implémentation

  1. Couche visuelle : utilisez la propriété CSS :user-invalid pour afficher les bordures/icônes.
  2. Couche d'accessibilité : utilisez aria-invalid et aria-errormessage pour communiquer l'état à la technologie d'assistance.
  3. Couche visuelle et d'accessibilité de transition : créez un utilitaire JavaScript léger qui écoute les événements blur et input, vérifie si l'élément correspond à :user-invalid et met à jour les attributs ARIA en conséquence.

Guide d'implémentation

1. Structure HTML

Associez votre entrée à son message d'erreur à l'aide de aria-errormessage (ou aria-describedby pour une compatibilité plus étendue).

<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

Contrôlez la visibilité du message d'erreur à l'aide de la pseudo-classe native :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

Comme il n'existe pas d'événement "UserInvalidChanged", utilisez les événements de formulaire standards pour vérifier l'état.

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);
  }
});

Création de remplacement et compatibilité avec les navigateurs

Détection de caractéristiques

Vous pouvez vérifier la compatibilité en CSS et en JavaScript.

Vérification JavaScript :

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

CSS pour la création de remplacement

Pour vous assurer que votre logique de remplacement est visuellement indiscernable du comportement natif, vous devez appliquer vos styles d'erreur à la fois à la pseudo-classe et à votre classe de remplacement.

/* 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;
}

Logique de remplacement

Si :user-invalid est manquant, suivez manuellement l'état d'interaction à l'aide d'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();

Autres points à prendre en compte

  1. aria-live vs aria-errormessage :

    • aria-errormessage associe l'entrée au texte, mais les lecteurs d'écran peuvent ne pas l'annoncer immédiatement lorsqu'il apparaît (uniquement lorsque l'entrée est sélectionnée).
    • Si vous avez besoin d'une annonce immédiate lorsque l'erreur s'affiche (par exemple, en cas de flou), envisagez d'ajouter role="alert" ou aria-live="polite" au conteneur du message d'erreur, mais effectuez des tests approfondis pour éviter une "double annonce" lorsque l'utilisateur sélectionne le champ pour corriger l'erreur.
  2. Internationalisation :

    • Assurez-vous que le contenu textuel de votre message d'erreur (#email-error) est traduit. La logique reste la même.