fix(a11y): rend conformes les boîtes de dialogue et les messages dynamiques

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).
This commit is contained in:
2026-08-16 13:34:09 +02:00
parent fc3cde3d65
commit 766d853d30
4 changed files with 161 additions and 6 deletions
@@ -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(
<Modal isOpen onClose={onClose} title="Modifier le service">
<input aria-label="Nom" />
<button>Enregistrer</button>
</Modal>,
);
}
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 à louverture', () => {
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 à linté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('naffiche rien quand elle est fermée', () => {
render(<Modal isOpen={false} onClose={() => {}} title="Titre">contenu</Modal>);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
describe('Messages dynamiques — annonce aux lecteurs d’écran', () => {
it('annonce une erreur immédiatement', () => {
render(<ErrorMessage message="Erreur 500 : service indisponible" />);
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(<LoadingSpinner message="Chargement de l'historique..." />);
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(<LoadingSpinner />);
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
});
});