Listbox accessible en HTML : role listbox et aria-selected
Le motif listbox côté web : quand préférer un select natif, role listbox, aria-selected, roving tabindex et critères RGAA d'une liste d'options accessible.
Il ne s'agit pas ici du contrôle ListBox de Windows Forms, de WPF ou de Tkinter : cette fiche traite du motif listbox du web, défini par WAI-ARIA (role="listbox"), c'est-à-dire une liste d'options sélectionnables (à sélection simple ou multiple) dans une page HTML, et de sa conformité au RGAA.
Et la première chose à savoir sur ce motif est qu'il est le plus souvent le mauvais choix. Un <select> natif (ou <select multiple>) est complet au clavier et au lecteur d'écran, offre gratuitement la recherche par frappe (type-ahead) et déclenche le sélecteur natif sur mobile, ce qu'aucun listbox ARIA ne peut égaler. Le listbox ARIA se réserve aux options à contenu riche (avatars, options sur deux lignes) ou à une sélection multiple que <select multiple> ne peut pas exprimer.
Quand ce motif est le mauvais choix
- Choisir une ou plusieurs valeurs simples :
<select>ou<select multiple>, sans ARIA. « Le design ne colle pas » se résout presque toujours en CSS, et un<select>reste la seule version qui obtient le sélecteur natif sur mobile. - La liste est attachée à un champ de saisie qui la filtre : c'est une combobox, dont le câblage diffère (le focus reste dans le champ, la liste se pilote par
aria-activedescendant). - Les options contiennent des actions (bouton « supprimer » dans l'option) :
role="option"ne peut contenir que du texte ; ce besoin relève d'une grille (grid), pas d'un listbox. - Une liste de liens de navigation n'est jamais un listbox :
<nav>+<ul>+<a>.
Le point de départ à toujours essayer d'abord, sans une ligne d'ARIA :
<label for="livraison">Mode de livraison</label>
<select id="livraison" name="livraison">
<option value="standard">Standard : 3 à 5 jours</option>
<option value="express">Express : le lendemain</option>
</select>
<!-- Sélection multiple native. Son ergonomie discutable est la seule
raison honnête de construire la version ARIA, pas le style. -->
<label for="etiquettes">Étiquettes</label>
<select id="etiquettes" name="etiquettes" multiple size="5">
<option value="a11y">Accessibilité</option>
<option value="css">CSS</option>
</select>
Critères RGAA applicables
- Critère 7.1 : scripts compatibles avec les technologies d'assistance. Les rôles
listboxetoptionet l'étataria-selecteddoivent être exposés ; des<div>sans rôles ne portent aucune sémantique de sélection. - Critère 7.3 : scripts contrôlables au clavier et au pointeur. Les flèches doivent parcourir les options, et le clic doit permettre la même sélection.
- Critère 10.7 : prise de focus visible. Avec le modèle roving tabindex, le focus réel se déplace et
:focus-visiblefonctionne ; avecaria-activedescendant, l'option active n'a pas le focus réel et doit être signalée visuellement par un style dédié. - Critère 11.1 : étiquette des champs de formulaire. Un élément portant
role="listbox"est un champ de formulaire au sens du RGAA : il lui faut un nom accessible, idéalementaria-labelledbypointant une étiquette visible. - Critère 12.8 : ordre de tabulation cohérent. Le listbox doit constituer un seul arrêt de tabulation, via roving tabindex ou
aria-activedescendant. - Critère 12.9 : absence de piège au clavier. Tab doit toujours quitter le listbox ; les flèches naviguent à l'intérieur.
Interaction clavier attendue
| Touche | Action |
|---|---|
| Flèche bas / Flèche haut | Option suivante / précédente |
| Début (Home) / Fin (End) | Première / dernière option (recommandé) |
| Frappe de caractères (type-ahead) | Saute à la prochaine option commençant par les caractères tapés |
| Espace | Sélection multiple : bascule la sélection de l'option active |
| Maj + Flèche bas / Maj + Flèche haut | Sélection multiple : déplace le focus et bascule la sélection |
| Ctrl + A | Sélection multiple : bascule la sélection de toutes les options |
| Tab | Quitte le listbox (jamais intercepté) |
La recherche par frappe mérite une insistance : elle est gratuite avec <select>, elle est due dans un listbox ARIA, car les utilisateurs l'attendent.
Rôles et attributs ARIA
Un listbox accessible choisit un seul modèle de focus et s'y tient :
- Roving tabindex : le focus réel se déplace d'option en option ;
tabindex="0"sur l'option sélectionnée,-1sur les autres. Le modèle le plus simple, à utiliser pour un listbox autonome. aria-activedescendant: le focus reste sur le conteneur, qui désigne l'option active par sonid. Nécessaire uniquement quand le focus doit rester dans un champ texte, c'est-à-dire dans une combobox.
Mélanger les deux est le bug caractéristique du motif : le lecteur d'écran reçoit deux réponses contradictoires sur la position de l'utilisateur. S'ajoutent aria-multiselectable="true" sur le conteneur pour la sélection multiple, et aria-setsize / aria-posinset uniquement pour les listes virtualisées dont toutes les options ne sont pas dans le DOM.
<!-- Roving tabindex : le conteneur n'est pas focusable, l'option
sélectionnée porte tabindex="0", les autres -1.
Un seul arrêt de tabulation pour tout le composant. -->
<span id="pays-label">Pays</span>
<ul role="listbox" aria-labelledby="pays-label" tabindex="-1">
<li id="opt-fr" role="option" aria-selected="true" tabindex="0">France</li>
<li id="opt-be" role="option" aria-selected="false" tabindex="-1">Belgique</li>
<li id="opt-lu" role="option" aria-selected="false" tabindex="-1">Luxembourg</li>
</ul>
// aria-selected et tabindex se déplacent TOUJOURS ensemble,
// et le focus réel suit : le navigateur fait défiler l'option
// visible sans code supplémentaire.
function selectionner(nouvelleOption) {
for (const option of options) {
const estSelectionnee = option === nouvelleOption;
option.setAttribute('aria-selected', String(estSelectionnee));
option.setAttribute('tabindex', estSelectionnee ? '0' : '-1');
}
nouvelleOption.focus();
}
La recherche par frappe, due dans un listbox ARIA, tient en quelques lignes :
// Type-ahead : sauter à la prochaine option commençant par les
// caractères tapés. Gratuit avec <select>, dû dans un listbox ARIA.
let tampon = '';
let minuterie;
listbox.addEventListener('keydown', (event) => {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey) return;
clearTimeout(minuterie);
tampon += event.key.toLowerCase();
minuterie = setTimeout(() => { tampon = ''; }, 500);
const cible = options.find(
(o) => o.textContent.toLowerCase().startsWith(tampon)
);
if (cible) selectionner(cible);
});
À l'inverse, avec aria-activedescendant, il n'y a pas de focus réel sur l'option : :focus-visible ne s'y déclenche jamais (styler l'option active soi-même) et le navigateur ne la fait pas défiler (appeler scrollIntoView soi-même).
Défauts fréquents et impact utilisateur
- Mélanger roving tabindex et
aria-activedescendant: deux réponses contradictoires sur la position de l'utilisateur ; la restitution devient imprévisible (critère 7.1). tabindex="0"sur toutes les options : chaque option devient un arrêt de tabulation ; traverser une liste de cinquante pays exige cinquante appuis sur Tab (critère 12.8).- Listbox sans nom accessible : annoncé « liste » sans que l'utilisateur sache ce qu'elle sélectionne (critère 11.1).
- Bouton ou lien à l'intérieur d'une option : les flèches se déplacent d'option en option, jamais à l'intérieur ; l'action est inatteignable au clavier.
aria-setsize/aria-posinsetmaintenus à la main sur une liste complète : ils se périment au premier filtrage et annoncent alors « 3 sur 12 » dans une liste de 3.aria-activedescendantsansscrollIntoView: sans focus réel, le navigateur ne fait pas défiler la liste ; l'option active reste hors de vue.
Ce que les outils automatiques ne détectent pas
- Le mélange des deux modèles de focus : il se débusque en inspectant
document.activeElementpendant qu'une option est active (voir le protocole ci-dessous). - L'absence de recherche par frappe et le non-défilement de l'option active : des vérifications manuelles au clavier.
- La question préalable « ce composant aurait-il dû être un
<select>? » : elle relève du jugement, pas d'un outil. - La cohérence de l'annonce de position (« France, 1 sur 3 ») : elle se vérifie au lecteur d'écran, notamment quand
aria-posinsetest posé à la main.
Les outils automatiques, dont notre scanner, repèrent en revanche un role="option" hors de tout listbox ou un listbox sans nom accessible : un premier filtre, à compléter par la passe manuelle.
Vérifier ce composant
Protocole manuel, dans l'ordre où les défauts sont les plus probables :
- La question préalable : si la seule raison de ne pas utiliser
<select>est le style, reconsidérer. On renonce au sélecteur mobile natif et au type-ahead gratuit. - Le test du modèle de focus : une option étant active, évaluer
document.activeElementdans la console. En roving tabindex, le résultat doit être l'option ; avecaria-activedescendant, le conteneur (ou le champ). Tout autre combinaison signale un mélange des deux modèles. - Au clavier seul : un seul Tab pour entrer (sur l'option sélectionnée), flèches pour naviguer, frappe d'une lettre pour sauter à une option, Tab pour sortir. Aucune de ces étapes ne doit échouer.
- Au lecteur d'écran : attendre « étiquette, liste » puis « France, sélectionné, 1 sur 3 ». Une position fausse trahit un
aria-posinsetmaintenu à la main.
Deux outils gratuits du site aident sur ce composant : le Simulateur Lecteur d'Écran pour visualiser l'arbre d'accessibilité (rôles listbox et option, nom accessible, état aria-selected), et le Testeur Focus Visible pour contrôler la visibilité du focus sur l'option active (critère 10.7).
Approfondir avec les fiches critères
Vérifier ce composant sur votre site ?
Le scan repère les défauts détectables automatiquement (rôles incohérents, champs sans étiquette, attributs ARIA orphelins) ; le reste se vérifie à la main avec les protocoles de cette fiche.
Lancer un scan gratuit