Composant · Menus et navigation

Barre d'outils accessible : role toolbar et roving tabindex

Implémenter role=toolbar avec un vrai roving tabindex : un seul arrêt de tabulation, navigation aux flèches, nom accessible, aria-pressed et critères RGAA à vérifier.

La barre d'outils (toolbar) est un conteneur qui regroupe des contrôles (boutons, boutons de menu, cases à cocher) et transforme une longue rangée d'arrêts de tabulation en un seul : la navigation interne se fait aux flèches. C'est toute la raison d'être de role="toolbar", et elle repose sur une technique précise, le roving tabindex : exactement un contrôle porte tabindex="0", tous les autres tabindex="-1", et cet attribut suit le dernier contrôle utilisé.

Il n'existe pas d'élément HTML natif pour ce motif. Et sans le bénéfice de l'arrêt de tabulation unique, de simples <button> sans rôle toolbar sont préférables : le rôle sans le comportement est une promesse rompue.

Quand ce motif est le mauvais choix

  • Deux ou trois boutons : rien à économiser. Le rôle toolbar impose à l'utilisateur de connaître le modèle aux flèches pour aucun gain ; des boutons simples suffisent.
  • Une rangée de liens : c'est de la navigation, donc <nav> + <ul> + <a>, jamais une barre d'outils.
  • Un composant qui consomme lui-même les flèches, comme une listbox ou une combobox, placé directement dans la barre : les gestions de flèches entrent en collision. L'envelopper dans un bouton de menu, ou repenser la structure.
  • Une barre riche d'un éditeur avec de nombreux contrôles : c'est le cas nominal du motif toolbar, à condition d'implémenter réellement le roving tabindex.

Critères RGAA applicables

Interaction clavier attendue

ToucheAction
TabEntre dans la barre d'outils, sur le premier contrôle ou sur le dernier contrôle utilisé ; un nouvel appui sort de la barre entière
Maj + TabSort de la barre d'outils
Flèche droite / Flèche gaucheContrôle suivant / précédent (barre horizontale), avec bouclage optionnel
Flèche bas / Flèche hautContrôle suivant / précédent quand la barre est verticale (aria-orientation="vertical")
Début / Fin(Optionnel) premier / dernier contrôle
Entrée / EspaceActivent le contrôle focalisé (comportement natif des boutons)

Rôles et attributs ARIA

Le focus réel se déplace (appel à .focus()), jamais aria-activedescendant. aria-label nomme la barre ; aria-orientation="vertical" si elle est verticale, pour que les flèches attendues soient haut et bas.

<!-- Nommée, car une page peut en contenir plusieurs. Le roving tabindex
     fait de toute la barre un arrêt de tabulation unique. -->
<div role="toolbar" aria-label="Mise en forme du texte" id="barre-mise-en-forme">
  <!-- Exactement un tabindex="0" : le point d'entrée. -->
  <button type="button" tabindex="0" aria-pressed="false">
    <svg aria-hidden="true" focusable="false"><use href="#icone-gras"/></svg>
    <span class="sr-only">Gras</span>
  </button>
  <button type="button" tabindex="-1" aria-pressed="false">
    <svg aria-hidden="true" focusable="false"><use href="#icone-italique"/></svg>
    <span class="sr-only">Italique</span>
  </button>

  <!-- Séparateur décoratif : role="separator" SANS tabindex, ce n'est pas
       le widget de redimensionnement, juste un trait. -->
  <span role="separator" aria-orientation="vertical"></span>

  <button type="button" tabindex="-1">
    <svg aria-hidden="true" focusable="false"><use href="#icone-lien"/></svg>
    <span class="sr-only">Insérer un lien</span>
  </button>
</div>
const barre = document.getElementById('barre-mise-en-forme');
const controles = [...barre.querySelectorAll('button')];

function focaliser(indice) {
  // tabindex et focus bougent ensemble : l'unique arrêt de tabulation de la
  // barre doit toujours être le dernier contrôle utilisé, pour qu'un retour
  // par Tab retombe au même endroit.
  controles.forEach((controle, i) =>
    controle.setAttribute('tabindex', i === indice ? '0' : '-1'));
  controles[indice].focus();
}

