From 766d853d3037e778c1ae64f64a33cef9e1e8e596 Mon Sep 17 00:00:00 2001 From: Anthony Coyaud Date: Sun, 16 Aug 2026 13:34:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(a11y):=20rend=20conformes=20les=20bo=C3=AEt?= =?UTF-8?q?es=20de=20dialogue=20et=20les=20messages=20dynamiques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dossier affirmait un focus trap sur les modales et des régions aria-live pour les mises à jour dynamiques. Ni l'un ni l'autre n'existait : Modal.jsx tenait en 19 lignes, sans role="dialog", sans aria-modal, sans gestion du clavier, et aucune occurrence d'aria-live dans le front. Plutôt que retirer ces affirmations, elles sont rendues vraies. Modal : - role="dialog", aria-modal et aria-labelledby relié au titre - focus placé à l'ouverture sur le premier champ du corps, à défaut sur le premier élément focusable - tabulation contenue dans la modale, dans les deux sens - fermeture par Échap et restitution du focus à l'élément d'origine - identifiant produit par useId : ni tirage aléatoire pendant le rendu, ni collision entre deux modales ErrorMessage et LoadingSpinner reçoivent role="alert"/"status" avec le niveau d'annonce correspondant ; les éléments purement décoratifs passent en aria-hidden. 9 tests ajoutés pour que ces comportements soient vérifiables autrement qu'en relisant le code (41 -> 50 tests front). --- .../src/__tests__/accessibilite.test.jsx | 89 +++++++++++++++++++ .../src/components/common/ErrorMessage.jsx | 7 +- .../src/components/common/LoadingSpinner.jsx | 6 +- DataSentinel/src/components/common/Modal.jsx | 65 +++++++++++++- 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 DataSentinel/src/__tests__/accessibilite.test.jsx diff --git a/DataSentinel/src/__tests__/accessibilite.test.jsx b/DataSentinel/src/__tests__/accessibilite.test.jsx new file mode 100644 index 0000000..3de26f7 --- /dev/null +++ b/DataSentinel/src/__tests__/accessibilite.test.jsx @@ -0,0 +1,89 @@ +/* Accessibilité des composants transverses. + + Le dossier affirme que les boîtes de dialogue piègent le focus et que les + messages dynamiques sont annoncés aux lecteurs d'écran. Ces tests rendent ces + affirmations vérifiables : elles étaient auparavant invalidables autrement + qu'en relisant le code. */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Modal from '../components/common/Modal'; +import ErrorMessage from '../components/common/ErrorMessage'; +import LoadingSpinner from '../components/common/LoadingSpinner'; + +function ouvrirModale(onClose = () => {}) { + return render( + + + + , + ); +} + +describe('Modal — accessibilité', () => { + it('est exposée comme boîte de dialogue modale', () => { + ouvrirModale(); + const dialogue = screen.getByRole('dialog'); + expect(dialogue).toHaveAttribute('aria-modal', 'true'); + }); + + it('est nommée par son titre', () => { + ouvrirModale(); + /* Le lecteur d'écran doit annoncer de quoi parle la modale. */ + expect(screen.getByRole('dialog', { name: 'Modifier le service' })).toBeInTheDocument(); + }); + + it('place le focus sur le premier champ à l’ouverture', () => { + ouvrirModale(); + expect(screen.getByLabelText('Nom')).toHaveFocus(); + }); + + it('referme la modale à la touche Échap', async () => { + const onClose = vi.fn(); + const utilisateur = userEvent.setup(); + ouvrirModale(onClose); + + await utilisateur.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalled(); + }); + + it('retient le focus à l’intérieur de la modale', async () => { + const utilisateur = userEvent.setup(); + ouvrirModale(); + + /* Depuis le dernier élément, Tab doit revenir au premier et non filer + vers la page située derrière. */ + const fermer = screen.getByRole('button', { name: 'Fermer' }); + fermer.focus(); + await utilisateur.tab(); + + expect(document.activeElement).not.toBe(document.body); + expect(screen.getByRole('dialog').contains(document.activeElement)).toBe(true); + }); + + it('n’affiche rien quand elle est fermée', () => { + render( {}} title="Titre">contenu); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); + +describe('Messages dynamiques — annonce aux lecteurs d’écran', () => { + it('annonce une erreur immédiatement', () => { + render(); + const alerte = screen.getByRole('alert'); + expect(alerte).toHaveAttribute('aria-live', 'assertive'); + expect(alerte).toHaveTextContent('service indisponible'); + }); + + it('annonce le chargement sans interrompre la lecture', () => { + render(); + const statut = screen.getByRole('status'); + expect(statut).toHaveAttribute('aria-live', 'polite'); + }); + + it('masque les éléments purement décoratifs', () => { + /* L'animation du spinner n'apporte rien à l'oral : elle doit être ignorée. */ + const { container } = render(); + expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument(); + }); +}); diff --git a/DataSentinel/src/components/common/ErrorMessage.jsx b/DataSentinel/src/components/common/ErrorMessage.jsx index f14cbad..4d3dcdb 100644 --- a/DataSentinel/src/components/common/ErrorMessage.jsx +++ b/DataSentinel/src/components/common/ErrorMessage.jsx @@ -4,9 +4,12 @@ import { AlertCircle } from 'lucide-react'; import styles from './ErrorMessage.module.css'; export default function ErrorMessage({ message = 'Une erreur est survenue.', onRetry }) { + /* role="alert" annonce l'erreur immédiatement au lecteur d'écran : elle + apparaît après le chargement de la page, sans quoi elle passerait + inaperçue pour un utilisateur non voyant. */ return ( -
- +
+