Skip to main content

max / makenotwork

7.1 KB · 184 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 /** Elements the browser already activates from the keyboard. */
78 const NATIVELY_ACTIVATED = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
79
80 /** Enter / Space on a `data-action` element the browser does not activate on
81 * its own. The charter requires a focus ring on custom interactive containers
82 * such as the sort headers, and a ring on something that cannot be operated
83 * from the keyboard is decoration. Opt in with `tabindex="0"`. */
84 function listenKeyActivate(): void {
85 document.addEventListener('keydown', (evt) => {
86 const key = (evt as KeyboardEvent).key;
87 if (key !== 'Enter' && key !== ' ') return;
88 const el = (evt.target as Element | null)?.closest('[data-action]');
89 if (!el || NATIVELY_ACTIVATED.has(el.tagName) || !el.hasAttribute('tabindex')) return;
90 evt.preventDefault();
91 const verb = el.getAttribute('data-action');
92 if (verb) run(el, verb, evt);
93 });
94 }
95
96 /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */
97 export function initActionDispatcher(): void {
98 listen('click', 'data-action', false);
99 listenKeyActivate();
100 listen('change', 'data-change', false);
101 listen('input', 'data-input', false);
102 listen('submit', 'data-submit', true);
103 }
104
105 // ---- HTMX callback dispatcher (hx-on:: replacement), ported from mnw.js:799 ----
106
107 interface HxDetail {
108 elt: HTMLElement;
109 successful?: boolean;
110 xhr?: XMLHttpRequest;
111 parameters?: Record<string, unknown>;
112 }
113
114 function refreshProfileTab(): void {
115 const t = document.getElementById('settings-body');
116 if (t) {
117 htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
118 } else {
119 byId('tab-profile')?.click();
120 }
121 }
122
123 type BehaviorFn = (el: HTMLElement, evt: CustomEvent<HxDetail>) => void;
124
125 const BEHAVIORS: Record<string, BehaviorFn> = {
126 'mark-added': (el) => {
127 el.textContent = 'Added';
128 (el as HTMLButtonElement).disabled = true;
129 },
130 // Faithful to the inline original: the row fades only on success, but the
131 // label flips to 'Added' unconditionally (registered under data-hx-always).
132 'fade-row-added': (el, evt) => {
133 if (evt.detail.successful) el.closest('tr')?.classList.add('is-faded');
134 el.textContent = 'Added';
135 },
136 'refresh-profile': () => refreshProfileTab(),
137 'refresh-profile-if-verified': (_el, evt) => {
138 if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) {
139 refreshProfileTab();
140 }
141 },
142 };
143
144 /** Install the `data-hx-*` afterRequest/configRequest dispatcher. */
145 export function initHxCallbackDispatcher(): void {
146 document.body.addEventListener('htmx:afterRequest', (e) => {
147 const evt = e as CustomEvent<HxDetail>;
148 const el = evt.detail.elt;
149 if (!el || !el.getAttribute) return;
150
151 const always = el.getAttribute('data-hx-always');
152 if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt);
153 if (el.hasAttribute('data-hx-always-click')) byId(el.getAttribute('data-hx-always-click'))?.click();
154
155 if (!evt.detail.successful) return;
156
157 const form = el as HTMLFormElement;
158 if (el.hasAttribute('data-hx-reset') && typeof form.reset === 'function') form.reset();
159 if (el.hasAttribute('data-hx-click')) byId(el.getAttribute('data-hx-click'))?.click();
160 if (el.hasAttribute('data-hx-get')) {
161 const url = el.getAttribute('data-hx-get');
162 const target = el.getAttribute('data-hx-target');
163 if (url && target) htmx.ajax('GET', url, target);
164 }
165 if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav') ?? '';
166 if (el.hasAttribute('data-hx-reload')) window.location.reload();
167 if (el.hasAttribute('data-hx-toast')) {
168 showToast(el.getAttribute('data-hx-toast') ?? '', el.getAttribute('data-hx-toast-type') || 'info');
169 }
170 const beh = el.getAttribute('data-hx-behavior');
171 if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt);
172 });
173
174 document.body.addEventListener('htmx:configRequest', (e) => {
175 const evt = e as CustomEvent<HxDetail>;
176 const el = evt.detail.elt;
177 if (el?.getAttribute?.('data-hx-config') === 'publish-at-iso') {
178 const params = evt.detail.parameters;
179 const v = params?.publish_at;
180 if (v && params) params.publish_at = new Date(v as string).toISOString();
181 }
182 });
183 }
184