RGAA Lab

Composants accessibles

L'arbre

C'est le composant le plus lourd en ARIA, et celui dont on a le moins souvent besoin. Une liste imbriquée avec des sections dépliables fait le même travail sans un seul rôle — voici quand l'arbre se justifie vraiment, et comment le coder alors.

5 critères RGAA en jeu

Essayez-le

  • export-audit
    • pages
      • accueil.html
      • recherche.html
      • contact.html
    • rapport.html

Ce que porte le nœud qui a le focus

Posez le focus sur un nœud de l’arbre : ses attributs réels s’affichent ici.

Tabulez : l’arbre entier ne coûte qu’un seul arrêt, quel que soit le nombre de fichiers. Puis lâchez la tabulation et prenez les flèches : descend d’une ligne visible, déplie puis entre, replie puis remonte. Le relevé à droite lit les attributs réels du nœud focalisé — et montre que son niveau n’est déclaré nulle part.

Cette démo existe aussi en page autonome.

L’erreur courante

Un menu de navigation à deux étages, à qui l’on colle des rôles d’arbre parce que ça y ressemble.

<!-- À ne pas faire : ce n'est pas un arbre, c'est un menu -->
<ul role="tree">
	<li role="treeitem"><a href="/criteres">Les critères</a></li>
	<li role="treeitem" aria-expanded="false">
		<a href="/composants">Les composants</a>
		<ul role="group">
			<li role="treeitem"><a href="/composants/accordeon">L'accordéon</a></li>
		</ul>
	</li>
</ul>

Ces rôles ne décrivent pas la page, ils promettent un comportement. Un lecteur d’écran annonce « arborescence, élément d’arborescence, réduit, niveau 1, 2 sur 2 » : l’utilisateur en déduit qu’il doit prendre les flèches, il les prend, et rien ne bouge. Avant les rôles, il entendait des liens dans une liste et savait quoi faire.

La faute de structure est aussi lourde. Le nœud focalisable d’un arbre, c’est le treeitem lui-même. Ici, le focus va au lien qu’il contient : chaque ligne devient un arrêt de tabulation, le tabindex roulant est impossible, et un lecteur d’écran annonce un lien focalisé à l’intérieur d’un élément d’arborescence qui ne l’est pas.

Trois variantes du même malentendu :

  • aria-expanded posé sur le <ul role="group"> plutôt que sur le nœud parent. L’état existe dans le DOM, mais il est sur un élément qui ne prend jamais le focus : personne ne l’entend jamais.
  • Le sous-arbre replié en height: 0. Ses nœuds restent focalisables : le focus quitte l’écran, parcourt une arborescence invisible, puis réapparaît plus bas.
  • role="tree" sans aucun clavier. Le composant se manipule à la souris, se décrit correctement, et reste inutilisable pour qui ne l’a pas.

Le code, pas à pas

1. Des listes imbriquées, d’abord

Écrivez la structure avant les rôles, et vérifiez qu’elle tient sans eux.

<ul>
	<li>
		export-audit
		<ul>
			<li>rapport.html</li>
		</ul>
	</li>
</ul>

Ce document est déjà correct : la hiérarchie est portée par l’imbrication, un lecteur d’écran annonce les listes et leur nombre d’éléments. Tout ce que les rôles ajouteront, c’est le pliage et la navigation aux flèches. S’ils ne sont pas nécessaires, vous avez fini.

2. Les trois rôles

<ul role="tree" aria-label="Fichiers de l'export d'audit">
	<li role="treeitem" aria-expanded="true" tabindex="0">
		<span>export-audit</span>

		<ul role="group">
			<li role="treeitem" tabindex="-1"><span>rapport.html</span></li>
		</ul>
	</li>
</ul>

role="tree" déclare l’arbre, role="treeitem" chaque nœud, role="group" chaque sous-arbre. Le aria-label du tree compte : sans lui, on entend « arborescence » sans savoir de quoi.

role="group" n’est pas décoratif, et il rend un service qu’on ne soupçonne pas. Le nom accessible d’un treeitem se calcule à partir de son contenu — or son contenu inclut tout le sous-arbre. Comme le nom d’un group ne se calcule pas à partir du contenu, le sous-arbre est écarté du calcul, et le nœud s’appelle « export-audit » plutôt que « export-audit rapport.html ». Remplacez le group par un <ul> nu et le nom du nœud grossit à chaque fichier ajouté.

3. aria-expanded va sur le nœud, jamais sur le groupe

<li role="treeitem" aria-expanded="false" tabindex="-1">
	<span>captures</span>
	<ul role="group" hidden></ul>
</li>

L’état appartient à l’élément qui prend le focus, parce que c’est en arrivant dessus qu’on l’entend. Sur le <ul role="group">, il est exact et muet.

Un nœud sans enfants ne porte pas aria-expanded, même à false : l’attribut annoncerait « réduit » sur une feuille, et laisserait croire qu’il y a quelque chose à déplier.

Le sous-arbre replié se masque par hidden, qui le retire de l’affichage et de l’ordre de tabulation d’un seul attribut. Une hauteur nulle ne fait que la moitié du travail — la moitié visible.

4. Le niveau : ce que vous n’avez pas à déclarer

