// HTMX lifecycle glue, ported from mnw.js: CSRF header injection (25), // response-error toast (281), loading buttons (350), success flash (471), and // the plain (non-HTMX) form-submit loading + bfcache reset (431). import { resolveHtmxLoadingButton } from './loading.ts'; import { csrfHeaders } from './net.ts'; import { autoDismiss, showToast } from './toast.ts'; /** htmx 4 hangs the whole request on `detail.ctx`, where htmx 2 spread it * across the detail itself as `elt`, `xhr`, `headers` and `successful`. Only * the fields this file reads are named. */ interface HxDetail { ctx: { sourceElement: HTMLElement; request: { headers: Record }; response?: { status: number; headers: Headers }; text?: string; }; } /** The element the request came from. `detail.elt` in htmx 2. */ function source(e: Event): HTMLElement { return (e as CustomEvent).detail.ctx.sourceElement; } function restoreLoadingButton(e: Event): void { const btn = resolveHtmxLoadingButton(source(e)); if (btn && btn.dataset.origText) { btn.textContent = btn.dataset.origText; btn.disabled = false; delete btn.dataset.origText; } } /** Install all delegated HTMX lifecycle listeners. Call once at startup. */ export function initHtmxGlue(): void { const body = document.body; // Attach the CSRF token live on every request. It rotates mid-session, so a // snapshot would go stale and 403 every mutation. body.addEventListener('htmx:config:request', (e) => { const token = document.querySelector('meta[name="csrf-token"]')?.content; if (token) (e as CustomEvent).detail.ctx.request.headers['X-CSRF-Token'] = token; }); // Error toast: prefer the HX-Error header (present on page routes too), then // a JSON {error}, then a generic string. Never render raw JSON. body.addEventListener('htmx:response:error', (e) => { const evt = e as CustomEvent; const container = document.getElementById('notifications'); if (!container) return; const toast = document.createElement('div'); toast.className = 'toast toast-error'; const msg = document.createElement('span'); let text = 'An error occurred.'; const headerMsg = evt.detail.ctx.response?.headers.get('HX-Error'); if (headerMsg) { text = headerMsg; } else { const bodyText = evt.detail.ctx.text; if (bodyText) { try { const parsed = JSON.parse(bodyText) as { error?: unknown }; if (parsed && typeof parsed.error === 'string' && parsed.error) text = parsed.error; } catch { /* non-JSON body (e.g. an HTML error page); keep the fallback */ } } } msg.textContent = text; toast.appendChild(msg); const retryBtn = document.createElement('button'); retryBtn.textContent = 'Retry'; retryBtn.className = 'toast-retry-btn'; retryBtn.onclick = () => { toast.remove(); // A click, whatever the element's `hx-trigger` says. The branch here used // to dispatch `htmx:trigger` for anything under an `hx-trigger`, which // htmx emits and has never listened for, so that half was always a no-op. evt.detail.ctx.sourceElement?.click(); }; toast.appendChild(retryBtn); const closeBtn = document.createElement('button'); closeBtn.className = 'toast-dismiss'; closeBtn.textContent = '×'; closeBtn.setAttribute('aria-label', 'Dismiss'); closeBtn.onclick = () => toast.remove(); toast.appendChild(closeBtn); container.appendChild(toast); // 6000 rather than the 3000 an ordinary toast gets: this one carries a Retry // button, so it has to outlive a glance. autoDismiss(toast, 6000); }); // Loading button on request start; restore on every terminal event. body.addEventListener('htmx:before:request', (e) => { const btn = resolveHtmxLoadingButton(source(e)); if (btn && !btn.dataset.origText) { btn.dataset.origText = btn.textContent ?? ''; btn.textContent = btn.dataset.loadingText || 'Saving...'; btn.disabled = true; } }); // One terminal event instead of the four htmx 2 needed. It fires whether the // request succeeded, failed, timed out or was cancelled, which is the whole // condition for putting the button back. body.addEventListener('htmx:finally:request', restoreLoadingButton); // Success flash: data-success-toast (for hx-swap="none") + data-success-text. body.addEventListener('htmx:after:request', (e) => { const evt = e as CustomEvent; // `detail.successful` in htmx 2. The status is what it summarised, and 4 // fires this event for error responses too. if ((evt.detail.ctx.response?.status ?? 0) >= 400) return; const elt = evt.detail.ctx.sourceElement; const toastEl = elt && elt.closest && elt.closest('[data-success-toast]'); if (toastEl) showToast(toastEl.dataset.successToast ?? '', 'info'); const btn = resolveHtmxLoadingButton(elt); if (!btn || !btn.dataset.successText) return; const successText = btn.dataset.successText; const restoreTo = btn.dataset.origText || btn.textContent || ''; btn.textContent = successText; btn.disabled = true; delete btn.dataset.origText; setTimeout(() => { if (btn.textContent === successText) { btn.textContent = restoreTo; btn.disabled = false; } }, 1200); }); // data-saves: the answer is a file the reader keeps, not a view. // // Emitted by the description layer from `Action::saving(name)`. It replaces // window.exportCsvButton, which said the same thing as a class name plus two // positional arguments and had to be repeated per button. A described screen // says what it means and this performs it once for all of them. // // htmx cannot do this itself: it swaps a response into the DOM, and a CSV is // not markup. So the request is cancelled here and reissued as a fetch whose // body becomes a download. A read never reaches this at all, because a link // carries a `download` attribute instead and the browser does the whole job. body.addEventListener('htmx:before:request', (e) => { const evt = e as CustomEvent; const elt = evt.detail.ctx.sourceElement; const saveAs = elt?.dataset?.saves; if (!saveAs) return; evt.preventDefault(); const url = elt.getAttribute('hx-post') ?? elt.getAttribute('hx-put') ?? ''; if (!url) return; const restore = elt.textContent ?? ''; elt.textContent = 'Exporting...'; if (elt instanceof HTMLButtonElement) elt.disabled = true; const done = (): void => { elt.textContent = restore; if (elt instanceof HTMLButtonElement) elt.disabled = false; }; void fetch(url, { method: 'POST', headers: csrfHeaders() }) .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.blob(); }) .then((blob) => { const href = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = href; link.download = saveAs; link.click(); // Or the blob is held for the life of the document. URL.revokeObjectURL(href); done(); }) .catch(() => { done(); showToast('Export failed', 'error'); }); }); // Plain (non-HTMX) form submit: swap the label while the browser round-trips // (e.g. Stripe checkout). Opt in via data-loading-text on the submit button. body.addEventListener( 'submit', (evt) => { if (evt.defaultPrevented) return; const form = evt.target as HTMLFormElement | null; if (!form || form.tagName !== 'FORM') return; if ( form.hasAttribute('hx-post') || form.hasAttribute('hx-get') || form.hasAttribute('hx-put') || form.hasAttribute('hx-delete') || form.hasAttribute('hx-patch') ) return; const btn = form.querySelector('[data-loading-text]'); if (!btn || btn.dataset.origText) return; btn.dataset.origText = btn.textContent ?? ''; btn.textContent = btn.dataset.loadingText ?? ''; // Defer disabling so the button's name/value still enters the form body. setTimeout(() => { btn.disabled = true; }, 0); }, true, ); // bfcache restore: a back-nav can restore a button stuck in its "Redirecting…" // state; reset it so the page is usable again. window.addEventListener('pageshow', (evt) => { if (!(evt as PageTransitionEvent).persisted) return; document.querySelectorAll('button[data-orig-text]').forEach((btn) => { btn.textContent = btn.dataset.origText ?? ''; btn.disabled = false; delete btn.dataset.origText; }); }); }