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