Annuncio di errore accessibile

Funzionalità web: :user-valid e :user-invalid

Il problema

La convalida HTML5 standard fornisce un feedback visivo (tramite :invalid o :user-invalid), ma non si sincronizza automaticamente con gli attributi di accessibilità come aria-invalid.

Se utilizzi lo stile :invalid standard, gli screen reader potrebbero annunciare "Voce non valida" nel momento in cui un utente passa a un campo obbligatorio attualmente vuoto. Ciò crea un'esperienza di disturbo per gli utenti che utilizzano tecnologie per la disabilità, poiché l'errore viene annunciato prima che si verifichi l'interazione.

La soluzione

Vogliamo che lo stato programmatico (aria-invalid="true") venga applicato nello stesso momento in cui viene applicato lo stato visivo (:user-invalid). Poiché :user-invalid si basa sul flag interno "user-interacted" del browser, possiamo utilizzare JavaScript per verificare che questo selettore corrisponda durante gli eventi di interazione standard.

Per maggiori dettagli, consulta la pagina MDN aria-invalid.

Strategia di implementazione

  1. Livello visivo: utilizza il CSS :user-invalid per mostrare bordi/icone.
  2. Livello di accessibilità: utilizza aria-invalid e aria-errormessage per comunicare lo stato alle tecnologie per la disabilità.
  3. Collega il livello visivo e quello di accessibilità: crea un'utilità JavaScript leggera che ascolta gli eventi blur e input, controlla se l'elemento corrisponde a :user-invalid e aggiorna gli attributi ARIA di conseguenza.

Guida all'implementazione

1. Struttura HTML

Collega l'input al messaggio di errore utilizzando aria-errormessage (o aria-describedby per un supporto più ampio).

<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

Controlla la visibilità del messaggio di errore utilizzando la pseudoclasse 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

Poiché non esiste un evento "UserInvalidChanged", esegui l'hook agli eventi del modulo standard per controllare lo stato.

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

Fallback e supporto del browser

Rilevamento delle caratteristiche

Puoi verificare il supporto in CSS e JavaScript.

Controllo JavaScript:

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

CSS per il fallback

Per assicurarti che la logica di riserva sia visivamente indistinguibile dal comportamento nativo, devi applicare gli stili di errore sia alla pseudo-classe sia alla classe di riserva.

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

Logica di riserva

Se :user-invalid non è presente, monitora manualmente lo stato dell'interazione utilizzando 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();

Altre considerazioni

  1. aria-live vs. aria-errormessage:

    • aria-errormessage collega l'input al testo, ma gli screen reader potrebbero non annunciarlo immediatamente quando viene visualizzato (solo quando l'input viene selezionato).
    • Se hai bisogno di un annuncio immediato quando viene visualizzato l'errore (ad esempio, in caso di sfocatura), valuta la possibilità di aggiungere role="alert" o aria-live="polite" al contenitore del messaggio di errore, ma esegui un test approfondito per evitare un "doppio annuncio" quando l'utente attiva il campo per correggerlo.
  2. Internazionalizzazione:

    • Assicurati che i contenuti testuali del messaggio di errore (#email-error) siano tradotti. La logica rimane la stessa.