From 6e663752332abc724948c6421dd49fd47ab540c7 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 12:28:36 +0200 Subject: [PATCH] feat(modals): close, popstate, dismiss, Esc, backdrop, response-error Centralizes state cleanup on the dialog's native 'close' event: every close source (Esc cancel event, backdrop click, [data-modal-dismiss], browser back, htmx:responseError on the modal fetch, or programmatic closeModal()) just calls dialog.close() and the single 'close' listener clears ?modal= from the URL and resets currentModalPath. This avoids the trap where legacy modal.js's backdrop close didn't sync our URL, and the trap where a 4xx response opened an empty modal. window.closeModal exposed for callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/l4d2web/static/js/modal-router.js | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/l4d2web/l4d2web/static/js/modal-router.js b/l4d2web/l4d2web/static/js/modal-router.js index 234de70..04a00e7 100644 --- a/l4d2web/l4d2web/static/js/modal-router.js +++ b/l4d2web/l4d2web/static/js/modal-router.js @@ -34,6 +34,55 @@ }); } + function closeModal() { + const dlg = document.getElementById("modal-container"); + if (dlg && dlg.open) dlg.close(); + } + + document.addEventListener("DOMContentLoaded", () => { + const dlg = document.getElementById("modal-container"); + if (!dlg) return; + + // Single source of truth: every close path funnels through the dialog's + // native 'close' event, so URL/state cleanup runs exactly once no matter + // who triggered the close (Esc, backdrop, dismiss button, response error, + // or the legacy modal.js backdrop handler). + dlg.addEventListener("close", () => { + currentModalPath = null; + const url = new URL(window.location.href); + if (url.searchParams.has("modal")) { + url.searchParams.delete("modal"); + history.pushState({}, "", url.toString()); + } + }); + + // Esc key fires 'cancel' on a ; preventDefault so we control the + // close path (the default action would close() too, but we want one path). + dlg.addEventListener("cancel", (event) => { + event.preventDefault(); + dlg.close(); + }); + + // Backdrop click on our own slot. The legacy modal.js handler also + // matches dialog.modal and closes; this listener is harmless when the + // legacy one wins because dialog.close() is idempotent. + dlg.addEventListener("click", (event) => { + if (event.target === dlg) dlg.close(); + }); + }); + + // Browser back/forward: re-evaluate URL state and either fetch+show a new + // modal or close the current one. Closing routes through dialog.close() so + // the close-event cleanup fires (no separate state update here). + window.addEventListener("popstate", () => { + const path = new URL(window.location.href).searchParams.get("modal"); + if (path) { + fetchAndShow(path); + } else { + closeModal(); + } + }); + document.addEventListener("click", (event) => { const link = event.target.closest("a[data-modal]"); if (!link) return; @@ -45,7 +94,28 @@ openModal(href); }); + // Dismiss triggers inside modal content (e.g. the editor's × and Cancel + // buttons render with data-modal-dismiss). Document-level delegation so + // HTMX-swapped content gets the behavior without re-binding. + document.addEventListener("click", (event) => { + if (event.target.closest("[data-modal-dismiss]")) { + event.preventDefault(); + closeModal(); + } + }); + + // HTMX response error (4xx/5xx from the modal fetch): close the modal so + // the user isn't left with an error fragment open and a stale ?modal= URL. + document.body.addEventListener("htmx:responseError", (event) => { + const target = event.detail && event.detail.target; + if (target && target.id === "modal-content") { + console.warn("[modal-router] server returned error, closing modal", event.detail.xhr && event.detail.xhr.status); + closeModal(); + } + }); + // Public API — used by files-overlay.js to open the editor from row clicks // that aren't a literal (existing event delegation). window.openModal = openModal; + window.closeModal = closeModal; })();