From f59396b51a9559c3ce66fc75d26029ecf08fa89c Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 12 Jun 2026 18:23:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(uix):=20RadioCards=20=E2=80=94=20card-styl?= =?UTF-8?q?e=20single-select=20reusing=20RadioGroup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New eidos component built ON TOP of RadioGroup with zero duplicated behaviour. RadioCards reuses RadioGroup's soma wholesale (selection, roving focus, role=radio, native
via HiddenInput) and adds only the card chrome: icon · title · description + an always-present corner radio. Vertical, horizontal and grid layouts. - morfo: scope:['eidos'], thin contract for presentation parts only (Provider marker, Item-card, Indicator, Icon, Content, Title, Description). The interactive contract + commit-select stay in radio-group, reused via its soma. No new soma, no new sema. - eidos: `radio-cards` recipe (card chrome) + wrappers + radio-cards.css. radio-group.css is NOT loaded here, only its foundation tokens — the card owns its visual, reusing --_radio-group-palette-* (inlined, since a :root token referencing the palette is invalid-at-computed-value). - demo: canonical structure (always-on stage + trace, tabs, chip/switch controls, Untitled-UI example patterns) + nav entry + README. check 0 · eidos-lint 0 invalid. Browser-verified: select (click + keyboard roving), disabled, the three layouts, controls drive the preview. Follow-up: icon/circle sizes are still px (mirroring radio-group's own size tokens) — making the control-size scale density-aware is a separate, family-wide change deferred per request. Co-Authored-By: Claude Opus 4.8 --- .../eidos/components/radio-cards/README.md | 89 ++++ src/uix/eidos/components/radio-cards/index.ts | 57 +++ .../radio-cards/radio-cards-content.svelte | 8 + .../radio-cards-description.svelte | 8 + .../radio-cards/radio-cards-icon.svelte | 8 + .../radio-cards/radio-cards-item.svelte | 24 + .../radio-cards/radio-cards-title.svelte | 8 + .../components/radio-cards/radio-cards.css | 278 ++++++++++++ .../components/radio-cards/radio-cards.svelte | 52 +++ src/uix/eidos/components/radio-cards/types.ts | 73 +++ src/uix/eidos/generated/base.css | 41 ++ src/uix/eidos/lib/recipes/base.ts | 55 +++ src/uix/morfo/components/radio-cards.ts | 121 +++++ web/routes/uix/+layout@.svelte | 1 + .../uix/components/radio-cards/+page.svelte | 414 ++++++++++++++++++ 15 files changed, 1237 insertions(+) create mode 100644 src/uix/eidos/components/radio-cards/README.md create mode 100644 src/uix/eidos/components/radio-cards/index.ts create mode 100644 src/uix/eidos/components/radio-cards/radio-cards-content.svelte create mode 100644 src/uix/eidos/components/radio-cards/radio-cards-description.svelte create mode 100644 src/uix/eidos/components/radio-cards/radio-cards-icon.svelte create mode 100644 src/uix/eidos/components/radio-cards/radio-cards-item.svelte create mode 100644 src/uix/eidos/components/radio-cards/radio-cards-title.svelte create mode 100644 src/uix/eidos/components/radio-cards/radio-cards.css create mode 100644 src/uix/eidos/components/radio-cards/radio-cards.svelte create mode 100644 src/uix/eidos/components/radio-cards/types.ts create mode 100644 src/uix/morfo/components/radio-cards.ts create mode 100644 web/routes/uix/components/radio-cards/+page.svelte diff --git a/src/uix/eidos/components/radio-cards/README.md b/src/uix/eidos/components/radio-cards/README.md new file mode 100644 index 000000000..52f68af50 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/README.md @@ -0,0 +1,89 @@ +# RadioCards + +Card-style single-select. **Built on top of `RadioGroup` — zero duplicated +behaviour.** RadioCards reuses RadioGroup's *soma* wholesale (selection state, +roving focus, `role=radio`, native `` participation via `HiddenInput`) and +only adds the card presentation: each card carries content (icon · title · +description) and an always-present corner radio. + +```svelte + + + + + {@render sparkle()} + + Free + For hobby projects. + + + + + Pro + For growing teams. + + + +``` + +## Parts + +| Part | Marker | Notes | +| --- | --- | --- | +| `RadioCards` (root) | `data-radio-cards` | Reuses `RadioGroup.Provider` (`role=radiogroup`). Adds the card layout marker. | +| `.Item` | `data-radio-cards-item` | The reused `RadioGroup.Item` (`role=radio`) wearing card chrome. Auto-renders the corner circle + `HiddenInput`. | +| `.Icon` | `data-radio-cards-icon` | Leading icon / media slot (decorative, `aria-hidden`). | +| `.Content` | `data-radio-cards-content` | Text column (title + description). | +| `.Title` | `data-radio-cards-title` | Primary label line (the accessible name). | +| `.Description` | `data-radio-cards-description` | Secondary line. | + +The corner radio circle (`data-radio-cards-indicator`) is auto-rendered by +`.Item` — an empty ring when unchecked, the inner dot scales in on select. + +## Props (root) + +`RadioCards` forwards RadioGroup's Provider surface — `bind:value`, `name`, +`orientation`, `disabled`, `readonly`, `required`, `invalid`, `dir`, callbacks — +plus the card visual knobs: + +| Prop | Type | Default | | +| --- | --- | --- | --- | +| `size` | `ResponsiveProp<'xs'…'xl'>` | `'md'` | padding · title/desc font · circle size | +| `color` | `'primary' \| 'secondary' \| 'neutral' \| 'affirm'` | `'primary'` | selected border + tint | +| `columns` | `number` | — | CSS grid with N columns | +| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | stack vs wrapping row | + +Like RadioGroup, the palette excludes risk/threat/loss (per book §3.1 radio is +not evaluative). Use `invalid` for validation — it paints a threat border +without touching the colour. + +## Layout + +- **Vertical** (default) — cards stack full-width. +- **Horizontal** — `orientation="horizontal"` lays cards in a wrapping row + (each `flex: 1 1 var(--radio-cards-horizontal-basis)`). +- **Grid** — `columns={N}` for an N-column CSS grid. + +## Recipe / cascade + +The `radio-cards` recipe (`lib/recipes/base.ts`) owns the card chrome; spacing is +all `--space-*` (density-aware). **`radio-group.css` is NOT loaded on a +RadioCards page** — only RadioGroup's *foundation* (generated `base.css`: palette +TSC, size + focus tokens). So there's no RadioGroup item rule to fight; the card +fully owns its visual, reusing only the tokens and the `--_radio-group-palette-*` +custom properties (in scope on the card via the shared `[data-radio-group]` +host). The selected treatment is **inlined** in `radio-cards.css` rather than +tokenised, because a `:root` token referencing the palette is +invalid-at-computed-value and poisons down the tree. + +## Architecture + +`morfo` is `scope:['eidos']` — a thin contract for the presentation parts only. +The interactive contract (Provider/Item, events, keyboard, a11y) lives in +[`radio-group`](../radio-group/README.md) and is reused via its soma. No new +soma, no new sema (`commit-select` fires from RadioGroup's runtime + pack). +This mirrors `toggle-group ↔ toggle` and `css-field ↔ spin-field`: share the +machinery, layer a distinct visual. diff --git a/src/uix/eidos/components/radio-cards/index.ts b/src/uix/eidos/components/radio-cards/index.ts new file mode 100644 index 000000000..60399b659 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/index.ts @@ -0,0 +1,57 @@ +// RadioCards — eidos compound API. Card-style single-select built on +// RadioGroup's soma (selection / roving / form integration), adding only the +// card chrome. Disciplined option C. +// +// import { RadioCards } from '$uix/eidos/components/radio-cards'; +// +// +// +// {@render zapIcon()} +// +// Free +// For hobby projects. +// +// +// +// +// Pro +// For growing teams. +// +// +// +import RadioCardsComponent from './radio-cards.svelte'; +import Item from './radio-cards-item.svelte'; +import Content from './radio-cards-content.svelte'; +import Title from './radio-cards-title.svelte'; +import Description from './radio-cards-description.svelte'; +import Icon from './radio-cards-icon.svelte'; + +type RadioCardsNamespace = typeof RadioCardsComponent & { + Item: typeof Item; + Content: typeof Content; + Title: typeof Title; + Description: typeof Description; + Icon: typeof Icon; +}; + +const RadioCards = RadioCardsComponent as RadioCardsNamespace; +RadioCards.Item = Item; +RadioCards.Content = Content; +RadioCards.Title = Title; +RadioCards.Description = Description; +RadioCards.Icon = Icon; + +export { RadioCards }; + +export default RadioCards; + +export type { + RadioCardsProps, + RadioCardsItemProps as ItemProps, + RadioCardsContentProps as ContentProps, + RadioCardsTitleProps as TitleProps, + RadioCardsDescriptionProps as DescriptionProps, + RadioCardsIconProps as IconProps, + RadioCardsSize, + RadioCardsColor +} from './types'; diff --git a/src/uix/eidos/components/radio-cards/radio-cards-content.svelte b/src/uix/eidos/components/radio-cards/radio-cards-content.svelte new file mode 100644 index 000000000..526eac06c --- /dev/null +++ b/src/uix/eidos/components/radio-cards/radio-cards-content.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/radio-cards/radio-cards-description.svelte b/src/uix/eidos/components/radio-cards/radio-cards-description.svelte new file mode 100644 index 000000000..6727c5969 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/radio-cards-description.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/radio-cards/radio-cards-icon.svelte b/src/uix/eidos/components/radio-cards/radio-cards-icon.svelte new file mode 100644 index 000000000..9883d48e0 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/radio-cards-icon.svelte @@ -0,0 +1,8 @@ + + + diff --git a/src/uix/eidos/components/radio-cards/radio-cards-item.svelte b/src/uix/eidos/components/radio-cards/radio-cards-item.svelte new file mode 100644 index 000000000..1c4c9b935 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/radio-cards-item.svelte @@ -0,0 +1,24 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + + + {/snippet} + diff --git a/src/uix/eidos/components/radio-cards/radio-cards-title.svelte b/src/uix/eidos/components/radio-cards/radio-cards-title.svelte new file mode 100644 index 000000000..6604f0978 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/radio-cards-title.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/radio-cards/radio-cards.css b/src/uix/eidos/components/radio-cards/radio-cards.css new file mode 100644 index 000000000..df3b035e4 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/radio-cards.css @@ -0,0 +1,278 @@ +/* RadioCards — card skin over RadioGroup. + * + * The root reuses RadioGroup's layout (flex column / `[data-columns]` grid) + * and palette (`--_radio-group-palette-*`). These rules repaint the reused + * `[data-radio-group-item]` (a small dot-box in RadioGroup) as a full card, + * and restyle the reused `[data-radio-group-indicator]` into a corner radio + * circle. The card carries BOTH markers — `data-radio-group-item` + * (behaviour) + `data-radio-cards-item` (presentation). + * + * Load note: only RadioGroup's FOUNDATION (generated base.css — palette TSC, + * size / focus tokens) is present on a RadioCards page; its component CSS + * (radio-group.css) is imported by the RadioGroup eidos wrapper, which + * RadioCards does not use. So these rules don't fight any RadioGroup item + * styling — the card fully owns its own visual, reusing only the tokens + + * the `--_radio-group-palette-*` custom properties (in scope on the card). + */ + +/* ── Root layout — owned here (radio-group.css is not loaded) ───────────── */ + +[data-radio-cards] { + display: flex; + flex-direction: column; + gap: var(--radio-cards-gap); +} + +[data-radio-cards][data-orientation='horizontal'] { + flex-direction: row; + flex-wrap: wrap; +} + +/* Cards share the row equally, wrapping to a new line when they can't keep a + * sensible minimum width. */ +[data-radio-cards][data-orientation='horizontal'] [data-radio-cards-item] { + flex: 1 1 var(--radio-cards-horizontal-basis); +} + +/* Grid when `columns` is set (publishes `--_radio-cards-columns` inline). */ +[data-radio-cards][data-columns] { + display: grid; + grid-template-columns: repeat(var(--_radio-cards-columns, 2), minmax(0, 1fr)); + align-items: stretch; +} + +/* ── Card item ──────────────────────────────────────────────────────────── */ + +[data-radio-cards] [data-radio-cards-item] { + display: flex; + align-items: flex-start; + justify-content: flex-start; + gap: var(--radio-cards-content-gap); + /* Width is driven by the layout: the flex column / grid stretch the card + * to fill; a horizontal row flexes each card equally (rule below). */ + block-size: auto; + padding: var(--radio-cards-padding-md); + border: var(--radio-cards-border-width) solid var(--radio-cards-border); + border-radius: var(--radio-cards-radius); + background: var(--radio-cards-bg); + text-align: start; + cursor: pointer; + outline: none; + transition: + background var(--radio-cards-transition-duration) var(--radio-cards-transition-ease), + border-color var(--radio-cards-transition-duration) var(--radio-cards-transition-ease), + box-shadow var(--radio-cards-transition-duration) var(--radio-cards-transition-ease); +} + +/* ── Sizes — padding + corner-circle size ──────────────────────────────── */ + +[data-radio-cards][data-size='xs'] [data-radio-cards-item] { + padding: var(--radio-cards-padding-xs); +} +[data-radio-cards][data-size='sm'] [data-radio-cards-item] { + padding: var(--radio-cards-padding-sm); +} +[data-radio-cards][data-size='lg'] [data-radio-cards-item] { + padding: var(--radio-cards-padding-lg); +} +[data-radio-cards][data-size='xl'] [data-radio-cards-item] { + padding: var(--radio-cards-padding-xl); +} + +/* ── Hover / focus ──────────────────────────────────────────────────────── */ + +[data-radio-cards] [data-radio-cards-item]:hover:not([data-disabled]):not([data-readonly]) { + border-color: var(--radio-cards-border-hover); + background: var(--radio-cards-bg-hover); +} + +[data-radio-cards] [data-radio-cards-item]:focus-visible { + box-shadow: var(--radio-cards-focus-ring); +} + +/* ── Checked — active border + faint tint ──────────────────────────────── + * RadioGroup's component CSS (radio-group.css) is NOT loaded on a RadioCards + * page — only its foundation tokens (palette, sizes, focus ring) are. So + * there is no base item rule to fight: a plain (0,3,0) selector wins, and we + * paint the selected state here using RadioGroup's active palette token, + * which is in scope on the card (inherited from the [data-radio-group][data-color] + * host). Inlined rather than tokenised because a :root token referencing the + * palette is invalid-at-computed-value. */ + +[data-radio-cards] [data-radio-cards-item][data-state='checked'] { + border-color: var(--_radio-group-palette-solid); + background: color-mix(in srgb, var(--_radio-group-palette-solid) 7%, var(--color-surface-default)); +} + +/* ── Disabled / readonly ────────────────────────────────────────────────── */ + +[data-radio-cards] [data-radio-cards-item][data-disabled] { + cursor: not-allowed; + opacity: var(--radio-cards-disabled-opacity); + background: var(--radio-cards-bg); +} + +/* ── Invalid (form validation) — threat border across the card ─────────── */ + +[data-radio-cards][data-invalid] [data-radio-cards-item] { + border-color: var(--color-threat-element); +} + +/* ── Leading icon / media ──────────────────────────────────────────────── */ + +[data-radio-cards-icon] { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + inline-size: var(--radio-cards-icon-size-md); + block-size: var(--radio-cards-icon-size-md); + color: var(--radio-cards-icon-color); +} + +[data-radio-cards-icon] svg { + inline-size: 100%; + block-size: 100%; +} + +[data-radio-cards][data-size='xs'] [data-radio-cards-icon] { + inline-size: var(--radio-cards-icon-size-xs); + block-size: var(--radio-cards-icon-size-xs); +} +[data-radio-cards][data-size='sm'] [data-radio-cards-icon] { + inline-size: var(--radio-cards-icon-size-sm); + block-size: var(--radio-cards-icon-size-sm); +} +[data-radio-cards][data-size='lg'] [data-radio-cards-icon] { + inline-size: var(--radio-cards-icon-size-lg); + block-size: var(--radio-cards-icon-size-lg); +} +[data-radio-cards][data-size='xl'] [data-radio-cards-icon] { + inline-size: var(--radio-cards-icon-size-xl); + block-size: var(--radio-cards-icon-size-xl); +} + +[data-radio-cards] [data-radio-cards-item][data-state='checked'] [data-radio-cards-icon] { + color: var(--_radio-group-palette-solid); +} + +/* ── Text column ───────────────────────────────────────────────────────── */ + +[data-radio-cards-content] { + display: flex; + flex-direction: column; + gap: var(--radio-cards-text-gap); + flex: 1 1 auto; + min-inline-size: 0; +} + +[data-radio-cards-title] { + font-family: var(--radio-cards-title-font-family); + font-size: var(--radio-cards-title-font-size-md); + font-weight: var(--radio-cards-title-font-weight); + line-height: var(--radio-cards-title-line-height); + color: var(--radio-cards-title-color); +} + +[data-radio-cards][data-size='xs'] [data-radio-cards-title] { + font-size: var(--radio-cards-title-font-size-xs); +} +[data-radio-cards][data-size='sm'] [data-radio-cards-title] { + font-size: var(--radio-cards-title-font-size-sm); +} +[data-radio-cards][data-size='lg'] [data-radio-cards-title] { + font-size: var(--radio-cards-title-font-size-lg); +} +[data-radio-cards][data-size='xl'] [data-radio-cards-title] { + font-size: var(--radio-cards-title-font-size-xl); +} + +[data-radio-cards-description] { + font-family: var(--radio-cards-title-font-family); + font-size: var(--radio-cards-description-font-size-md); + line-height: var(--radio-cards-description-line-height); + color: var(--radio-cards-description-color); +} + +[data-radio-cards][data-size='xs'] [data-radio-cards-description] { + font-size: var(--radio-cards-description-font-size-xs); +} +[data-radio-cards][data-size='sm'] [data-radio-cards-description] { + font-size: var(--radio-cards-description-font-size-sm); +} +[data-radio-cards][data-size='lg'] [data-radio-cards-description] { + font-size: var(--radio-cards-description-font-size-lg); +} +[data-radio-cards][data-size='xl'] [data-radio-cards-description] { + font-size: var(--radio-cards-description-font-size-xl); +} + +/* ── Corner radio circle ──────────────────────────────────────────────── + * An always-present ring (reusing RadioGroup's border + size tokens) with an + * inner dot (`::after`) that scales in on the item's checked state. The dot + * colour is RadioGroup's active palette token. RadioGroup's own Indicator is + * checked-only, so the card renders this affordance itself. */ + +[data-radio-cards-indicator] { + position: relative; + flex: 0 0 auto; + align-self: flex-start; + margin-inline-start: auto; + inline-size: var(--radio-group-item-size-md); + block-size: var(--radio-group-item-size-md); + border: var(--radio-group-indicator-border-width) solid var(--radio-group-indicator-border); + border-radius: var(--radius-full); + background: var(--radio-group-indicator-bg); + transition: border-color var(--radio-cards-transition-duration) var(--radio-cards-transition-ease); +} + +[data-radio-cards-indicator]::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + inline-size: 46%; + block-size: 46%; + border-radius: var(--radius-full); + background: var(--_radio-group-palette-solid); + transform: scale(0); + transition: transform var(--radio-cards-transition-duration) cubic-bezier(0.4, 0, 0.2, 1); +} + +[data-radio-cards][data-size='xs'] [data-radio-cards-indicator] { + inline-size: var(--radio-group-item-size-xs); + block-size: var(--radio-group-item-size-xs); +} +[data-radio-cards][data-size='sm'] [data-radio-cards-indicator] { + inline-size: var(--radio-group-item-size-sm); + block-size: var(--radio-group-item-size-sm); +} +[data-radio-cards][data-size='lg'] [data-radio-cards-indicator] { + inline-size: var(--radio-group-item-size-lg); + block-size: var(--radio-group-item-size-lg); +} +[data-radio-cards][data-size='xl'] [data-radio-cards-indicator] { + inline-size: var(--radio-group-item-size-xl); + block-size: var(--radio-group-item-size-xl); +} + +/* Checked: active ring + the inner dot scales in. */ +[data-radio-cards] [data-radio-cards-item][data-state='checked'] [data-radio-cards-indicator] { + border-color: var(--_radio-group-palette-solid); +} +[data-radio-cards] [data-radio-cards-item][data-state='checked'] [data-radio-cards-indicator]::after { + transform: scale(1); +} + +[data-radio-cards] [data-radio-cards-item][data-disabled] [data-radio-cards-indicator] { + border-color: var(--radio-group-indicator-border-disabled); + background: var(--radio-group-indicator-bg-disabled); +} + +@media (prefers-reduced-motion: reduce) { + [data-radio-cards] [data-radio-cards-item], + [data-radio-cards-indicator], + [data-radio-cards-indicator]::after { + transition: none; + } +} diff --git a/src/uix/eidos/components/radio-cards/radio-cards.svelte b/src/uix/eidos/components/radio-cards/radio-cards.svelte new file mode 100644 index 000000000..566e8905c --- /dev/null +++ b/src/uix/eidos/components/radio-cards/radio-cards.svelte @@ -0,0 +1,52 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/radio-cards/types.ts b/src/uix/eidos/components/radio-cards/types.ts new file mode 100644 index 000000000..a458b9db9 --- /dev/null +++ b/src/uix/eidos/components/radio-cards/types.ts @@ -0,0 +1,73 @@ +import type { Snippet } from 'svelte'; +import type { AffirmativeColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types'; +import type { + ProviderProps, + ItemProps, + IndicatorProps, + HiddenInputProps +} from '$soma/components/radio-group'; + +/** + * RadioCards visual size — same five-step subset as RadioGroup. Drives card + * padding, title/description font-size and the corner indicator size. + */ +export type RadioCardsSize = Extract; + +/** + * RadioCards color subset — `primary | secondary | neutral | affirm`, + * identical to RadioGroup (radio carries no risk/threat/loss; validation + * lives in the surrounding form via the `invalid` prop). The active colour + * paints the selected card's border + faint tint. + */ +export type RadioCardsColor = AffirmativeColorRole; + +/** + * Root props. Reuses RadioGroup's Provider surface (value, name, + * orientation, disabled/required/readonly/invalid, callbacks) and adds the + * card visual knobs. RadioCards is a card-style skin over RadioGroup — it + * shares the same soma provider, selection state, roving focus and form + * integration, adding only the card chrome. + */ +export type RadioCardsProps = ProviderProps & { + /** Visual size preset. Resolved via `Eidos.resolve`. @default 'md' */ + size?: ResponsiveProp; + /** Colour palette for the selected card. @default 'primary' */ + color?: RadioCardsColor; + /** + * Lay the cards out in a CSS grid with this many columns. Without it the + * cards stack vertically (or flow in a row when `orientation='horizontal'`). + * @default undefined + */ + columns?: number; +}; + +/** Card item props — the reused RadioGroup.Item with card chrome. */ +export type RadioCardsItemProps = ItemProps; + +/** Text-column wrapper (title + description). */ +export type RadioCardsContentProps = { + children?: Snippet; + [key: string]: unknown; +}; + +/** Card title (primary label line). */ +export type RadioCardsTitleProps = { + children?: Snippet; + [key: string]: unknown; +}; + +/** Card description (secondary line under the title). */ +export type RadioCardsDescriptionProps = { + children?: Snippet; + [key: string]: unknown; +}; + +/** Leading icon / media slot. */ +export type RadioCardsIconProps = { + children?: Snippet; + [key: string]: unknown; +}; + +/** The corner radio indicator — reused from RadioGroup, restyled as a circle. */ +export type RadioCardsIndicatorProps = IndicatorProps; +export type RadioCardsHiddenInputProps = HiddenInputProps; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 1c9616845..06dc82964 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1898,6 +1898,47 @@ --radio-group-label-font-weight: var(--font-weight-medium); --radio-group-label-line-height: var(--leading-ui); --radio-group-label-color: var(--color-content-primary); + --radio-cards-gap: var(--space-3); + --radio-cards-horizontal-basis: 12rem; + --radio-cards-padding-xs: var(--space-3); + --radio-cards-padding-sm: var(--space-3); + --radio-cards-padding-md: var(--space-4); + --radio-cards-padding-lg: var(--space-4); + --radio-cards-padding-xl: var(--space-5); + --radio-cards-radius: var(--radius-lg); + --radio-cards-border-width: var(--border-width); + --radio-cards-border: var(--color-border-default); + --radio-cards-border-hover: var(--color-border-strong); + --radio-cards-bg: var(--color-surface-default); + --radio-cards-bg-hover: var(--color-surface-raised); + --radio-cards-content-gap: var(--space-3); + --radio-cards-text-gap: var(--space-0-5); + --radio-cards-title-font-family: var(--font-ui); + --radio-cards-title-font-size-xs: var(--font-size-sm); + --radio-cards-title-font-size-sm: var(--font-size-sm); + --radio-cards-title-font-size-md: var(--font-size-md); + --radio-cards-title-font-size-lg: var(--font-size-md); + --radio-cards-title-font-size-xl: var(--font-size-lg); + --radio-cards-title-font-weight: var(--font-weight-medium); + --radio-cards-title-line-height: var(--leading-ui); + --radio-cards-title-color: var(--color-content-primary); + --radio-cards-description-font-size-xs: var(--font-size-xs); + --radio-cards-description-font-size-sm: var(--font-size-xs); + --radio-cards-description-font-size-md: var(--font-size-sm); + --radio-cards-description-font-size-lg: var(--font-size-sm); + --radio-cards-description-font-size-xl: var(--font-size-md); + --radio-cards-description-line-height: var(--leading-normal); + --radio-cards-description-color: var(--color-content-secondary); + --radio-cards-icon-size-xs: 18px; + --radio-cards-icon-size-sm: 20px; + --radio-cards-icon-size-md: 24px; + --radio-cards-icon-size-lg: 28px; + --radio-cards-icon-size-xl: 32px; + --radio-cards-icon-color: var(--color-content-secondary); + --radio-cards-focus-ring: var(--radio-group-focus-ring); + --radio-cards-transition-duration: var(--duration-fast); + --radio-cards-transition-ease: var(--ease-default); + --radio-cards-disabled-opacity: 0.6; --rating-group-gap-xs: var(--space-0-5); --rating-group-gap-sm: var(--space-1); --rating-group-gap-md: var(--space-1-5); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index b623418d2..40948bc50 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1937,6 +1937,61 @@ export const THEME_BASE_RECIPE_TOKENS = { }, '_palette-text': { value: 'var(--radio-group-dot-color)', scope: 'host' } }, + // RadioCards — card-style skin over RadioGroup. Reuses RadioGroup's + // palette (`--_radio-group-palette-solid`, declared on the shared + // [data-radio-group] host) and `--radio-group-focus-ring`; adds only the + // card chrome (padding, radius, surface, title/description type). + 'radio-cards': { + gap: 'var(--space-3)', + // Min card width in a horizontal row before wrapping (rem → scales with + // root font-size / zoom). + 'horizontal-basis': '12rem', + 'padding-xs': 'var(--space-3)', + 'padding-sm': 'var(--space-3)', + 'padding-md': 'var(--space-4)', + 'padding-lg': 'var(--space-4)', + 'padding-xl': 'var(--space-5)', + radius: 'var(--radius-lg)', + 'border-width': 'var(--border-width)', + border: 'var(--color-border-default)', + 'border-hover': 'var(--color-border-strong)', + bg: 'var(--color-surface-default)', + 'bg-hover': 'var(--color-surface-raised)', + 'content-gap': 'var(--space-3)', + 'text-gap': 'var(--space-0-5)', + 'title-font-family': 'var(--font-ui)', + 'title-font-size-xs': 'var(--font-size-sm)', + 'title-font-size-sm': 'var(--font-size-sm)', + 'title-font-size-md': 'var(--font-size-md)', + 'title-font-size-lg': 'var(--font-size-md)', + 'title-font-size-xl': 'var(--font-size-lg)', + 'title-font-weight': 'var(--font-weight-medium)', + 'title-line-height': 'var(--leading-ui)', + 'title-color': 'var(--color-content-primary)', + 'description-font-size-xs': 'var(--font-size-xs)', + 'description-font-size-sm': 'var(--font-size-xs)', + 'description-font-size-md': 'var(--font-size-sm)', + 'description-font-size-lg': 'var(--font-size-sm)', + 'description-font-size-xl': 'var(--font-size-md)', + 'description-line-height': 'var(--leading-normal)', + 'description-color': 'var(--color-content-secondary)', + 'icon-size-xs': '18px', + 'icon-size-sm': '20px', + 'icon-size-md': '24px', + 'icon-size-lg': '28px', + 'icon-size-xl': '32px', + 'icon-color': 'var(--color-content-secondary)', + // NOTE: the checked treatment (active border + faint tint + icon tint) + // is NOT a token here. It references `--_radio-group-palette-solid`, + // which is undefined at :root (declared only on [data-radio-group] + // hosts) — a :root token referencing it is invalid-at-computed-value + // and poisons down the tree. It's inlined in radio-cards.css instead, + // applied on the card item where the palette IS in scope. + 'focus-ring': 'var(--radio-group-focus-ring)', + 'transition-duration': 'var(--duration-fast)', + 'transition-ease': 'var(--ease-default)', + 'disabled-opacity': '0.6' + }, 'rating-group': { 'gap-xs': 'var(--space-0-5)', 'gap-sm': 'var(--space-1)', diff --git a/src/uix/morfo/components/radio-cards.ts b/src/uix/morfo/components/radio-cards.ts new file mode 100644 index 000000000..1bd640313 --- /dev/null +++ b/src/uix/morfo/components/radio-cards.ts @@ -0,0 +1,121 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +/** + * RadioCards — card-style single-select built ON TOP of RadioGroup. + * + * Reuses RadioGroup's soma wholesale (Provider / Item / Indicator / + * HiddenInput → selection state, roving focus, native `` + * participation, `role=radio` semantics). RadioCards adds ONLY the card + * presentation: each reused Item becomes a clickable card carrying + * `data-radio-cards-item`, with optional Icon / Content / Title / + * Description slots inside. + * + * No new soma, no new events, no new sema — `commit-select` fires from the + * reused RadioGroup item runtime, handled by RadioGroup's sema pack. This + * morfo is `scope:['eidos']`: a thin contract for the card-presentation + * parts the recipe targets. All behaviour + a11y live in `radio-group.ts`; + * declaring them here would duplicate the contract. + * + * 0-event surface (like Badge): the only interactive act is selecting a + * radio, which is RadioGroup's `commit-select`. RadioCards owns no events. + */ +export const radioCardsMorfo = { + name: 'RadioCards', + kebab: 'radio-cards', + scope: ['eidos'], + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/radio/', + texts: { + label: '#?components.radio-cards.label|Radio cards' + }, + parts: [ + { + // Root — the SAME element as the reused RadioGroup.Provider. + // Carries `data-radio-cards` so the card recipe scopes its + // box-model overrides to RadioCards instances only (the bare + // `[data-radio-group]` base still computes the palette tokens). + name: 'Provider', + kebab: 'provider', + archetype: 'provider', + kind: 'public', + defaultElement: 'div', + optional: false, + data: [], + aria: [] + }, + { + // Card surface — the reused RadioGroup.Item with a presentation + // marker. `role=radio` / `data-state` / keyboard all come from + // RadioGroup; this part adds only `data-radio-cards-item` so the + // recipe can repaint the dot-box as a full card. + name: 'Item', + kebab: 'item', + archetype: 'item', + kind: 'public', + defaultElement: 'button', + optional: false, + data: [], + aria: [] + }, + { + // Always-present corner radio circle. Empty ring when unchecked; + // the inner dot (a recipe `::after`) scales in on the item's + // checked state. RadioGroup's own Indicator is checked-only, so the + // card owns this always-visible affordance. + name: 'Indicator', + kebab: 'indicator', + archetype: 'indicator', + kind: 'public', + defaultElement: 'span', + optional: false, + data: [], + aria: [{ attr: 'aria-hidden', value: v.literal('true') }] + }, + { + // Leading icon / media slot inside the card. Decorative — the + // Title carries the semantic label. + name: 'Icon', + kebab: 'icon', + archetype: 'indicator', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [], + aria: [{ attr: 'aria-hidden', value: v.literal('true') }] + }, + { + // Text column wrapper (title + description) — lets the recipe lay + // out the textual content independent of the icon / indicator. + name: 'Content', + kebab: 'content', + archetype: 'content', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [], + aria: [] + }, + { + // Card title — primary label line. + name: 'Title', + kebab: 'title', + archetype: 'title', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [], + aria: [] + }, + { + // Secondary descriptive line under the title. + name: 'Description', + kebab: 'description', + archetype: 'description', + kind: 'public', + defaultElement: 'span', + optional: true, + data: [], + aria: [] + } + ] +} as const satisfies Morfo; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 9c0577611..d038b6521 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -145,6 +145,7 @@ { slug: '/uix/components/switch', label: 'Switch' }, { slug: '/uix/components/checkbox', label: 'Checkbox' }, { slug: '/uix/components/radio-group', label: 'Radio group' }, + { slug: '/uix/components/radio-cards', label: 'Radio cards' }, { slug: '/uix/components/rating-group', label: 'Rating group' }, { slug: '/uix/components/slider', label: 'Slider' }, { slug: '/uix/components/collapsible', label: 'Collapsible' }, diff --git a/web/routes/uix/components/radio-cards/+page.svelte b/web/routes/uix/components/radio-cards/+page.svelte new file mode 100644 index 000000000..26464f5ad --- /dev/null +++ b/web/routes/uix/components/radio-cards/+page.svelte @@ -0,0 +1,414 @@ + + + + + + +{#snippet icon(name: string)} + {#if name === 'sparkle'} + + {:else if name === 'bolt'} + + {:else} + + {/if} +{/snippet} + +
+
+
Selection · Radio cards
+

Radio cards

+

+ Card-style single-select built ON TOP of RadioGroup — it reuses RadioGroup's + soma wholesale (selection, roving focus, native <form> integration, + role=radio) and adds only the card chrome: icon · title · description and an + always-present corner radio. Vertical, horizontal and grid layouts. +

+
+ parts{compiled.parts.order.length} + somareused + colors4 + apgradio ↗ +
+
+ +
+
+ + {#each plans as p (p.value)} + + {#if withIcon}{@render icon(p.icon)}{/if} + + {p.title} + {#if withDescription}{p.desc}{/if} + + + {/each} + +
+
+ trace + {#if trace.length === 0} + select a card to see commit events + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + value {value || '∅'} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

Props grouped by layer: soma behavior + form (reused from RadioGroup), eidos card visual.

+ +
soma props · reused from RadioGroup
+
+ + + + + + +
+ +
eidos props · card visual
+
+ + + + + +
+ +
+
+ eidos + card visual over RadioGroup's reused soma + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'examples'} +
+

Examples

+

Common radio-card patterns — vertical with description, horizontal, grid, payment method.

+ +
Horizontal — billing period
+ + + + Monthly + $12 / month, billed monthly. + + + + + Annual · save 17% + $120 / year, billed annually. + + + + +
Grid — 2 columns
+ + {#each [['comfortable', 'Comfortable', 'Roomy spacing for everyday use.'], ['compact', 'Compact', 'Denser layout that fits more.'], ['cozy', 'Cozy', 'A balanced middle ground.'], ['spacious', 'Spacious', 'Maximum breathing room.']] as [v, t, d] (v)} + + + {t} + {d} + + + {/each} + + +
Payment method — with icon, color affirm
+ + + {@render icon('bolt')} + + Card ending 4242 + Visa · expires 09/27 + + + + {@render icon('sparkle')} + + PayPal + Connected account. + + + +
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + +
PropTypeDefaultDescription
value bindablestring''Selected value (reused from RadioGroup).
disabled / readonly / required / invalidbooleanfalseField-integration flags.
orientation'vertical'|'horizontal''vertical'Stack vs wrapping row.
namestring—Form field name (auto-renders HiddenInput per card).
size eidosResponsiveProp<'xs'…'xl'>'md'Padding · title/desc font · circle size.
color eidos'primary'|'secondary'|'neutral'|'affirm''primary'Selected border + tint.
columns eidosnumber—CSS grid columns.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

morfo · declarative contract

+

Source: src/uix/morfo/components/radio-cards.ts. scope:['eidos'] — only the card-presentation parts. The interactive contract (Provider/Item, role=radio, keyboard, commit-select) belongs to RadioGroup and is reused via its soma.

+
+ + + + {#each partsList as part} + + + + + + + + {/each} + +
PartMarkerElementArchetypeOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

Selectors at src/uix/eidos/components/radio-cards/radio-cards.css. radio-group.css is not loaded here — only RadioGroup's foundation tokens; the card owns its visual, reusing --_radio-group-palette-*. Spacing is all --space-* (density-aware). Honours prefers-reduced-motion.

+
+ + + + + + + + + + +
SelectorSource
[data-radio-cards][data-orientation / data-columns]eidos · layout
[data-radio-cards] [data-radio-cards-item]eidos · card chrome
[data-radio-cards-item][data-state='checked']morfo state · reused
[data-radio-cards-indicator] + ::aftereidos · corner radio
[data-radio-cards-title / -description / -icon]eidos · content
@media (prefers-reduced-motion: reduce)eidos · a11y
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

APG radio pattern — inherited from RadioGroup. The whole card is the radio (role=radio); roving tabindex; auto-select on focus. Corner circle + icon are aria-hidden; the Title carries the accessible name.

+
Keyboard
+
+ + + + + + + + +
KeyAction
TabFocus enters / leaves group at the checked (or first enabled) card.
↓ / →Move focus + select next enabled card.
↑ / ←Move focus + select previous enabled card.
SpaceSelect focused card.
+
+
ARIA contract
+
+ + + + + + + +
PartAttributeValue
rootrole / aria-orientation / aria-invalid"radiogroup" / vary
card (item)role / aria-checked / tabindex"radio" / bool / 0|−1
indicator / icon / hidden-inputaria-hidden"true"
+
+
+ {/if} +