import { createSignal, onCleanup, onMount } from 'solid-js'; import { defineWebComponent } from './define'; import { Card, type CardAppearance, type CardOrientation } from '../ui/card'; interface Props extends Record { /** Surface treatment: `outlined` (default) | `filled` | `plain` | `accent`. * Attribute: `appearance`. */ appearance?: CardAppearance; /** `vertical` (default, media on top) | `horizontal` (media at the start) | * `responsive` (horizontal when the card's container is wide enough, else * vertical — a container query on the card's own width). Attribute: `orientation`. */ orientation?: CardOrientation; /** The card width below which a `responsive` card collapses to vertical and the * footer actions stack. A CSS length; default `28rem`. Attribute: `collapse`. */ collapse?: string; /** Tighter spacing for dense lists. Attribute: `dense`. */ dense?: boolean; /** Show a close (×) that hides the card and emits `kai-dismiss`. Attribute: * `dismissible`. Off by default. */ dismissible?: boolean; /** Render the whole card as a link. Attribute: `href`. Wins over `clickable`. */ href?: string; /** `target` for the `href` anchor. Attribute: `target`. */ target?: string; /** `rel` for the `href` anchor. Attribute: `rel`. */ rel?: string; /** Make the whole card a button (`role="button"`, Enter/Space, hover affordance) * that emits `kai-card-click`. Attribute: `clickable`. Ignored when `href` is set. */ clickable?: boolean; } /** Events fired by ``. */ interface Events { /** The card was dismissed via its × (it also hides itself). */ 'kai-dismiss': void; /** A `clickable`/`href` card was activated (click, or Enter/Space). */ 'kai-card-click': void; } /** The named slots whose occupancy gates a card region. An empty `` is * always a truthy node, so the facade tracks which are actually filled and only * passes those regions to the primitive. */ const SLOT_NAMES = ['media', 'header', 'header-actions', 'footer', 'footer-actions'] as const; type SlotName = (typeof SLOT_NAMES)[number]; /** * `` — the kit's presentational card, modeled on the WebAwesome card: * ONE element whose flexibility comes from a few structural slots, `appearance` * and `orientation` variants, themeable `::part`s, and a single * `--kai-card-spacing` knob. The title/description are not slots — they are body * (default slot) or `slot="header"` content you mark up yourself. * * Slots: `media` (full-bleed image/video/illustration), `header` + `header-actions`, * the default slot (body), `footer` + `footer-actions`. * * Behaviors (all OFF by default): `dismissible` (× → `kai-dismiss`), and * `href`/`clickable` (whole card a link/button → `kai-card-click`). A * clickable/href card must NOT also contain action buttons. * * ```html * *
…
*

2× usage

* Do more with a higher session limit. * Start task *
* ``` */ defineWebComponent('kai-card', { appearance: 'outlined', orientation: 'vertical', collapse: '28rem', dense: false, dismissible: false, href: undefined, target: undefined, rel: undefined, clickable: false, }, (props, { element, dispatch, flag }) => { // Track which named slots are filled, plus whether the default slot (body) has // any content. Re-read on child mutations so streamed/late content lights up // its region. An unfilled region is never rendered (no stray padding/divider). const [filled, setFilled] = createSignal>({ 'media': false, 'header': false, 'header-actions': false, 'footer': false, 'footer-actions': false, }); const [hasBody, setHasBody] = createSignal(false); onMount(() => { const read = () => { const next = {} as Record; for (const name of SLOT_NAMES) next[name] = !!element.querySelector(`:scope > [slot="${name}"]`); setFilled(next); // Body = any direct child without a slot attribute (element, or non-blank text). setHasBody( Array.from(element.childNodes).some( (n) => (n.nodeType === Node.ELEMENT_NODE && !(n as Element).hasAttribute('slot')) || (n.nodeType === Node.TEXT_NODE && (n.textContent ?? '').trim() !== ''), ), ); }; read(); const observer = new MutationObserver(read); observer.observe(element, { childList: true, characterData: true, subtree: true }); onCleanup(() => observer.disconnect()); }); const region = (name: SlotName) => (filled()[name] ? : undefined); return ( dispatch('kai-dismiss')} onCardClick={() => dispatch('kai-card-click')} media={region('media')} header={region('header')} headerActions={region('header-actions')} footer={region('footer')} footerActions={region('footer-actions')} hasBody={hasBody()} > ); });