Skip to main content

max / makenotwork

6.0 KB · 163 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 { 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