Composant · Formulaires et saisie

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

Interaction clavier attendue

ToucheAction
Flèche bas / Flèche hautOption 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
EspaceSélection multiple : bascule la sélection de l'option active
Maj + Flèche bas / Maj + Flèche hautSélection multiple : déplace le focus et bascule la sélection
Ctrl + ASélection multiple : bascule la sélection de toutes les options
TabQuitte 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, -1 sur 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 son id. 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-posinset maintenus à 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-activedescendant sans scrollIntoView : 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.activeElement pendant 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-posinset est 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 :

  1. 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.
  2. Le test du modèle de focus : une option étant active, évaluer document.activeElement dans la console. En roving tabindex, le résultat doit être l'option ; avec aria-activedescendant, le conteneur (ou le champ). Tout autre combinaison signale un mélange des deux modèles.
  3. 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.
  4. Au lecteur d'écran : attendre « étiquette, liste » puis « France, sélectionné, 1 sur 3 ». Une position fausse trahit un aria-posinset maintenu à 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

Toutes les fiches composants accessibles