Skip to main content

max / makenotwork

10.7 KB · 257 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:after:request` 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: the ones that operate on `data-target` element ids, and the
43 * ones that ask the browser for something.
44 *
45 * The second group is here rather than in `static/actions-*.js` because no
46 * description will ever own it. Printing, going back, opening a second tab and
47 * submitting the form an input sits in are the user agent's own verbs: quasi
48 * describes what a screen says, and none of these say anything about this
49 * server. `nav` was already spelled this way; these read their argument off the
50 * same `data-href`, or off the element itself. */
51 const BUILTINS: Record<string, BuiltinFn> = {
52 show: (el) => targets(el).forEach((t) => t.classList.remove('hidden')),
53 hide: (el) => targets(el).forEach((t) => t.classList.add('hidden')),
54 toggle: (el) => targets(el).forEach((t) => t.classList.toggle('hidden')),
55 click: (el) => targets(el).forEach((t) => t.click()),
56 remove: (el) => targets(el).forEach((t) => t.remove()),
57 'remove-self': (el) => el.remove(),
58 'remove-parent': (el) => el.parentElement?.remove(),
59 // Runs nothing on purpose. It is what lets an element carry `data-stop` or
60 // `data-prevent` without also naming an action, and both modifiers are
61 // applied by `run` before any verb is looked up.
62 noop: () => {},
63 print: () => window.print(),
64 back: () => window.history.back(),
65 open: (el) => {
66 const href = el.getAttribute('data-href');
67 if (href) window.open(href, '_blank');
68 },
69 // A select whose own value is the address to go to.
70 'nav-to-value': (el) => {
71 const { value } = el as HTMLSelectElement;
72 if (value) window.location.href = value;
73 },
74 'submit-form': (el) => (el as HTMLInputElement).form?.requestSubmit(),
75 };
76
77 /** Collect positional args from `data-arg` / `data-arg2`. Exported for tests. */
78 export function collectArgs(el: Element): string[] {
79 const args: string[] = [];
80 const a1 = el.getAttribute('data-arg');
81 if (a1 !== null) args.push(a1);
82 const a2 = el.getAttribute('data-arg2');
83 if (a2 !== null) args.push(a2);
84 return args;
85 }
86
87 function run(el: Element, verb: string, evt: Event): void {
88 if (el.hasAttribute('data-prevent')) evt.preventDefault();
89 if (el.hasAttribute('data-stop')) evt.stopPropagation();
90 if (verb === 'nav') {
91 const href = el.getAttribute('data-href');
92 if (href) window.location.href = href;
93 return;
94 }
95 const builtin = BUILTINS[verb];
96 if (builtin) {
97 builtin(el);
98 return;
99 }
100 const args = collectArgs(el);
101 // Registry first, then the legacy `window.<verb>` global (strangler bridge).
102 const registered = registry.get(verb);
103 if (registered) {
104 registered.call(el, args, evt);
105 return;
106 }
107 const global = (window as unknown as Record<string, unknown>)[verb];
108 if (typeof global !== 'function') {
109 console.warn('data-action: no handler for', verb);
110 return;
111 }
112 (global as LegacyHandler).apply(el, args);
113 }
114
115 /** Run every verb named in a space-separated attribute value, in written order.
116 * One element can ask for more than one thing: a form that resets itself and
117 * then refreshes the panel it lives in says `reset refresh`. */
118 function runList(el: Element, attr: string, evt: Event): void {
119 const value = el.getAttribute(attr);
120 if (!value) return;
121 for (const verb of value.split(/\s+/)) {
122 if (verb) run(el, verb, evt);
123 }
124 }
125
126 function listen(eventName: string, attr: string, autoPrevent: boolean): void {
127 document.addEventListener(eventName, (evt) => {
128 const el = (evt.target as Element | null)?.closest('[' + attr + ']');
129 if (!el) return;
130 if (autoPrevent) evt.preventDefault();
131 const verb = el.getAttribute(attr);
132 if (verb) run(el, verb, evt);
133 });
134 }
135
136 /** Elements the browser already activates from the keyboard. */
137 const NATIVELY_ACTIVATED = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
138
139 /** Enter / Space on a `data-action` element the browser does not activate on
140 * its own. The charter requires a focus ring on custom interactive containers
141 * such as the sort headers, and a ring on something that cannot be operated
142 * from the keyboard is decoration. Opt in with `tabindex="0"`. */
143 function listenKeyActivate(): void {
144 document.addEventListener('keydown', (evt) => {
145 const key = (evt as KeyboardEvent).key;
146 if (key !== 'Enter' && key !== ' ') return;
147 const el = (evt.target as Element | null)?.closest('[data-action]');
148 if (!el || NATIVELY_ACTIVATED.has(el.tagName) || !el.hasAttribute('tabindex')) return;
149 evt.preventDefault();
150 const verb = el.getAttribute('data-action');
151 if (verb) run(el, verb, evt);
152 });
153 }
154
155 /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */
156 export function initActionDispatcher(): void {
157 listen('click', 'data-action', false);
158 listenKeyActivate();
159 listen('change', 'data-change', false);
160 listen('input', 'data-input', false);
161 listen('submit', 'data-submit', true);
162 }
163
164 // ---- The `data-after` family, dispatched on htmx's request lifecycle ----
165 //
166 // These were ten private `data-hx-*` attributes with their own dispatcher, a
167 // second vocabulary sitting beside `data-action` and resolving nothing the same
168 // way. Two of the ten (`data-hx-get`, `data-hx-target`) also collided with
169 // htmx's own `data-hx-*` alias, so at eight sites htmx read the private
170 // directive as a real htmx attribute and acted on it as well.
171 //
172 // They are verbs now, in the one registry, taking their arguments from the
173 // `data-arg` / `data-target` / `data-href` conventions the click family already
174 // uses. What is left is the hook they run on, which is what `data-after` and
175 // `data-after-always` name.
176
177 /** htmx 4 hangs the whole request on `detail.ctx`; htmx 2 spread it across the
178 * detail itself as `elt`, `xhr`, `parameters` and `successful`. Only the
179 * fields the verbs below read are named. */
180 interface HxDetail {
181 ctx: {
182 sourceElement: HTMLElement;
183 /** `body` is FormData while `htmx:config:request` runs, `parameters` in 2.x. */
184 request: { body?: FormData };
185 response?: { status: number };
186 /** The response body, `xhr.responseText` in 2.x. */
187 text?: string;
188 };
189 }
190
191 function refreshProfileTab(): void {
192 // `settings-profile` since `6b24f2df` step 4 described the settings sub-nav:
193 // six sections shared one `settings-body` pane while the nav was hand-written,
194 // and each has its own frame now.
195 const t = document.getElementById('settings-profile');
196 if (t) {
197 htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
198 } else {
199 byId('tab-profile')?.click();
200 }
201 }
202
203 /** Verbs the `data-after` family introduced. Registered rather than kept in a
204 * map of their own, so `data-action="reload"` and `data-after="reload"` are the
205 * same verb reached through different hooks. */
206 function registerAfterVerbs(): void {
207 register('reset', function () {
208 const form = this as unknown as HTMLFormElement;
209 if (typeof form.reset === 'function') form.reset();
210 });
211 register('reload', () => window.location.reload());
212 register('refresh', (args) => {
213 const [url, target] = args;
214 if (url && target) htmx.ajax('GET', url, target);
215 });
216 register('toast', (args) => showToast(args[0] ?? '', args[1] || 'info'));
217 register('label-added', function () {
218 this.textContent = 'Added';
219 });
220 register('mark-added', function () {
221 this.textContent = 'Added';
222 (this as HTMLButtonElement).disabled = true;
223 });
224 // `label-added` + `fade-row` is the old `fade-row-added`, which relabelled
225 // unconditionally and faded only on success. Two hooks say that between them;
226 // one verb had to read the outcome to say it. Note it relabels without
227 // disabling, which is what that button did and is not what `mark-added` does.
228 register('fade-row', function () {
229 this.closest('tr')?.classList.add('is-faded');
230 });
231 register('refresh-profile', () => refreshProfileTab());
232 // The only verb that reads the response rather than the element. The verify
233 // endpoint answers 200 whether or not the TXT record matched, so success
234 // alone does not mean verified.
235 register('refresh-profile-if-verified', (_args, evt) => {
236 const text = (evt as CustomEvent<HxDetail>).detail?.ctx?.text;
237 if (text && text.indexOf('verified successfully') !== -1) refreshProfileTab();
238 });
239 }
240
241 /** Install the `data-after` / `data-after-always` dispatcher. */
242 export function initAfterRequestDispatcher(): void {
243 registerAfterVerbs();
244
245 document.body.addEventListener('htmx:after:request', (e) => {
246 const evt = e as CustomEvent<HxDetail>;
247 const el = evt.detail.ctx.sourceElement;
248 if (!el || !el.getAttribute) return;
249
250 runList(el, 'data-after-always', evt);
251 // `detail.successful` in htmx 2. The status is what it summarised, and 4
252 // fires this event for error responses too.
253 if ((evt.detail.ctx.response?.status ?? 0) >= 400) return;
254 runList(el, 'data-after', evt);
255 });
256 }
257