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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 12:28:36 +02:00
parent 3de68b7539
commit 6e66375233
No known key found for this signature in database

View file

@ -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 <dialog>; 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) => { document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-modal]"); const link = event.target.closest("a[data-modal]");
if (!link) return; if (!link) return;
@ -45,7 +94,28 @@
openModal(href); 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 // Public API — used by files-overlay.js to open the editor from row clicks
// that aren't a literal <a data-modal> (existing event delegation). // that aren't a literal <a data-modal> (existing event delegation).
window.openModal = openModal; window.openModal = openModal;
window.closeModal = closeModal;
})(); })();