New eidos component built ON TOP of RadioGroup with zero duplicated behaviour. RadioCards reuses RadioGroup's soma wholesale (selection, roving focus, role=radio, native <form> 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 <noreply@anthropic.com>active-uix
parent
7633b1360c
commit
f59396b51a
@ -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 `<form>` participation via `HiddenInput`) and
|
||||
only adds the card presentation: each card carries content (icon · title ·
|
||||
description) and an always-present corner radio.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { RadioCards } from '$uix/eidos/components/radio-cards';
|
||||
let plan = $state('pro');
|
||||
</script>
|
||||
|
||||
<RadioCards bind:value={plan} color="primary" columns={2} name="plan">
|
||||
<RadioCards.Item value="free">
|
||||
<RadioCards.Icon>{@render sparkle()}</RadioCards.Icon>
|
||||
<RadioCards.Content>
|
||||
<RadioCards.Title>Free</RadioCards.Title>
|
||||
<RadioCards.Description>For hobby projects.</RadioCards.Description>
|
||||
</RadioCards.Content>
|
||||
</RadioCards.Item>
|
||||
<RadioCards.Item value="pro">
|
||||
<RadioCards.Content>
|
||||
<RadioCards.Title>Pro</RadioCards.Title>
|
||||
<RadioCards.Description>For growing teams.</RadioCards.Description>
|
||||
</RadioCards.Content>
|
||||
</RadioCards.Item>
|
||||
</RadioCards>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@ -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';
|
||||
//
|
||||
// <RadioCards bind:value color="primary" columns={2} name="plan">
|
||||
// <RadioCards.Item value="free">
|
||||
// <RadioCards.Icon>{@render zapIcon()}</RadioCards.Icon>
|
||||
// <RadioCards.Content>
|
||||
// <RadioCards.Title>Free</RadioCards.Title>
|
||||
// <RadioCards.Description>For hobby projects.</RadioCards.Description>
|
||||
// </RadioCards.Content>
|
||||
// </RadioCards.Item>
|
||||
// <RadioCards.Item value="pro">
|
||||
// <RadioCards.Content>
|
||||
// <RadioCards.Title>Pro</RadioCards.Title>
|
||||
// <RadioCards.Description>For growing teams.</RadioCards.Description>
|
||||
// </RadioCards.Content>
|
||||
// </RadioCards.Item>
|
||||
// </RadioCards>
|
||||
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';
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
/** `<RadioCards.Content>` — text column wrapping Title + Description. */
|
||||
import type { RadioCardsContentProps } from './types';
|
||||
|
||||
let { children, ...rest }: RadioCardsContentProps = $props();
|
||||
</script>
|
||||
|
||||
<span data-radio-cards-content="" {...rest}>{@render children?.()}</span>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
/** `<RadioCards.Description>` — secondary line under the title. */
|
||||
import type { RadioCardsDescriptionProps } from './types';
|
||||
|
||||
let { children, ...rest }: RadioCardsDescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<span data-radio-cards-description="" {...rest}>{@render children?.()}</span>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
/** `<RadioCards.Icon>` — leading icon / media slot. Decorative. */
|
||||
import type { RadioCardsIconProps } from './types';
|
||||
|
||||
let { children, ...rest }: RadioCardsIconProps = $props();
|
||||
</script>
|
||||
|
||||
<span data-radio-cards-icon="" aria-hidden="true" {...rest}>{@render children?.()}</span>
|
||||
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<RadioCards.Item>` — the reused Soma `<RadioGroup.Item>` (a
|
||||
* `<button role=radio>` driving selection, roving and form value) wearing
|
||||
* the card marker `data-radio-cards-item`. Auto-renders the always-present
|
||||
* corner radio circle + the native HiddenInput so consumers only compose
|
||||
* content (Icon / Content / Title / Description) inside. The circle is an
|
||||
* empty ring when unchecked; the inner dot scales in on select via the
|
||||
* recipe reading the item's `data-state` (RadioGroup's own Indicator is
|
||||
* checked-only, so the card owns this always-visible affordance).
|
||||
*/
|
||||
import * as RadioGroup from '$soma/components/radio-group';
|
||||
import type { RadioCardsItemProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: RadioCardsItemProps = $props();
|
||||
</script>
|
||||
|
||||
<RadioGroup.Item data-radio-cards-item="" {...rest}>
|
||||
{#snippet children(snippetProps)}
|
||||
{@render bodyContent?.(snippetProps)}
|
||||
<span data-radio-cards-indicator="" aria-hidden="true"></span>
|
||||
<RadioGroup.HiddenInput />
|
||||
{/snippet}
|
||||
</RadioGroup.Item>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
/** `<RadioCards.Title>` — primary label line of a card. */
|
||||
import type { RadioCardsTitleProps } from './types';
|
||||
|
||||
let { children, ...rest }: RadioCardsTitleProps = $props();
|
||||
</script>
|
||||
|
||||
<span data-radio-cards-title="" {...rest}>{@render children?.()}</span>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import './radio-cards.css';
|
||||
/**
|
||||
* Eidos `<RadioCards>` — card-style single-select. A thin card skin over
|
||||
* Soma's `<RadioGroup.Provider>`: shares selection state, roving focus,
|
||||
* `<form>` integration and `role=radiogroup` semantics. Adds only the
|
||||
* card chrome (size, color, columns) + the `data-radio-cards` marker that
|
||||
* scopes the card recipe.
|
||||
*
|
||||
* <RadioCards bind:value color="primary" columns={2}>
|
||||
* <RadioCards.Item value="free">
|
||||
* <RadioCards.Content>
|
||||
* <RadioCards.Title>Free</RadioCards.Title>
|
||||
* <RadioCards.Description>For hobby projects.</RadioCards.Description>
|
||||
* </RadioCards.Content>
|
||||
* </RadioCards.Item>
|
||||
* </RadioCards>
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { composeInlineStyle } from '$uix/eidos/lib/style';
|
||||
import * as RadioGroup from '$soma/components/radio-group';
|
||||
import type { RadioCardsProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
columns,
|
||||
value = $bindable(''),
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: RadioCardsProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
// radio-group.css is not loaded on a RadioCards page, so the root owns its
|
||||
// own flex/grid layout in radio-cards.css, reading this column count.
|
||||
const columnsStyle = $derived(columns ? `--_radio-cards-columns: ${columns};` : undefined);
|
||||
const mergedStyle = $derived(composeInlineStyle(style, columnsStyle));
|
||||
</script>
|
||||
|
||||
<RadioGroup.Provider
|
||||
{...rest}
|
||||
bind:value
|
||||
data-radio-cards=""
|
||||
data-size={resolvedSize}
|
||||
data-color={color}
|
||||
data-columns={columns ?? undefined}
|
||||
style={mergedStyle}
|
||||
>
|
||||
{@render children?.()}
|
||||
</RadioGroup.Provider>
|
||||
@ -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<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
|
||||
|
||||
/**
|
||||
* 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<RadioCardsSize>;
|
||||
/** 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;
|
||||
@ -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 `<form>`
|
||||
* 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;
|
||||
Loading…
Reference in new issue