Fenêtre modale et accessibilité
Une fenêtre qui s'ouvre au-dessus de la page, utilisable au clavier et par les lecteurs d'écran, sans écrire une ligne de gestion du focus.
Objectifs de la séance
- comprendre ce qu'est une fenêtre modale et ce que doit faire un bon composant ;
- ajouter le composant
Dialoget le composer ; - utiliser la prop
renderpour transformer un élément en déclencheur ; - adapter le code généré (traduire un texte) ;
- tester l'ouverture et la fermeture avec la touche Échap ;
- vérifier l'accessibilité au clavier.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- jouer une partie complète et décrire la couleur prise par la bonne réponse après un choix
- lancer
npm testet lire le nombre de tests réussis - dire pourquoi le formulaire utilise le composant
Alertpour afficher l'erreur de saisie
📌 Réponse attendue
Notions théoriques
Une fenêtre modale, c'est difficile à bien faire
Une fenêtre modale (dialog) s'affiche au-dessus de la page et demande l'attention de l'utilisateur. Pour être correcte, elle doit :
- piéger le focus : la touche Tab ne doit circuler qu'à l'intérieur de la fenêtre ;
- se fermer avec Échap et par un bouton de fermeture ;
- rendre le focus au bouton qui l'a ouverte quand elle se ferme ;
- être annoncée aux lecteurs d'écran (
role="dialog", titre, description) ; - bloquer l'interaction avec le reste de la page.
Écrire tout cela à la main est long et plein de pièges. C'est précisément ce que fournit la bibliothèque de composants accessibles utilisée par shadcn/ui : vous n'avez à écrire que la structure.
Ajouter le composant
npx shadcn@latest add dialog
Le fichier src/components/ui/dialog.tsx est copié dans votre projet. Il exporte plusieurs morceaux qui se composent comme des poupées russes :
| Composant | Rôle |
|---|---|
Dialog | l'ensemble (gère l'état ouvert ou fermé) |
DialogTrigger | l'élément qui ouvre la fenêtre |
DialogContent | la fenêtre elle-même (avec son bouton de fermeture) |
DialogHeader | l'en-tête |
DialogTitle | le titre (lu par les lecteurs d'écran) |
DialogDescription | la description (lue aussi) |
La composition
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function ReglesDuJeu() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>Règles du jeu</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Règles du jeu</DialogTitle>
<DialogDescription>
Choisissez un niveau, puis répondez aux questions une par une. Chaque bonne réponse
rapporte un point. La correction s'affiche tout de suite après votre choix.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
Le composant n'a aucun état, aucun useState : le Dialog gère lui-même son ouverture. Si vous aviez besoin d'ouvrir la fenêtre depuis le code (par exemple après une erreur), il accepte aussi les props open et onOpenChange pour être contrôlé par un état, comme un champ de formulaire.
La prop render
Par défaut, DialogTrigger affiche son propre bouton. Pour que ce soit votre Button stylé, on le passe par la prop render :
<DialogTrigger render={<Button variant="outline" />}>Règles du jeu</DialogTrigger>
Le texte entre les balises devient le contenu de votre bouton, et le déclencheur garde son comportement (ouvrir la fenêtre) tout en prenant l'apparence de Button. On évite ainsi un bouton à l'intérieur d'un bouton, qui est du HTML invalide.
Selon la version de l'outil et la bibliothèque de base choisie, cette adaptation s'écrit render={<Button />} ou asChild (<DialogTrigger asChild><Button>...</Button></DialogTrigger>). Les deux font la même chose. Regardez le code de dialog.tsx généré chez vous pour savoir lequel s'applique.
Le code généré vous appartient
Ouvrez src/components/ui/dialog.tsx : le bouton de fermeture contient un texte réservé aux lecteurs d'écran, écrit en anglais (Close). L'application étant en français, modifiez-le : c'est votre code.
<span className="sr-only">Fermer</span>
La classe sr-only masque le texte à l'écran mais le laisse disponible pour les lecteurs d'écran.
Tester une fenêtre modale
Testing Library trouve la fenêtre par son rôle dialog. On vérifie qu'elle est absente au départ, qu'elle apparaît au clic, et qu'elle disparaît avec Échap :
await userEvent.click(screen.getByRole("button", { name: "Règles du jeu" }));
expect(await screen.findByRole("dialog")).toHaveTextContent("Chaque bonne réponse");
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
findByRoleattend l'apparition de l'élément (la fenêtre s'ouvre avec une petite animation) ;waitForrépète la vérification jusqu'à ce qu'elle réussisse (ou qu'un délai soit dépassé).
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Votre mission : ajouter une fenêtre « Règles du jeu » à l'accueil, la tester et vérifier son comportement au clavier.
Étape 1 — Ajouter le composant Dialog
Après l'ajout, git status montre src/components/ui/dialog.tsx comme nouveau fichier. Lisez-le avant de continuer.
Étape 2 — Le composant ReglesDuJeu
Créez src/components/ReglesDuJeu.tsx. Complétez le déclencheur.
DialogTitle et DialogDescription ne sont pas décoratifs : les lecteurs d'écran les annoncent à l'ouverture. Une fenêtre sans titre est un piège pour un utilisateur non voyant.
Étape 3 — L'afficher sur la page d'accueil
Modifiez PageAccueil pour placer la fenêtre sous le formulaire.
ReglesDuJeu ne gère aucun état : la bibliothèque s'en charge. Moins d'état à écrire, c'est moins de bugs possibles.
Étape 4 — Traduire le bouton de fermeture
Dans src/components/ui/dialog.tsx, cherchez le texte Close à l'intérieur de la balise <span className="sr-only"> et remplacez-le.
Plus vous modifiez un fichier généré, plus une future mise à jour de l'outil sera pénible. Corrigez les textes et le style, et laissez la mécanique intacte.
Étape 5 — Tester l'ouverture et la fermeture
Créez src/components/ReglesDuJeu.test.tsx. Complétez le test de la touche Échap.
Le test d'Échap prouve que la fenêtre est utilisable sans souris. Si un jour quelqu'un remplace le composant par une version « maison », ce test détectera la régression.
Étape 6 — Contrôler à la main, au clavier
Lancez npm run dev et posez la souris. Vérifiez avec le clavier seul :
- Tab jusqu'au bouton « Règles du jeu », puis Entrée : la fenêtre s'ouvre ;
- Tab plusieurs fois : le focus reste dans la fenêtre ;
- Échap : la fenêtre se ferme et le focus revient sur le bouton « Règles du jeu ».
Les tests automatiques ne remplacent pas un essai réel : naviguer au clavier cinq minutes révèle des problèmes (ordre du focus, bouton invisible) qu'aucun test ne signale.
📌 Une solution
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
| Fenêtre modale | Doit piéger le focus, se fermer avec Échap, rendre le focus et être annoncée aux lecteurs d'écran. |
Dialog | Se compose avec DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription. |
render / asChild | Donne au déclencheur l'apparence de votre propre Button. |
| Code généré | Il vous appartient : on peut traduire un texte, mais on évite les grosses modifications. |
| Tests | findByRole("dialog"), waitFor, userEvent.keyboard("{Escape}"). |
| Accessibilité | Titre et description, sr-only, essai réel au clavier. |
Bilan de la série
Félicitations : vous avez construit, étape par étape, une application React complète :
- un projet Vite + TypeScript avec composants, props, listes et conditions ;
- de l'état bien géré (
useState, formulaires contrôlés, état remonté,useReducer) ; - des effets propres, un chargement de données robuste (erreur, annulation, validation) et des hooks personnalisés ;
- plusieurs pages avec React Router, des paramètres d'URL validés et un contexte ;
- des tests automatiques et une version de production ;
- une interface habillée avec Tailwind CSS et shadcn/ui.
Pour la suite : la série Expo applique les mêmes idées à une application mobile, avec une vraie base de données et un serveur qui calcule les scores.