English

Le laissez-passer A-38 — Partie 3 : Guider jusqu'au bout

Table des matières

Guider vers la sortie

La première partie posait les bonnes questions. La deuxième écoutait les réponses. Ton formulaire sait maintenant demander, valider et corriger.

Mais un formulaire d'une seule page, c'est le cas simple. Dès que tu découpes en étapes, informations personnelles, adresse, paiement, confirmation, de nouveaux problèmes apparaissent. L'utilisateur ne sait pas combien d'étapes restent. Il perd sa saisie en revenant en arrière. Le focus ne suit pas la progression. Le récapitulatif final est inaccessible au clavier.

Cet article couvre tout ce qui fait la différence entre un tunnel de commande fluide et un labyrinthe : l'indicateur de progression, la persistance des données, le récapitulatif avant validation, et la gestion du focus entre les étapes.

L'utilisateur doit savoir où il en est

Un formulaire multi-étapes sans indicateur de progression, c'est un trajet en voiture sans GPS. Tu avances, mais tu ne sais pas combien de kilomètres il reste, ni si tu es sur la bonne route.

L'indicateur de progression doit répondre à trois questions en permanence :

  • Où je suis : quelle étape est en cours
  • Combien il reste : nombre total d'étapes
  • Combien de temps ça prend : estimation réaliste
stepper.html
<nav aria-label="Progression du formulaire">
  <ol>
    <li>
      <a href="#step-1" aria-current="step">
        <span>Étape 1 sur 4</span>
        Informations personnelles
      </a>
    </li>
    <li>
      <a href="#step-2">
        <span>Étape 2 sur 4</span>
        Adresse
      </a>
    </li>
    <li>
      <span>Étape 3 sur 4</span>
      Paiement
    </li>
    <li>
      <span>Étape 4 sur 4</span>
      Confirmation
    </li>
  </ol>
</nav>
 
<p>Temps estimé : 3 minutes</p>

aria-current="step" indique à la technologie d'assistance quelle étape est active. Le lecteur d'écran annonce "Étape 1 sur 4, Informations personnelles, étape courante." L'utilisateur sait exactement où il en est sans voir l'indicateur visuel.

Les étapes complétées et l'étape courante sont des liens, l'utilisateur peut revenir. Les étapes futures ne sont pas des liens : leur indisponibilité est déjà portée par le fait qu'elles ne sont pas activables. Pas besoin d'aria-disabled sur un <li> : cet attribut n'est pas pris en charge sur le rôle listitem, il n'a aucun effet garanti. Au fur et à mesure de la progression, les étapes deviennent des liens.

L'estimation de temps n'est pas un gadget. Pour un utilisateur avec des difficultés motrices qui tape lentement, ou un utilisateur de lecteur d'écran qui navigue champ par champ, savoir que le formulaire prend 3 minutes permet de décider s'il commence maintenant ou s'il revient plus tard, surtout si le formulaire propose une sauvegarde brouillon.

L'utilisateur ne doit rien perdre

Le pire scénario d'un formulaire multi-étapes : l'utilisateur remplit 3 étapes, le navigateur plante, il revient, tout est perdu. Ou il clique "Retour" pour corriger l'étape 1, et l'étape 2 est vidée.

La persistance de la saisie n'est pas un bonus. C'est ce qui rend le formulaire utilisable.

Trois mécanismes, par ordre de robustesse :

Sauvegarde côté client

session-persistence.js
const STORAGE_KEY = 'checkout';
const SENSITIVE_TYPES = new Set(['password']);
 
const isSensitive = (field) =>
  SENSITIVE_TYPES.has(field.type) || (field.autocomplete || '').startsWith('cc-');
 
const isMultiSelect = (field) => field.tagName === 'SELECT' && field.multiple;
 
const isPersistable = (field) =>
  Boolean(field.name) && !isSensitive(field) && field.type !== 'file';
 
// Sauvegarder : conserver les champs à valeurs multiples, écarter le sensible
form.addEventListener('input', () => {
  const entries = {};
  for (const field of form.elements) {
    if (!isPersistable(field)) continue;
    if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) continue;
    const value = isMultiSelect(field)
      ? [...field.selectedOptions].map((option) => option.value)
      : field.value;
    entries[field.name] = field.name in entries
      ? [].concat(entries[field.name], value)
      : value;
  }
  sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
});
 
// Restaurer : traiter chaque type de champ selon sa nature
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
  const entries = JSON.parse(saved);
  for (const field of form.elements) {
    if (!isPersistable(field)) continue;
    const value = entries[field.name];
    if (field.type === 'checkbox' || field.type === 'radio') {
      field.checked = value !== undefined && [].concat(value).includes(field.value);
    } else if (isMultiSelect(field) && Array.isArray(value)) {
      for (const option of field.options) option.selected = value.includes(option.value);
    } else if (typeof value === 'string') {
      field.value = value;
    }
  }
}

