import { type JSX, Show, splitProps, mergeProps, createSignal, createUniqueId } from 'solid-js'; import { Dynamic } from 'solid-js/web'; import { cn } from '../utils/cn'; import { AlertTriangle, X } from 'lucide-solid'; export interface CardProps extends JSX.HTMLAttributes { /** Heading rendered in the card chrome (the contract's CardEnvelope.title). * NB: NOT `title` — `title` is a reserved IDL attr (see define.tsx RESERVED). */ heading?: string; /** Optional supporting text under the heading. */ description?: string; /** Media region (image/icon) rendered above the heading. */ media?: JSX.Element; /** Footer action region (buttons). */ actions?: JSX.Element; /** When set, the card renders its standard inline error state INSTEAD of body. */ errorMessage?: string; /** Compact spacing for dense lists of cards. */ dense?: boolean; /** Stable id for the heading (so composing cards can `aria-labelledby` it). * Auto-generated when omitted. */ headingId?: string; /** Render a dismiss (×) button that hides the card and calls `onDismiss`. * Opt-in, OFF by default — the contract cards never set it, so they are * unaffected. */ dismissible?: boolean; /** Called after the card is dismissed via its ×. */ onDismiss?: () => void; /** Render the whole card as a link (``). Wins over `clickable`. */ href?: string; /** `target` for the `href` anchor. */ target?: string; /** `rel` for the `href` anchor (default `noopener noreferrer` when `target="_blank"`). */ rel?: string; /** Make the whole card behave like a button: `role="button"`, a tabindex, and * Enter/Space activation, firing `onCardClick`. Ignored when `href` is set. * A clickable/href card must NOT also contain footer action buttons — that * nests interactive elements. Use `actions` OR clickable, never both. */ clickable?: boolean; /** Called when a `clickable` (or `href`) card is activated (click, or Enter/Space). */ onCardClick?: (event: MouseEvent | KeyboardEvent) => void; } /** * `Card` — the shared presentational chrome every native card composes from: * an optional media region, a heading + description, a body (default slot), an * actions footer, and one consistent inline **error** state (the contract's * "never a broken/partial card" rule). It is intentionally chrome-only: it reads * no `CardContext` and emits no `CardEvent` — the cards that compose it (e.g. * `kai-form`) own the contract interaction. * * The optional `dismissible` / `href` / `clickable` behaviors are all OFF by * default, so the contract cards (which set none of them) keep their original * presentational behavior unchanged. * * a11y: a `clickable` or `href` card MUST NOT also contain footer action buttons * — that nests interactive controls inside a button/link. Use `actions` OR * make the card clickable, never both. */ export function Card(props: CardProps): JSX.Element { const merged = mergeProps({ dense: false }, props); const [local, rest] = splitProps(merged, [ 'heading', 'description', 'media', 'actions', 'errorMessage', 'dense', 'headingId', 'dismissible', 'onDismiss', 'href', 'target', 'rel', 'clickable', 'onCardClick', 'class', 'children', ]); const autoId = createUniqueId(); const headingId = () => local.headingId ?? `kai-card-heading-${autoId}`; const hasError = () => local.errorMessage !== undefined && local.errorMessage !== ''; const hasHeader = () => Boolean(local.heading) || Boolean(local.description); const [open, setOpen] = createSignal(true); const dismiss = (event: MouseEvent) => { // Don't let the × activate a clickable/href card underneath it. event.stopPropagation(); setOpen(false); local.onDismiss?.(); }; const isLink = () => Boolean(local.href); const isButton = () => !isLink() && Boolean(local.clickable); const isInteractive = () => isLink() || isButton(); const activate = (event: MouseEvent | KeyboardEvent) => local.onCardClick?.(event); const onKeyDown = (event: KeyboardEvent) => { if (!isButton()) return; if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); activate(event); } }; // The interactive elements (`` / role="button") get keyboard + hover/focus // affordances; the plain card is a `
`. Anchors carry a safe default `rel` // when opening a new tab. const rel = () => local.rel ?? (local.target === '_blank' ? 'noopener noreferrer' : undefined); // The card's heading + body + actions column. const inner = ( <>

{local.heading}

{local.description}

{local.children}
{local.actions}
} >
); return (
{local.media}
{inner}
); }