Skip to main content

max / makenotwork

6.1 KB · 164 lines History Blame Raw
1 // The action dispatcher + HTMX callback dispatcher, ported from mnw.js:724-871.
2 //
3 // These let templates drop inline `on*` / `hx-on::` handlers so the CSP keeps
4 // `script-src 'self'` (no 'unsafe-inline'). The one behavioral change from the
5 // port: `data-action` verbs resolve against a typed `register()` registry
6 // FIRST, falling back to a `window.<verb>` global for handlers not yet migrated
7 // off the legacy `static/*.js` files. New code should `register()`, never add a
8 // global — the `frontend_globals` seal enforces the ratchet.
9
10 import { byId, targets } from './dom.ts';
11 import { showToast } from './toast.ts';
12
13 export type ActionHandler = (this: Element, ...args: string[]) => void;
14
15 const registry = new Map<string, ActionHandler>();
16
17 /** Register a named `data-action` handler. Preferred over a `window.<name>`
18 * global; the dispatcher checks the registry before the global fallback. */
19 export function register(name: string, fn: ActionHandler): void {
20 registry.set(name, fn);
21 }
22
23 type BuiltinFn = (el: Element) => void;
24
25 /** Built-in verbs operating on `data-target` element ids. */
26 const BUILTINS: Record<string, BuiltinFn> = {
27 show: (el) => targets(el).forEach((t) => t.classList.remove('hidden')),
28 hide: (el) => targets(el).forEach((t) => t.classList.add('hidden')),
29 toggle: (el) => targets(el).forEach((t) => t.classList.toggle('hidden')),
30 click: (el) => targets(el).forEach((t) => t.click()),
31 remove: (el) => targets(el).forEach((t) => t.remove()),
32 'remove-self': (el) => el.remove(),
33 };
34
35 /** Collect positional args from `data-arg` / `data-arg2`. Exported for tests. */
36 export function collectArgs(el: Element): string[] {
37 const args: string[] = [];
38 const a1 = el.getAttribute('data-arg');
39 if (a1 !== null) args.push(a1);
40 const a2 = el.getAttribute('data-arg2');
41 if (a2 !== null) args.push(a2);
42 return args;
43 }
44
45 function run(el: Element, verb: string, evt: Event): void {
46 if (el.hasAttribute('data-prevent')) evt.preventDefault();
47 if (el.hasAttribute('data-stop')) evt.stopPropagation();
48 if (verb === 'nav') {
49 const href = el.getAttribute('data-href');
50 if (href) window.location.href = href;
51 return;
52 }
53 const builtin = BUILTINS[verb];
54 if (builtin) {
55 builtin(el);
56 return;
57 }
58 // Registry first, then the legacy `window.<verb>` global (strangler bridge).
59 const handler = registry.get(verb) ?? (window as unknown as Record<string, unknown>)[verb];
60 if (typeof handler !== 'function') {
61 console.warn('data-action: no handler for', verb);
62 return;
63 }
64 (handler as ActionHandler).apply(el, collectArgs(el));
65 }
66
67 function listen(eventName: string, attr: string, autoPrevent: boolean): void {
68 document.addEventListener(eventName, (evt) => {
69 const el = (evt.target as Element | null)?.closest('[' + attr + ']');
70 if (!el) return;
71 if (autoPrevent) evt.preventDefault();
72 const verb = el.getAttribute(attr);
73 if (verb) run(el, verb, evt);
74 });
75 }
76
77 /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */
78 export function initActionDispatcher(): void {
79 listen('click', 'data-action', false);
80 listen('change', 'data-change', false);
81 listen('input', 'data-input', false);
82 listen('submit', 'data-submit', true);
83 }
84
85 // ---- HTMX callback dispatcher (hx-on:: replacement), ported from mnw.js:799 ----
86
87 interface HxDetail {
88 elt: HTMLElement;
89 successful?: boolean;
90 xhr?: XMLHttpRequest;
91 parameters?: Record<string, unknown>;
92 }
93
94 function refreshProfileTab(): void {
95 const t = document.getElementById('settings-body');
96 if (t) {
97 htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
98 } else {
99 byId('tab-profile')?.click();
100 }
101 }
102
103 type BehaviorFn = (el: HTMLElement, evt: CustomEvent<HxDetail>) => void;
104
105 const BEHAVIORS: Record<string, BehaviorFn> = {
106 'mark-added': (el) => {
107 el.textContent = 'Added';
108 (el as HTMLButtonElement).disabled = true;
109 },
110 // Faithful to the inline original: the row fades only on success, but the
111 // label flips to 'Added' unconditionally (registered under data-hx-always).
112 'fade-row-added': (el, evt) => {
113 if (evt.detail.successful) el.closest('tr')?.classList.add('is-faded');
114 el.textContent = 'Added';
115 },
116 'refresh-profile': () => refreshProfileTab(),
117 'refresh-profile-if-verified': (_el, evt) => {
118 if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) {
119 refreshProfileTab();
120 }
121 },
122 };
123
124 /** Install the `data-hx-*` afterRequest/configRequest dispatcher. */
125 export function initHxCallbackDispatcher(): void {
126 document.body.addEventListener('htmx:afterRequest', (e) => {
127 const evt = e as CustomEvent<HxDetail>;
128 const el = evt.detail.elt;
129 if (!el || !el.getAttribute) return;
130
131 const always = el.getAttribute('data-hx-always');
132 if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt);
133 if (el.hasAttribute('data-hx-always-click')) byId(el.getAttribute('data-hx-always-click'))?.click();
134
135 if (!evt.detail.successful) return;
136
137 const form = el as HTMLFormElement;
138 if (el.hasAttribute('data-hx-reset') && typeof form.reset === 'function') form.reset();
139 if (el.hasAttribute('data-hx-click')) byId(el.getAttribute('data-hx-click'))?.click();
140 if (el.hasAttribute('data-hx-get')) {
141 const url = el.getAttribute('data-hx-get');
142 const target = el.getAttribute('data-hx-target');
143 if (url && target) htmx.ajax('GET', url, target);
144 }
145 if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav') ?? '';
146 if (el.hasAttribute('data-hx-reload')) window.location.reload();
147 if (el.hasAttribute('data-hx-toast')) {
148 showToast(el.getAttribute('data-hx-toast') ?? '', el.getAttribute('data-hx-toast-type') || 'info');
149 }
150 const beh = el.getAttribute('data-hx-behavior');
151 if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt);
152 });
153
154 document.body.addEventListener('htmx:configRequest', (e) => {
155 const evt = e as CustomEvent<HxDetail>;
156 const el = evt.detail.elt;
157 if (el?.getAttribute?.('data-hx-config') === 'publish-at-iso') {
158 const params = evt.detail.parameters;
159 const v = params?.publish_at;
160 if (v && params) params.publish_at = new Date(v as string).toISOString();
161 }
162 });
163 }
164