03-comment-je-travaille/tutos/treeview-accessible.md

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.
  • hidden masque la sous-liste pour tous (visuel + lecteur d'Ă©cran + tabulation).
  • Le chevron purement dĂ©coratif est aria-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 faire outline: none sans remplacement net. Voir Navigation au clavier.
  • Ne pas piĂ©ger le focus ; pas de tabindex positif.

É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 : Tab atteint chaque lien/bouton, focus visible, EntrĂ©e/Espace replient/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Ă© de aria-current="page", et aria-expanded sur les boutons de section.

Pour aller plus loin

Assistant documentaire

Posez une question sur la documentation. Les réponses citent leurs sources — un clic ouvre le document à gauche.

Loading…
Loading the web debug toolbar…
Attempt #