fix(a11y): les modales du site public déclaraient aria-modal sans piéger le focus

Les deux modales (groupes sans coordonnées, mentions légales / FAQ) portent
role="dialog" et aria-modal="true" — ce qui affirme aux lecteurs d'écran que
tout le reste de la page est inerte. C'était faux : aucune gestion du focus.

Concrètement, à l'ouverture le focus restait derrière le voile, la tabulation
ressortait sur des commandes invisibles, et la fermeture ne rendait pas le
focus à son point de départ. Un utilisateur au clavier se retrouvait perdu
dans une page dont on venait de lui dire qu'elle était inaccessible.

Le dashboard admin faisait déjà tout cela correctement ; on porte le même
traitement, factorisé en openBackdrop / closeBackdrop puisque les modales du
site public sont des éléments statiques que l'on bascule, et non créés à la
volée comme côté admin.

Non couvert par un test : le site public n'a aucun harnais de bout en bout —
les 89 parcours Playwright ne visent que l'admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas FRYDER 2026-08-22 13:57:58 +02:00
parent 582fdc5451
commit 1fd0c8933b

View file

@ -1187,9 +1187,55 @@ async function loadNoLocationBands() {
}
}
// ------------------------------------------------------------------
// Modales — focus
//
// Les deux modales déclarent aria-modal="true", ce qui affirme aux lecteurs
// d'écran que tout le reste de la page est inerte. Sans piège à focus, c'était
// faux : la tabulation ressortait derrière le voile, sur des commandes
// invisibles, et la fermeture ne rendait pas le focus à son point de départ.
// Même traitement que le dashboard admin, qui le faisait déjà correctement.
// ------------------------------------------------------------------
const FOCUSABLES = 'button, [href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
let lastFocusedBeforeModal = null;
function trapModalFocus(backdrop) {
if (!backdrop || backdrop.__trap) return;
const onKeydown = (e) => {
if (e.key !== "Tab") return;
const f = backdrop.querySelectorAll(FOCUSABLES);
if (!f.length) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
backdrop.addEventListener("keydown", onKeydown);
backdrop.__trap = onKeydown;
}
function openBackdrop(backdrop) {
if (!backdrop) return;
lastFocusedBeforeModal = document.activeElement;
backdrop.classList.add("on");
backdrop.setAttribute("aria-hidden", "false");
trapModalFocus(backdrop);
const f = backdrop.querySelectorAll(FOCUSABLES);
if (f.length) f[0].focus();
}
function closeBackdrop(backdrop) {
if (!backdrop || !backdrop.classList.contains("on")) return;
backdrop.classList.remove("on");
backdrop.setAttribute("aria-hidden", "true");
if (lastFocusedBeforeModal && document.contains(lastFocusedBeforeModal)) {
lastFocusedBeforeModal.focus();
lastFocusedBeforeModal = null;
}
}
function closeModal() {
modalBackdrop.classList.remove("on");
modalBackdrop.setAttribute("aria-hidden", "true");
closeBackdrop(modalBackdrop);
}
modalClose?.addEventListener("click", closeModal);
@ -1209,14 +1255,11 @@ function openInfoModal(title, html) {
if (!infoBackdrop || !infoBody || !infoTitle) return;
infoTitle.textContent = title;
infoBody.innerHTML = html;
infoBackdrop.classList.add("on");
infoBackdrop.setAttribute("aria-hidden", "false");
openBackdrop(infoBackdrop);
}
function closeInfoModal() {
if (!infoBackdrop) return;
infoBackdrop.classList.remove("on");
infoBackdrop.setAttribute("aria-hidden", "true");
closeBackdrop(infoBackdrop);
}
if (infoClose) infoClose.addEventListener("click", closeInfoModal);
@ -1332,8 +1375,7 @@ setupDropdown("themeToggle", "themeSelect");
// --- Buttons / controls ---
$("btnNoLocation")?.addEventListener("click", () => {
modalTitle.textContent = t("without_coords_modal");
modalBackdrop.classList.add("on");
modalBackdrop.setAttribute("aria-hidden", "false");
openBackdrop(modalBackdrop);
loadNoLocationBands();
});