Commentaires sur les champs obligatoires

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

Le problème

Marquer les champs obligatoires avec un état d'erreur dès le chargement de la page peut être déroutant. Idéalement, un champ obligatoire ne doit être considéré comme "non valide" que si l'utilisateur a tenté de le remplir et a échoué.

La solution

La pseudo-classe :user-invalid résout parfaitement ce problème. Pour un champ obligatoire, il ne correspondra pas au chargement de page. La correspondance n'est établie que si :

  1. L'utilisateur interagit avec le champ (par exemple, il saisit un caractère et le supprime), puis le quitte (flou), le laissant vide.
  2. L'utilisateur tente d'envoyer le formulaire alors que le champ est vide.

Stratégie d'implémentation

  1. Contrainte HTML : ajoutez l'attribut required à vos entrées.
  2. Commentaires visuels : utilisez :user-invalid pour appliquer un style de bordure rouge et afficher un texte d'aide "Obligatoire".
  3. Timing : fiez-vous au timing natif du navigateur pour le feedback visuel. Vous n'avez plus besoin de gestionnaires onBlur pour ajouter une classe touched, même si un peu de JavaScript est toujours nécessaire pour synchroniser les attributs ARIA (voir ci-dessous).

Guide d'implémentation

1. Structure HTML

<form id="feedback-form">
  <div class="field">
    <label for="full-name">Full Name</label>
    <input
      type="text"
      id="full-name"
      name="full-name"
      required
      aria-errormessage="name-error"
    >
    <!-- MANDATORY: Include an icon or distinct non-color indicator alongside error text -->
    <div id="name-error" class="error-msg">
      <span aria-hidden="true">❌</span> This field is required.
    </div>
  </div>
</form>

2. CSS

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

/*
  Only highlight empty required fields AFTER the user visits them.
  MANDATORY: Provide multiple indicators (border shift + helper text/icon) to avoid color-only state communication.
*/
input:user-invalid {
  border-color: #d93025;
  background-color: #fce8e6;
}

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

/* Optional: Subtle indicator for required fields that are valid */
input:required:user-valid {
  border-color: #188038;
  border-width: 2px;
}

3. Synchronisation de l'état JavaScript

OBLIGATOIRE : Étant donné que :user-invalid est un état visuel, vous DEVEZ fournir un pont JavaScript pour synchroniser aria-invalid="true" de manière dynamique pour les technologies d'assistance lorsqu'un utilisateur quitte un champ non valide ou tente d'envoyer un formulaire.

const form = document.getElementById('feedback-form');

const syncAriaInvalid = (input) => {
  if (!input.checkValidity()) {
    input.setAttribute('aria-invalid', 'true');
  } else {
    input.removeAttribute('aria-invalid');
  }
};

// Sync on blur when a user finishes interacting
form.addEventListener('blur', (e) => {
  if (e.target.matches('input[required]')) {
    syncAriaInvalid(e.target);
  }
}, true);

// Sync all required fields when submission is attempted
form.addEventListener('submit', () => {
  form.querySelectorAll('input[required]').forEach(syncAriaInvalid);
});

// Remove error state immediately upon correction
form.addEventListener('input', (e) => {
  if (e.target.matches('input[required]') && e.target.checkValidity()) {
    e.target.removeAttribute('aria-invalid');
  }
});

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. Astérisques : il est toujours recommandé d'indiquer visuellement les champs obligatoires (par exemple, avec un astérisque *) dans le libellé, afin que les utilisateurs sachent à quoi s'attendre avant d'interagir.
  2. Boutons d'envoi : contrairement aux boutons disabled, laissez votre bouton d'envoi activé. Si l'utilisateur clique dessus, le navigateur déclenche automatiquement :user-invalid sur tous les champs obligatoires vides et sélectionne le premier. C'est excellent pour l'accessibilité et l'expérience utilisateur.
  3. 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);
});