Retour au cours

frontend / react

Accessibilité en React

Leçon 231 exercice

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 useId pour 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 clavierActivable avec Entrée/EspaceAnnoncé comme interactif
<div onClick>Non (sans tabIndex)NonNon
<button onClick>Oui, nativementOui, nativementOui, 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.

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>;
}
jsx
// 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>
    );
}
jsx
// 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>
    );
}
jsx
// 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
jsx
// 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-live annonce les changements asynchrones aux lecteurs d'écran, invisibles autrement.
  • useId génère des identifiants stables et uniques, indispensables en SSR et pour les composants instanciés plusieurs fois.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →