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:
@@ -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 à 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(<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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user