Chapitre 10.4 — Combobox & listbox
⏱️ TL;DR — La listbox (une liste d’options qu’on choisit) et surtout la combobox (un champ de saisie qui ouvre une liste de suggestions : l’autocomplétion) sont les patterns les plus complexes de l’APG. Le champ porte
role="combobox",aria-expanded,aria-controlsvers la liste, etaria-activedescendantpointant l’option surlignée — car ici le focus reste dans l’input pendant qu’on parcourt les suggestions aux flèches. Rôles, états, annonces, sélection : beaucoup de pièces mobiles, un cauchemar à faire fonctionner sur tous les lecteurs d’écran. C’est le cas d’école où une lib éprouvée est presque toujours le bon choix.
🎯 Objectifs
- Distinguer une listbox d’une combobox et connaître leurs rôles.
- Comprendre pourquoi la combobox utilise
aria-activedescendant(et pas un roving tabindex). - Câbler
aria-expanded,aria-controls,aria-activedescendantetaria-selected. - Savoir pourquoi c’est si dur — et quand déléguer à une bibliothèque.
Listbox : choisir dans une liste
Une listbox est une liste d’options sélectionnables, plus riche visuellement qu’un <select> (icônes, mise en forme). Deux rôles : role="listbox" sur le conteneur, role="option" sur chaque entrée, avec aria-selected sur l’option choisie. La navigation se fait aux flèches.
<label id="lbl-tri">Trier par</label>
<ul role="listbox" aria-labelledby="lbl-tri" tabindex="0">
<li role="option" aria-selected="true">Popularité</li>
<li role="option" aria-selected="false">Nouveautés</li>
<li role="option" aria-selected="false">Prix croissant</li>
</ul>Avant de coder ça, une question : un <select> natif ne suffirait-il pas ? Dans 90 % des cas, si. Le natif t’offre le clavier, le focus, l’annonce et l’ouverture système gratuitement. Ne pars sur une listbox custom que si le besoin visuel l’impose vraiment.
Combobox : le pattern le plus dur
La combobox, c’est un champ de saisie couplé à une liste : l’utilisateur tape, une liste de suggestions apparaît, il la parcourt et en choisit une. C’est l’autocomplétion — et c’est le pattern qui concentre le plus de pièces mobiles de tout ce cours.
Le champ porte plusieurs attributs, tous nécessaires :
| Attribut | Rôle |
|---|---|
role="combobox" | déclare le champ comme combobox |
aria-expanded | la liste de suggestions est-elle ouverte ? |
aria-controls | pointe l’id de la liste (listbox) |
aria-activedescendant | pointe l’id de l’option surlignée |
aria-autocomplete | type d’autocomplétion (list, both…) |
Le point qui déroute : le focus ne quitte jamais l’input. Quand l’utilisateur appuie sur Flèche bas, on ne déplace pas le focus vers la liste — il doit rester dans le champ pour continuer à taper. On utilise donc le modèle aria-activedescendant (vu en 10.1) : un attribut sur l’input pointe l’id de l’option « active », et c’est elle que le lecteur d’écran annonce, sans que le focus réel bouge.
<label id="lbl-ville">Ville</label>
<input role="combobox" aria-expanded="true" aria-controls="liste-villes"
aria-activedescendant="ville-2" aria-autocomplete="list"
aria-labelledby="lbl-ville" type="text">
<ul role="listbox" id="liste-villes">
<li role="option" id="ville-1">Paris</li>
<li role="option" id="ville-2" aria-selected="true">Lyon</li>
<li role="option" id="ville-3">Marseille</li>
</ul>// Esquisse simplifiée : le focus reste sur l'input, on déplace l'index actif
function Combobox({ suggestions }) {
const [ouvert, setOuvert] = useState(false);
const [actif, setActif] = useState(-1);
const onKey = (e) => {
if (e.key === 'ArrowDown') setActif((actif + 1) % suggestions.length);
if (e.key === 'ArrowUp') setActif((actif - 1 + suggestions.length) % suggestions.length);
if (e.key === 'Escape') setOuvert(false);
if (e.key === 'Enter' && actif >= 0) choisir(suggestions[actif]);
};
return (
<>
<input
role="combobox"
aria-expanded={ouvert}
aria-controls="lb"
aria-activedescendant={actif >= 0 ? `opt-${actif}` : undefined}
aria-autocomplete="list"
onKeyDown={onKey}
/>
<ul role="listbox" id="lb" hidden={!ouvert}>
{suggestions.map((s, i) => (
<li key={i} role="option" id={`opt-${i}`} aria-selected={i === actif}>
{s}
</li>
))}
</ul>
</>
);
}Clavier attendu : Flèche bas/haut déplacent l’option active (via aria-activedescendant), Entrée valide la sélection, Échap ferme la liste (une première fois) puis efface le champ (une seconde fois), et taper filtre les suggestions.
♿ Côté utilisateur — Avec
aria-activedescendantbien câblé, une personne au lecteur d’écran tape « Ly », entend « Lyon, 2 sur 3 » quand elle appuie surFlèche bas, valide avecEntrée— sans jamais perdre le curseur du champ. Si tu déplaçais le focus réel vers la liste, elle ne pourrait plus taper : chaque frappe la ramènerait ou la sortirait. C’est précisément pour ça que la combobox existe comme pattern à part.
Pourquoi c’est si dur
La combobox cumule tout ce qui est difficile : deux zones (champ + liste) à synchroniser, un modèle de focus contre-intuitif (aria-activedescendant), des annonces à gérer (« 5 résultats », « aucun résultat »), la sélection, le filtrage, la fermeture… et surtout, des comportements qui varient d’un lecteur d’écran à l’autre. NVDA, JAWS et VoiceOver n’interprètent pas aria-activedescendant exactement pareil ; ce qui marche sur l’un peut rester muet sur l’autre.
C’est le pattern où « je l’ai testé sur mon poste, ça marche » est le plus trompeur. Une combobox réellement robuste demande des tests sur plusieurs paires navigateur + lecteur d’écran — un travail que les grandes libs ont déjà fait pour toi.
💡 Réflexe — Pour une combobox, la question n’est pas « comment je la code ? » mais « quelle lib éprouvée je prends ? ». Radix UI, React Aria (Adobe) et Headless UI implémentent le pattern APG, testé sur les principaux lecteurs d’écran et maintenu dans le temps. Réécrire une combobox accessible from scratch, c’est réinventer — mal — des mois de travail de spécialistes. On détaille ces libs en Partie 11.
📚 Aller plus loin — L’APG propose plusieurs variantes de combobox (édition libre, liste seule, saisie avec ou sans autocomplétion), chacune avec son clavier précis. C’est la page la plus dense du guide, et c’est normal :
w3.org/WAI/ARIA/apg, section Combobox Pattern. Lis-la avant toute implémentation manuelle — mais commence par te demander si une lib ne t’en dispense pas.
🧭 Sur A11yLearn — La recherche de cours d’A11yLearn (« tape un mot-clé, choisis dans les suggestions ») était une combobox maison : le focus sautait dans la liste à la première flèche, si bien qu’on ne pouvait plus taper, et NVDA n’annonçait jamais l’option surlignée. On a tenté de patcher
aria-activedescendantà la main pendant deux jours avant d’admettre l’évidence : on a remplacé le tout par le composant Combobox de React Aria. Focus maintenu dans l’input, annonces du nombre de résultats, clavier complet — le tout testé sur NVDA, JAWS et VoiceOver. Le custom, ici, n’apportait que des bugs.
✏️ Exercices
Exercice 1 — Le focus qui s’échappe. Un dev signale : « Dans mon autocomplétion, dès que j’appuie sur Flèche bas, je ne peux plus taper. » Quel est le défaut de conception, et quel attribut le corrige ?
✅ Solution
Le dev déplace le focus réel vers la liste au lieu de le garder dans l’input. Résultat : la frappe ne va plus dans le champ. Le bon modèle est aria-activedescendant : le focus reste sur l’input, et un attribut pointe l’id de l’option surlignée, que le lecteur d’écran annonce sans bouger le focus. C’est le cœur du pattern combobox.
Exercice 2 — Listbox ou natif ? On te demande un sélecteur de tri (Popularité / Nouveautés / Prix) sans mise en forme particulière. Que proposes-tu, et pourquoi ?
✅ Solution
Un <select> natif. Sans besoin visuel spécifique, le natif offre gratuitement le clavier, le focus, l’annonce et l’ouverture système, testés partout. Une role="listbox" custom n’apporterait ici que du code et des risques de bugs clavier. On ne code une listbox custom que si le design l’impose vraiment (icônes, contenu riche).
🧠 Quiz de révision
1. Quelle est la différence entre listbox et combobox ?
Une listbox est une liste d’options sélectionnables (role="listbox" + role="option"). Une combobox couple un champ de saisie (role="combobox") à une liste : l’utilisateur tape et une liste de suggestions s’ouvre (autocomplétion). La combobox est nettement plus complexe car elle synchronise deux zones.
2. Pourquoi une combobox utilise-t-elle aria-activedescendant plutôt qu’un roving tabindex ?
aria-activedescendant plutôt qu’un roving tabindex ?Parce que le focus réel doit rester dans l’input pour que l’utilisateur puisse continuer à taper. On ne peut donc pas déplacer le focus vers la liste (roving tabindex). À la place, aria-activedescendant pointe l’id de l’option surlignée, que le lecteur d’écran annonce sans bouger le focus.
3. Quels attributs porte le champ d’une combobox ?
role="combobox", aria-expanded (liste ouverte ou non), aria-controls (pointant la liste), aria-activedescendant (l’option active) et aria-autocomplete. Les options portent role="option" et aria-selected. Beaucoup de pièces à synchroniser.
4. Pourquoi la combobox est-elle si difficile à réussir ?
Elle cumule deux zones à synchroniser, un modèle de focus contre-intuitif, des annonces (nombre de résultats), le filtrage et la sélection — et surtout des comportements variables selon le lecteur d’écran (NVDA, JAWS, VoiceOver interprètent aria-activedescendant différemment). « Ça marche sur mon poste » y est particulièrement trompeur.
5. Quel est le bon réflexe face à un besoin de combobox ?
Prendre une lib éprouvée (Radix, React Aria, Headless UI) plutôt que de coder le pattern à la main. Ces libs implémentent l’APG et sont testées sur les principaux lecteurs d’écran. Réécrire une combobox accessible from scratch revient à refaire, moins bien, des mois de travail de spécialistes.
Chapitre suivant : Tooltips, carrousels & Cie — les patterns restants, et le fil conducteur : plus c’est complexe, plus il faut se demander « en ai-je vraiment besoin ? ».