Tuto — Navigation en treeview accessible
Public visé : développeur front qui construit une sidebar arborescente (ex. le viewer
/docs, cf. specs/docs-web.md).
Objectif : un arbre de navigation utilisable au clavier et au lecteur d'écran, qui
fonctionne sans JavaScript, conforme RGAA 4.1 AA.
Référence transverse : Accessibilité — interface utilisateur.
Le choix du pattern (Ă trancher en premier)
Deux patterns ARIA répondent au besoin — leur coût n'est pas le même :
| Pattern | Quand | Coût |
|---|---|---|
| Liste imbriquée + disclosure (recommandé pour une nav de docs) | Liens de navigation hiérarchiques | Faible : HTML sémantique + un bouton aria-expanded par section |
role="tree" (W3C Tree View) |
Vrai widget de sélection (explorateur de fichiers, un seul tabstop, navigation aux flèches) | Élevé : gérer flèches, Home/End, typeahead, aria-selected, tabindex roving |
Pour une navigation (chaque item est un lien vers une page), le pattern tree est
surdimensionné et fragile : il capture les flèches et déroute l'utilisateur de lecteur
d'écran qui attend des liens. On retient la liste imbriquée avec disclosure — plus
robuste, et naturellement compatible avec l'amélioration progressive.
Règle : utiliser
role="tree"seulement si on construit un vrai widget de sélection mono-tabstop. Une sidebar de liens n'en est pas un.
Étape 1 — Structure HTML (sans JavaScript)
Le socle est une liste imbriquée de liens, rendue côté serveur. Sans une ligne de JS, c'est déjà navigable (Tab entre les liens) et sémantiquement correct.
<nav aria-label="Documentation"> <ul> <li> <a href="/docs/guides">Guides</a> <ul> <li><a href="/docs/guides/ssl-tls">SSL/TLS</a></li> <li><a href="/docs/guides/nouveau-domaine" aria-current="page">Nouveau domaine</a></li> </ul> </li> <li><a href="/docs/glossaire">Glossaire</a></li> </ul> </nav>
Points clés :
<nav aria-label="…">: un repère (landmark) nommé, distinct des autres<nav>de la page.- Listes imbriquées
<ul>/<li>: la hiérarchie est portée par la sémantique, pas par du CSS. Un lecteur d'écran annonce « liste, N éléments » et l'imbrication. aria-current="page"sur le lien de la page courante (cf. § « Page active »).
Étape 2 — Améliorer avec le repli/déploiement (disclosure)
Le JS n'ajoute qu'un confort : replier les branches. Chaque section repliable reçoit un
bouton aria-expanded, séparé du lien (un bouton agit, un lien navigue — ne pas
mélanger les deux rôles) :
<li> <button type="button" aria-expanded="true" aria-controls="sect-guides"> <span class="chevron" aria-hidden="true"></span> <span class="visually-hidden">Déplier/replier </span>Guides </button> <a href="/docs/guides">Guides</a> <ul id="sect-guides"> … </ul> </li>
document.querySelectorAll('nav [aria-expanded]').forEach((btn) => { btn.addEventListener('click', () => { const open = btn.getAttribute('aria-expanded') === 'true'; btn.setAttribute('aria-expanded', String(!open)); document.getElementById(btn.getAttribute('aria-controls')).hidden = open; }); });
- L'état est porté par
aria-expanded(annoncé : « réduit » / « développé »), pas par une classe CSS seule. hiddenmasque la sous-liste pour tous (visuel + lecteur d'écran + tabulation).- Le
chevronpurement décoratif estaria-hidden="true".
Étape 3 — Clavier et focus
Avec le pattern disclosure, rien d'exotique : l'ordre de tabulation natif suffit.
Tab/Maj+Tab: parcourt boutons et liens dans l'ordre du DOM.Entrée/Espace: active le bouton (replie/déplie) ou suit le lien.- Focus visible obligatoire (
:focus-visible) — ne jamais faireoutline: nonesans remplacement net. Voir Navigation au clavier. - Ne pas piéger le focus ; pas de
tabindexpositif.
Étape 4 — Marquer la page active
L'item courant porte aria-current="page" (et un style visuel non basé sur la seule
couleur). Ses parents repliables sont dépliés au chargement pour que l'item soit
visible. Cf. interface-utilisateur.md § Navigation.
Étape 5 — Construire l'arbre (Symfony / Twig)
L'arbre dérive de l'arborescence réelle du dépôt doc (toujours exacte), enrichie par
toc.md pour l'ordre et les libellés (docs-web.md §13.5). Côté Twig, un macro récursif rend l'imbrication :
{% macro branche(noeuds, pathCourant) %} <ul> {% for n in noeuds %} <li> <a href="{{ path('docs_show', { path: n.path }) }}" {{ n.path == pathCourant ? 'aria-current="page"' : '' }}>{{ n.label }}</a> {% if n.enfants %}{{ _self.branche(n.enfants, pathCourant) }}{% endif %} </li> {% endfor %} </ul> {% endmacro %}
Le contrôleur fournit noeuds (arbre construit depuis le système de fichiers + toc.md,
exclusions SCRATCH.md/inputs/legacy/ appliquées avant, cf.
docs-web.md §6) et pathCourant.
Tester
- Sans JS : désactiver JavaScript → la nav reste entièrement navigable (liens servis).
- Clavier seul :
Tabatteint chaque lien/bouton, focus visible,Entrée/Espacereplient/déplient. - Lecteur d'écran (NVDA/VoiceOver) : le repère « Documentation » est listé ; l'état « développé/réduit » est annoncé ; la page courante est identifiée.
- Automatique : test fonctionnel qui asserte
nav[aria-label], l'unicité dearia-current="page", etaria-expandedsur les boutons de section.
Pour aller plus loin
- Spécification du viewer
/docs:specs/docs-web.md - Tuto compagnon (rendu) : Markdown accessible en Symfony
- Accessibilité UI (navigation, clavier, landmarks) :
accessibility/interface-utilisateur.md - W3C ARIA APG — Disclosure et Tree View (pour le cas widget)