import { type JSX, For, Show, splitProps, mergeProps, createMemo, createEffect, on, ErrorBoundary, } from 'solid-js'; import { cn } from '../utils/cn'; import { Button } from '../ui/button'; import { Card } from './card'; import type { CardEnvelope, CardEvent, CardHost, CardResolution } from '../primitives/card-contract'; import { useCardResolution } from './use-card-resolution'; import { emitCardEvent } from '../primitives/card-routing'; import { useCardHost } from '../primitives/card-host'; import { AlertTriangle, Check, X } from 'lucide-solid'; // ───────────────────────────────────────────────────────────────────────────── // Types (confirm.schema.json) — see src/primitives/card-schemas/confirm.schema.json // ───────────────────────────────────────────────────────────────────────────── export type ConfirmActionStyle = 'primary' | 'default' | 'destructive'; export type ConfirmTone = 'default' | 'warning' | 'danger'; export interface ConfirmAction { id: string; label: string; style?: ConfirmActionStyle; payload?: unknown; default?: boolean; } export interface ConfirmCardData { heading?: string; body?: string; tone?: ConfirmTone; actions: ConfirmAction[]; // 1..4 dismissible?: boolean; } export type ConfirmCardEnvelope = CardEnvelope<'confirm', ConfirmCardData>; export const CONFIRM_CARD_TYPE = 'confirm' as const; const VALID_STYLES = new Set(['primary', 'default', 'destructive']); // ───────────────────────────────────────────────────────────────────────────── // Pure helpers (unit-tested in isolation). // ───────────────────────────────────────────────────────────────────────────── /** Map an action's `style` to a Button variant (falls back + warns on unknown). */ export function buttonVariantForStyle( style: ConfirmActionStyle | undefined, ): 'default' | 'outline' | 'destructive' { switch (style) { case 'primary': return 'default'; case 'destructive': return 'destructive'; case 'default': case undefined: return 'outline'; default: // eslint-disable-next-line no-console console.warn(`[kc-confirm] unknown action style "${style as string}"; using default`); return 'outline'; } } /** De-dupe actions by id (first wins) and validate their shape. Returns the usable * list + an optional error message when there's nothing renderable. */ export function normalizeActions(actions: unknown): { actions: ConfirmAction[]; error?: string; } { if (!Array.isArray(actions) || actions.length === 0) { return { actions: [], error: "This card couldn't be displayed." }; } const seen = new Set(); const out: ConfirmAction[] = []; for (const a of actions) { if (!a || typeof a !== 'object') continue; const action = a as Partial; if (typeof action.id !== 'string' || action.id.length === 0) continue; if (typeof action.label !== 'string' || action.label.length === 0) continue; if (seen.has(action.id)) { // eslint-disable-next-line no-console console.warn(`[kc-confirm] duplicate action id "${action.id}" ignored`); continue; } seen.add(action.id); const style = VALID_STYLES.has(action.style as ConfirmActionStyle) ? (action.style as ConfirmActionStyle) : undefined; if (action.style !== undefined && style === undefined) { // eslint-disable-next-line no-console console.warn(`[kc-confirm] unknown action style "${String(action.style)}"; using default`); } out.push({ id: action.id, label: action.label, style, payload: action.payload, default: action.default === true, }); } if (out.length === 0) return { actions: [], error: "This card couldn't be displayed." }; return { actions: out }; } /** The id of the default action (first with `default:true`), if any. */ export function defaultActionId(actions: ConfirmAction[]): string | undefined { return actions.find((a) => a.default)?.id; } // ───────────────────────────────────────────────────────────────────────────── // The component. // ───────────────────────────────────────────────────────────────────────────── export interface ConfirmCardProps { /** The confirm definition (CardEnvelope.data). */ data?: ConfirmCardData; /** The card id used to correlate every emitted CardEvent. */ cardId?: string; /** The envelope title rendered in the card chrome. */ heading?: string; /** Optional explicit CardHost (otherwise read from a CardProvider, otherwise the * bubbling `kc-card` CustomEvent off `hostElement`). */ host?: CardHost; /** The custom-element host node, for the bubbling `kc-card` fallback emit. */ hostElement?: HTMLElement; /** Focus the default action on mount. Default OFF (no focus-stealing mid-stream). */ autofocus?: boolean; class?: string; /** When set, render the chromed read-only view instead of the buttons. */ resolution?: CardResolution; } /** * `ConfirmCard` — a named-intent approval card. Renders a title + body + a small * set of action buttons inside `Card` chrome. Activating an action emits the Card * contract's `action` verb (`{ kind:'action', cardId, action, payload }`) and * resolves the card (other actions disabled, the chosen one marked) so the same * approval can't double-fire. Emits `ready` on mount, `dismiss` for the optional * close affordance, and `error` for an unusable definition (inline error state). */ export function ConfirmCard(props: ConfirmCardProps): JSX.Element { const merged = mergeProps({ cardId: 'kc-confirm', autofocus: false }, props); const [local] = splitProps(merged, [ 'data', 'cardId', 'heading', 'host', 'hostElement', 'autofocus', 'class', 'resolution', ]); const ctxHost = useCardHost(); const emit = (event: CardEvent): void => { const h = local.host ?? ctxHost; if (h) h.emit(event); else if (local.hostElement) emitCardEvent(local.hostElement, event); }; const normalized = createMemo(() => normalizeActions(local.data?.actions)); const valid = createMemo(() => normalized().error === undefined); const errorMessage = createMemo(() => normalized().error ?? ''); const actions = createMemo(() => normalized().actions); const tone = (): ConfirmTone => local.data?.tone ?? 'default'; const isDanger = () => tone() === 'danger'; const defaultId = createMemo(() => defaultActionId(actions())); const res = useCardResolution({ prop: () => local.resolution, data: () => local.data }); const resolvedAction = createMemo(() => { const r = res.resolution(); if (!r || r.kind !== 'action') return undefined; const found = actions().find((a) => a.id === r.action); return { label: found?.label ?? r.action }; }); // ready / error lifecycle emits. createEffect( on(valid, (ok) => { if (ok) emit({ kind: 'ready', cardId: local.cardId }); else emit({ kind: 'error', cardId: local.cardId, message: errorMessage() }); }), ); const onAction = (action: ConfirmAction): void => { if (res.isResolved()) return; // single-shot emit({ kind: 'action', cardId: local.cardId, action: action.id, ...(action.payload !== undefined ? { payload: action.payload } : {}), }); res.setLocal({ kind: 'action', action: action.id, ...(action.payload !== undefined ? { payload: action.payload } : {}), }); }; const onDismiss = (): void => emit({ kind: 'dismiss', cardId: local.cardId }); let bodyRef: HTMLDivElement | undefined; // Surface the resolved action id for host styling. createEffect(() => { const el = local.hostElement; if (!el) return; const r = res.resolution(); if (r && r.kind === 'action') el.setAttribute('data-kc-resolved', r.action); else el.removeAttribute('data-kc-resolved'); }); return ( }> { emit({ kind: 'error', cardId: local.cardId, message: 'The card failed to render.' }); return ; }} > } >
{ // Enter on the card body (not on a focused button) invokes the default action. if (e.key !== 'Enter') return; const target = e.target as HTMLElement; if (target.tagName === 'BUTTON') return; const id = defaultId(); if (id === undefined) return; const action = actions().find((a) => a.id === id); if (action) onAction(action); }} tabindex={defaultId() !== undefined ? 0 : undefined} >

{local.data?.heading}

{local.data?.body}

); }