// The action dispatcher + HTMX callback dispatcher, ported from mnw.js:724-871. // // These let templates drop inline `on*` / `hx-on::` handlers so the CSP keeps // `script-src 'self'` (no 'unsafe-inline'). The one behavioral change from the // port: `data-action` 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. import { byId, targets } from './dom.ts'; import { showToast } from './toast.ts'; export type ActionHandler = (this: Element, ...args: string[]) => void; const registry = new Map(); /** Register a named `data-action` handler. 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; } // Registry first, then the legacy `window.` global (strangler bridge). const handler = registry.get(verb) ?? (window as unknown as Record)[verb]; if (typeof handler !== 'function') { console.warn('data-action: no handler for', verb); return; } (handler as ActionHandler).apply(el, collectArgs(el)); } 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); }); } /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */ export function initActionDispatcher(): void { listen('click', 'data-action', false); listen('change', 'data-change', false); listen('input', 'data-input', false); listen('submit', 'data-submit', true); } // ---- HTMX callback dispatcher (hx-on:: replacement), ported from mnw.js:799 ---- interface HxDetail { elt: HTMLElement; successful?: boolean; xhr?: XMLHttpRequest; parameters?: Record; } function refreshProfileTab(): void { const t = document.getElementById('settings-body'); if (t) { htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' }); } else { byId('tab-profile')?.click(); } } type BehaviorFn = (el: HTMLElement, evt: CustomEvent) => void; const BEHAVIORS: Record = { 'mark-added': (el) => { el.textContent = 'Added'; (el as HTMLButtonElement).disabled = true; }, // Faithful to the inline original: the row fades only on success, but the // label flips to 'Added' unconditionally (registered under data-hx-always). 'fade-row-added': (el, evt) => { if (evt.detail.successful) el.closest('tr')?.classList.add('is-faded'); el.textContent = 'Added'; }, 'refresh-profile': () => refreshProfileTab(), 'refresh-profile-if-verified': (_el, evt) => { if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) { refreshProfileTab(); } }, }; /** Install the `data-hx-*` afterRequest/configRequest dispatcher. */ export function initHxCallbackDispatcher(): void { document.body.addEventListener('htmx:afterRequest', (e) => { const evt = e as CustomEvent; const el = evt.detail.elt; if (!el || !el.getAttribute) return; const always = el.getAttribute('data-hx-always'); if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt); if (el.hasAttribute('data-hx-always-click')) byId(el.getAttribute('data-hx-always-click'))?.click(); if (!evt.detail.successful) return; const form = el as HTMLFormElement; if (el.hasAttribute('data-hx-reset') && typeof form.reset === 'function') form.reset(); if (el.hasAttribute('data-hx-click')) byId(el.getAttribute('data-hx-click'))?.click(); if (el.hasAttribute('data-hx-get')) { const url = el.getAttribute('data-hx-get'); const target = el.getAttribute('data-hx-target'); if (url && target) htmx.ajax('GET', url, target); } if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav') ?? ''; if (el.hasAttribute('data-hx-reload')) window.location.reload(); if (el.hasAttribute('data-hx-toast')) { showToast(el.getAttribute('data-hx-toast') ?? '', el.getAttribute('data-hx-toast-type') || 'info'); } const beh = el.getAttribute('data-hx-behavior'); if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt); }); document.body.addEventListener('htmx:configRequest', (e) => { const evt = e as CustomEvent; const el = evt.detail.elt; if (el?.getAttribute?.('data-hx-config') === 'publish-at-iso') { const params = evt.detail.parameters; const v = params?.publish_at; if (v && params) params.publish_at = new Date(v as string).toISOString(); } }); }