Composant · Menus et navigation

Menu button accessible : role menu, souvent le mauvais choix

Le motif menu button ARIA sert un menu d'actions, pas la navigation d'un site : quand préférer nav ou un disclosure, clavier attendu, aria-haspopup et critères RGAA.

Avant toute chose : le motif menu button est le plus souvent le mauvais choix. role="menu" modélise un menu d'application de bureau, c'est-à-dire une liste d'actions à déclencher (Modifier, Dupliquer, Supprimer), pas le mot « menu » au sens du web design. Un bouton qui révèle la navigation du site ou un panneau de contenu est un disclosure (aria-expanded sur un bouton), sans aucun ARIA de menu. Le vrai menu button est un bouton qui ouvre un authentique menu d'actions, avec navigation aux flèches et déplacement du focus réel. Pour un menu déroulant de navigation, le pas-à-pas est du côté du blog : Menus déroulants accessibles : Guide technique complet.

Quand ce motif est le mauvais choix

  • Le panneau contient des liens de navigation (Accueil, À propos, Contact) : <nav> + <ul> + <a>, sans ARIA. Des liens ne sont pas des éléments de menu.
  • Un bouton « hamburger » qui révèle la navigation : un disclosure enveloppant un <nav>.
  • Le panneau contient du contenu arbitraire (formulaire, texte, panneau riche) : disclosure encore.
  • Le composant choisit une valeur soumise dans un formulaire : une combobox ou un <select>.
  • Des options de filtre ou de tri qui modifient une vue : disclosure + cases à cocher ou boutons radio, qui sont des contrôles de formulaire.
  • Le panneau contient des actions sur un objet (Modifier, Dupliquer, Supprimer) : menu button, cette fiche.

Le coût de l'erreur est concret : posez role="menu" sur une navigation et le lecteur d'écran annonce « menu, 5 éléments » puis bascule en mode application ; les touches de lecture habituelles cessent de fonctionner ; l'utilisateur est invité à naviguer aux flèches, que des liens n'implémentent pas. C'est strictement pire que le même balisage sans aucun ARIA. En cas de doute, le disclosure est presque toujours la bonne réponse, et bien plus difficile à rater.

Critères RGAA applicables

Interaction clavier attendue

ToucheAction
Entrée / Espace / Flèche bas (sur le bouton)Ouvre le menu et place le focus sur le premier élément
Flèche haut (sur le bouton)Ouvre le menu et place le focus sur le dernier élément
Flèche bas / Flèche haut (dans le menu)Élément suivant / précédent, avec bouclage
Début / FinPremier / dernier élément
ÉchapFerme le menu et rend le focus au bouton
TabFerme le menu et poursuit la tabulation, jamais piégé, jamais renvoyé au bouton

Rôles et attributs ARIA

Contrairement à la combobox, ce motif déplace le focus réel : on appelle .focus() sur l'élément de menu, jamais aria-activedescendant. Les seuls enfants valides de role="menu" sont menuitem, menuitemradio et menuitemcheckbox : un <a href> ou un <button> nu y est invalide et restitué de façon incohérente. Chaque élément porte tabindex="-1" (roving tabindex) : on l'atteint aux flèches, jamais à Tab.

<button type="button" id="btn-actions" aria-haspopup="menu"
        aria-expanded="false" aria-controls="menu-actions">
  Actions
</button>

<!-- Éléments = <button role="menuitem"> : le bouton natif fournit focus et
     activation, le rôle menuitem remplace son rôle par celui que role="menu"
     exige de ses enfants. tabindex="-1" les sort de l'ordre de tabulation. -->
<ul id="menu-actions" role="menu" aria-labelledby="btn-actions" hidden>
  <li role="none">
    <button type="button" role="menuitem" tabindex="-1">Modifier</button>
  </li>
  <li role="none">
    <button type="button" role="menuitem" tabindex="-1">Dupliquer</button>
  </li>
  <li role="none">
    <button type="button" role="menuitem" tabindex="-1">Supprimer</button>
  </li>
</ul>

role="none" sur les <li> retire leur sémantique d'élément de liste, qui s'intercalerait sinon entre le menu et ses éléments. Et aria-expanded vit sur le bouton, jamais sur le menu.

function ouvrirMenu(indice = 0) {
  bouton.setAttribute('aria-expanded', 'true');
  menu.hidden = false;
  elements[indice].focus(); // focus RÉEL, pas aria-activedescendant
}

