Interaction avec le menu de sélection

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

Le problème

Pour les menus déroulants obligatoires (par exemple, "Sélectionnez un pays"), la validation standard signale immédiatement le champ comme non valide si l'option par défaut est vide. Cela peut créer du bruit visuel. Nous voulons afficher l'erreur uniquement si l'utilisateur ouvre le menu et le ferme sans choisir d'option, ou s'il tente d'envoyer le formulaire.

La solution

La pseudo-classe :user-invalid fonctionne parfaitement avec les éléments <select>. Il respecte le flux d'interaction de l'utilisateur : le simple chargement de la page ou le fait de sélectionner/désélectionner un champ sans apporter de modification n'est pas considéré comme une interaction. Le champ reste donc neutre jusqu'à ce que l'utilisateur tente activement d'effectuer une sélection.

Stratégie d'implémentation

  1. Contrainte HTML : utilisez un <select> avec required. La première option doit être value="" et, idéalement, être désactivée/masquée pour forcer un choix valide.
  2. Retour visuel : utilisez :user-invalid pour styliser la bordure de la zone de sélection.
  3. Timing : le navigateur considère que l'utilisateur a interagi avec le champ s'il modifie la valeur (même s'il revient à l'état non valide par défaut) avant de quitter le contrôle ou lors de l'envoi du formulaire.

Guide d'implémentation

1. Structure HTML

L'option "placeholder" est essentielle ici.

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

Création de remplacement et compatibilité avec les navigateurs

Solutions de secours et compatibilité des navigateurs pour :user-valid et :user-invalid

CSS pour la création de remplacement

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

JavaScript de remplacement

Utilisez un utilitaire réutilisable qui suit l'état d'interaction à l'aide d'un WeakMap. Cela évite de polluer le DOM avec des classes ou des attributs de données "sales".

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

Autres points à prendre en compte

  1. Comportement sur mobile : sur les appareils mobiles, le "flou" peut se produire différemment selon le sélecteur de l'OS. Il est recommandé d'effectuer des tests sur des appareils réels.
  2. Accessibilité : l'élément :user-invalid natif ne se synchronise pas automatiquement avec les attributs ARIA. Ajoutez le code JavaScript suivant pour que aria-invalid reste synchronisé avec l'état visuel :
// 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);
});