barre.addEventListener('keydown', (evenement) => {
  const indice = controles.indexOf(document.activeElement);
  if (indice === -1) return;

  let suivant = null;
  if (evenement.key === 'ArrowRight') suivant = (indice + 1) % controles.length;
  if (evenement.key === 'ArrowLeft') suivant = (indice - 1 + controles.length) % controles.length;
  if (evenement.key === 'Home') suivant = 0;
  if (evenement.key === 'End') suivant = controles.length - 1;

  // Tab, Entrée et Espace passent sans interception : preventDefault
  // uniquement pour les touches réellement gérées.
  if (suivant === null) return;
  evenement.preventDefault();
  focaliser(suivant);
});

Les boutons à bascule portent aria-pressed, et l'état enfoncé doit rester perceptible autrement que par la seule couleur (critère 3.1) : un liseré, un fond nettement distinct, ou les deux.

/* Le focus réel se déplace ici : :focus-visible fonctionne. */
[role="toolbar"] button:focus-visible {
  outline: 2px solid #0056b3;
  outline-offset: -2px;
}

/* État enfoncé : pas la couleur seule. */
[role="toolbar"] button[aria-pressed="true"] {
  background: #0056b3;
  color: #fff;
  box-shadow: inset 0 0 0 2px #003d80;
}

Défauts fréquents et impact utilisateur

  • role="toolbar" sans roving tabindex : LE bug du motif. Le rôle annonce « barre d'outils » et promet un arrêt de tabulation unique, mais chaque bouton reste un arrêt distinct. La promesse est rompue ; c'est pire que l'absence de rôle.
  • Plusieurs contrôles en tabindex="0" : le modèle roving est rompu, les contrôles redeviennent des arrêts de tabulation séparés.
  • Barre d'outils sans nom accessible : deux barres annoncées « barre d'outils », sans qu'on sache laquelle met en forme et laquelle insère.
  • aria-orientation="vertical" sur une barre visuellement horizontale : l'utilisateur essaie haut et bas pendant que le script écoute gauche et droite ; rien ne bouge.
  • preventDefault appliqué à toutes les touches : Tab (la seule sortie), Entrée et Espace sont neutralisés ; la barre devient un piège au clavier.
  • Réinitialiser le focus au premier contrôle à chaque sortie : l'utilisateur qui revient par Tab perd sa position à chaque aller-retour, et recommence sa navigation aux flèches depuis le début.

Ce que les outils automatiques ne détectent pas

Un scanner automatique, le nôtre compris, repère une barre d'outils sans nom accessible. Mais le défaut central du motif lui échappe entièrement :

  • le roving tabindex manquant : role="toolbar" avec vingt arrêts de tabulation est de l'ARIA parfaitement valide, et parfaitement inutile. Seul un parcours clavier le révèle ;
  • le retour de position : revenir dans la barre doit ramener sur le dernier contrôle utilisé, pas sur le premier ; c'est un comportement, pas un attribut ;
  • la cohérence entre l'orientation annoncée et les touches réellement gérées ;
  • la pertinence même du rôle : trois boutons n'ont pas besoin d'une barre d'outils, et ce jugement reste humain.

Vérifier ce composant

Protocole manuel, trois minutes :

  1. Le test de l'arrêt unique, la raison d'être du motif : entrez dans la barre par Tab, puis appuyez à nouveau sur Tab. Vous devez vous retrouver après la barre entière. Si vous atterrissez sur le deuxième bouton, le roving tabindex manque et le rôle ment.
  2. Le test du retour : allez au troisième contrôle aux flèches, sortez par Tab, revenez par Maj + Tab. Vous devez retomber sur le troisième contrôle, pas sur le premier.
  3. Au lecteur d'écran : attendez-vous à « Mise en forme du texte, barre d'outils » puis « Gras, bouton à bascule, non enfoncé ». Si aucun nom de barre n'est annoncé, ajoutez aria-label.

Deux outils gratuits du site pour appuyer la vérification :

  • le testeur de focus visible valide vos styles :focus : indispensable ici, puisque le focus réel se déplace à chaque flèche ;
  • le simulateur de lecteur d'écran affiche l'arbre d'accessibilité : le rôle toolbar, son nom et les états aria-pressed de chaque bouton s'y lisent directement.

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