| 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 { csrfHeaders } from './net.ts'; |
| 7 |
import { showErrorToast, showToast } from './toast.ts'; |
| 8 |
import { revertMs } from './timing.ts'; |
| 9 |
|
| 10 |
/** htmx 4 hangs the whole request on `detail.ctx`, where htmx 2 spread it |
| 11 |
* across the detail itself as `elt`, `xhr`, `headers` and `successful`. Only |
| 12 |
* the fields this file reads are named. */ |
| 13 |
interface HxDetail { |
| 14 |
ctx: { |
| 15 |
sourceElement: HTMLElement; |
| 16 |
request: { headers: Record<string, string> }; |
| 17 |
response?: { status: number; headers: Headers }; |
| 18 |
text?: string; |
| 19 |
}; |
| 20 |
} |
| 21 |
|
| 22 |
/** The element the request came from. `detail.elt` in htmx 2. */ |
| 23 |
function source(e: Event): HTMLElement { |
| 24 |
return (e as CustomEvent<HxDetail>).detail.ctx.sourceElement; |
| 25 |
} |
| 26 |
|
| 27 |
function restoreLoadingButton(e: Event): void { |
| 28 |
const btn = resolveHtmxLoadingButton(source(e)); |
| 29 |
if (btn && btn.dataset.origText) { |
| 30 |
btn.textContent = btn.dataset.origText; |
| 31 |
btn.disabled = false; |
| 32 |
delete btn.dataset.origText; |
| 33 |
} |
| 34 |
} |
| 35 |
|
| 36 |
/** Install all delegated HTMX lifecycle listeners. Call once at startup. */ |
| 37 |
export function initHtmxGlue(): void { |
| 38 |
const body = document.body; |
| 39 |
|
| 40 |
// Attach the CSRF token live on every request. It rotates mid-session, so a |
| 41 |
// snapshot would go stale and 403 every mutation. |
| 42 |
body.addEventListener('htmx:config:request', (e) => { |
| 43 |
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content; |
| 44 |
if (token) (e as CustomEvent<HxDetail>).detail.ctx.request.headers['X-CSRF-Token'] = token; |
| 45 |
}); |
| 46 |
|
| 47 |
// Error toast: prefer the HX-Error header (present on page routes too), then |
| 48 |
// a JSON {error}, then a generic string. Never render raw JSON. The element |
| 49 |
// itself is `toast.ts`'s, like every other toast on the page; what is decided |
| 50 |
// here is the text and what Retry means. |
| 51 |
body.addEventListener('htmx:response:error', (e) => { |
| 52 |
const evt = e as CustomEvent<HxDetail>; |
| 53 |
let text = 'An error occurred.'; |
| 54 |
const headerMsg = evt.detail.ctx.response?.headers.get('HX-Error'); |
| 55 |
if (headerMsg) { |
| 56 |
text = headerMsg; |
| 57 |
} else { |
| 58 |
const bodyText = evt.detail.ctx.text; |
| 59 |
if (bodyText) { |
| 60 |
try { |
| 61 |
const parsed = JSON.parse(bodyText) as { error?: unknown }; |
| 62 |
if (parsed && typeof parsed.error === 'string' && parsed.error) text = parsed.error; |
| 63 |
} catch { |
| 64 |
/* non-JSON body (e.g. an HTML error page); keep the fallback */ |
| 65 |
} |
| 66 |
} |
| 67 |
} |
| 68 |
// A click, whatever the element's `hx-trigger` says. The branch here used |
| 69 |
// to dispatch `htmx:trigger` for anything under an `hx-trigger`, which htmx |
| 70 |
// emits and has never listened for, so that half was always a no-op. |
| 71 |
showErrorToast(text, () => evt.detail.ctx.sourceElement?.click()); |
| 72 |
}); |
| 73 |
|
| 74 |
// Loading button on request start; restore on every terminal event. |
| 75 |
body.addEventListener('htmx:before:request', (e) => { |
| 76 |
const btn = resolveHtmxLoadingButton(source(e)); |
| 77 |
if (btn && !btn.dataset.origText) { |
| 78 |
btn.dataset.origText = btn.textContent ?? ''; |
| 79 |
btn.textContent = btn.dataset.loadingText || 'Saving...'; |
| 80 |
btn.disabled = true; |
| 81 |
} |
| 82 |
}); |
| 83 |
// One terminal event instead of the four htmx 2 needed. It fires whether the |
| 84 |
// request succeeded, failed, timed out or was cancelled, which is the whole |
| 85 |
// condition for putting the button back. |
| 86 |
body.addEventListener('htmx:finally:request', restoreLoadingButton); |
| 87 |
|
| 88 |
// Success flash: data-success-toast (for hx-swap="none") + data-success-text. |
| 89 |
body.addEventListener('htmx:after:request', (e) => { |
| 90 |
const evt = e as CustomEvent<HxDetail>; |
| 91 |
// `detail.successful` in htmx 2. The status is what it summarised, and 4 |
| 92 |
// fires this event for error responses too. |
| 93 |
if ((evt.detail.ctx.response?.status ?? 0) >= 400) return; |
| 94 |
const elt = evt.detail.ctx.sourceElement; |
| 95 |
|
| 96 |
const toastEl = elt && elt.closest && elt.closest<HTMLElement>('[data-success-toast]'); |
| 97 |
if (toastEl) showToast(toastEl.dataset.successToast ?? '', 'info'); |
| 98 |
|
| 99 |
const btn = resolveHtmxLoadingButton(elt); |
| 100 |
if (!btn || !btn.dataset.successText) return; |
| 101 |
const successText = btn.dataset.successText; |
| 102 |
const restoreTo = btn.dataset.origText || btn.textContent || ''; |
| 103 |
btn.textContent = successText; |
| 104 |
btn.disabled = true; |
| 105 |
delete btn.dataset.origText; |
| 106 |
// The same temporary label a copy button flashes, so the same intent. |
| 107 |
setTimeout(() => { |
| 108 |
if (btn.textContent === successText) { |
| 109 |
btn.textContent = restoreTo; |
| 110 |
btn.disabled = false; |
| 111 |
} |
| 112 |
}, revertMs()); |
| 113 |
}); |
| 114 |
|
| 115 |
// data-saves: the answer is a file the reader keeps, not a view. |
| 116 |
// |
| 117 |
// Emitted by the description layer from `Action::saving(name)`. It replaces |
| 118 |
// window.exportCsvButton, which said the same thing as a class name plus two |
| 119 |
// positional arguments and had to be repeated per button. A described screen |
| 120 |
// says what it means and this performs it once for all of them. |
| 121 |
// |
| 122 |
// htmx cannot do this itself: it swaps a response into the DOM, and a CSV is |
| 123 |
// not markup. So the request is cancelled here and reissued as a fetch whose |
| 124 |
// body becomes a download. A read never reaches this at all, because a link |
| 125 |
// carries a `download` attribute instead and the browser does the whole job. |
| 126 |
body.addEventListener('htmx:before:request', (e) => { |
| 127 |
const evt = e as CustomEvent<HxDetail>; |
| 128 |
const elt = evt.detail.ctx.sourceElement; |
| 129 |
const saveAs = elt?.dataset?.saves; |
| 130 |
if (!saveAs) return; |
| 131 |
evt.preventDefault(); |
| 132 |
|
| 133 |
const url = elt.getAttribute('hx-post') ?? elt.getAttribute('hx-put') ?? ''; |
| 134 |
if (!url) return; |
| 135 |
const restore = elt.textContent ?? ''; |
| 136 |
elt.textContent = 'Exporting...'; |
| 137 |
if (elt instanceof HTMLButtonElement) elt.disabled = true; |
| 138 |
const done = (): void => { |
| 139 |
elt.textContent = restore; |
| 140 |
if (elt instanceof HTMLButtonElement) elt.disabled = false; |
| 141 |
}; |
| 142 |
|
| 143 |
void fetch(url, { method: 'POST', headers: csrfHeaders() }) |
| 144 |
.then((r) => { |
| 145 |
if (!r.ok) throw new Error(String(r.status)); |
| 146 |
return r.blob(); |
| 147 |
}) |
| 148 |
.then((blob) => { |
| 149 |
const href = URL.createObjectURL(blob); |
| 150 |
const link = document.createElement('a'); |
| 151 |
link.href = href; |
| 152 |
link.download = saveAs; |
| 153 |
link.click(); |
| 154 |
// Or the blob is held for the life of the document. |
| 155 |
URL.revokeObjectURL(href); |
| 156 |
done(); |
| 157 |
}) |
| 158 |
.catch(() => { |
| 159 |
done(); |
| 160 |
showToast('Export failed', 'error'); |
| 161 |
}); |
| 162 |
}); |
| 163 |
|
| 164 |
// Plain (non-HTMX) form submit: swap the label while the browser round-trips |
| 165 |
// (e.g. Stripe checkout). Opt in via data-loading-text on the submit button. |
| 166 |
body.addEventListener( |
| 167 |
'submit', |
| 168 |
(evt) => { |
| 169 |
if (evt.defaultPrevented) return; |
| 170 |
const form = evt.target as HTMLFormElement | null; |
| 171 |
if (!form || form.tagName !== 'FORM') return; |
| 172 |
if ( |
| 173 |
form.hasAttribute('hx-post') || |
| 174 |
form.hasAttribute('hx-get') || |
| 175 |
form.hasAttribute('hx-put') || |
| 176 |
form.hasAttribute('hx-delete') || |
| 177 |
form.hasAttribute('hx-patch') |
| 178 |
) |
| 179 |
return; |
| 180 |
const btn = form.querySelector<HTMLButtonElement>('[data-loading-text]'); |
| 181 |
if (!btn || btn.dataset.origText) return; |
| 182 |
btn.dataset.origText = btn.textContent ?? ''; |
| 183 |
btn.textContent = btn.dataset.loadingText ?? ''; |
| 184 |
// Defer disabling so the button's name/value still enters the form body. |
| 185 |
setTimeout(() => { |
| 186 |
btn.disabled = true; |
| 187 |
}, 0); |
| 188 |
}, |
| 189 |
true, |
| 190 |
); |
| 191 |
|
| 192 |
// bfcache restore: a back-nav can restore a button stuck in its "Redirecting…" |
| 193 |
// state; reset it so the page is usable again. |
| 194 |
window.addEventListener('pageshow', (evt) => { |
| 195 |
if (!(evt as PageTransitionEvent).persisted) return; |
| 196 |
document.querySelectorAll<HTMLButtonElement>('button[data-orig-text]').forEach((btn) => { |
| 197 |
btn.textContent = btn.dataset.origText ?? ''; |
| 198 |
btn.disabled = false; |
| 199 |
delete btn.dataset.origText; |
| 200 |
}); |
| 201 |
}); |
| 202 |
} |
| 203 |
|