L'ancienne version avait trois bugs : field.value = value ne restaure ni les cases à cocher ni les boutons radio (il faut checked), Object.fromEntries(data.entries()) écrase les champs à valeurs multiples (plusieurs cases cochées portant le même name se réduisent à une seule), et la sauvegarde capturait aussi les mots de passe et les données bancaires.

sessionStorage persiste le temps de la session navigateur. L'utilisateur qui navigue entre les étapes ou rafraîchit la page ne perd rien. En revanche, ce n'est pas un coffre-fort : n'importe quel script de la page peut le lire, il survit aux rechargements, et certains navigateurs le restaurent après une fermeture. On n'y met donc jamais de mot de passe ni de données bancaires, et on le purge après soumission :

purge-storage.js
form.addEventListener('submit', () => sessionStorage.removeItem(STORAGE_KEY));

Sauvegarde côté serveur

Pour les formulaires longs (candidature, déclaration administrative), la session navigateur ne suffit pas. L'utilisateur doit pouvoir fermer son navigateur et revenir le lendemain. Une sauvegarde automatique côté serveur, ou un bouton "Enregistrer le brouillon", est alors nécessaire.

Retour en arrière sans perte

Quand l'utilisateur clique sur une étape précédente dans le stepper, les données des étapes suivantes doivent être préservées. Le retour en arrière est une correction, pas un reset. Si l'utilisateur modifie une donnée à l'étape 1 qui invalide l'étape 2, un message clair doit l'en informer, pas un vidage silencieux.

L'utilisateur doit tout voir avant de valider

Tu ne signes pas un contrat sans le lire. Un formulaire multi-étapes avec des conséquences (paiement, candidature, suppression de compte) mérite le même traitement : un récapitulatif complet avant la validation finale.

Le récapitulatif porte trois responsabilités, pas une seule :

  1. L'intitulé du lien est explicite. Quatre liens "Modifier" vers quatre cibles, c'est quatre liens indiscernables dans la liste des liens d'un lecteur d'écran. GOV.UK résout ce problème avec du texte masqué visuellement : Change<span class="govuk-visually-hidden"> name</span>. Le lecteur d'écran entend "Change name", l'utilisateur voyant lit "Change". Ici, on va plus loin : l'intitulé complet ("Modifier le nom complet") est visible. La commande vocale (Dragon prononce ce qu'il voit), les troubles cognitifs, la basse vision et le zoom bénéficient tous d'un intitulé explicite sans dépendre d'un texte masqué.
  2. Le lien vise le champ, pas l'étape. Renvoyer vers #step-1 dépose l'utilisateur au début d'une étape, à lui de retrouver le champ. La cible doit être le champ lui-même.
  3. Le retour au récapitulatif est assuré. Une fois la correction faite, rien ne doit forcer l'utilisateur à refaire toutes les étapes. C'est le principe du motif Check your answers de GOV.UK.

Le balisage s'inspire du motif Summary list de GOV.UK, le second <dd> porte l'action :

recap.html
<section id="recap">
  <h2>Vérifiez vos informations</h2>
 
  <dl class="recap">
    <div class="recap__row">
      <dt>Nom complet</dt>
      <dd>Yoann Fortin</dd>
      <dd class="recap__action">
        <a href="#name" data-return-to="recap">Modifier le nom complet</a>
      </dd>
    </div>
    <div class="recap__row">
      <dt>Adresse email</dt>
      <dd>yoann@example.com</dd>
      <dd class="recap__action">
        <a href="#email" data-return-to="recap">Modifier l'adresse email</a>
      </dd>
    </div>
    <div class="recap__row">
      <dt>Adresse de livraison</dt>
      <dd>12 rue des Lilas, 75011 Paris</dd>
      <dd class="recap__action">
        <a href="#address" data-return-to="recap">Modifier l'adresse de livraison</a>
      </dd>
    </div>
    <div class="recap__row">
      <dt>Mode de livraison</dt>
      <dd>Express (24h)</dd>
      <dd class="recap__action">
        <a href="#shipping" data-return-to="recap">Modifier le mode de livraison</a>
      </dd>
    </div>
  </dl>
 
  <button type="button">Confirmer la commande</button>
</section>

Note de sémantique : le second <dd> est une action, pas une description du terme. C'est le compromis retenu par GOV.UK. Si l'étape est changée en JavaScript sans navigation réelle, ce n'est plus un lien mais un <button>, et le data-return-to reste identique.

