From 1fd0c8933b4873baa7a2323d663746b9100d121c Mon Sep 17 00:00:00 2001 From: Nicolas FRYDER Date: Sat, 22 Aug 2026 13:57:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(a11y):=20les=20modales=20du=20site=20public?= =?UTF-8?q?=20d=C3=A9claraient=20aria-modal=20sans=20pi=C3=A9ger=20le=20fo?= =?UTF-8?q?cus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/site/app.js | 60 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/apps/web/site/app.js b/apps/web/site/app.js index b2d2d39..d686a18 100644 --- a/apps/web/site/app.js +++ b/apps/web/site/app.js @@ -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(); });