aria-level, aria-setsize et aria-posinset existent, et c’est ce qui fait la réputation de ce composant. Ils ne servent que si la structure du DOM ne reflète pas l’arborescence : des nœuds à plat, indentés au CSS, comme le font certaines grilles virtualisées où seules les lignes visibles existent.

Quand chaque role="group" est bien à l’intérieur du treeitem qui le gouverne, le navigateur calcule le niveau, le rang et le total tout seul. Le relevé de la démo le montre : rien n’est déclaré, et le niveau est juste. Trois attributs de moins à tenir à jour, c’est trois occasions de moins de mentir.

5. Un seul arrêt de tabulation, quatre flèches

Le nœud focalisé porte tabindex="0", tous les autres tabindex="-1". On entre dans l’arbre d’une tabulation, on en sort d’une autre, et entre les deux, ce sont les flèches qui déplacent le focus.

function surTouche(evenement, noeud) {
	const visibles = [...arbre.querySelectorAll('[role="treeitem"]')].filter(
		(candidat) => !candidat.closest('ul[hidden]'),
	);
	const rang = visibles.indexOf(noeud);
	const deplie = noeud.getAttribute('aria-expanded') === 'true';

	if (evenement.key === 'ArrowRight') {
		if (deplie) focaliser(visibles[rang + 1]);
		else deplier(noeud);
	} else if (evenement.key === 'ArrowLeft') {
		if (deplie) replier(noeud);
		else focaliser(noeud.parentElement.closest('[role="treeitem"]'));
	}
	// ↓ et ↑ : visibles[rang ± 1]. Début et Fin : le premier et le dernier.
	evenement.preventDefault();
}

Deux points valent qu’on s’y arrête.

La liste des nœuds parcourables se relit dans le DOM, filtrée sur ce qui n’est pas dans un groupe masqué. Un tableau parallèle tenu à la main diverge dès le premier pliage, et le focus part alors sur un nœud invisible.

et font chacune deux choses, mais jamais les deux à la fois : déplier ou entrer, replier ou remonter. C'est ce qui rend la descente prévisible — on ne saute jamais deux niveaux d'une pression.

Le preventDefault() n’est pas optionnel : sans lui, la flèche déplace le focus et fait défiler la page.

6. Avant de coder ça : en avez-vous besoin ?

C’est le composant le plus coûteux de cette collection, et celui qui se justifie le plus rarement. Trois questions le tranchent.

  • Combien de niveaux ? À deux niveaux, un accordéon par section fait le même travail : un bouton natif, un état annoncé, aucun rôle. L’arbre commence à payer vers trois ou quatre niveaux réels.
  • Navigue-t-on, ou lit-on ? Un menu de site se lit et se clique : ce sont des liens dans une liste. Un explorateur de fichiers se parcourt : c’est un arbre.
  • Faut-il lier un nœud, l’imprimer, le trouver par la recherche du navigateur ? Un arbre ne met rien dans l’URL et masque tout le reste.

Une liste imbriquée dont chaque branche est un <details> couvre la plupart des besoins, sans un seul rôle ARIA ni une ligne de gestion du focus.

Au clavier

ToucheEffet
TabEntre dans l’arbre (un seul arrêt), puis en sort vers la suite de la page.
Va au nœud visible suivant ou précédent, en traversant les niveaux.
Déplie le nœud si le focus est sur un nœud replié ; sinon, va au premier enfant.
Replie le nœud si le focus est sur un nœud déplié ; sinon, remonte au parent.
Début / FinVa au premier ou au dernier nœud visible.
Une lettreVa au nœud visible suivant dont le nom commence par cette lettre.
Entrée / EspaceSélectionne le nœud, ou déplie le dossier.

Ce que le lecteur d’écran restitue

Sur un nœud : le nom de l’arbre, la nature, le nom du nœud, son état, son niveau et son rang — de l’ordre de « Fichiers de l’export d’audit, arborescence, captures, élément d’arborescence, réduit, niveau 2, 2 sur 3 ». La formulation varie d’un lecteur à l’autre ; ces informations, non.

Le diagnostic inverse se fait sur ce qui manque :

  • Pas de niveau ni de rang annoncés : les role="group" ne sont pas dans les nœuds qu’ils prolongent, l’imbrication est fausse.
  • Le nom du nœud suivi de tous ses fichiers : le sous-arbre entre dans le nom accessible, il manque un role="group".
  • « réduit » sur un fichier : aria-expanded traîne sur une feuille.
  • « lien » ou « bouton » à la place d’« élément d’arborescence » : le focus va à un élément interactif contenu dans le nœud, pas au nœud.

Checklist

  • Vérifier d’abord qu’un accordéon ou des <details> imbriqués ne suffisent pas.
  • role="tree" nommé, role="treeitem", role="group" — les trois, ou aucun.
  • Le treeitem est l’élément focalisable : rien d’interactif à l’intérieur.
  • aria-expanded sur le nœud parent, absent sur les feuilles.
  • Le sous-arbre replié masqué par hidden, jamais par une hauteur nulle.
  • Un seul tabindex="0" dans tout l’arbre, les autres à -1.
  • Les quatre flèches, Début et Fin, avec preventDefault().
  • aria-level et aria-posinset seulement si le DOM ne reflète pas la hiérarchie.

Les critères RGAA en jeu

Vérifiez votre composant avec l'extension

Pour aller plus loin

Sources