// src/primitives/card-routing.ts // Framework-agnostic card-event plumbing: the bubbling kc-card emitter, the single // policy router (used by BOTH the native listener and the remote transport), and a // host-side listener helper for the bare-element path. import type { CardEvent, CardPolicy } from './card-contract'; /** The single contract event name. */ export const CARD_EVENT_NAME = 'kc-card'; /** Dispatch a CardEvent as the bubbling, composed `kc-card` event a host listener * routes. NB: this is deliberately different from defineWebComponent's built-in * non-bubbling dispatch. */ export function emitCardEvent(element: HTMLElement, event: CardEvent): void { element.dispatchEvent( new CustomEvent(CARD_EVENT_NAME, { detail: event, bubbles: true, composed: true }), ); } const SAFE_SCHEMES = ['http:', 'https:', 'mailto:']; function isSafeUrl(url: string): boolean { try { return SAFE_SCHEMES.includes(new URL(url, 'http://_invalid_base').protocol); } catch { return false; } } function warnNoHandler(kind: string): void { // eslint-disable-next-line no-console console.warn(`[kc-card] no policy handler for "${kind}"`); } /** Apply the contract's policy to one event. The ONE place routing lives. */ export function routeCardEvent(policy: CardPolicy | undefined, event: CardEvent): void { const p: CardPolicy = policy ?? {}; switch (event.kind) { case 'ready': break; // lifecycle; host may react via its own listener case 'submit': p.onSubmit ? p.onSubmit(event.cardId, event.data) : warnNoHandler('submit'); break; case 'action': p.onAction ? p.onAction(event.cardId, event.action, event.payload) : warnNoHandler('action'); break; case 'send-prompt': { const requested = event.mode ?? 'compose'; const mode = p.maxSendPromptMode === 'send' ? requested : 'compose'; p.onSendPrompt ? p.onSendPrompt(event.text, { mode, context: event.context }) : warnNoHandler('send-prompt'); break; } case 'open': { if (!isSafeUrl(event.url)) { p.onError ? p.onError(event.cardId, `Blocked unsafe url: ${event.url}`) : warnNoHandler('open(unsafe)'); break; } const target = event.target ?? 'tab'; if (p.onOpen) p.onOpen(event.url, target); else if (typeof window !== 'undefined') window.open(event.url, '_blank', 'noopener,noreferrer'); break; } case 'state': p.onState ? p.onState(event.cardId, event.patch) : warnNoHandler('state'); break; case 'dismiss': p.onDismiss ? p.onDismiss(event.cardId) : warnNoHandler('dismiss'); break; case 'error': p.onError ? p.onError(event.cardId, event.message) : warnNoHandler('error'); break; case 'resize': break; // transport plumbing (iframe height); not an app-policy concern natively } } /** Attach a host-level `kc-card` listener that routes every bubbling card event * through `policy`. Returns an unsubscribe fn. For the bare-element path. */ export function listenForCardEvents( root: HTMLElement | Document, policy: CardPolicy, ): () => void { const handler = (e: Event) => routeCardEvent(policy, (e as CustomEvent).detail); root.addEventListener(CARD_EVENT_NAME, handler as EventListener); return () => root.removeEventListener(CARD_EVENT_NAME, handler as EventListener); }