Composant · Données et contenus riches

Treegrid accessible : aria-level et quand un tableau suffit

Grille arborescente (treegrid) : deux modes clavier, aria-expanded sur la ligne, aria-level obligatoire, et l'alternative du tableau à lignes dépliables.

Une grille arborescente (treegrid) est une grille dont les lignes forment une hiérarchie dépliable, avec plusieurs colonnes et des cellules interactives : gestionnaire de fichiers en colonnes, arborescence budgétaire éditable. Aucun élément natif n'existe, et c'est le motif le plus complexe de l'ARIA APG : il croise la grille (navigation aux flèches en deux dimensions) et l'arborescence (dépliage hiérarchique), sans rien y ajouter d'autre que de la surface d'erreur.

À ne retenir qu'en dernier recours, donc. Son coût réel : deux modèles clavier, une bascule de mode, et des attributs de hiérarchie maintenus à la main.

Quand ce motif est le mauvais choix

Presque toutes les « treegrids » rencontrées en production auraient dû être autre chose :

Ce que vous avez vraimentLe bon motif
Hiérarchie sur une seule colonne de libellésarborescence (treeview)
Données tabulaires plates avec cellules interactivesgrille (grid)
Tableau en lecture seule dont certaines lignes se déplient<table> + boutons aria-expanded dans une cellule
Hiérarchie ET plusieurs colonnes ET interaction au niveau de la cellulegrille arborescente (cette fiche)

La troisième ligne est le cas que l'on manque le plus souvent : un tableau dépliable en lecture seule n'a besoin ni de role="treegrid", ni de mode application, ni de modèle aux flèches, et chaque utilisateur sait déjà s'en servir.

Critères RGAA applicables

Interaction clavier attendue

Deux modes de navigation, avec une bascule explicite :

ToucheAction
Flèche bas / haut (mode ligne)Passe d'une ligne visible à l'autre.
Flèche droite (mode ligne)Déplie la ligne ; si elle est déjà dépliée, entre dans ses cellules.
Flèche gauche (mode ligne)Replie la ligne, ou remonte au parent.
Entrée (mode ligne)Entre dans les cellules de la ligne (bascule en mode cellule).
Flèche droite / gauche (mode cellule)Passe d'une cellule à l'autre.
Flèche bas / haut (mode cellule)Change de ligne dans la même colonne.
Échap (mode cellule)Revient au mode ligne.
Début / FinPremière / dernière ligne, ou première / dernière cellule de la ligne.
Ctrl + Début / Ctrl + FinPremier / dernier élément de la grille arborescente.
TabSort du widget. Jamais capturé.

Choisir un modèle et s'y tenir : si un même appui sur Flèche gauche replie la ligne ET déplace la cellule, le widget paraît cassé.

Rôles et attributs ARIA

role="treegrid" sur le conteneur (avec nom accessible), role="row" sur chaque <tr>, cellules en gridcell / rowheader / columnheader. aria-expanded se place sur la ligne (<tr>), jamais sur une cellule, et uniquement sur les lignes qui ont des enfants. aria-level, aria-posinset et aria-setsize se placent sur la ligne et sont indispensables : les <tr> ne peuvent pas s'imbriquer, le DOM est plat, donc rien d'autre ne porte la hiérarchie. C'est le seul motif où les maintenir à la main est la bonne pratique.

Mais d'abord, l'alternative à envisager sérieusement :

<!-- Un tableau à boutons de dépliage : pas de role treegrid, pas de
     mode application, pas de modèle aux flèches. Pour une hiérarchie
     en lecture seule, c'est le bon choix. -->
<table>
  <caption>Budget du projet par service</caption>
  <thead>
    <tr><th scope="col">Service</th><th scope="col">Budget</th></tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">
        <button type="button" aria-expanded="true" aria-controls="lignes-inge">
          Ingénierie
        </button>
      </th>
      <td>120 000 €</td>
    </tr>
  </tbody>
  <!-- Lignes enfants dans leur propre tbody : une seule cible
       pour aria-controls. -->
  <tbody id="lignes-inge">
    <tr><th scope="row">Front-end</th><td>70 000 €</td></tr>
    <tr><th scope="row">Back-end</th><td>50 000 €</td></tr>
  </tbody>
