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
+63 -2
View File
@@ -1,13 +1,74 @@
import { useEffect, useId, useRef } from 'react';
import styles from './Modal.module.css';
/* Sélecteur des éléments qui peuvent recevoir le focus au clavier. */
const ELEMENTS_FOCUSABLES =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
export default function Modal({ isOpen, onClose, title, children }) {
const conteneur = useRef(null);
/* useId fournit un identifiant stable et unique par instance, sans tirage
aléatoire pendant le rendu et sans collision si deux modales coexistent. */
const titreId = useId();
/* Piège le focus dans la modale et gère la touche Échap.
Sans cela, la tabulation continue de parcourir la page située derrière,
ce qui rend la boîte de dialogue inutilisable au clavier. */
useEffect(() => {
if (!isOpen) return undefined;
const elementActifAvant = document.activeElement;
/* On vise d'abord le contenu : sur une modale de formulaire, ouvrir sur le
premier champ évite à l'utilisateur une tabulation inutile. À défaut, on
se rabat sur le premier élément focusable, souvent le bouton de fermeture. */
const corps = conteneur.current?.querySelector(`.${styles.modalBody}`);
const premier = corps?.querySelector(ELEMENTS_FOCUSABLES)
?? conteneur.current?.querySelector(ELEMENTS_FOCUSABLES);
premier?.focus();
function handleKeyDown(evenement) {
if (evenement.key === 'Escape') {
onClose();
return;
}
if (evenement.key !== 'Tab') return;
const focusables = conteneur.current?.querySelectorAll(ELEMENTS_FOCUSABLES);
if (!focusables?.length) return;
const premier = focusables[0];
const dernier = focusables[focusables.length - 1];
if (evenement.shiftKey && document.activeElement === premier) {
evenement.preventDefault();
dernier.focus();
} else if (!evenement.shiftKey && document.activeElement === dernier) {
evenement.preventDefault();
premier.focus();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
elementActifAvant?.focus?.();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div
className={styles.modal}
ref={conteneur}
role="dialog"
aria-modal="true"
aria-labelledby={titreId}
onClick={e => e.stopPropagation()}
>
<div className={styles.modalHeader}>
<h2>{title}</h2>
<h2 id={titreId}>{title}</h2>
<button className={styles.closeBtn} onClick={onClose} aria-label="Fermer">×</button>
</div>
<div className={styles.modalBody}>