RGAA Lab

Composants accessibles

L'autocomplétion

Un champ, une liste de suggestions, et un lien entre les deux qui n'existe que dans les attributs. Le point dur n'est pas d'afficher la liste : c'est de la parcourir sans jamais sortir du champ. Démo, code, critères RGAA.

5 critères RGAA en jeu

Essayez-la

Ce que porte le champ, relevé dans le DOM à chaque frappe :

Tapez co, puis descendez avec la flèche bas. Le relevé du bas lit le DOM à chaque frappe : le focus reste sur le champ de saisie, et c’est aria-activedescendant qui change pour désigner l’option courante. Continuez à taper sans rien valider — la saisie reprend là où elle en était, parce qu’elle ne l’a jamais quittée.

Cette démo existe aussi en page autonome.

L’erreur courante

La liste s’affiche, elle est jolie, et la flèche bas y envoie le focus.

<!-- À ne pas faire -->
<input type="text" oninput="filtrer()" placeholder="Thème du référentiel" />
<ul class="suggestions">
	<li tabindex="0">Couleurs</li>
	<li tabindex="0">Consultation</li>
</ul>
// À ne pas faire : la flèche fait sortir du champ
champ.addEventListener('keydown', (evenement) => {
	if (evenement.key === 'ArrowDown') options[0].focus();
});

C’est le réflexe le plus naturel, et c’est celui qui casse le composant.

Dès que le focus passe sur <li>, le champ n’est plus le champ : le curseur de saisie n’y est plus, taper une lettre de plus ne filtre rien, et la touche

