Files
bookhoard/web/src/confirm.ts
T
John O'Keefe ace9e57d27 fix(ui): hx-confirm interception actually worked — htmx 2 detail shape
User report: Remove Feed still used the browser dialog. Headless-proven
root cause (tests/ui/confirm_probe.spec.ts): the htmx:confirm handler
read evt.detail.element, which htmx 2 does not provide (its detail is
{ elt, question, issueRequest, ... }) — the handler returned at
'if (!elt) return' BEFORE preventDefault, so every hx-confirm sitewide
fell through to htmx's native confirm. The acbcc61a migration only ever
worked for direct confirmDialog() callers; hx-confirm interception was
dead on arrival against htmx 2 and no test watched the JS side.

Fix: read detail.question (htmx's own closest-attribute resolution of
hx-confirm); return without preventing default when absent. Probe
spec pins: click Remove Feed → #confirm-modal opens with the message,
cancel hides it, no dialog event fires.

Companion fixes in the same user-test round (one commit, one defect
family — templ boolean attributes + confirm-modal placement):
- Add-modal Download order never revealed: #podcast-download-all was
  nested in a wrapper div, so ':checked ~ .podcast-add-order' had no
  sibling relationship. Input, label, and order row are now siblings.
- Order/auto-download dropdowns always DISPLAYED the last option
  ('Newest first'/'Newest') whatever was saved: templ selected={ bool }
  emits selected="false" — presence selects, so every option was
  selected and the browser honored the last. Swept to selected?={...}
  across podcast_detail (14), book_detail_modals (10, incl. two
  always-checked checkboxes), collection_modal (2, same). Saves were
  correct all along; display only.
- Global confirm modal moved from SidebarSignIn (signed-out only!) into
  Header — signed-in pages had no #confirm-modal, so confirmDialog
  degraded to window.confirm even with a working interceptor.

Facet bar scale (user question): values now carry feed counts, sort
frequency-first, cap at 6 chips per group with a native <details>
'+N more' expander.

Probes: tests/ui/confirm_probe.spec.ts + add_order_probe.spec.ts
(live-server, BH_FEED-gated) — both pass against the dev instance.
2026-10-30 12:24:16 -04:00

86 lines
3.1 KiB
TypeScript

// 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".)
//
// htmx 2 detail shape: { elt, question, issueRequest, ... } — question
// is the CLOSEST hx-confirm value htmx already resolved. (The original
// read detail.element, which htmx 2 does not provide; the handler
// bailed before preventDefault and EVERY hx-confirm fell through to
// the native dialog — user report 2026-10-30, headless-proven.)
document.body.addEventListener("htmx:confirm", function (evt) {
const custom = evt as CustomEvent<{
elt?: HTMLElement;
question?: string;
issueRequest: (skipConfirmation?: boolean) => void;
}>;
const question = custom.detail.question;
if (!question) return; // not a confirm-gated request — normal flow
evt.preventDefault();
wireButtons();
confirmDialog(question).then((ok) => {
if (ok) custom.detail.issueRequest(true);
});
});