// The delegated dispatcher, ported from mnw.js:724-871. // // It lets templates drop inline `on*` / `hx-on::` handlers so the CSP keeps // `script-src 'self'` (no 'unsafe-inline'). Verbs resolve against a typed // `register()` registry FIRST, falling back to a `window.` global for // handlers not yet migrated off the legacy `static/*.js` files. New code should // `register()`, never add a global, the `frontend_globals` seal enforces the // ratchet. // // ONE vocabulary, several hooks. A verb names what happens; the attribute names // when. `data-action` is a click, `data-change` / `data-input` / `data-submit` // are the other three DOM events, and `data-after` / `data-after-always` are // htmx's request lifecycle. Arguments come from `data-arg` / `data-arg2`, the // target of a verb that acts on another element from `data-target` or // `data-href`, whichever hook dispatched it. The same verb reached two ways is // the same registry entry. import { byId, targets } from './dom.ts'; import { showToast } from './toast.ts'; /** A named verb. `args` are the positional `data-arg` / `data-arg2` values. * `evt` is whatever triggered the dispatch: a DOM event for the click family, * htmx's `htmx:after:request` CustomEvent for the `data-after` family. Most * verbs ignore it; the ones that branch on the request outcome do not. */ export type ActionHandler = (this: Element, args: string[], evt: Event) => void; /** The legacy `window.` bridge calls positionally, which is the shape * those handlers were written against and the reason they are still callable * without being ported. */ type LegacyHandler = (this: Element, ...args: string[]) => void; const registry = new Map(); /** Register a named verb. Preferred over a `window.` global; the * dispatcher checks the registry before the global fallback. */ export function register(name: string, fn: ActionHandler): void { registry.set(name, fn); } type BuiltinFn = (el: Element) => void; /** Built-in verbs operating on `data-target` element ids. */ const BUILTINS: Record = { show: (el) => targets(el).forEach((t) => t.classList.remove('hidden')), hide: (el) => targets(el).forEach((t) => t.classList.add('hidden')), toggle: (el) => targets(el).forEach((t) => t.classList.toggle('hidden')), click: (el) => targets(el).forEach((t) => t.click()), remove: (el) => targets(el).forEach((t) => t.remove()), 'remove-self': (el) => el.remove(), }; /** Collect positional args from `data-arg` / `data-arg2`. Exported for tests. */ export function collectArgs(el: Element): string[] { const args: string[] = []; const a1 = el.getAttribute('data-arg'); if (a1 !== null) args.push(a1); const a2 = el.getAttribute('data-arg2'); if (a2 !== null) args.push(a2); return args; } function run(el: Element, verb: string, evt: Event): void { if (el.hasAttribute('data-prevent')) evt.preventDefault(); if (el.hasAttribute('data-stop')) evt.stopPropagation(); if (verb === 'nav') { const href = el.getAttribute('data-href'); if (href) window.location.href = href; return; } const builtin = BUILTINS[verb]; if (builtin) { builtin(el); return; } const args = collectArgs(el); // Registry first, then the legacy `window.` global (strangler bridge). const registered = registry.get(verb); if (registered) { registered.call(el, args, evt); return; } const global = (window as unknown as Record)[verb]; if (typeof global !== 'function') { console.warn('data-action: no handler for', verb); return; } (global as LegacyHandler).apply(el, args); } /** Run every verb named in a space-separated attribute value, in written order. * One element can ask for more than one thing: a form that resets itself and * then refreshes the panel it lives in says `reset refresh`. */ function runList(el: Element, attr: string, evt: Event): void { const value = el.getAttribute(attr); if (!value) return; for (const verb of value.split(/\s+/)) { if (verb) run(el, verb, evt); } } function listen(eventName: string, attr: string, autoPrevent: boolean): void { document.addEventListener(eventName, (evt) => { const el = (evt.target as Element | null)?.closest('[' + attr + ']'); if (!el) return; if (autoPrevent) evt.preventDefault(); const verb = el.getAttribute(attr); if (verb) run(el, verb, evt); }); } /** Elements the browser already activates from the keyboard. */ const NATIVELY_ACTIVATED = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']); /** Enter / Space on a `data-action` element the browser does not activate on * its own. The charter requires a focus ring on custom interactive containers * such as the sort headers, and a ring on something that cannot be operated * from the keyboard is decoration. Opt in with `tabindex="0"`. */ function listenKeyActivate(): void { document.addEventListener('keydown', (evt) => { const key = (evt as KeyboardEvent).key; if (key !== 'Enter' && key !== ' ') return; const el = (evt.target as Element | null)?.closest('[data-action]'); if (!el || NATIVELY_ACTIVATED.has(el.tagName) || !el.hasAttribute('tabindex')) return; evt.preventDefault(); const verb = el.getAttribute('data-action'); if (verb) run(el, verb, evt); }); } /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */ export function initActionDispatcher(): void { listen('click', 'data-action', false); listenKeyActivate(); listen('change', 'data-change', false); listen('input', 'data-input', false); listen('submit', 'data-submit', true); } // ---- The `data-after` family, dispatched on htmx's request lifecycle ---- // // These were ten private `data-hx-*` attributes with their own dispatcher, a // second vocabulary sitting beside `data-action` and resolving nothing the same // way. Two of the ten (`data-hx-get`, `data-hx-target`) also collided with // htmx's own `data-hx-*` alias, so at eight sites htmx read the private // directive as a real htmx attribute and acted on it as well. // // They are verbs now, in the one registry, taking their arguments from the // `data-arg` / `data-target` / `data-href` conventions the click family already // uses. What is left is the hook they run on, which is what `data-after` and // `data-after-always` name. /** htmx 4 hangs the whole request on `detail.ctx`; htmx 2 spread it across the * detail itself as `elt`, `xhr`, `parameters` and `successful`. Only the * fields the verbs below read are named. */ interface HxDetail { ctx: { sourceElement: HTMLElement; /** `body` is FormData while `htmx:config:request` runs, `parameters` in 2.x. */ request: { body?: FormData }; response?: { status: number }; /** The response body, `xhr.responseText` in 2.x. */ text?: string; }; } function refreshProfileTab(): void { // `settings-profile` since `6b24f2df` step 4 described the settings sub-nav: // six sections shared one `settings-body` pane while the nav was hand-written, // and each has its own frame now. const t = document.getElementById('settings-profile'); if (t) { htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' }); } else { byId('tab-profile')?.click(); } } /** Verbs the `data-after` family introduced. Registered rather than kept in a * map of their own, so `data-action="reload"` and `data-after="reload"` are the * same verb reached through different hooks. */ function registerAfterVerbs(): void { register('reset', function () { const form = this as unknown as HTMLFormElement; if (typeof form.reset === 'function') form.reset(); }); register('reload', () => window.location.reload()); register('refresh', (args) => { const [url, target] = args; if (url && target) htmx.ajax('GET', url, target); }); register('toast', (args) => showToast(args[0] ?? '', args[1] || 'info')); register('label-added', function () { this.textContent = 'Added'; }); register('mark-added', function () { this.textContent = 'Added'; (this as HTMLButtonElement).disabled = true; }); // `label-added` + `fade-row` is the old `fade-row-added`, which relabelled // unconditionally and faded only on success. Two hooks say that between them; // one verb had to read the outcome to say it. Note it relabels without // disabling, which is what that button did and is not what `mark-added` does. register('fade-row', function () { this.closest('tr')?.classList.add('is-faded'); }); register('refresh-profile', () => refreshProfileTab()); // The only verb that reads the response rather than the element. The verify // endpoint answers 200 whether or not the TXT record matched, so success // alone does not mean verified. register('refresh-profile-if-verified', (_args, evt) => { const text = (evt as CustomEvent).detail?.ctx?.text; if (text && text.indexOf('verified successfully') !== -1) refreshProfileTab(); }); } /** Install the `data-after` / `data-after-always` / `data-config` dispatcher. */ export function initAfterRequestDispatcher(): void { registerAfterVerbs(); document.body.addEventListener('htmx:after:request', (e) => { const evt = e as CustomEvent; const el = evt.detail.ctx.sourceElement; if (!el || !el.getAttribute) return; runList(el, 'data-after-always', evt); // `detail.successful` in htmx 2. The status is what it summarised, and 4 // fires this event for error responses too. if ((evt.detail.ctx.response?.status ?? 0) >= 400) return; runList(el, 'data-after', evt); }); document.body.addEventListener('htmx:config:request', (e) => { const evt = e as CustomEvent; const el = evt.detail.ctx.sourceElement; if (el?.getAttribute?.('data-config') === 'publish-at-iso') { const body = evt.detail.ctx.request.body; const v = body?.get('publish_at'); if (typeof v === 'string' && v) body?.set('publish_at', new Date(v).toISOString()); } }); }