Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1418 minIntermédiaire

Accessibilité des composants React

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • éviter les pièges d'accessibilité propres aux composants React ;
  • rendre un formulaire et ses erreurs accessibles ;
  • faire annoncer les contenus qui apparaissent sans rechargement ;
  • gérer le titre et le focus à chaque changement de page ;
  • vérifier l'accessibilité avec le linter, les tests et le clavier.

3 exercices corrigés

Une application React est accessible quand une personne qui utilise un lecteur d'écran, le clavier seul, un zoom ou une commande vocale peut s'en servir aussi bien que les autres. React ne rend rien accessible tout seul : il produit le HTML que tu écris. Ce chapitre reprend les règles de l'accessibilité web du point de vue d'une application monopage : HTML sémantique, formulaires, changements de page sans rechargement, contenus qui apparaissent dynamiquement, fenêtres modales, et les outils pour vérifier.

Ce qui ne change pas avec React

Les obligations légales (RGAA, European Accessibility Act), les quatre principes des WCAG et les vérifications de base (contrastes, textes alternatifs, titres, navigation au clavier) sont expliqués dans le chapitre Accessibilité web : RGAA et WCAG du cours HTML et CSS. Ils s'appliquent tels quels : le navigateur et le lecteur d'écran ne voient que le HTML final, pas tes composants.

La règle d'or ne change pas non plus : un élément HTML natif vaut mieux qu'un attribut ARIA. Un <button> est focalisable, s'active avec Entrée et Espace, est annoncé « bouton » par les lecteurs d'écran. Un <div onClick> ne fait rien de tout cela.

Les pièges propres à React

1. Le div cliquable. Les composants rendent tentant d'attacher un onClick à n'importe quoi :

tsx
// Inaccessible : pas de focus au clavier, pas annoncé comme un bouton, Entrée ne fait rien
<div className="bouton" onClick={supprimer}>Supprimer</div>

// Accessible : un vrai bouton
<button type="button" className="bouton" onClick={supprimer}>Supprimer</button>

Même chose pour la navigation : un lien est un <Link> (qui produit un <a href>), pas un bouton ou un div avec navigate(). Règle simple : un bouton fait une action, un lien mène à une adresse.

2. Les div inutiles. Un composant doit renvoyer un seul élément : on l'enveloppe souvent dans un div par réflexe. Dans un tableau ou une liste, ce div casse la structure (<ul><div><li>) et les lecteurs d'écran n'annoncent plus « liste de 3 éléments ». Utilise un fragment <>…</>.

3. Les icônes sans nom. Un bouton qui ne contient qu'une icône n'a pas de nom accessible. Donne-lui un aria-label, et précise ce qu'il vise quand plusieurs boutons identiques se suivent :

tsx
<button type="button" onClick={() => supprimer(f.id)} aria-label={`Supprimer le frais « ${f.libelle} »`}>
  <IconeCorbeille aria-hidden="true" />
</button>

Dans le tableau des frais d'AppliFrais, chaque bouton « Supprimer » a ainsi un nom distinct : un utilisateur de lecteur d'écran qui liste les boutons entend « Supprimer le frais « Taxi » » et non dix fois « Supprimer ».

4. Les identifiants en double. <label htmlFor="libelle"> relie l'étiquette au champ id="libelle". Si un composant de champ est affiché deux fois sur la page, les deux champs ont le même id et la seconde étiquette pointe vers le premier champ. Le hook useId génère un identifiant unique et stable par instance :

tsx
import { useId, type ComponentProps } from 'react';

type ChampProps = ComponentProps<'input'> & { libelle: string; aide?: string };

export function Champ({ libelle, aide, ...reste }: ChampProps) {
  const id = useId();
  return (
    <p>
      <label htmlFor={id}>{libelle}</label>
      <input id={id} aria-describedby={aide ? `${id}-aide` : undefined} {...reste} />
      {aide && <small id={`${id}-aide`}>{aide}</small>}
    </p>
  );
}

<Champ libelle="Montant (€)" name="montant" type="number" step="0.01" aide="Montant TTC, centimes compris" />

