@kitn.ai/chat
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
80 lines (74 loc) • 3.38 kB
text/typescript
// 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<CardEvent>(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<CardEvent>).detail);
root.addEventListener(CARD_EVENT_NAME, handler as EventListener);
return () => root.removeEventListener(CARD_EVENT_NAME, handler as EventListener);
}