</table>

Et la vraie grille arborescente, quand le cumul hiérarchie + colonnes + interaction la justifie :

<table role="treegrid" aria-label="Fichiers du projet">
  <thead>
    <tr><th scope="col">Nom</th><th scope="col">Taille</th></tr>
  </thead>
  <tbody>
    <!-- aria-expanded sur la LIGNE. aria-level, aria-posinset et
         aria-setsize sont obligatoires : le DOM est plat, rien d'autre
         ne porte la hiérarchie. -->
    <tr role="row" aria-expanded="true" aria-level="1"
        aria-posinset="1" aria-setsize="2" tabindex="0">
      <th scope="row" tabindex="-1">src</th>
      <td tabindex="-1">-</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2"
        tabindex="-1">
      <th scope="row" tabindex="-1">index.js</th>
      <td tabindex="-1">2,4 Ko</td>
    </tr>
    <!-- Ligne repliée : hidden, pas seulement invisible (critère 10.8). -->
    <tr role="row" aria-expanded="false" aria-level="2" aria-posinset="2"
        aria-setsize="2" tabindex="-1">
      <th scope="row" tabindex="-1">composants</th>
      <td tabindex="-1">-</td>
    </tr>
    <tr role="row" aria-level="3" aria-posinset="1" aria-setsize="1"
        tabindex="-1" hidden>
      <th scope="row" tabindex="-1">Bouton.js</th>
      <td tabindex="-1">1,1 Ko</td>
    </tr>
  </tbody>
</table>
/* L'indentation est décorative : la hiérarchie réelle est aria-level. */
[role="treegrid"] tr[aria-level="2"] th { padding-inline-start: 1.5rem; }
[role="treegrid"] tr[aria-level="3"] th { padding-inline-start: 3rem; }

/* Les deux modes de focus doivent être visibles ET différents :
   l'utilisateur doit savoir dans quel mode il se trouve (critère 10.7). */
[role="treegrid"] tr:focus-visible {
  outline: 2px solid #0056b3;
  outline-offset: -2px;
}
[role="treegrid"] td:focus-visible,
[role="treegrid"] th:focus-visible {
  outline: 2px dashed #1a7f37;
  outline-offset: -2px;
}
// Au dépliage, ne révéler que les enfants directs : les petits-enfants
// dont le parent est encore replié restent masqués.
function deplierLigne(ligne, deplie) {
  ligne.setAttribute('aria-expanded', String(deplie));
  const niveau = Number(ligne.getAttribute('aria-level'));
  for (const enfant of lignesEnfants(ligne)) {
    const niveauEnfant = Number(enfant.getAttribute('aria-level'));
    if (niveauEnfant === niveau + 1) enfant.hidden = !deplie;
    else if (!deplie) enfant.hidden = true;
  }
}

Les contre-exemples du motif, tous plus fréquents que la version correcte :

<!-- À ne pas faire : aria-expanded sur une cellule. L'erreur la plus
     courante du motif : le mauvais élément est annoncé dépliable, la
     ligne elle-même n'annonce aucun état. -->
<tr role="row">
  <th scope="row" aria-expanded="true">src</th>
</tr>

<!-- À ne pas faire : omettre aria-level. Le DOM est plat : sans lui, la
     grille est annoncée comme un tableau plat, la hiérarchie disparaît
     (critère 7.1). -->
<tr role="row" aria-expanded="true" tabindex="0">
  <th scope="row">src</th>
</tr>

<!-- À ne pas faire : aria-expanded="false" sur une ligne feuille.
     Elle prétend avoir des enfants qu'elle n'a pas : l'utilisateur
     tente de la déplier en boucle. -->
<tr role="row" aria-expanded="false" aria-level="2">
  <th scope="row">index.js</th>
