Skip to main content

max / makenotwork

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