Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9bcf4c4d | ||
|
|
766d853d30 | ||
|
|
fc3cde3d65 |
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,12 @@ import { AlertCircle } from 'lucide-react';
|
|||||||
import styles from './ErrorMessage.module.css';
|
import styles from './ErrorMessage.module.css';
|
||||||
|
|
||||||
export default function ErrorMessage({ message = 'Une erreur est survenue.', onRetry }) {
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container} role="alert" aria-live="assertive">
|
||||||
<AlertCircle size={20} color="var(--color-primary)" />
|
<AlertCircle size={20} color="var(--color-primary)" aria-hidden="true" />
|
||||||
<p className={styles.text}>{message}</p>
|
<p className={styles.text}>{message}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button className={styles.retryBtn} onClick={onRetry}>
|
<button className={styles.retryBtn} onClick={onRetry}>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import styles from './LoadingSpinner.module.css';
|
import styles from './LoadingSpinner.module.css';
|
||||||
|
|
||||||
export default function LoadingSpinner({ message = 'Chargement...' }) {
|
export default function LoadingSpinner({ message = 'Chargement...' }) {
|
||||||
|
/* aria-live="polite" fait annoncer la fin du chargement sans interrompre la
|
||||||
|
lecture en cours ; l'animation elle-même n'apporte rien à l'oral. */
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container} role="status" aria-live="polite">
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} aria-hidden="true" />
|
||||||
{message && <p className={styles.message}>{message}</p>}
|
{message && <p className={styles.message}>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,74 @@
|
|||||||
|
import { useEffect, useId, useRef } from 'react';
|
||||||
import styles from './Modal.module.css';
|
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 }) {
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<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}>
|
<div className={styles.modalHeader}>
|
||||||
<h2>{title}</h2>
|
<h2 id={titreId}>{title}</h2>
|
||||||
<button className={styles.closeBtn} onClick={onClose} aria-label="Fermer">×</button>
|
<button className={styles.closeBtn} onClick={onClose} aria-label="Fermer">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalBody}>
|
<div className={styles.modalBody}>
|
||||||
|
|||||||
Reference in New Issue
Block a user