frontend / react
Accessibilité en React
Explication
Ce que vous allez apprendre
- Comprendre pourquoi React n'ajoute aucune accessibilité automatique
- Remplacer un
<div onClick>par un élément interactif natif accessible au clavier - Gérer explicitement le focus clavier à l'ouverture d'une modale
- Annoncer un contenu asynchrone à un lecteur d'écran avec
aria-live - Générer des identifiants uniques et stables avec
useIdpour relier un label à son champ
Dans quel contexte ?
Un audit d'accessibilité externe pointe que la modale de confirmation de suppression dans src/components/DeleteConfirmModal.jsx est totalement inutilisable au clavier : impossible de la fermer avec Échap, le focus reste bloqué sur le bouton qui l'a ouverte, et un lecteur d'écran ne l'annonce jamais. Ces trois problèmes, très fréquents dans les modales React mal construites, sont exactement ceux que cette leçon apprend à corriger.
Étape 1 : qu'est-ce que l'accessibilité web
L'accessibilité consiste à concevoir une interface utilisable par TOUS, y compris les personnes qui naviguent uniquement au clavier ou utilisent un lecteur d'écran.
Étape 2 : pourquoi React n'ajoute aucune magie automatique
Contrairement à une idée reçue, utiliser React ne rend pas une interface accessible par défaut : les mêmes règles HTML et ARIA continuent de s'appliquer exactement comme dans une page HTML classique.
Étape 3 : le piège numéro un
Le réflexe fréquent d'écrire <div onClick={...}> pour un élément cliquable ignore un fait essentiel : une div n'est, par nature, ni focalisable au clavier, ni annoncée comme interactive.
Piège fréquent
<div onClick={fermer}>Fermer</div> fonctionne à la souris mais reste invisible à la navigation au clavier (pas de tabIndex, pas de déclenchement via Entrée/Espace) et n'est jamais annoncé comme un bouton par un lecteur d'écran. Un simple <button onClick={fermer}>Fermer</button> règle tout ça gratuitement.
| Élément utilisé | Focalisable au clavier | Activable avec Entrée/Espace | Annoncé comme interactif |
|---|---|---|---|
<div onClick> | Non (sans tabIndex) | Non | Non |
<button onClick> | Oui, nativement | Oui, nativement | Oui, nativement |
Étape 4 : la solution simple
Utiliser un vrai <button> ou <a> résout tous ces problèmes gratuitement, puisque le navigateur gère nativement ce comportement pour les éléments interactifs standards.
Étape 5 : une responsabilité que React laisse au développeur
Contrairement au rendu visuel, React ne gère jamais automatiquement le focus clavier : à l'ouverture d'une modale, c'est au développeur de déplacer explicitement le focus dessus.
Étape 6 : et pour un contenu qui change de façon asynchrone
Un changement de contenu purement asynchrone reste invisible pour un lecteur d'écran sauf à utiliser une "live region" (aria-live), qui l'annonce vocalement.
Un dernier outil, useId
Il reste un problème propre au rendu par composants : générer des identifiants uniques et stables pour relier un label à son champ, même rendu plusieurs fois sur une page. useId résout exactement ça.
Et ensuite ?
Une fois l'accessibilité en React comprise, la prochaine étape ouvre la boîte noire du moteur de rendu lui-même : la réconciliation et l'hydration.
Commandes & code
Accessibilité en React
React n'ajoute aucune magie d'accessibilité automatique : les mêmes règles HTML/ARIA s'appliquent, avec des pièges spécifiques au JSX.
// Piège n°1 : un <div onClick> n'est JAMAIS un bouton accessible
// MAUVAIS
function CarteMauvaise({ onOuvrir }) {
return <div onClick={onOuvrir}>Voir le détail</div>;
// pas focusable au clavier, pas annoncé comme interactif, pas déclenchable via Entrée/Espace
}
// BON
function CarteBonne({ onOuvrir }) {
return <button onClick={onOuvrir} className="carte-bouton">Voir le détail</button>;
}// Gestion du focus après une action -- React ne le fait jamais automatiquement
function ModaleConfirmation({ ouverte, onFermer, titre }) {
const boutonFermerRef = useRef(null);
const dernierFocusRef = useRef(null);
useEffect(() => {
if (ouverte) {
dernierFocusRef.current = document.activeElement; // pour restaurer à la fermeture
boutonFermerRef.current?.focus();
} else {
dernierFocusRef.current?.focus();
}
}, [ouverte]);
if (!ouverte) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby="titre-modale">
<h2 id="titre-modale">{titre}</h2>
<button ref={boutonFermerRef} onClick={onFermer}>Fermer</button>
</div>
);
}// Live region pour annoncer un changement asynchrone (ex: résultat de recherche, erreur de soumission)
function Recherche() {
const [resultats, setResultats] = useState([]);
const [statut, setStatut] = useState("");
async function rechercher(terme) {
setStatut("Recherche en cours...");
const data = await fetch(`/api/recherche?q=${terme}`).then((r) => r.json());
setResultats(data);
setStatut(`${data.length} résultat(s) trouvé(s)`);
}
return (
<div>
<input onChange={(e) => rechercher(e.target.value)} aria-label="Rechercher" />
{/* zone invisible visuellement mais annoncée par un lecteur d'écran à chaque changement */}
<div aria-live="polite" className="sr-only">{statut}</div>
<ul>{resultats.map((r) => <li key={r.id}>{r.titre}</li>)}</ul>
</div>
);
}// useId : génère un id stable et unique par instance, essentiel pour relier label/aria-describedby
// sans collision en cas de rendu multiple du même composant (SSR compris)
import { useId } from "react";
function ChampMotDePasse({ label }) {
const id = useId();
const idAide = `${id}-aide`;
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type="password" aria-describedby={idAide} />
<p id={idAide}>8 caractères minimum</p>
</div>
);
}
// avant useId : un id codé en dur ("mdp-input") cassait dès que le composant était rendu deux fois sur la page// eslint-plugin-jsx-a11y : détecte automatiquement les erreurs d'accessibilité courantes en CI
// npm install --save-dev eslint-plugin-jsx-a11y
// .eslintrc : "extends": ["plugin:jsx-a11y/recommended"]
// signale : img sans alt, onClick sans onKeyDown sur un élément non interactif,
// label sans association, contraste de couleur insuffisant (avec certains plugins étendus)Résumé
- Un élément interactif custom doit rester un vrai
<button>/<a>, jamais un<div onClick>sans rattrapage clavier. - La gestion du focus (ouverture/fermeture de modale) reste entièrement à la charge du développeur, via
useRef/useEffect. aria-liveannonce les changements asynchrones aux lecteurs d'écran, invisibles autrement.useIdgénère des identifiants stables et uniques, indispensables en SSR et pour les composants instanciés plusieurs fois.
Exercices pratiques
Mission : rendre DeleteConfirmModal réellement utilisable au clavier
Objectif : Corriger une modale de confirmation inaccessible : focus jamais déplacé, fermeture au clavier absente, contenu asynchrone jamais annoncé.
Contexte
L'audit d'accessibilité sur DeleteConfirmModal.jsx relève trois problèmes précis : impossible de la fermer avec Échap, le focus reste bloqué sur le bouton "Supprimer" qui a ouvert la modale (jamais déplacé vers la modale elle-même), et le message "Suppression en cours..." qui s'affiche pendant l'appel API n'est annoncé par aucun lecteur d'écran. La modale utilise actuellement <div className="bouton-fermer" onClick={onFermer}>Fermer</div> pour son bouton de fermeture.