Interazione con il menu Seleziona

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

Il problema

Per i menu a discesa obbligatori (ad es. "Scegli un paese"), la convalida standard contrassegna immediatamente il campo come non valido se l'opzione predefinita ha un valore vuoto. Questo può creare rumore visivo. Vogliamo mostrare l'errore solo se l'utente apre il menu e lo chiude senza scegliere un'opzione o tenta di inviare il modulo.

La soluzione

La pseudoclasse :user-invalid funziona perfettamente con gli elementi <select>. Rispetta il flusso di interazione dell'utente: il semplice caricamento della pagina o l'attivazione/disattivazione dello stato attivo senza apportare modifiche non viene conteggiato come interazione, pertanto il campo rimane neutro finché l'utente non tenta attivamente di effettuare una selezione.

Strategia di implementazione

  1. Vincolo HTML: utilizza un <select> con required. La prima opzione deve avere value="" e idealmente essere disattivata/nascosta per forzare una scelta valida.
  2. Feedback visivo: utilizza :user-invalid per applicare uno stile al bordo della casella di selezione.
  3. Tempistica: il browser considera il campo "interagito" se l'utente modifica il valore (anche tornando allo stato non valido predefinito) prima di sfocare il controllo o al momento dell'invio del modulo.

Guida all'implementazione

1. Struttura HTML

L'opzione "placeholder" è fondamentale in questo caso.

<form>
  <div class="field">
    <label for="country">Country</label>
    <select
      id="country"
      name="country"
      required
      aria-errormessage="country-error"
    >
      <option value="" disabled selected>Select a country...</option>
      <option value="us">United States</option>
      <option value="ca">Canada</option>
      <option value="uk">United Kingdom</option>
    </select>
    <div id="country-error" class="error-msg">
      Please select a country.
    </div>
  </div>
</form>

2. CSS

.error-msg {
  display: none;
  color: #d93025;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/*
  Only show error after the user visits the select menu.
*/
select:user-invalid {
  border-color: #d93025;
  background-color: #fce8e6;
}

select:user-invalid + .error-msg {
  display: block;
}

select:user-valid {
  border-color: #188038;
}

Fallback e supporto del browser

Fallback e supporto del browser per :user-valid e :user-invalid

CSS per il fallback

input:user-invalid,
input.user-invalid-fallback {
  border-color: #d93025;
  background-color: #fce8e6;
}

input:user-invalid + .error-msg,
input.user-invalid-fallback + .error-msg {
  display: block;
}

Fallback JavaScript

Utilizza un'utilità riutilizzabile che monitora lo stato dell'interazione utilizzando un WeakMap. In questo modo, eviti di inquinare il DOM con classi o attributi di dati "sporchi".

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') {
      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.checkValidity) 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 = (root = document) => {
    if (CSS.supports('selector(:user-invalid)')) return;

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

  return { init };
})();

// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);

Altre considerazioni

  1. Comportamento sui dispositivi mobili: sui dispositivi mobili, la "sfocatura" potrebbe avvenire in modo diverso a seconda del selettore del sistema operativo. Ti consigliamo di eseguire i test su dispositivi reali.
  2. Accessibilità: l'elemento :user-invalid nativo non si sincronizza automaticamente con gli attributi ARIA. Aggiungi il seguente codice JavaScript per mantenere aria-invalid sincronizzato con lo stato visivo:
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
  el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};

// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
  if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});