import { type JSX, Show, splitProps, mergeProps, createSignal, createUniqueId } from 'solid-js'; import { Dynamic } from 'solid-js/web'; import { cn } from '../utils/cn'; import { X } from 'lucide-solid'; export type CardAppearance = 'outlined' | 'filled' | 'plain' | 'accent'; /** `vertical` (media on top), `horizontal` (media at the start), or `responsive` * (horizontal when the card's container is at least ~28rem wide, else vertical — * a CSS container query on the card's own width, NOT the viewport). */ export type CardOrientation = 'vertical' | 'horizontal' | 'responsive'; /** Surface treatment. `outlined` (default) = bordered card; `filled` = a raised * opaque surface; `plain` = no border/background (a padded region); `accent` = * the bold primary fill (announcements). */ const APPEARANCE: Record = { outlined: 'border border-border bg-card text-card-foreground kai-elevation-sm', filled: 'border border-transparent bg-surface-strong text-card-foreground', plain: 'border border-transparent bg-transparent', accent: 'border border-transparent bg-primary text-primary-foreground', }; export interface CardProps extends JSX.HTMLAttributes { /** Surface treatment: `outlined` | `filled` | `plain` | `accent`. */ appearance?: CardAppearance; /** `vertical` (media on top) or `horizontal` (media at the start). */ orientation?: CardOrientation; /** The card width below which a `responsive` card collapses to vertical and the * footer action cluster stacks. A CSS length (e.g. `'24rem'`); default `'28rem'`. * Container-query breakpoints can't be CSS variables, so this prop is baked into * a per-card `@container` rule. */ collapse?: string; /** Tighter spacing for dense lists (shrinks `--kai-card-spacing`). */ dense?: boolean; /** Full-bleed media region (image/video/illustration) at the top (vertical) or * start (horizontal). Clipped to the card's corners. */ media?: JSX.Element; /** Header content (e.g. a title). Rendered above the body, padded. */ header?: JSX.Element; /** An actions cluster pinned to the end of the header row. */ headerActions?: JSX.Element; /** Footer content. Rendered below the body, padded. */ footer?: JSX.Element; /** An actions cluster pinned to the end of the footer row. */ footerActions?: JSX.Element; /** Whether the default slot (body) has content — the facade computes this so an * empty body region isn't rendered (an empty `` is always truthy). */ hasBody?: boolean; /** Render a dismiss (×) that hides the card and calls `onDismiss`. Off by default. */ dismissible?: boolean; onDismiss?: () => void; /** Render the whole card as a link (``). Wins over `clickable`. */ href?: string; target?: string; rel?: string; /** Make the whole card a `role="button"` with Enter/Space activation. A * clickable/href card must NOT also contain footer action buttons. */ clickable?: boolean; onCardClick?: (event: MouseEvent | KeyboardEvent) => void; } /** * `Card` — the kit's presentational card surface (the `` primitive), * modeled on the WebAwesome card: ONE element whose flexibility comes from a few * structural slots (`media`, `header` + actions, `footer` + actions; body is the * default slot), `appearance` + `orientation` variants, `::part` styling, and a * single `--kai-card-spacing` knob. The title/description are NOT slots — they are * body/header content the consumer marks up, because a slot earns its place only * where the shadow boundary blocks the consumer (a pinned media/footer region), * not for a text node. * * Distinct from the generative-UI contract chrome in `../components/card.tsx` * (which the Card Contract cards compose); this one is purely presentational. * * a11y: a `clickable`/`href` card MUST NOT also contain action buttons — that * nests interactive controls inside a button/link. */ export function Card(props: CardProps): JSX.Element { const merged = mergeProps( { appearance: 'outlined' as CardAppearance, orientation: 'vertical' as CardOrientation, collapse: '28rem', dense: false }, props, ); const [local, rest] = splitProps(merged, [ 'appearance', 'orientation', 'collapse', 'dense', 'media', 'header', 'headerActions', 'footer', 'footerActions', 'hasBody', 'dismissible', 'onDismiss', 'href', 'target', 'rel', 'clickable', 'onCardClick', 'class', 'children', ]); const [open, setOpen] = createSignal(true); const dismiss = (event: MouseEvent) => { event.stopPropagation(); setOpen(false); local.onDismiss?.(); }; const isLink = () => Boolean(local.href); const isButton = () => !isLink() && Boolean(local.clickable); const isInteractive = () => isLink() || isButton(); // The responsive layout is driven by a per-instance @container rule (a query on // the card's OWN width, not the viewport) rather than fixed Tailwind classes, so // the breakpoint is the configurable `collapse` prop. Base + query rules share a // selector; the query (later) wins above the breakpoint. The card collapses to // vertical (responsive orientation) and stacks its footer actions below `collapse`. const COLLAPSE_RE = /^[\d.]+(px|rem|em|ch|vw|vh|%)$/; const collapseAt = () => (COLLAPSE_RE.test(local.collapse) ? local.collapse : '28rem'); // Scope the injected
{local.media}
{sections}
); }