Aller au contenu principal

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 Dialog et le composer ;
  • utiliser la prop render pour 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...

  1. jouer une partie complète et décrire la couleur prise par la bonne réponse après un choix
  2. lancer npm test et lire le nombre de tests réussis
  3. dire pourquoi le formulaire utilise le composant Alert pour 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 :

ComposantRôle
Dialogl'ensemble (gère l'état ouvert ou fermé)
DialogTriggerl'élément qui ouvre la fenêtre
DialogContentla fenêtre elle-même (avec son bouton de fermeture)
DialogHeaderl'en-tête
DialogTitlele titre (lu par les lecteurs d'écran)
DialogDescriptionla description (lue aussi)

La composition​

src/components/ReglesDuJeu.tsx
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.

asChild ou render ?

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();
});
  • findByRole attend l'apparition de l'élément (la fenêtre s'ouvre avec une petite animation) ;
  • waitFor ré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​


Que doit faire une bonne fenêtre modale avec la touche Tab ?


Quel composant déclenche l'ouverture du Dialog ?


À quoi sert la prop render={<Button variant="outline" />} sur DialogTrigger ?


Pourquoi peut-on traduire le texte Close du bouton de fermeture ?


Pourquoi utiliser findByRole plutôt que getByRole pour trouver la fenêtre après un clic ?


Que fait la classe Tailwind sr-only ?



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​


Bonne pratique - Un commit avant, un diff après

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.


Bonne pratique - Un titre et une description

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.


Bonne pratique - Le composant reste sans état

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.


Bonne pratique - Garder les modifications du code généré petites

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.


Bonne pratique - Tester le clavier

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 :

  1. Tab jusqu'au bouton « Règles du jeu », puis Entrée : la fenêtre s'ouvre ;
  2. Tab plusieurs fois : le focus reste dans la fenêtre ;
  3. Échap : la fenêtre se ferme et le focus revient sur le bouton « Règles du jeu ».

Bonne pratique - Le test manuel au clavier

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​

NotionRésumé
Fenêtre modaleDoit piéger le focus, se fermer avec Échap, rendre le focus et être annoncée aux lecteurs d'écran.
DialogSe compose avec DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription.
render / asChildDonne 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.
TestsfindByRole("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.