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