Le lien ouvre l'étape voulue, pose le focus sur le champ à corriger, et mémorise d'où l'on vient. À la validation de l'étape, on revient au récapitulatif, focus sur son titre :

recap-navigation.js
const RECAP_ID = 'recap';
 
// Aller au champ à corriger, en mémorisant le point de retour
document.querySelectorAll('[data-return-to]').forEach((trigger) => {
  trigger.addEventListener('click', (event) => {
    event.preventDefault();
    const field = document.querySelector(trigger.getAttribute('href'));
    sessionStorage.setItem('returnTo', trigger.dataset.returnTo);
    goToStep(stepOf(field));
    field.focus();
  });
});
 
// Après validation d'une étape, revenir au récapitulatif s'il nous a envoyés
function afterStepValidated() {
  const returnTo = sessionStorage.getItem('returnTo');
  if (returnTo !== RECAP_ID) return;
  sessionStorage.removeItem('returnTo');
  goToStep(recapStep);
  const heading = document.getElementById(RECAP_ID).querySelector('h2');
  heading.setAttribute('tabindex', '-1');
  heading.focus();
}

Deux détails qui comptent. Le focus va sur le champ, pas sur le titre de l'étape : l'utilisateur est déjà arrivé, il sait pourquoi. Et au retour, le focus va sur le titre du récapitulatif, pas sur la ligne modifiée, pour qu'il entende "Vérifiez vos informations" et reprenne sa lecture d'ensemble.

Pour un utilisateur de lecteur d'écran qui a rempli 4 étapes champ par champ, le récapitulatif est la seule occasion de vérifier l'ensemble de sa saisie d'un seul tenant. Sans récapitulatif, il doit revenir étape par étape et naviguer chaque champ pour retrouver ce qu'il a saisi.

Pour les formulaires avec des conséquences financières ou légales, le récapitulatif n'est pas juste une bonne pratique, c'est une exigence du RGAA (critère 11.12).

Le focus doit suivre

Dans un formulaire classique, le focus reste sur la page. Dans un multi-étapes, chaque transition entre étapes est un changement de contexte. Du contenu disparaît, du nouveau contenu apparaît. Si le focus ne suit pas, l'utilisateur clavier ne sait pas que quelque chose a changé.

step-transition.js
function goToStep(stepNumber) {
  // Masquer l'étape courante, afficher la suivante
  currentStep.hidden = true;
  nextStep.hidden = false;
 
  // Mettre à jour la navigation du stepper
  updateStepper(stepNumber);
 
  // Déplacer le focus sur le titre de la nouvelle étape
  const heading = nextStep.querySelector('h2');
  heading.setAttribute('tabindex', '-1');
  heading.focus();
}

À chaque transition, le focus est envoyé sur le titre de la nouvelle étape. Le lecteur d'écran annonce "Étape 2, Adresse" et l'utilisateur sait qu'il a avancé. Sans ce déplacement de focus, le lecteur d'écran ne dit rien. L'utilisateur reste dans le vide.

Le même principe s'applique au retour en arrière : quand l'utilisateur clique sur une étape précédente dans le stepper, le focus doit atterrir sur le titre de cette étape, pas rester sur le lien du stepper.

Et si le formulaire est une SPA qui change de route entre les étapes, le même problème se pose que pour tout changement de route. Le focus doit être géré manuellement (voir l'article sur la navigation clavier).

Les critères d'ensemble

Le RGAA encadre les formulaires à conséquences et la navigation multi-étapes :

  • Critère 6.1 — chaque lien est-il explicite ? ("Modifier" répété quatre fois vers quatre cibles n'est pas explicite hors contexte)
  • Critère 7.1 — chaque script est compatible avec les technologies d'assistance (aria-current="step", gestion du focus)
  • Critère 7.3 — chaque script est contrôlable au clavier (navigation entre les étapes du stepper)
  • Critère 11.3 — chaque étiquette associée à un champ ayant la même fonction est-elle cohérente d'une page à l'autre ? ("Email" ici et "Adresse mail" là, c'est un piège cognitif)
  • Critère 11.12 — pour chaque formulaire dont la validation a des conséquences financières, juridiques ou sur des données personnelles, l'utilisateur peut-il modifier ou annuler les données et les actions effectuées ?

Les trois parties de cette série couvrent ensemble l'intégralité du thème 11 (Formulaires) du RGAA, de l'étiquetage des champs (partie 1) à la validation et les erreurs (partie 2), jusqu'aux formulaires multi-étapes et la confirmation (partie 3).

Sources