feat(ui): global confirm modal — browser dialogs retired sitewide (issues #5/#4/#7)

ONE styled modal answers every hx-confirm in the app: a body-level
htmx:confirm interceptor (web/src/confirm.ts, canonical htmx pattern)
preventDefaults and shows the dialog, issuing the request on Confirm.
Nine hx-confirm call sites upgrade without touching them. The modal
lives in the header partial so every page has it; confirmDialog() is
exported for JS call sites and degrades to the native dialog if the
header is absent (bare layouts).

Migrated the five window.confirm sites (admin.ts ×3, book-detail.ts ×2)
to the same promise API.

Podcast feed detail (issues #4/#7): the unsubscribe copy is
role-aware — non-admins get 'Unsubscribe from this feed?' with silent
retention (file state is admin knowledge); an admin who is the LAST
subscriber gets a three-choice modal instead: Unsubscribe (files kept,
the D6 default — sliding-window feeds make purge unrecoverable) /
Remove feed + files / Cancel. Admins with co-subscribers keep the
files-note confirm.
This commit is contained in:
John O'Keefe committed 2026-10-29 20:12:30 -04:00
1 parent 30766020c8
commit acbcc61ae4
5 files changed
+113 -10

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
// Global confirm-dialog replacement (issue #5): ONE styled modal
// answers every hx-confirm sitewide (via the htmx:confirm event) and
// exposes a promise API for the legacy JS call sites. No more native
// window.confirm anywhere.
let activeResolve: ((ok: boolean) => void) | null = null;
function modalElements(): { overlay: HTMLElement | null; message: HTMLElement | null } {
return {
overlay: document.getElementById("confirm-modal"),
message: document.getElementById("confirm-modal-message"),
};
}
function hide(ok: boolean): void {
const { overlay } = modalElements();
if (overlay) overlay.classList.add("hidden");
const resolve = activeResolve;
activeResolve = null;
if (resolve) resolve(ok);
}
/**
* confirmDialog shows the global modal and resolves with the answer.
* Safe to call from any module (import this instead of window.confirm).
*/
export function confirmDialog(message: string): Promise<boolean> {
const { overlay, message: messageEl } = modalElements();
if (!overlay || !messageEl) {
// Header missing (bare layouts): degrade to the native dialog
// rather than silently swallowing the confirmation.
return Promise.resolve(window.confirm(message));
}
messageEl.textContent = message;
overlay.classList.remove("hidden");
return new Promise<boolean>((resolve) => {
activeResolve = resolve;
});
}
// Wire the static buttons once.
function wireButtons(): void {
const { overlay } = modalElements();
const accept = document.getElementById("confirm-modal-accept");
const dismiss = document.getElementById("confirm-modal-cancel");
if (accept && !accept.hasAttribute("data-wired")) {
accept.setAttribute("data-wired", "1");
accept.addEventListener("click", () => hide(true));
}
if (dismiss && !dismiss.hasAttribute("data-wired")) {
dismiss.setAttribute("data-wired", "1");
dismiss.addEventListener("click", () => hide(false));
}
// Backdrop click cancels.
if (overlay && !overlay.hasAttribute("data-wired")) {
overlay.setAttribute("data-wired", "1");
overlay.addEventListener("click", (e) => {
if (e.target === overlay) hide(false);
});
}
}
// htmx:confirm interceptor: every element with hx-confirm gets the
// modal instead of the browser dialog; confirming issues the request.
// (Canonical htmx pattern — see htmx docs, "confirming with a modal".)
document.body.addEventListener("htmx:confirm", function (evt) {
const custom = evt as CustomEvent<{
element: HTMLElement;
issueRequest: (override?: boolean) => void;
}>;
const elt = custom.detail.element;
if (!elt) return;
const question = elt.getAttribute("hx-confirm");
if (question === null) return;
evt.preventDefault();
wireButtons();
confirmDialog(question).then((ok) => {
if (ok) custom.detail.issueRequest(true);
});
});