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(); });