// 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 { showToast } from './toast.ts'; interface HxDetail { elt: HTMLElement; successful?: boolean; xhr?: XMLHttpRequest; } function restoreLoadingButton(e: Event): void { const btn = resolveHtmxLoadingButton((e as CustomEvent).detail.elt); 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:configRequest', (e) => { const token = document.querySelector('meta[name="csrf-token"]')?.content; if (token) (e as CustomEvent<{ headers: Record }>).detail.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:responseError', (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 xhr = evt.detail && evt.detail.xhr; const headerMsg = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Error'); if (headerMsg) { text = headerMsg; } else { const bodyText = xhr && xhr.responseText; 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(); const elt = evt.detail.elt; if (elt) htmx.trigger(elt, htmx.closest(elt, '[hx-trigger]') ? 'htmx:trigger' : '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); setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 300); }, 6000); }); // Loading button on request start; restore on every terminal event. body.addEventListener('htmx:beforeRequest', (e) => { const btn = resolveHtmxLoadingButton((e as CustomEvent).detail.elt); if (btn && !btn.dataset.origText) { btn.dataset.origText = btn.textContent ?? ''; btn.textContent = btn.dataset.loadingText || 'Saving...'; btn.disabled = true; } }); body.addEventListener('htmx:afterRequest', restoreLoadingButton); body.addEventListener('htmx:responseError', restoreLoadingButton); body.addEventListener('htmx:sendError', restoreLoadingButton); body.addEventListener('htmx:timeout', restoreLoadingButton); // Success flash: data-success-toast (for hx-swap="none") + data-success-text. body.addEventListener('htmx:afterRequest', (e) => { const evt = e as CustomEvent; if (!evt.detail.successful) return; const elt = evt.detail.elt; 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); }); // 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; }); }); }