| 1 |
// HTMX lifecycle glue, ported from mnw.js: CSRF header injection (25), |
| 2 |
// response-error toast (281), loading buttons (350), success flash (471), and |
| 3 |
// the plain (non-HTMX) form-submit loading + bfcache reset (431). |
| 4 |
|
| 5 |
import { resolveHtmxLoadingButton } from './loading.ts'; |
| 6 |
import { showToast } from './toast.ts'; |
| 7 |
|
| 8 |
interface HxDetail { |
| 9 |
elt: HTMLElement; |
| 10 |
successful?: boolean; |
| 11 |
xhr?: XMLHttpRequest; |
| 12 |
} |
| 13 |
|
| 14 |
function restoreLoadingButton(e: Event): void { |
| 15 |
const btn = resolveHtmxLoadingButton((e as CustomEvent<HxDetail>).detail.elt); |
| 16 |
if (btn && btn.dataset.origText) { |
| 17 |
btn.textContent = btn.dataset.origText; |
| 18 |
btn.disabled = false; |
| 19 |
delete btn.dataset.origText; |
| 20 |
} |
| 21 |
} |
| 22 |
|
| 23 |
/** Install all delegated HTMX lifecycle listeners. Call once at startup. */ |
| 24 |
export function initHtmxGlue(): void { |
| 25 |
const body = document.body; |
| 26 |
|
| 27 |
// Attach the CSRF token live on every request — it rotates mid-session, so a |
| 28 |
// snapshot would go stale and 403 every mutation. |
| 29 |
body.addEventListener('htmx:configRequest', (e) => { |
| 30 |
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content; |
| 31 |
if (token) (e as CustomEvent<{ headers: Record<string, string> }>).detail.headers['X-CSRF-Token'] = token; |
| 32 |
}); |
| 33 |
|
| 34 |
// Error toast: prefer the HX-Error header (present on page routes too), then |
| 35 |
// a JSON {error}, then a generic string. Never render raw JSON. |
| 36 |
body.addEventListener('htmx:responseError', (e) => { |
| 37 |
const evt = e as CustomEvent<HxDetail>; |
| 38 |
const container = document.getElementById('notifications'); |
| 39 |
if (!container) return; |
| 40 |
const toast = document.createElement('div'); |
| 41 |
toast.className = 'toast toast-error'; |
| 42 |
const msg = document.createElement('span'); |
| 43 |
let text = 'An error occurred.'; |
| 44 |
const xhr = evt.detail && evt.detail.xhr; |
| 45 |
const headerMsg = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Error'); |
| 46 |
if (headerMsg) { |
| 47 |
text = headerMsg; |
| 48 |
} else { |
| 49 |
const bodyText = xhr && xhr.responseText; |
| 50 |
if (bodyText) { |
| 51 |
try { |
| 52 |
const parsed = JSON.parse(bodyText) as { error?: unknown }; |
| 53 |
if (parsed && typeof parsed.error === 'string' && parsed.error) text = parsed.error; |
| 54 |
} catch { |
| 55 |
/* non-JSON body (e.g. an HTML error page); keep the fallback */ |
| 56 |
} |
| 57 |
} |
| 58 |
} |
| 59 |
msg.textContent = text; |
| 60 |
toast.appendChild(msg); |
| 61 |
|
| 62 |
const retryBtn = document.createElement('button'); |
| 63 |
retryBtn.textContent = 'Retry'; |
| 64 |
retryBtn.className = 'toast-retry-btn'; |
| 65 |
retryBtn.onclick = () => { |
| 66 |
toast.remove(); |
| 67 |
const elt = evt.detail.elt; |
| 68 |
if (elt) htmx.trigger(elt, htmx.closest(elt, '[hx-trigger]') ? 'htmx:trigger' : 'click'); |
| 69 |
}; |
| 70 |
toast.appendChild(retryBtn); |
| 71 |
|
| 72 |
const closeBtn = document.createElement('button'); |
| 73 |
closeBtn.className = 'toast-dismiss'; |
| 74 |
closeBtn.textContent = '×'; |
| 75 |
closeBtn.setAttribute('aria-label', 'Dismiss'); |
| 76 |
closeBtn.onclick = () => toast.remove(); |
| 77 |
toast.appendChild(closeBtn); |
| 78 |
|
| 79 |
container.appendChild(toast); |
| 80 |
setTimeout(() => { |
| 81 |
toast.classList.add('fade-out'); |
| 82 |
setTimeout(() => toast.remove(), 300); |
| 83 |
}, 6000); |
| 84 |
}); |
| 85 |
|
| 86 |
// Loading button on request start; restore on every terminal event. |
| 87 |
body.addEventListener('htmx:beforeRequest', (e) => { |
| 88 |
const btn = resolveHtmxLoadingButton((e as CustomEvent<HxDetail>).detail.elt); |
| 89 |
if (btn && !btn.dataset.origText) { |
| 90 |
btn.dataset.origText = btn.textContent ?? ''; |
| 91 |
btn.textContent = btn.dataset.loadingText || 'Saving...'; |
| 92 |
btn.disabled = true; |
| 93 |
} |
| 94 |
}); |
| 95 |
body.addEventListener('htmx:afterRequest', restoreLoadingButton); |
| 96 |
body.addEventListener('htmx:responseError', restoreLoadingButton); |
| 97 |
body.addEventListener('htmx:sendError', restoreLoadingButton); |
| 98 |
body.addEventListener('htmx:timeout', restoreLoadingButton); |
| 99 |
|
| 100 |
// Success flash: data-success-toast (for hx-swap="none") + data-success-text. |
| 101 |
body.addEventListener('htmx:afterRequest', (e) => { |
| 102 |
const evt = e as CustomEvent<HxDetail>; |
| 103 |
if (!evt.detail.successful) return; |
| 104 |
const elt = evt.detail.elt; |
| 105 |
|
| 106 |
const toastEl = elt && elt.closest && elt.closest<HTMLElement>('[data-success-toast]'); |
| 107 |
if (toastEl) showToast(toastEl.dataset.successToast ?? '', 'info'); |
| 108 |
|
| 109 |
const btn = resolveHtmxLoadingButton(elt); |
| 110 |
if (!btn || !btn.dataset.successText) return; |
| 111 |
const successText = btn.dataset.successText; |
| 112 |
const restoreTo = btn.dataset.origText || btn.textContent || ''; |
| 113 |
btn.textContent = successText; |
| 114 |
btn.disabled = true; |
| 115 |
delete btn.dataset.origText; |
| 116 |
setTimeout(() => { |
| 117 |
if (btn.textContent === successText) { |
| 118 |
btn.textContent = restoreTo; |
| 119 |
btn.disabled = false; |
| 120 |
} |
| 121 |
}, 1200); |
| 122 |
}); |
| 123 |
|
| 124 |
// Plain (non-HTMX) form submit: swap the label while the browser round-trips |
| 125 |
// (e.g. Stripe checkout). Opt in via data-loading-text on the submit button. |
| 126 |
body.addEventListener( |
| 127 |
'submit', |
| 128 |
(evt) => { |
| 129 |
if (evt.defaultPrevented) return; |
| 130 |
const form = evt.target as HTMLFormElement | null; |
| 131 |
if (!form || form.tagName !== 'FORM') return; |
| 132 |
if ( |
| 133 |
form.hasAttribute('hx-post') || |
| 134 |
form.hasAttribute('hx-get') || |
| 135 |
form.hasAttribute('hx-put') || |
| 136 |
form.hasAttribute('hx-delete') || |
| 137 |
form.hasAttribute('hx-patch') |
| 138 |
) |
| 139 |
return; |
| 140 |
const btn = form.querySelector<HTMLButtonElement>('[data-loading-text]'); |
| 141 |
if (!btn || btn.dataset.origText) return; |
| 142 |
btn.dataset.origText = btn.textContent ?? ''; |
| 143 |
btn.textContent = btn.dataset.loadingText ?? ''; |
| 144 |
// Defer disabling so the button's name/value still enters the form body. |
| 145 |
setTimeout(() => { |
| 146 |
btn.disabled = true; |
| 147 |
}, 0); |
| 148 |
}, |
| 149 |
true, |
| 150 |
); |
| 151 |
|
| 152 |
// bfcache restore: a back-nav can restore a button stuck in its "Redirecting…" |
| 153 |
// state; reset it so the page is usable again. |
| 154 |
window.addEventListener('pageshow', (evt) => { |
| 155 |
if (!(evt as PageTransitionEvent).persisted) return; |
| 156 |
document.querySelectorAll<HTMLButtonElement>('button[data-orig-text]').forEach((btn) => { |
| 157 |
btn.textContent = btn.dataset.origText ?? ''; |
| 158 |
btn.disabled = false; |
| 159 |
delete btn.dataset.origText; |
| 160 |
}); |
| 161 |
}); |
| 162 |
} |
| 163 |
|