Skip to main content

max / makenotwork

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