Chapitre 1316 minDébutant
Accessibilité des interactions : clavier, focus et ARIA
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- choisir l'élément HTML natif qui rend une interaction accessible ;
- rendre une interface utilisable entièrement au clavier ;
- gérer le focus après une action ;
- annoncer un changement avec
aria-liveet utiliser les attributs ARIA de base.
3 exercices corrigés
Une interaction JavaScript est accessible quand tout le monde peut l'utiliser : au clavier seul, avec un lecteur d'écran qui lit la page à voix haute, avec une loupe ou une commande vocale. Le JavaScript est souvent ce qui casse l'accessibilité d'une page bien écrite en HTML : un div cliquable qu'on n'atteint pas au clavier, un message d'erreur qui apparaît sans être annoncé, un focus perdu après un rafraîchissement. Ce chapitre donne les règles qui évitent ces problèmes : choisir le bon élément, gérer le clavier et le focus, annoncer les changements, et utiliser les attributs ARIA de base. Le cadre légal (RGAA, WCAG, European Accessibility Act) et les vérifications d'une page statique sont dans le chapitre Accessibilité web du cours HTML et CSS.
Le bon élément HTML d'abord
La règle la plus efficace : utilise l'élément HTML natif qui fait déjà ce que tu veux. Un <button> reçoit le focus avec Tab, se déclenche avec Entrée et Espace, est annoncé « bouton » par les lecteurs d'écran, et se désactive avec disabled. Un <div> avec un écouteur de clic n'a rien de tout cela.
<!-- Inaccessible : pas de focus clavier, pas de rôle, pas d'activation au clavier -->
<div class="btn" id="ajouter">Ajouter</div>
<!-- Accessible sans une ligne de code en plus -->
<button type="button" id="ajouter">Ajouter</button>
| Besoin | Élément à utiliser | À éviter |
|---|---|---|
| Déclencher une action (ajouter, supprimer, ouvrir) | <button type="button"> | <div>, <span>, <a href="#"> |
| Aller vers une autre page ou une autre adresse | <a href="…"> | Un bouton qui change location |
| Envoyer un formulaire | <button type="submit"> dans un <form> | Un clic qui lit les champs à la main |
| Choisir oui ou non | <input type="checkbox"> | Un interrupteur dessiné en div |
| Choisir une option dans une liste | <select> ou des boutons radio | Une liste déroulante construite en div |
| Afficher une boîte de dialogue | <dialog> avec showModal() | Une div positionnée par-dessus la page |
| Afficher ou masquer un bloc de détails | <details> et <summary> | Une div cliquable |
Le type button compte : dans un formulaire, un <button> sans type est un bouton d'envoi, et chaque clic envoie le formulaire.
Utilisable au clavier
Beaucoup de personnes naviguent sans souris : handicap moteur, lecteur d'écran, ou simple préférence. La touche Tab passe d'un élément interactif au suivant, Maj + Tab revient en arrière, Entrée et Espace activent.
- Tout ce qui se clique doit être atteignable et activable au clavier. Avec les éléments natifs, c'est automatique.
- L'ordre du focus suit l'ordre du HTML. Ne réorganise pas visuellement la page en CSS au point que
Tabsaute d'un bout à l'autre de l'écran. - Le focus doit rester visible. Ne supprime jamais le contour de focus (
outline: none) sans le remplacer par un style au moins aussi visible ; préfère:focus-visible, qui ne s'affiche que pour la navigation au clavier. tabindex:tabindex="0"rend focalisable un élément qui ne l'est pas (rarement nécessaire si tu utilises les bons éléments),tabindex="-1"le rend focalisable par le code seulement (element.focus()), sans l'ajouter au parcoursTab. N'utilise jamais de valeur positive : elle bouleverse l'ordre de toute la page.- Pas de piège : l'utilisateur doit toujours pouvoir sortir d'une zone au clavier.
Les raccourcis clavier se gèrent avec l'événement keydown et la propriété event.key. Par exemple, Échap vide la recherche de Gestock :
champRecherche.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
champRecherche.value = '';
etat.recherche = '';
rafraichir();
}
});
Un <input type="search"> le fait d'ailleurs déjà dans la plupart des navigateurs : encore un comportement natif offert.
Gérer le focus après une action
Le focus est l'élément qui reçoit les frappes du clavier. Quand ton script retire ou remplace cet élément, le focus retombe au début de la page : l'utilisateur au clavier doit tout reparcourir, et le lecteur d'écran ne sait plus où il en est.
C'est exactement ce qui arrive dans Gestock : un clic sur le bouton + d'une ligne reconstruit tout le tableau avec replaceChildren, donc le bouton cliqué disparaît et le nouveau bouton n'a pas le focus. La correction tient en une ligne, après le rafraîchissement : retrouver le bouton équivalent dans le nouveau tableau, grâce à ses attributs data-*, et lui rendre le focus.
tbody.addEventListener('click', (event) => {
const b = event.target.closest('button[data-action]');
if (!b) return;
const { action, ref } = b.dataset;
modifierStock(ajusterQuantite(etat.pieces, ref, action === 'ajouter' ? 1 : -1));
// Le tableau a été reconstruit : on rend le focus au même bouton.
tbody.querySelector(`button[data-ref="${ref}"][data-action="${action}"]`)?.focus();
});
Vérifié dans le navigateur : après un clic sur + de RAM-16, document.activeElement est bien le nouveau bouton + de RAM-16, et Entrée peut être pressée plusieurs fois de suite pour ajouter plusieurs unités.
Les autres situations où ton code doit déplacer le focus :
| Situation | Où mettre le focus |
|---|---|
| Formulaire envoyé avec des erreurs | Le premier champ en erreur (afficherErreurs le fait, chapitre 8) |
| Ouverture d'une boîte de dialogue | Dans la boîte ; showModal() le fait tout seul |
| Fermeture de la boîte | Sur le bouton qui l'avait ouverte ; le navigateur le fait pour <dialog> |
| Suppression d'une ligne | Sur la ligne suivante, ou sur le titre de la liste si elle est vide |
| Changement de « page » sans rechargement | Sur le titre principal de la nouvelle vue (avec tabindex="-1" sur ce titre) |
L'élément <dialog> gère pour toi l'essentiel d'une fenêtre modale : showModal() place le focus dans la boîte, rend le reste de la page inerte, et Échap la ferme.
<dialog id="confirmation" aria-labelledby="titre-confirmation">
<h2 id="titre-confirmation">Supprimer la pièce ?</h2>
<form method="dialog">
<button value="annuler">Annuler</button>
<button value="supprimer">Supprimer</button>
</form>
</dialog>
const dialogue = document.querySelector('#confirmation');
dialogue.showModal();
dialogue.addEventListener('close', () => {
if (dialogue.returnValue === 'supprimer') {
// supprimer la pièce
}
}, { once: true });
Un <form method="dialog"> ferme la boîte quand on clique sur l'un de ses boutons, et returnValue contient la value du bouton choisi.
Annoncer les changements : aria-live
Quand ton script modifie la page sans que le focus bouge (un résumé qui change, un message « Pièce ajoutée »), une personne aveugle ne s'en aperçoit pas : son lecteur d'écran lit l'endroit où se trouve le focus, pas le reste. Une région live demande au lecteur d'écran d'annoncer ce qui change dans un élément.
<p id="resume" aria-live="polite"></p>
<p id="message" role="status"></p>
| Écriture | Annonce |
|---|---|
aria-live="polite" | Quand le lecteur d'écran a fini sa phrase en cours : pour la plupart des messages |
role="status" | Équivaut à une région polite : messages de confirmation, compteurs |
aria-live="assertive" ou role="alert" | Tout de suite, en interrompant : seulement pour l'urgent (session expirée, échec d'enregistrement) |
Deux conditions pour que ça marche : la région doit exister dans la page dès le chargement (vide), et c'est son contenu qui doit changer, par textContent. Une région ajoutée au DOM en même temps que son texte n'est souvent pas annoncée.
Dans Gestock, #resume annonce « 5 références, 2 en alerte, valeur du stock : 970,80 € » après chaque modification du stock, et #message annonce « Pièce VEN-12 ajoutée. » ou « Le formulaire contient des erreurs. ». N'en abuse pas : une région qui change à chaque frappe (un résultat de recherche mis à jour pendant la saisie) devient vite insupportable. Pour la recherche, une annonce du nombre de résultats après une courte pause suffit.
Les attributs ARIA de base
ARIA (Accessible Rich Internet Applications) est un ensemble d'attributs qui décrivent aux technologies d'assistance ce que le HTML ne dit pas. Ils ne changent rien au comportement : role="button" sur un div ne le rend ni focalisable ni activable au clavier. Ceux que tu utiliseras dans une application de gestion :
| Attribut | Rôle | Exemple dans Gestock |
|---|---|---|
aria-label | Donne un nom accessible à un élément qui n'a pas de texte clair | Le bouton « + » d'une ligne : aria-label="Ajouter une unité : SSD NVMe 512 Go" |
aria-labelledby | Désigne l'élément dont le texte sert de nom | <section aria-labelledby="titre-liste"> |
aria-describedby | Désigne un texte complémentaire, lu après le nom | Le message d'erreur d'un champ |
aria-invalid="true" | Signale un champ en erreur | Posé par afficherErreurs |
aria-expanded="true/false" | Dit si le bloc que contrôle un bouton est ouvert | Un bouton « Filtres » qui déplie un panneau |
aria-controls | Désigne l'élément que le bouton contrôle | Le panneau des filtres |
aria-pressed="true/false" | Bouton bascule enfoncé ou non | Un bouton « Mode compact » |
aria-current="page" | L'élément actif dans une navigation | Le lien de la page en cours |
aria-hidden="true" | Cache un élément décoratif aux lecteurs d'écran | Une icône à côté d'un texte |
Sans aria-label, un lecteur d'écran lirait les boutons de Gestock « bouton moins, bouton plus », dix fois de suite sans dire de quelle pièce il s'agit. C'est pourquoi vue.js les nomme ainsi :
b.setAttribute('aria-label', `${action === 'retirer' ? 'Retirer' : 'Ajouter'} une unité : ${piece.nom}`);
Un bouton qui ouvre et ferme un panneau doit dire son état :
<button type="button" id="bouton-filtres" aria-expanded="false" aria-controls="filtres">Filtres</button>
<div id="filtres" hidden>…</div>
const boutonFiltres = document.querySelector('#bouton-filtres');
const panneau = document.querySelector('#filtres');
boutonFiltres.addEventListener('click', () => {
const ouvert = boutonFiltres.getAttribute('aria-expanded') === 'true';
boutonFiltres.setAttribute('aria-expanded', String(!ouvert));
panneau.hidden = ouvert;
});
Les attributs ARIA sont des chaînes : écris 'true' et 'false', d'où le String(…). Et l'attribut hidden masque le panneau pour tout le monde, lecteurs d'écran compris.
Tester l'accessibilité de tes interactions
- Débranche la souris (ou ne la touche plus) et utilise toute l'application au clavier : peux-tu tout faire ? Vois-tu toujours où est le focus ? Ne te retrouves-tu jamais au début de la page sans raison ?
- Lance un audit automatique : l'onglet Lighthouse de Chrome (catégorie Accessibilité) ou l'extension axe DevTools. Ils trouvent les boutons sans nom, les contrastes insuffisants, les champs sans étiquette, mais pas les problèmes de logique (focus perdu, annonce manquante).
- Écoute ta page avec un lecteur d'écran : NVDA (gratuit, Windows), VoiceOver (intégré à macOS et iOS,
Cmd+F5), TalkBack sur Android. Ajoute une pièce et écoute ce qui est annoncé. - Agrandis le texte à 200 % : rien ne doit se chevaucher ni disparaître.
L'onglet Éléments de Chrome a un volet Accessibilité qui montre, pour l'élément sélectionné, son nom et son rôle accessibles : le moyen le plus rapide de vérifier qu'un aria-label est bien pris en compte.
Exercices
Exercice 1 sur 3 Corriger un composant Réussi À revoir
Relève tous les problèmes d'accessibilité de ce code et corrige-le.
<div class="onglet" onclick="afficherOnglet('alertes')">
<img src="cloche.svg"> Alertes
</div>
Voir la réponseMasquer la réponse de l'exercice 1
- Un
divcliquable : ni focalisable, ni activable au clavier, ni annoncé comme interactif. Il faut un<button type="button">. - L'attribut
onclickmélange code et HTML (et est bloqué par une CSP) : utiliseaddEventListener. - L'image n'a pas d'attribut
alt: un lecteur d'écran peut lire le nom du fichier. Elle est décorative (le texte « Alertes » suffit) :alt="". - Pour un onglet, l'état sélectionné doit être annoncé :
aria-pressedpour un bouton bascule simple (ou le motif complet des onglets ARIA,role="tab"etaria-selected, si c'est une vraie barre d'onglets).
<button type="button" class="onglet" id="onglet-alertes" aria-pressed="false">
<img src="cloche.svg" alt=""> Alertes
</button>
const onglet = document.querySelector('#onglet-alertes');
onglet.addEventListener('click', () => {
afficherOnglet('alertes');
onglet.setAttribute('aria-pressed', 'true');
});
Tu avais trouvé ?
Exercice 2 sur 3 Annoncer le résultat d'une recherche Réussi À revoir
Dans Gestock, la liste se filtre pendant la frappe. Ajoute sous le champ de recherche un paragraphe qui annonce aux lecteurs d'écran « 2 pièces trouvées » (ou « Aucune pièce trouvée », « 1 pièce trouvée »), seulement quand l'utilisateur a arrêté de taper depuis 500 ms.
Voir la réponseMasquer la réponse de l'exercice 2
<p id="nb-resultats" role="status"></p>
const zoneResultats = document.querySelector('#nb-resultats');
let minuterie;
champRecherche.addEventListener('input', () => {
etat.recherche = champRecherche.value;
rafraichir();
clearTimeout(minuterie);
minuterie = setTimeout(() => {
const n = filtrerPieces(etat.pieces, etat.recherche, etat.alertesSeules).length;
zoneResultats.textContent =
n === 0 ? 'Aucune pièce trouvée' : `${n} pièce${n > 1 ? 's' : ''} trouvée${n > 1 ? 's' : ''}`;
}, 500);
});
Chaque frappe annule la minuterie précédente (clearTimeout) et en relance une : l'annonce n'a lieu qu'une demi-seconde après la dernière frappe. Cette technique s'appelle un debounce. Le paragraphe existe dès le chargement, vide, avec role="status".
Tu avais trouvé ?
Exercice 3 sur 3 Trier au clavier Réussi À revoir
On veut pouvoir trier le tableau de Gestock par prix en cliquant sur l'en-tête de la colonne. Propose un HTML et un code accessibles : utilisable au clavier, et qui indique aux lecteurs d'écran le sens du tri.
Voir la réponseMasquer la réponse de l'exercice 3
L'en-tête contient un vrai bouton ; l'attribut aria-sort de la cellule d'en-tête indique le sens du tri (ascending, descending, ou none).
<th scope="col" aria-sort="none">
<button type="button" id="trier-prix">Prix unitaire</button>
</th>
const boutonTri = document.querySelector('#trier-prix');
const enTete = boutonTri.closest('th');
boutonTri.addEventListener('click', () => {
const croissant = enTete.getAttribute('aria-sort') !== 'ascending';
enTete.setAttribute('aria-sort', croissant ? 'ascending' : 'descending');
const tries = etat.pieces.toSorted((a, b) => (croissant ? a.prix - b.prix : b.prix - a.prix));
afficherPieces(tbody, tries);
});
Le focus reste sur le bouton de l'en-tête, qui n'est pas reconstruit : rien à restaurer. toSorted laisse l'état intact.
Tu avais trouvé ?