N'utilise pas useId pour les clés d'une liste : il sert aux attributs d'accessibilité.

Les formulaires accessibles

Un formulaire accessible :

  • a une étiquette visible pour chaque champ, reliée par htmlFor (un placeholder ne remplace pas une étiquette : il disparaît dès qu'on tape) ;
  • indique les champs obligatoires (required, et un texte visible, pas seulement un astérisque rouge) ;
  • annonce les erreurs et les relie au champ concerné ;
  • ne retire pas le focus de l'utilisateur au milieu de sa saisie.

Pour une erreur liée à un champ précis, aria-invalid signale le champ en erreur et aria-describedby fait lire le message quand le champ reçoit le focus :

tsx
<label htmlFor="montant">Montant (€)</label>
<input
  id="montant"
  name="montant"
  type="number"
  aria-invalid={erreurMontant ? true : undefined}
  aria-describedby={erreurMontant ? 'erreur-montant' : undefined}
/>
{erreurMontant && <p id="erreur-montant" className="erreur">{erreurMontant}</p>}

Pour une erreur globale (le serveur a refusé), le role="alert" du formulaire d'ajout (chapitre 8) la fait annoncer immédiatement. Le chapitre Formulaire HTML du cours HTML et CSS détaille ces techniques.

Les contenus qui changent : les régions live

Dans une application monopage, des contenus apparaissent sans rechargement : « Chargement… », un message d'erreur, une notification « Frais ajouté ». Un utilisateur voyant les remarque ; un lecteur d'écran, lui, ne lit que ce sur quoi se trouve le focus. Pour qu'il annonce ces changements, on les place dans une région live :

AttributAnnonceUsage
role="status" (équivaut à aria-live="polite")À la fin de ce que le lecteur est en train de direChargement, confirmation « Frais ajouté », nombre de résultats
role="alert" (équivaut à aria-live="assertive")Immédiatement, en interrompantErreurs qui empêchent de continuer
tsx
{isPending && <p role="status">Chargement des fiches…</p>}
{isError && <p role="alert">Impossible de charger les fiches : {error.message}</p>}

Les changements de page sans rechargement

Quand un site classique charge une nouvelle page, le lecteur d'écran annonce son titre et repart du début. Dans une SPA, React Router change le contenu sans charger de page : sans précaution, le lecteur d'écran ne dit rien, le focus reste sur le lien cliqué (qui a peut-être disparu), et l'utilisateur ne sait pas que la page a changé. Trois mesures, déjà en place dans AppliFrais :

  1. Un titre d'onglet par page, mis à jour à chaque navigation : le hook useTitrePage du chapitre 11.
  2. Le focus déplacé vers le contenu principal après chaque changement de page :
tsx
// src/pages/Layout.tsx (extrait)
import { useEffect, useRef } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router';

export function Layout() {
  const { pathname } = useLocation();
  const main = useRef<HTMLElement>(null);
  const precedent = useRef(pathname);

  // Après un changement de page, le focus va au contenu principal.
  useEffect(() => {
    if (precedent.current !== pathname) {
      precedent.current = pathname;
      main.current?.focus();
    }
  }, [pathname]);

  return (
    <>
      <a href="#contenu" className="lien-evitement">Aller au contenu</a>
      <header>…</header>
      <main id="contenu" ref={main} tabIndex={-1}>
        <Outlet />
      </main>
    </>
  );
}

tabIndex={-1} permet de donner le focus au <main> par le code sans l'ajouter à l'ordre de tabulation. Le lecteur d'écran annonce la région principale et l'utilisateur continue depuis le début du contenu. On compare avec le chemin précédent plutôt que de sauter le premier rendu avec un drapeau : le double appel des effets du mode strict (chapitre 6) ne déclenche ainsi aucun focus au chargement initial.

  1. Un lien d'évitement « Aller au contenu », premier élément focalisable de la page, caché jusqu'à ce qu'il reçoive le focus : il évite de retraverser l'en-tête et la navigation à chaque page.
css
.lien-evitement { position: absolute; left: -999px; }
.lien-evitement:focus { left: 1rem; }

Le NavLink ajoute de lui-même aria-current="page" au lien de la page actuelle (chapitre 10).

Les fenêtres modales : l'élément dialog

Une boîte de confirmation (« Supprimer ce frais ? ») doit garder le focus à l'intérieur tant qu'elle est ouverte, se fermer avec Échap, rendre le reste de la page inerte, puis rendre le focus au bouton qui l'a ouverte. Le faire à la main est difficile. L'élément HTML natif <dialog>, ouvert avec sa méthode showModal(), fait tout cela :

tsx
import { useRef } from 'react';

type ConfirmationProps = {
  question: string;
  onConfirmer: () => void;
  children: string;   // le texte du bouton qui ouvre la boîte
};

export function BoutonConfirme({ question, onConfirmer, children }: ConfirmationProps) {
  const dialogue = useRef<HTMLDialogElement>(null);

  return (
    <>
      <button type="button" onClick={() => dialogue.current?.showModal()}>{children}</button>
      <dialog ref={dialogue} aria-labelledby="titre-confirmation">
        <h2 id="titre-confirmation">{question}</h2>
        <form method="dialog">
          <button value="non">Annuler</button>
          <button value="oui" onClick={onConfirmer}>Confirmer</button>
        </form>
      </dialog>
    </>
  );
}

<BoutonConfirme question={`Supprimer le frais « ${f.libelle} » ?`} onConfirmer={() => suppression.mutate(f.id)}>
  Supprimer
</BoutonConfirme>

Un formulaire method="dialog" dans un <dialog> ferme la boîte quand on clique sur un de ses boutons, sans envoyer de requête. Le navigateur gère le piège à focus, la touche Échap, le fond inerte et le retour du focus. Si deux boîtes peuvent être présentes sur la même page, remplace "titre-confirmation" par un identifiant créé avec useId.

Vérifier l'accessibilité

OutilCe qu'il trouveQuand
Le linter avec le plugin jsx-a11yimg sans alt, div cliquable, lien sans href… pendant que tu tapesEn continu
Les tests avec Testing LibraryUn test qui cherche getByRole('button', { name: 'Ajouter le frais' }) échoue si le bouton n'a pas de nom accessible (chapitre 13)À chaque npm test
L'extension axe DevTools, l'onglet Lighthouse de ChromeContrastes, attributs ARIA invalides, champs sans étiquetteAvant chaque livraison
Le clavier seulOrdre de tabulation, focus invisible, piège à focusÀ chaque nouvel écran : débranche la souris
Un lecteur d'écran (NVDA sous Windows, VoiceOver sous macOS)Ce qui est vraiment annoncéSur les parcours principaux

Le linter oxlint installé par Vite sait vérifier les règles d'accessibilité. Active le plugin jsx-a11y dans .oxlintrc.json :

json
{
  "plugins": ["react", "typescript", "oxc", "jsx-a11y"],
  "rules": {
    "react/rules-of-hooks": "error",
    "react/exhaustive-deps": "warn",
    "react/only-export-components": ["warn", { "allowConstantExport": true }]
  }
}

Sur un <img src="logo.svg" /> sans alt, npm run lint affiche alors jsx-a11y(alt-text): Missing alt attribute, et sur un <div onClick>, jsx-a11y(no-static-element-interactions). (Le plugin suggère aussi de remplacer <p role="status"> par l'élément <output> : les deux sont corrects ; <output> est surtout pensé pour le résultat d'un calcul de formulaire.)

Exercices

Exercice 1 sur 3 Corriger un composant Réussi À revoir

Ce composant affiche un frais avec un bouton de suppression et une icône d'information. Relève les problèmes d'accessibilité et corrige-le.

tsx
function LigneFrais({ f, onSupprimer }: { f: Frais; onSupprimer: () => void }) {
  return (
    <div className="ligne">
      <span>{f.libelle}</span>
      <span style={{ color: f.montant > 25 ? 'red' : 'black' }}>{f.montant} €</span>
      <img src="info.svg" onClick={() => alert('Montant TTC')} />
      <div className="btn" onClick={onSupprimer}>🗑</div>
    </div>
  );
}
Voir la réponseMasquer la réponse de l'exercice 1
  1. Le dépassement du plafond n'est indiqué que par la couleur : invisible pour un daltonien ou un lecteur d'écran. Ajouter un texte.
  2. L'image cliquable n'a pas d'alt et n'est pas focalisable : un bouton avec un nom.
  3. Le div avec une icône comme seul contenu : pas de focus, pas de rôle, pas de nom. Un button avec aria-label.
  4. Une alert() pour une information : un texte visible ou relié par aria-describedby est plus simple à lire.
tsx
function LigneFrais({ f, onSupprimer }: { f: Frais; onSupprimer: () => void }) {
  const auDela = f.montant > 25;
  return (
    <tr>
      <td>{f.libelle}</td>
      <td className={auDela ? 'au-dela' : undefined}>
        {formatMontant(f.montant)} TTC{auDela && ' (au-delà du plafond)'}
      </td>
      <td>
        <button type="button" onClick={onSupprimer} aria-label={`Supprimer le frais « ${f.libelle} »`}>
          <span aria-hidden="true">🗑</span>
        </button>
      </td>
    </tr>
  );
}

Dans un tableau, chaque frais devient une ligne <tr>, et l'en-tête des colonnes (<th scope="col">) donne le contexte aux lecteurs d'écran.

Tu avais trouvé ?

Exercice 2 sur 3 Annoncer ou pas Réussi À revoir

Pour chaque événement d'AppliFrais, choisis : role="status", role="alert", ou aucune annonce.

  1. La liste des fiches est en cours de chargement.
  2. L'API a refusé l'ajout d'un frais (409).
  3. Le frais a bien été ajouté.
  4. Le compteur « 46 caractères restants » change à chaque frappe.
  5. La session a expiré et l'application renvoie vers la connexion.
Voir la réponseMasquer la réponse de l'exercice 2
  1. role="status".
  2. role="alert" : l'utilisateur doit savoir tout de suite que son action a échoué.
  3. role="status" (une notification polie).
  4. Aucune annonce à chaque frappe, ce serait épuisant. On relie le compteur au champ avec aria-describedby : il est lu quand le champ reçoit le focus. Au besoin, une région status peut annoncer seulement quand il reste moins de 10 caractères.
  5. La page de connexion s'affiche avec un titre d'onglet « Connexion » et le focus sur le contenu principal ; un message en role="alert" (« Ta session a expiré, reconnecte-toi ») évite que l'utilisateur croie à une erreur de sa part.

Tu avais trouvé ?

Exercice 3 sur 3 Tester au clavier Réussi À revoir

Décris, étape par étape, comment un utilisateur au clavier seul ajoute un frais à sa fiche d'octobre dans AppliFrais, depuis la page « Mes fiches ». Pour chaque étape, indique l'élément qui doit avoir le focus.

Voir la réponseMasquer la réponse de l'exercice 3
  1. Tab : le lien d'évitement « Aller au contenu » apparaît. Entrée : le focus passe au <main>.
  2. Tab : la liste déroulante « État » (filtre) ; Tab encore : le lien « Fiche de frais – octobre 2026 ». Entrée : la page de la fiche s'affiche, le focus est sur le <main>, le titre de l'onglet change.
  3. Tab : « Retour à mes fiches », puis les boutons « Supprimer le frais « … » » du tableau, puis le champ Date.
  4. Saisie de la date, Tab, libellé, Tab, montant, Tab, bouton « Ajouter le frais », Entrée (ou Entrée directement depuis un champ, qui envoie le formulaire).
  5. Pendant l'envoi, le bouton annonce « Enregistrement… » ; en cas d'erreur, le message role="alert" est lu immédiatement.

À chaque étape, le focus doit être visible (ne supprime jamais le contour avec outline: none sans le remplacer).

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Build et déploiement d'une application React (Vite)Revoir le sommaire du cours