Skip to main content

max / makenotwork

9.1 KB · 230 lines History Blame Raw
1 // The delegated dispatcher, ported from mnw.js:724-871.
2 //
3 // It lets templates drop inline `on*` / `hx-on::` handlers so the CSP keeps
4 // `script-src 'self'` (no 'unsafe-inline'). Verbs resolve against a typed
5 // `register()` registry FIRST, falling back to a `window.<verb>` global for
6 // handlers not yet migrated off the legacy `static/*.js` files. New code should
7 // `register()`, never add a global, the `frontend_globals` seal enforces the
8 // ratchet.
9 //
10 // ONE vocabulary, several hooks. A verb names what happens; the attribute names
11 // when. `data-action` is a click, `data-change` / `data-input` / `data-submit`
12 // are the other three DOM events, and `data-after` / `data-after-always` are
13 // htmx's request lifecycle. Arguments come from `data-arg` / `data-arg2`, the
14 // target of a verb that acts on another element from `data-target` or
15 // `data-href`, whichever hook dispatched it. The same verb reached two ways is
16 // the same registry entry.
17
18 import { byId, targets } from './dom.ts';
19 import { showToast } from './toast.ts';
20
21 /** A named verb. `args` are the positional `data-arg` / `data-arg2` values.
22 * `evt` is whatever triggered the dispatch: a DOM event for the click family,
23 * htmx's `htmx:afterRequest` CustomEvent for the `data-after` family. Most
24 * verbs ignore it; the ones that branch on the request outcome do not. */
25 export type ActionHandler = (this: Element, args: string[], evt: Event) => void;
26
27 /** The legacy `window.<verb>` bridge calls positionally, which is the shape
28 * those handlers were written against and the reason they are still callable
29 * without being ported. */
30 type LegacyHandler = (this: Element, ...args: string[]) => void;
31
32 const registry = new Map<string, ActionHandler>();
33
34 /** Register a named verb. Preferred over a `window.<name>` global; the
35 * dispatcher checks the registry before the global fallback. */
36 export function register(name: string, fn: ActionHandler): void {
37 registry.set(name, fn);
38 }
39
40 type BuiltinFn = (el: Element) => void;
41
42 /** Built-in verbs operating on `data-target` element ids. */
43 const BUILTINS: Record<string, BuiltinFn> = {
44 show: (el) => targets(el).forEach((t) => t.classList.remove('hidden')),
45 hide: (el) => targets(el).forEach((t) => t.classList.add('hidden')),
46 toggle: (el) => targets(el).forEach((t) => t.classList.toggle('hidden')),
47 click: (el) => targets(el).forEach((t) => t.click()),
48 remove: (el) => targets(el).forEach((t) => t.remove()),
49 'remove-self': (el) => el.remove(),
50 };
51
52 /** Collect positional args from `data-arg` / `data-arg2`. Exported for tests. */
53 export function collectArgs(el: Element): string[] {
54 const args: string[] = [];
55 const a1 = el.getAttribute('data-arg');
56 if (a1 !== null) args.push(a1);
57 const a2 = el.getAttribute('data-arg2');
58 if (a2 !== null) args.push(a2);
59 return args;
60 }
61
62 function run(el: Element, verb: string, evt: Event): void {
63 if (el.hasAttribute('data-prevent')) evt.preventDefault();
64 if (el.hasAttribute('data-stop')) evt.stopPropagation();
65 if (verb === 'nav') {
66 const href = el.getAttribute('data-href');
67 if (href) window.location.href = href;
68 return;
69 }
70 const builtin = BUILTINS[verb];
71 if (builtin) {
72 builtin(el);
73 return;
74 }
75 const args = collectArgs(el);
76 // Registry first, then the legacy `window.<verb>` global (strangler bridge).
77 const registered = registry.get(verb);
78 if (registered) {
79 registered.call(el, args, evt);
80 return;
81 }
82 const global = (window as unknown as Record<string, unknown>)[verb];
83 if (typeof global !== 'function') {
84 console.warn('data-action: no handler for', verb);
85 return;
86 }
87 (global as LegacyHandler).apply(el, args);
88 }
89
90 /** Run every verb named in a space-separated attribute value, in written order.
91 * One element can ask for more than one thing: a form that resets itself and
92 * then refreshes the panel it lives in says `reset refresh`. */
93 function runList(el: Element, attr: string, evt: Event): void {
94 const value = el.getAttribute(attr);
95 if (!value) return;
96 for (const verb of value.split(/\s+/)) {
97 if (verb) run(el, verb, evt);
98 }
99 }
100
101 function listen(eventName: string, attr: string, autoPrevent: boolean): void {
102 document.addEventListener(eventName, (evt) => {
103 const el = (evt.target as Element | null)?.closest('[' + attr + ']');
104 if (!el) return;
105 if (autoPrevent) evt.preventDefault();
106 const verb = el.getAttribute(attr);
107 if (verb) run(el, verb, evt);
108 });
109 }
110
111 /** Elements the browser already activates from the keyboard. */
112 const NATIVELY_ACTIVATED = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
113
114 /** Enter / Space on a `data-action` element the browser does not activate on
115 * its own. The charter requires a focus ring on custom interactive containers
116 * such as the sort headers, and a ring on something that cannot be operated
117 * from the keyboard is decoration. Opt in with `tabindex="0"`. */
118 function listenKeyActivate(): void {
119 document.addEventListener('keydown', (evt) => {
120 const key = (evt as KeyboardEvent).key;
121 if (key !== 'Enter' && key !== ' ') return;
122 const el = (evt.target as Element | null)?.closest('[data-action]');
123 if (!el || NATIVELY_ACTIVATED.has(el.tagName) || !el.hasAttribute('tabindex')) return;
124 evt.preventDefault();
125 const verb = el.getAttribute('data-action');
126 if (verb) run(el, verb, evt);
127 });
128 }
129
130 /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */
131 export function initActionDispatcher(): void {
132 listen('click', 'data-action', false);
133 listenKeyActivate();
134 listen('change', 'data-change', false);
135 listen('input', 'data-input', false);
136 listen('submit', 'data-submit', true);
137 }
138
139 // ---- The `data-after` family, dispatched on htmx's request lifecycle ----
140 //
141 // These were ten private `data-hx-*` attributes with their own dispatcher, a
142 // second vocabulary sitting beside `data-action` and resolving nothing the same
143 // way. Two of the ten (`data-hx-get`, `data-hx-target`) also collided with
144 // htmx's own `data-hx-*` alias, so at eight sites htmx read the private
145 // directive as a real htmx attribute and acted on it as well.
146 //
147 // They are verbs now, in the one registry, taking their arguments from the
148 // `data-arg` / `data-target` / `data-href` conventions the click family already
149 // uses. What is left is the hook they run on, which is what `data-after` and
150 // `data-after-always` name.
151
152 interface HxDetail {
153 elt: HTMLElement;
154 successful?: boolean;
155 xhr?: XMLHttpRequest;
156 parameters?: Record<string, unknown>;
157 }
158
159 function refreshProfileTab(): void {
160 const t = document.getElementById('settings-body');
161 if (t) {
162 htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
163 } else {
164 byId('tab-profile')?.click();
165 }
166 }
167
168 /** Verbs the `data-after` family introduced. Registered rather than kept in a
169 * map of their own, so `data-action="reload"` and `data-after="reload"` are the
170 * same verb reached through different hooks. */
171 function registerAfterVerbs(): void {
172 register('reset', function () {
173 const form = this as unknown as HTMLFormElement;
174 if (typeof form.reset === 'function') form.reset();
175 });
176 register('reload', () => window.location.reload());
177 register('refresh', (args) => {
178 const [url, target] = args;
179 if (url && target) htmx.ajax('GET', url, target);
180 });
181 register('toast', (args) => showToast(args[0] ?? '', args[1] || 'info'));
182 register('label-added', function () {
183 this.textContent = 'Added';
184 });
185 register('mark-added', function () {
186 this.textContent = 'Added';
187 (this as HTMLButtonElement).disabled = true;
188 });
189 // `label-added` + `fade-row` is the old `fade-row-added`, which relabelled
190 // unconditionally and faded only on success. Two hooks say that between them;
191 // one verb had to read the outcome to say it. Note it relabels without
192 // disabling, which is what that button did and is not what `mark-added` does.
193 register('fade-row', function () {
194 this.closest('tr')?.classList.add('is-faded');
195 });
196 register('refresh-profile', () => refreshProfileTab());
197 // The only verb that reads the response rather than the element. The verify
198 // endpoint answers 200 whether or not the TXT record matched, so success
199 // alone does not mean verified.
200 register('refresh-profile-if-verified', (_args, evt) => {
201 const xhr = (evt as CustomEvent<HxDetail>).detail?.xhr;
202 if (xhr && xhr.responseText.indexOf('verified successfully') !== -1) refreshProfileTab();
203 });
204 }
205
206 /** Install the `data-after` / `data-after-always` / `data-config` dispatcher. */
207 export function initAfterRequestDispatcher(): void {
208 registerAfterVerbs();
209
210 document.body.addEventListener('htmx:afterRequest', (e) => {
211 const evt = e as CustomEvent<HxDetail>;
212 const el = evt.detail.elt;
213 if (!el || !el.getAttribute) return;
214
215 runList(el, 'data-after-always', evt);
216 if (!evt.detail.successful) return;
217 runList(el, 'data-after', evt);
218 });
219
220 document.body.addEventListener('htmx:configRequest', (e) => {
221 const evt = e as CustomEvent<HxDetail>;
222 const el = evt.detail.elt;
223 if (el?.getAttribute?.('data-config') === 'publish-at-iso') {
224 const params = evt.detail.parameters;
225 const v = params?.publish_at;
226 if (v && params) params.publish_at = new Date(v as string).toISOString();
227 }
228 });
229 }
230