function fermerMenu({ rendreFocus = true } = {}) {
  bouton.setAttribute('aria-expanded', 'false');
  menu.hidden = true;
  if (rendreFocus) bouton.focus(); // retour au bouton : Échap oui, Tab non
}

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

  if (evenement.key === 'ArrowDown') { evenement.preventDefault(); elements[(indice + 1) % elements.length].focus(); }
  if (evenement.key === 'ArrowUp') { evenement.preventDefault(); elements[(indice - 1 + elements.length) % elements.length].focus(); }
  if (evenement.key === 'Escape') fermerMenu();
  if (evenement.key === 'Tab') fermerMenu({ rendreFocus: false }); // Tab passe (pas de preventDefault)
});

// Fermeture au clic extérieur : pointerdown, pas click, pour ne pas avaler
// le clic sur un élément du menu.
document.addEventListener('pointerdown', (evenement) => {
  if (!menu.hidden && !evenement.target.closest('.bouton-menu')) {
    fermerMenu({ rendreFocus: false });
  }
});

À ne pas faire :

<!-- À ne pas faire : role="menu" sur la navigation du site.
     L'erreur emblématique du motif. Le lecteur d'écran bascule en mode
     application, les touches de lecture cessent de fonctionner et les
     flèches promises ne font rien sur des liens. La navigation d'un site,
     c'est <nav><ul><li><a>, sans rien d'autre. -->
<nav>
  <ul role="menu">
    <li role="none"><a role="menuitem" href="/">Accueil</a></li>
    <li role="none"><a role="menuitem" href="/a-propos">À propos</a></li>
  </ul>
</nav>

Défauts fréquents et impact utilisateur

  • role="menu" sur la navigation du site : mode application forcé, touches de lecture perdues, flèches inopérantes sur des liens. Pire que l'absence totale d'ARIA.
  • Liens <a href> dans un role="menu" : enfants invalides, annoncés tantôt « lien », tantôt « élément de menu » selon le lecteur d'écran. Et si les éléments naviguent, ce n'est pas un menu.
  • Ouvrir le menu sans y déplacer le focus : les éléments étant en tabindex="-1", l'utilisateur clavier voit un menu ouvert qu'il ne peut jamais atteindre.
  • aria-expanded posé sur le menu au lieu du bouton : le bouton n'annonce aucun état ; l'utilisateur ne sait jamais qu'il ouvre quelque chose.
  • Fermer le menu au blur du bouton : le focus qui entre dans le menu fait perdre le focus au bouton, donc le menu se ferme à l'instant précis où il s'ouvre.
  • Renvoyer le focus au bouton quand Tab ferme le menu : Tab semble ne rien faire et l'utilisateur tourne en boucle. Le retour au bouton est réservé à Échap.

Ce que les outils automatiques ne détectent pas

Un scanner automatique, le nôtre compris, repère des enfants invalides dans un role="menu" ou un menu sans nom accessible. Mais le défaut principal lui échappe entièrement :

  • role="menu" posé sur une navigation est de l'ARIA parfaitement valide et parfaitement faux. Seul le jugement humain (les éléments naviguent-ils ?) le détecte.
  • L'arrivée du focus sur le premier élément à l'ouverture, et son retour sur le bouton à la fermeture par Échap, ne se vérifient qu'au clavier.
  • L'annonce attendue (« Actions, bouton menu, réduit ») : si seul « bouton » est annoncé, aria-haspopup manque, et rien ne le signalera automatiquement.
  • La fermeture au clic extérieur et l'absence de piège à Tab restent des vérifications manuelles.

Vérifier ce composant

Protocole manuel, trois minutes :

  1. La question préalable, avant tout test technique : que contient le panneau ? Des éléments qui naviguent vers une autre page ne font pas un menu, quel que soit le soin du balisage. Le quiz Bouton ou Lien ? entraîne précisément ce réflexe action / navigation.
  2. Au clavier seul : Entrée ouvre le menu et le focus doit atterrir sur le premier élément (s'il reste sur le bouton, le menu est inatteignable). Les flèches bouclent. Échap ferme et rend le focus au bouton. Tab ferme et avance, sans jamais revenir en arrière.
  3. Au lecteur d'écran : le bouton doit annoncer « Actions, bouton menu, réduit », puis « développé » une fois ouvert.

Le simulateur de lecteur d'écran affiche l'arbre d'accessibilité : aria-haspopup, aria-expanded et les rôles menu / menuitem s'y lisent directement, avant même le test au vrai lecteur d'écran décrit dans le guide Tester avec un lecteur d'écran.

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