</tr>

<!-- À ne pas faire : replier en CSS seulement. Les lignes restent dans
     l'arbre d'accessibilité et dans le parcours aux flèches
     (critère 10.8). Utiliser hidden. -->
<tr role="row" style="visibility: collapse">...</tr>

<!-- À ne pas faire : role="treegrid" sur un tableau dépliable en
     lecture seule. Mode application et double modèle clavier imposés
     pour ce qu'un <table> à boutons de dépliage sert sans apprentissage. -->
<table role="treegrid">
  <tr role="row" aria-expanded="false">
    <th scope="row">Ingénierie</th><td>120 000 €</td>
  </tr>
</table>

Défauts fréquents et impact utilisateur

  • aria-expanded sur une cellule au lieu de la ligne : l'erreur la plus courante du motif. Le mauvais élément est annoncé dépliable, et la ligne elle-même n'annonce aucun état ; l'utilisateur de lecteur d'écran ne sait ni quoi déplier ni si c'est fait.
  • aria-level absent : le DOM étant plat, la grille arborescente est annoncée comme un tableau plat ; toute la hiérarchie disparaît et le motif perd sa raison d'être (critère 7.1).
  • aria-expanded="false" sur des lignes feuilles : chaque feuille prétend avoir des enfants ; l'utilisateur tente de déplier des lignes vides en boucle.
  • Lignes repliées masquées seulement en CSS (hauteur nulle, visibility: collapse) : elles restent dans l'arbre d'accessibilité et dans le parcours aux flèches ; l'utilisateur navigue dans des lignes invisibles à l'écran (critère 10.8).
  • aria-posinset / aria-setsize calculés une seule fois au chargement : après une insertion ou un filtre, le lecteur d'écran annonce avec assurance « 3 sur 7 » dans une branche qui en compte 4.
  • role="treegrid" sur un tableau dépliable en lecture seule : mode application et double modèle clavier imposés pour un contenu qu'un <table> avec boutons de dépliage servait sans aucun apprentissage.

Ce que les outils automatiques ne détectent pas

  • Un aria-expanded posé sur la cellule plutôt que sur la ligne, des niveaux aria-level absents ou faux, des compteurs aria-posinset / aria-setsize périmés : autant d'états sémantiques que seul un test au lecteur d'écran révèle.
  • Le choix du motif lui-même : une grille arborescente qui aurait dû être un tableau à lignes dépliables est syntaxiquement irréprochable.
  • La séparation des deux modes clavier et la sortie par Tab : vérifications entièrement manuelles.

Les outils automatiques, dont notre scanner, repèrent en revanche une structure de rôles invalide ou un widget sans nom accessible : nécessaire, très loin d'être suffisant sur ce motif.

Vérifier ce composant

Protocole manuel, dans cet ordre :

  1. La première question, avant tout test : fallait-il un treegrid ? Lignes en lecture seule : un <table> à boutons de dépliage. Une seule colonne : une arborescence. Lignes plates : une grille. Un motif correct n'est pas pour autant le bon motif.
  2. Le test de placement d'aria-expanded : focaliser une ligne dépliable au lecteur d'écran. C'est la LIGNE qui doit annoncer « déplié », pas une cellule.
  3. Le test de hiérarchie : focaliser une ligne imbriquée. Attendre « niveau 2, 1 sur 2 » ; sans annonce de niveau, aria-level manque et rien d'autre ne porte la hiérarchie.
  4. Le test des modes : en mode ligne, Flèche gauche replie ; en mode cellule, elle déplace la cellule. Si un même appui fait les deux, les modes ne sont pas séparés. Et Tab doit toujours sortir du widget (critère 12.9).

Deux outils gratuits du site aident à ce contrôle : le Testeur Focus Visible vérifie que vos styles :focus distinguent bien les deux modes (critère 10.7), et le Simulateur Lecteur d'Écran affiche l'arbre d'accessibilité : rôles treegrid / row / gridcell, états aria-expanded et niveaux aria-level 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