Accessibilité
Ce que guide fait pour un utilisateur au clavier et pour un lecteur d'écran, et les exceptions assumées.
Le travail d’accessibilité vit dans le cœur, dans a11y.ts, pour qu’un rendu maison dispose des
mêmes primitives que la couche MUI. Cette page décrit ce que le code livré fait. Elle ne décrit pas
une intention.
Le focus au début et à la fin d’une visite
start() note document.activeElement avant tout changement. Le popover se démonte et se remonte à
chaque étape : son propre piège de focus ne peut donc pas être ce qui rend le focus à la fin, et
c’est le provider qui garde l’origine.
Pendant qu’une étape est à l’écran, le focus est dans le popover. Quand la visite atteint idle ou
completed, le focus retourne à l’élément d’origine, si cet élément est encore dans le document.
S’il a disparu entre-temps, rien n’est forcé.
Le piège de focus
useFocusTrap(container, active, options) retient Tab dans le conteneur tant que active est vrai.
Il écoute Tab en phase de capture et boucle du dernier élément focusable au premier, et
réciproquement. L’ensemble focusable est a[href], les button, textarea, input et select
non désactivés, et tout ce qui porte un tabindex autre que -1. Il n’y a pas de filtre de
visibilité : le popover monte et démonte ses contrôles plutôt que de les cacher.
initialFocus décide où le focus atterrit à l’entrée. Par défaut, 'first' prend le premier
élément focusable. StepPopover passe 'container' à la place, pour que le focus se pose sur la
boîte de dialogue elle-même, qui porte tabIndex={-1}. C’est délibéré : avec le focus sur le bouton
de fermeture, un Entrée réflexe après une touche fléchée mettrait fin à la visite.
Au démontage, le piège rend le focus à ce qui l’avait au moment de son installation.
Le clavier sur une étape
Tant qu’une étape est ouverte, StepPopover écoute au niveau du document :
| Touche | Effet |
|---|---|
Échap |
Arrête la visite |
Flèche droite |
Étape suivante, ou fin sur la dernière |
Flèche gauche |
Étape précédente, ignorée sur la première |
Le gestionnaire se retire quand la cible de l’événement est une zone de saisie : un input, un
textarea, un select, ou tout élément contenteditable. Dans un champ, les flèches déplacent le
curseur, ce qui est bien leur rôle sur une étape interactive.
Pendant qu’une étape attend sa cible, rien n’est dessiné : ni popover, ni voile, ni indicateur de chargement. Un gestionnaire d’Échap reste monté pour exactement cette fenêtre, si bien qu’une visite en cours mais invisible peut toujours être terminée au clavier.
Ce que reçoit un lecteur d’écran
Le popover est un role="dialog", étiqueté par son titre et décrit par son corps. Le bouton de
fermeture porte un nom accessible, pris dans labels.close, dont la valeur par défaut est Close.
Le traduire n’est pas cosmétique.
Pendant l’étape, l’élément mis en avant reçoit lui-même un aria-describedby pointant vers le corps
du popover, et l’attribut est retiré à la fin de l’étape. Une personne qui se déplace jusqu’à
l’élément entend donc ce que l’étape en dit.
Le voile est aria-hidden="true". C’est de la décoration, il n’a rien à lire.
L’annonceur
useAnnouncer() renvoie une fonction qui écrit dans un nœud unique et partagé, créé une seule fois,
portant aria-live="polite" et aria-atomic="true" et placé hors écran. GuideProvider l’appelle
quand une étape est réellement à l’écran, c’est-à-dire quand la visite est en cours et que l’élément
cible a été résolu, avec la position de l’étape sous la forme 1 / 3.
C’est toute l’annonce. Le titre et le corps ne passent pas par la région live : ils sont lus depuis la boîte de dialogue au moment où le focus y entre.
Mouvement réduit
usePrefersReducedMotion() lit (prefers-reduced-motion: reduce) et s’abonne aux changements de la
media query, si bien qu’un réglage modifié en cours de session prend effet. Le voile est la seule
partie animée : le trou passe normalement d’une cible à l’autre en 200 ms, et sous mouvement réduit
il n’a aucune transition et saute.
Pourquoi une étape interactive n’est pas modale
GuideTour passe modal={!active.interactive} au popover. Notez active.interactive et non
active.step.interactive : le noyau dérive lui-même la modalité, parce qu’une étape qui déclare
advanceOn doit elle aussi laisser passer le clic, sans quoi elle attendrait une action qu’elle
rend impossible. Une étape marquée interactive: true, comme une étape qui attend un clic, est
donc rendue sans piège de focus et sans aria-modal, et le voile cesse de recevoir les événements
de pointeur.
Une boîte de dialogue modale est une prison clavier par construction : Tab tourne à l’intérieur et n’atteint jamais la page. Une étape qui demande de cliquer sur un bouton tout en tenant le clavier à l’écart de ce bouton demande quelque chose d’impossible. Une étape interactive rend donc le clavier. Le prix est réel : le focus n’est plus retenu, et l’étape se perd plus facilement de vue. Réservez-la aux étapes qui demandent une action, ce n’est pas un réglage par défaut.
La ligne de checklist : une case à côté du bouton, pas dedans
Chaque ligne est un ListItem dont la secondaryAction est la case à cocher et dont l’enfant est
un ListItemButton. Les deux sont frères.
Ils ne font pas la même chose, et c’est la raison de cette forme. Le bouton active l’élément : il
lance la visite associée, ou navigue vers le href de l’élément, ou, pour un élément qui n’a ni
l’un ni l’autre, bascule sa coche. La case, elle, ne fait que cocher et décocher. Imbriquer la case
dans le bouton mettrait un contrôle dans un autre, laissant à un utilisateur au clavier un seul
arrêt pour deux actions et aucun moyen d’atteindre la seconde. En frères, les deux sont dans l’ordre
de tabulation et chacun fait une chose.
La case porte son propre nom accessible, qui énonce l’action et non l’état. Par défaut, Mark <titre> as complete, ou Mark <titre> as not complete une fois cochée. Ces deux textes viennent de
labels.markComplete et labels.markNotComplete sur Checklist : une application qui n’est pas en
anglais les remplace au lieu de livrer les valeurs par défaut.
La valeur de la barre de progression est arrondie dans le composant plutôt que laissée à MUI, parce
que MUI 7 et MUI 9 arrondissent aria-valuenow différemment et que les deux sont des pairs
supportés. Le pourcentage annoncé par un lecteur d’écran est donc le même sur l’un comme sur
l’autre.
Le nom accessible du lanceur
Le lanceur est un bouton flottant affichant 2/5, entouré d’un anneau de progression. L’anneau est
aria-hidden="true" : c’est une forme, elle ne dit rien à qui ne la regarde pas.
Le compte passe donc dans le nom accessible du bouton, en toutes lettres :
Get started, 2 of 5 complete
C’est labels.fabLabel sur ChecklistLauncher, dont la valeur par défaut est
(title, done, total) => `${title}, ${done} of ${total} complete`. Elle reçoit le title du
lanceur, avec Checklist en repli quand aucun n’est passé. Le popover qu’il ouvre est un
role="dialog" avec aria-modal="true", étiqueté par le même titre.
Le popover reste ouvert quand un élément est simplement coché, parce que cocher les éléments les uns après les autres est la façon normale d’utiliser la liste. Il ne se ferme que si l’élément activé passe la main à quelque chose qui a besoin de l’écran : une visite, ou une navigation.
Ce que laisse une fermeture
Fermer la checklist depuis l’intérieur du popover démonte le bouton et le popover dans le même
commit. Il ne reste alors aucune ancre à laquelle MUI puisse rendre le focus, et un utilisateur au
clavier se retrouve sur document.body : pas d’anneau de focus, pas d’annonce, et le Tab suivant
repart du haut de la page.
Le lanceur ne reste pas à l’écran pour éviter cela, puisque disparaître est tout l’intérêt d’une
fermeture. Il laisse à la place un élément hors écran à l’endroit qu’il vient de quitter, y met le
focus, et le retire dès que le focus s’en va. Son texte vient de labels.dismissed, dont la valeur
par défaut est <titre> dismissed.
Deux détails sont délibérés. Cet élément n’est pas une région live : c’est le fait d’y mettre le focus qui l’annonce, et une région live polie insérée et focalisée dans le même commit est lue deux fois par plusieurs lecteurs d’écran. Et il n’apparaît que si la fermeture a eu lieu ici, dans cette session, depuis le popover. Une checklist que le stockage rapporte déjà comme fermée ne rend rien du tout : un utilisateur qui revient n’est pas informé de ce qu’il a fait la semaine dernière.
Ce qu’une application non anglophone doit remplacer
Tout ce que les composants de checklist écrivent d’eux-mêmes a une valeur anglaise par défaut, et
chacune de ces valeurs est une entrée de labels : le bouton de fermeture, le texte de progression
à côté du titre, les deux noms de case, le nom accessible du lanceur et le message de fermeture
décrit plus haut. Ces valeurs par défaut sont ce qu’un lecteur d’écran lit quand rien n’est fourni,
et c’est pour cela qu’elles sont écrites en toutes lettres sur cette page. Checklist accepte
Partial<ChecklistLabels> ; ChecklistLauncher accepte Partial<ChecklistLauncherLabels> et les
transmet à la liste de son popover. Les entrées qui portent un compte ou un titre d’élément sont des
fonctions : l’ordre des mots autour de cette valeur vous appartient.
Côté visites, la même porte de sortie existe avec StepPopoverLabels, par la prop labels de
GuideTour.
Pour aller plus loin
- Visites guidées pour
interactiveet les options d’étape. - Checklist pour le lanceur et le comportement des éléments.
- Référence de l’API pour
useFocusTrap,useAnnounceretusePrefersReducedMotion.