Skip to main content

max / makenotwork

9.9 KB · 242 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 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 /** htmx 4 hangs the whole request on `detail.ctx`; htmx 2 spread it across the
153 * detail itself as `elt`, `xhr`, `parameters` and `successful`. Only the
154 * fields the verbs below read are named. */
155 interface HxDetail {
156 ctx: {
157 sourceElement: HTMLElement;
158 /** `body` is FormData while `htmx:config:request` runs, `parameters` in 2.x. */
159 request: { body?: FormData };
160 response?: { status: number };
161 /** The response body, `xhr.responseText` in 2.x. */
162 text?: string;
163 };
164 }
165
166 function refreshProfileTab(): void {
167 // `settings-profile` since `6b24f2df` step 4 described the settings sub-nav:
168 // six sections shared one `settings-body` pane while the nav was hand-written,
169 // and each has its own frame now.
170 const t = document.getElementById('settings-profile');
171 if (t) {
172 htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
173 } else {
174 byId('tab-profile')?.click();
175 }
176 }
177
178 /** Verbs the `data-after` family introduced. Registered rather than kept in a
179 * map of their own, so `data-action="reload"` and `data-after="reload"` are the
180 * same verb reached through different hooks. */
181 function registerAfterVerbs(): void {
182 register('reset', function () {
183 const form = this as unknown as HTMLFormElement;
184 if (typeof form.reset === 'function') form.reset();
185 });
186 register('reload', () => window.location.reload());
187 register('refresh', (args) => {
188 const [url, target] = args;
189 if (url && target) htmx.ajax('GET', url, target);
190 });
191 register('toast', (args) => showToast(args[0] ?? '', args[1] || 'info'));
192 register('label-added', function () {
193 this.textContent = 'Added';
194 });
195 register('mark-added', function () {
196 this.textContent = 'Added';
197 (this as HTMLButtonElement).disabled = true;
198 });
199 // `label-added` + `fade-row` is the old `fade-row-added`, which relabelled
200 // unconditionally and faded only on success. Two hooks say that between them;
201 // one verb had to read the outcome to say it. Note it relabels without
202 // disabling, which is what that button did and is not what `mark-added` does.
203 register('fade-row', function () {
204 this.closest('tr')?.classList.add('is-faded');
205 });
206 register('refresh-profile', () => refreshProfileTab());
207 // The only verb that reads the response rather than the element. The verify
208 // endpoint answers 200 whether or not the TXT record matched, so success
209 // alone does not mean verified.
210 register('refresh-profile-if-verified', (_args, evt) => {
211 const text = (evt as CustomEvent<HxDetail>).detail?.ctx?.text;
212 if (text && text.indexOf('verified successfully') !== -1) refreshProfileTab();
213 });
214 }
215
216 /** Install the `data-after` / `data-after-always` / `data-config` dispatcher. */
217 export function initAfterRequestDispatcher(): void {
218 registerAfterVerbs();
219
220 document.body.addEventListener('htmx:after:request', (e) => {
221 const evt = e as CustomEvent<HxDetail>;
222 const el = evt.detail.ctx.sourceElement;
223 if (!el || !el.getAttribute) return;
224
225 runList(el, 'data-after-always', evt);
226 // `detail.successful` in htmx 2. The status is what it summarised, and 4
227 // fires this event for error responses too.
228 if ((evt.detail.ctx.response?.status ?? 0) >= 400) return;
229 runList(el, 'data-after', evt);
230 });
231
232 document.body.addEventListener('htmx:config:request', (e) => {
233 const evt = e as CustomEvent<HxDetail>;
234 const el = evt.detail.ctx.sourceElement;
235 if (el?.getAttribute?.('data-config') === 'publish-at-iso') {
236 const body = evt.detail.ctx.request.body;
237 const v = body?.get('publish_at');
238 if (typeof v === 'string' && v) body?.set('publish_at', new Date(v).toISOString());
239 }
240 });
241 }
242