Retour arrière ne corrige rien. Pire, la plupart des implémentations referment la liste au `blur` du champ — le focus part donc vers une liste qui disparaît sous lui, et atterrit nulle part. Sur mobile, le clavier virtuel se referme au passage. Sous NVDA et JAWS, le focus qui passe sur un `
  • ` fait quitter le mode formulaire : les frappes suivantes ne vont plus dans le champ mais pilotent la navigation.

    Trois variantes, plus discrètes :

    • aria-expanded posé une fois à true et jamais remis à false : le champ annonce « développé » alors que la liste est fermée, et l’on cherche une liste qui n’est plus là.
    • Un compteur affiché sans région de statut — « 3 résultats » écrit sous le champ. Qui ne voit pas l’écran continue de taper sans savoir qu’il a abouti.
    • Un <div> entre la liste et ses options : role="listbox" sur le <ul>, role="option" sur des <li> enveloppés dans autre chose. Le lien de propriété est rompu, le rang « 2 sur 5 » n’est plus annoncé.

    Le code, pas à pas

    1. D’abord un champ de formulaire ordinaire

    <label for="theme">Thème du référentiel</label>
    <input id="theme" type="text" />

    Avant tout ARIA : c’est un champ, il lui faut une étiquette liée. Le placeholder ne compte pas — il s’efface dès la première lettre, et le champ se retrouve sans nom exactement au moment où l’on s’en sert.

    2. Le champ déclare qu’il commande une liste

    <input
    	id="theme"
    	type="text"
    	role="combobox"
    	aria-expanded="false"
    	aria-controls="theme-liste"
    	aria-autocomplete="list"
    	autocomplete="off"
    />

    role="combobox" se pose sur le champ, pas sur un conteneur autour : c’est lui qui reçoit la saisie et commande la liste. aria-expanded dit si la liste est ouverte, et doit suivre chaque ouverture et chaque fermeture. aria-controls désigne la liste gouvernée, aria-autocomplete="list" annonce que les propositions arrivent dans une liste sans que le champ se complète tout seul.

    autocomplete="off" écarte l’historique du navigateur, qui recouvrirait la liste. Attention : si le champ porte une donnée concernant l’utilisateur — une adresse, une ville, un pays — le critère 11.13 attend au contraire un autocomplete pourvu d’une valeur pertinente. Sur ces champs-là, il faut choisir la valeur juste (address-level2, country-name…) et vivre avec la superposition, pas la supprimer.

    3. La liste : listbox et option, sans rien entre les deux

    <ul role="listbox" id="theme-liste" aria-label="Suggestions">
    	<li role="option" id="theme-option-0" aria-selected="false">Couleurs</li>
    	<li role="option" id="theme-option-1" aria-selected="false">Consultation</li>
    </ul>

    Les options sont les enfants directs de la liste : un conteneur intercalé rompt le lien de propriété, et le rang cesse d’être annoncé. Chaque option porte un id — c’est ce qui la rend désignable à l’étape suivante.

    Le aria-label de la liste lui donne un nom. Sans lui, on entend « liste » sans savoir de quoi.

    4. aria-activedescendant : désigner sans déplacer le focus

    champ.setAttribute('aria-activedescendant', options[actif].id);
    options.forEach((option, rang) => {
    	option.setAttribute('aria-selected', String(rang === actif));
    });

    C’est l’attribut qui règle tout le problème. Le focus reste sur le champ ; le champ pointe vers l’option courante, et le lecteur d’écran l’annonce comme si elle avait le focus. La saisie continue de fonctionner pendant qu’on parcourt.

    Deux conditions pour que cela marche : la valeur doit être l’id d’un élément réellement présent dans la liste gouvernée, et l’attribut doit être retiré quand plus aucune option n’est désignée. Un aria-activedescendant qui pointe vers un id disparu après filtrage n’annonce plus rien du tout.

    L’état visuel suit la même désignation. Comme il ne s’agit pas d’un survol, distinguez l’option courante autrement que par la seule couleur de fond — la démo lui ajoute un liseré et une graisse.

    5. Le nombre de suggestions, dans une région de statut

    <!-- présente dès le premier rendu, et vide -->
    <div role="status"></div>
    region.textContent =
    	suggestions.length === 0
    		? 'Aucune suggestion.'
    		: `${suggestions.length} suggestions disponibles, parcourez-les avec les flèches.`;

    La liste apparaît sans changement de page : rien n’avertit qui ne la voit pas. Le critère 7.5 demande role="status" pour ce genre de message.

    Deux précautions. La région doit exister dès le premier rendu, vide : créée au moment de l’annonce, elle n’est pas restituée par tous les lecteurs d’écran. Et elle ne porte que le compte — y verser les suggestions elles-mêmes ferait parler la région plusieurs secondes à chaque frappe, en couvrant l’écho de la saisie.

    6. Le clavier, en entier

    Tout se traite sur le champ, puisque c’est lui qui garde le focus. Le preventDefault() sur les flèches n’est pas facultatif : sans lui, la flèche déplace aussi le curseur de saisie au début ou à la fin du texte.

    Échap ferme la liste sans rien choisir — et laisse la saisie intacte. Entrée ne prend la suggestion désignée que s'il y en a une ; sinon elle doit valider le formulaire comme dans n'importe quel champ. Enfin, en sélectionnant à la souris, empêchez le `mousedown` par défaut : sans cela le champ perd le focus avant que le clic n'aboutisse.

    7. <datalist>, quand il suffit

    <label for="theme">Thème du référentiel</label>
    <input id="theme" list="themes" />
    <datalist id="themes">
    	<option value="Couleurs"></option>
    	<option value="Consultation"></option>
    </datalist>

    Le navigateur donne alors la liste, le filtrage, le clavier et le lien avec le champ, sans une ligne de script. C’est la bonne réponse quand la liste est courte et que les suggestions sont de simples valeurs à recopier.

    Ses limites sont réelles : l’apparence de la liste ne se met pas en forme, on ne choisit pas la façon dont le navigateur filtre, on n’y met pas de contenu riche, et la restitution varie d’un couple navigateur/lecteur d’écran à l’autre. Testez sur vos cibles avant de conclure — mais posez la question avant d’écrire les cent lignes du motif complet.

    Au clavier

    ToucheEffet
    Saisie de texteOuvre la liste et la filtre. Le focus reste dans le champ, toujours.
    Ouvre la liste, puis désigne l’option suivante — sans déplacer le focus.
    Désigne l’option précédente, puis revient au champ seul avant de boucler.
    EntréePrend l’option désignée et referme la liste. Sans option désignée, valide le formulaire.
    ÉchapFerme la liste en gardant la saisie. Liste déjà fermée, vide le champ.
    TabSort du champ. Le focus n’entre jamais dans la liste.

    Ce que le lecteur d’écran restitue

    À la prise de focus : le nom du champ, sa nature et l’état de la liste — de l’ordre de « Thème du référentiel, zone d’édition combinée, réduit ». À la première frappe, la région de statut annonce « 3 suggestions disponibles ». Puis, à chaque flèche, l’option désignée et son rang : « Consultation, 2 sur 3 ». La formulation varie d’un lecteur à l’autre, pas les informations.

    Si les flèches ne produisent rien, aria-activedescendant est absent ou pointe vers un id que le filtrage vient de retirer. Si vous entendez « développé » alors que la liste est fermée, aria-expanded n’est pas remis à jour. Si vous entendez l’option sans son rang, un élément s’intercale entre la listbox et ses option. Et si le nombre de suggestions n’est jamais dit, la région de statut manque — ou elle a été créée en même temps que le message.

    Checklist

    • Une étiquette liée au champ, jamais un placeholder en guise d’intitulé.
    • role="combobox" sur le champ, avec aria-expanded tenu à jour à chaque ouverture et fermeture.
    • Les role="option" enfants directs du role="listbox", chacun avec un id.
    • aria-activedescendant désigne l’option courante, et le focus ne bouge pas.
    • Le nombre de suggestions dans une région role="status" présente dès le départ.
    • Flèches avec preventDefault(), Échap qui ferme sans effacer, mousedown empêché au clic.
    • Sur un champ portant une donnée de l’utilisateur, un autocomplete pertinent plutôt que off.
    • Demandez-vous d’abord si <datalist> ne suffirait pas.

    Les critères RGAA en jeu

    • Critère 7.1 — Compatibilité des scripts

      Dès qu'on quitte <datalist> pour une liste construite au script, plus rien n'expose le composant : le rôle du champ, l'état ouvert ou fermé de la liste et l'option courante n'existent qu'à l'écran tant qu'on ne les déclare pas.

    • Critère 7.3 — Contrôle des scripts au clavier

      Les suggestions doivent se parcourir et se choisir au clavier — et sans que le focus quitte le champ, sous peine d'interrompre la saisie au moment où elle devait s'affiner.

    • Critère 7.5 — Messages de statut

      Le nombre de suggestions n'existe qu'à l'écran. Sans région de statut présente d'avance, personne ne sait que trois propositions viennent d'apparaître, ni qu'il n'y en a aucune.

    • Critère 11.1 — Étiquette de champ de formulaire

      Le champ d'une autocomplétion reste un champ de formulaire : le placeholder qui lui sert d'intitulé s'efface à la première frappe, et le champ se retrouve sans nom au moment où l'on s'en sert.

    • Critère 11.13 — Remplissage automatique des champs

      La recette du combobox pose autocomplete=off pour écarter le remplissage du navigateur ; sur un champ qui porte une donnée de l'utilisateur, cela retire précisément ce que le critère demande.

    Vérifiez votre composant avec l'extension

    Pour aller plus loin

    Sources