feat(uix): CardGroup composes ToggleGroup + Collapsible; ToggleGroup gains selection cardinality

CardGroup rebuilt as an eidos-only composition shell — selection delegated to
ToggleGroup, disclosure to Collapsible, item = card via structural identity
(data-card), title = system Button. Eidos owns only grid / depth / concentric /
cascade / size propagation. The bespoke card-group morfo + soma were removed.

ToggleGroup now owns the canonical selection cardinality: one knob
enabledSelections (number | [min, max]) + whenFull + a commit-block event on
rejection, consuming $libs/selection. Backward-compatible: type / deselectable
derive to the limit (tests 5/5).
active-uix
dev 3 months ago
parent 584df80654
commit 79cd5d7978

@ -0,0 +1,169 @@
/**
* Selection — pure cardinality engine for set-based item selection.
*
* The canonical model the whole system uses for "pick items from a set"
* (cards, list options, toggle buttons, table rows, …). The cardinality is
* expressed as ONE value — a `number | [min, max]` union — which removes the
* redundant `single | multiple` mode enum (the mode is derived from the
* ceiling) and gains range selection for free:
*
* number form (floor 0):
* 0 → none (not selectable)
* 1 → single (picking a new value REPLACES the old; deselectable)
* N → up to N (multiple, capped)
* UNLIMITED → unbounded multiple
*
* tuple form [min, max] (adds the floor):
* [1, 3] → min 1, max 3 (range)
* [1, 1] → single, REQUIRED (radio that won't empty)
* [1, UNLIMITED] → at least one, no ceiling
*
* Pure: it computes the NEXT selection from the current one + the picked
* value + the limit, and returns a structured result the caller applies to
* its own reactive value. No DOM, no runes — usable by soma providers, eidos
* wrappers, `$libs/datagrid`, or the server.
*
* The older per-component flags (`deselectable` in toggle-group,
* `allowDeselect` in the combobox/select list-selection layer) are subsumed:
* "cannot empty the selection" is just a floor of `1` (the `[1, …]` form).
*/
/** Sentinel for an uncapped (unbounded) multiple selection. */
export const UNLIMITED = Infinity;
/** The readable name for a cardinality, derived from the ceiling. */
export type SelectionMode = 'none' | 'single' | 'multiple';
/** Canonical sema events emitted when an item transitions. */
export type SelectionEvent = 'commit-select' | 'commit-unselect';
/**
* What happens when a NEW value is picked while the selection is already at
* the ceiling — only meaningful for a multi cap (`max > 1`):
* - `reject` → the pick is a no-op (a checkbox group at its cap)
* - `replace-oldest` → drop the earliest selected value, add the new one
* Single selection (`max === 1`) always replaces, regardless of this flag.
*/
export type SelectionFullBehavior = 'reject' | 'replace-oldest';
/** Why a pick resolved to a constraint no-op. */
export type SelectionRejection = 'disabled' | 'at-min' | 'at-max';
/**
* The selection cardinality. A bare number is the ceiling with an implicit
* floor of `0`; the `[min, max]` tuple sets both bounds.
*/
export type SelectionLimit = number | readonly [min: number, max: number];
/** Normalized bounds. */
export interface SelectionBounds {
readonly min: number;
readonly max: number;
}
export interface SelectionResult {
/** The next selection (echoes `current` unchanged when `changed` is false). */
next: readonly string[];
/** The sema event the transition emits, or `undefined` for a no-op. */
event: SelectionEvent | undefined;
/** Whether `next` differs from `current`. */
changed: boolean;
/** Why the action was blocked, present only on a constraint no-op. */
rejected?: SelectionRejection;
}
/**
* Normalize the `number | [min, max]` union to `{ min, max }`. A bare number
* is the ceiling (floor 0); the tuple sets both, with `min` clamped to never
* exceed `max` and both clamped to `>= 0`.
*/
export function selectionBounds(limit: SelectionLimit): SelectionBounds {
if (typeof limit === 'number') return { min: 0, max: Math.max(0, limit) };
const lo = Math.max(0, limit[0] ?? 0);
const hi = Math.max(0, limit[1] ?? 0);
return { min: Math.min(lo, hi), max: hi };
}
function noop(current: readonly string[], rejected?: SelectionRejection): SelectionResult {
return { next: current, event: undefined, changed: false, ...(rejected ? { rejected } : {}) };
}
/**
* Compute the next selection given the current set, the picked value and the
* limit. Pure — the caller applies `next` to its own state and dispatches
* `event` against the resolved DOM target.
*
* Single (`max === 1`): picking a different value replaces the current one (a
* single `commit-select`; the dropped value is silent). Re-picking the
* selected value deselects it unless the floor is `1` (required / radio).
*
* Multiple (`max > 1`): picking toggles; at the cap a new pick is rejected,
* or replaces the oldest when `whenFull: 'replace-oldest'`.
*/
export function resolveSelection(
current: readonly string[],
pick: string,
limit: SelectionLimit,
whenFull: SelectionFullBehavior = 'reject'
): SelectionResult {
const { min, max } = selectionBounds(limit);
// Not selectable (ceiling below 1).
if (max < 1) return noop(current, 'disabled');
const isSelected = current.includes(pick);
// Deselecting an already-selected value.
if (isSelected) {
// Removing it would drop below the required floor → blocked no-op.
if (current.length - 1 < min) return noop(current, 'at-min');
return { next: current.filter((v) => v !== pick), event: 'commit-unselect', changed: true };
}
// Selecting a new value — single replaces whatever was there.
if (max <= 1) return { next: [pick], event: 'commit-select', changed: true };
// Multiple under the cap — accumulate.
if (current.length < max) {
return { next: [...current, pick], event: 'commit-select', changed: true };
}
// Multiple at the cap — replace-oldest or reject.
if (whenFull === 'replace-oldest') {
return { next: [...current.slice(1), pick], event: 'commit-select', changed: true };
}
return noop(current, 'at-max');
}
/** Derive the readable mode from the ceiling: `<1` none · `1` single · `>1` multiple. */
export function selectionModeOf(limit: SelectionLimit): SelectionMode {
const { max } = selectionBounds(limit);
return max < 1 ? 'none' : max === 1 ? 'single' : 'multiple';
}
/** Whether anything can be selected at all (ceiling `>= 1`). */
export function isSelectable(limit: SelectionLimit): boolean {
return selectionBounds(limit).max >= 1;
}
/** Whether the limit requires at least one selected value (floor `>= 1`). */
export function isSelectionRequired(limit: SelectionLimit): boolean {
return selectionBounds(limit).min >= 1;
}
/**
* Coerce an externally-supplied value array to satisfy the limit — for
* initialization or a bound `value` that violates the ceiling. Dedupes, then
* trims the excess beyond `max` (keeps the first ones). It does NOT fabricate
* selections to satisfy the floor: the host can't invent which item to pick,
* so under-selection is left to the UI.
*/
export function clampSelection(
value: readonly string[],
limit: SelectionLimit
): readonly string[] {
const { max } = selectionBounds(limit);
if (max < 1) return [];
const unique = value.length === new Set(value).size ? value : [...new Set(value)];
return unique.length <= max ? unique : unique.slice(0, max);
}

@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import {
UNLIMITED,
selectionBounds,
selectionModeOf,
isSelectable,
isSelectionRequired,
clampSelection,
resolveSelection
} from './index';
describe('selectionBounds', () => {
it('treats a bare number as the ceiling with floor 0', () => {
expect(selectionBounds(0)).toEqual({ min: 0, max: 0 });
expect(selectionBounds(1)).toEqual({ min: 0, max: 1 });
expect(selectionBounds(3)).toEqual({ min: 0, max: 3 });
expect(selectionBounds(UNLIMITED)).toEqual({ min: 0, max: UNLIMITED });
});
it('reads both bounds from the tuple', () => {
expect(selectionBounds([1, 3])).toEqual({ min: 1, max: 3 });
expect(selectionBounds([1, 1])).toEqual({ min: 1, max: 1 });
expect(selectionBounds([1, UNLIMITED])).toEqual({ min: 1, max: UNLIMITED });
});
it('clamps negatives and a floor that exceeds the ceiling', () => {
expect(selectionBounds(-2)).toEqual({ min: 0, max: 0 });
expect(selectionBounds([5, 2])).toEqual({ min: 2, max: 2 });
expect(selectionBounds([-1, 3])).toEqual({ min: 0, max: 3 });
});
});
describe('selectionModeOf / isSelectable / isSelectionRequired', () => {
it('derives the readable mode from the ceiling', () => {
expect(selectionModeOf(0)).toBe('none');
expect(selectionModeOf(1)).toBe('single');
expect(selectionModeOf(2)).toBe('multiple');
expect(selectionModeOf(UNLIMITED)).toBe('multiple');
expect(selectionModeOf([1, 1])).toBe('single');
expect(selectionModeOf([1, 3])).toBe('multiple');
});
it('reports selectability and requiredness', () => {
expect(isSelectable(0)).toBe(false);
expect(isSelectable(1)).toBe(true);
expect(isSelectionRequired(1)).toBe(false);
expect(isSelectionRequired([1, 3])).toBe(true);
expect(isSelectionRequired([0, 3])).toBe(false);
});
});
describe('resolveSelection — none (0)', () => {
it('rejects every pick as disabled', () => {
expect(resolveSelection([], 'a', 0)).toEqual({
next: [],
event: undefined,
changed: false,
rejected: 'disabled'
});
});
});
describe('resolveSelection — single (1)', () => {
it('selects into an empty set', () => {
const r = resolveSelection([], 'a', 1);
expect(r.next).toEqual(['a']);
expect(r.event).toBe('commit-select');
expect(r.changed).toBe(true);
});
it('replaces the current value when picking a different one', () => {
const r = resolveSelection(['a'], 'b', 1);
expect(r.next).toEqual(['b']);
expect(r.event).toBe('commit-select');
});
it('deselects on re-pick (deselectable by default)', () => {
const r = resolveSelection(['a'], 'a', 1);
expect(r.next).toEqual([]);
expect(r.event).toBe('commit-unselect');
});
});
describe('resolveSelection — single required ([1, 1])', () => {
it('switches between values', () => {
expect(resolveSelection(['a'], 'b', [1, 1]).next).toEqual(['b']);
});
it('blocks deselecting the last value (radio)', () => {
const r = resolveSelection(['a'], 'a', [1, 1]);
expect(r.next).toEqual(['a']);
expect(r.changed).toBe(false);
expect(r.rejected).toBe('at-min');
});
});
describe('resolveSelection — multiple capped (3)', () => {
it('accumulates under the cap', () => {
expect(resolveSelection(['a'], 'b', 3).next).toEqual(['a', 'b']);
expect(resolveSelection(['a', 'b'], 'c', 3).next).toEqual(['a', 'b', 'c']);
});
it('toggles an existing value off', () => {
const r = resolveSelection(['a', 'b'], 'a', 3);
expect(r.next).toEqual(['b']);
expect(r.event).toBe('commit-unselect');
});
it('rejects a new pick at the cap by default', () => {
const r = resolveSelection(['a', 'b', 'c'], 'd', 3);
expect(r.next).toEqual(['a', 'b', 'c']);
expect(r.changed).toBe(false);
expect(r.rejected).toBe('at-max');
});
it('replaces the oldest at the cap when asked', () => {
const r = resolveSelection(['a', 'b', 'c'], 'd', 3, 'replace-oldest');
expect(r.next).toEqual(['b', 'c', 'd']);
expect(r.event).toBe('commit-select');
});
});
describe('resolveSelection — range ([1, 3])', () => {
it('blocks deselecting below the floor', () => {
const r = resolveSelection(['a'], 'a', [1, 3]);
expect(r.changed).toBe(false);
expect(r.rejected).toBe('at-min');
});
it('allows deselect while above the floor', () => {
expect(resolveSelection(['a', 'b'], 'a', [1, 3]).next).toEqual(['b']);
});
it('caps at the ceiling', () => {
expect(resolveSelection(['a', 'b', 'c'], 'd', [1, 3]).rejected).toBe('at-max');
});
});
describe('resolveSelection — unbounded (UNLIMITED)', () => {
it('never rejects a new pick', () => {
const r = resolveSelection(['a', 'b', 'c', 'd'], 'e', UNLIMITED);
expect(r.next).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(r.rejected).toBeUndefined();
});
});
describe('clampSelection', () => {
it('returns empty when not selectable', () => {
expect(clampSelection(['a', 'b'], 0)).toEqual([]);
});
it('dedupes', () => {
expect(clampSelection(['a', 'a', 'b'], UNLIMITED)).toEqual(['a', 'b']);
});
it('trims to the ceiling', () => {
expect(clampSelection(['a', 'b', 'c', 'd'], 2)).toEqual(['a', 'b']);
expect(clampSelection(['a', 'b', 'c'], 1)).toEqual(['a']);
});
it('passes a valid value through unchanged', () => {
const value = ['a', 'b'];
expect(clampSelection(value, [1, 3])).toBe(value);
});
});

@ -0,0 +1,148 @@
# CardGroup
A responsive group of cards with optional **disclosure** (collapse the whole
group) and optional **selection**. CardGroup is a **composition shell, not a new
primitive** — it owns no selection or disclosure behaviour of its own:
- **Selection → [`ToggleGroup`](../../../soma/components/toggle-group/README.md)**
(`single` / `multiple`, capped, ranged). Cardinality, `value`,
`onValueChange`, roving focus, keyboard, `aria-pressed` and the
`commit-toggle` / `commit-block` sema all come from ToggleGroup.
- **Disclosure → [`Collapsible`](../collapsible/README.md)** — the Title is a
`Collapsible.Trigger` (rendered as the system `<Button>`); the Content
collapses via the `hidden` attribute. The `expand` / `collapse` sema is
Collapsible's.
- **Card visual → [`Card`](../card/README.md)** — each item IS a card via
structural identity (`data-card`), reusing the entire Card recipe
(variant / size / color / selected ring / hover / press).
- **Layout → eidos** — this is the only genuinely CardGroup-owned concern: the
responsive grid, concentric cards, `data-depth` chrome, the coordinated
cascade and `size` propagation.
This mirrors how `RadioCards` skins `RadioGroup`: *share the machinery, layer a
distinct visual.* There is **no `card-group` morfo / soma / sema** — the
contracts come from the composed primitives.
```svelte
<script lang="ts">
import { CardGroup } from '$uix/eidos/components/card-group';
import { Card } from '$uix/eidos/components/card';
import { UNLIMITED } from '$libs/selection';
let open = $state(true);
let value = $state<string[]>([]);
</script>
<!-- collapsible, multi-select group of mixed card types -->
<CardGroup toggleable bind:open enabledSelections={UNLIMITED} bind:value minChildWidth={260} motion="scale-fade">
<CardGroup.Title>Workspace</CardGroup.Title>
<CardGroup.Content>
<CardGroup.Description>Metrics, notes and plans — grouped.</CardGroup.Description>
<CardGroup.Item value="rev">
<Card.Description>Revenue</Card.Description>
<strong>$48.2k</strong>
</CardGroup.Item>
<CardGroup.Item value="pro">
<Card.Header><Card.Title>Pro plan</Card.Title></Card.Header>
<Card.Body>Everything in Starter, plus advanced analytics.</Card.Body>
<Card.Footer><strong>$29 / mo</strong></Card.Footer>
</CardGroup.Item>
</CardGroup.Content>
</CardGroup>
```
The item IS the card — compose the Card **sub-parts** (`Card.Header` /
`Card.Title` / `Card.Body` / `Card.Footer` / `Card.Description`) or any content
inside `CardGroup.Item`, not a wrapping `<Card>`.
## Parts
| Part | Marker | Notes |
| --- | --- | --- |
| `CardGroup` (root) | `data-card-group` | The `ToggleGroup.Provider` (`role=group`) when selectable, else a plain `role=group` div. Carries `data-depth` / `data-size`. |
| `.Title` | `data-card-group-title` | Toggleable → `Collapsible.Trigger` rendered as the system `<Button>` (ghost) + chevron. Not toggleable → a static heading. |
| `.Description` | `data-card-group-description` | Secondary prose under the title. |
| `.Content` | `data-card-group-content` | Toggleable → `Collapsible.Content` (collapses via `hidden`); holds the `[data-card-group-grid]` (the `[data-stagger]` cascade container). |
| `.Item` | `data-card-group-item` + `data-card` | The card. Selectable → also a `ToggleGroup.Item` (`aria-pressed`, roving, sema). |
## Props (root)
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `toggleable` | `boolean` | `false` | Make the group collapsible (composes Collapsible). |
| `open` | `boolean` (bindable) | `true` | Whether expanded. Only meaningful when toggleable. |
| `enabledSelections` | `number \| [min, max]` | `0` | Selection cardinality (delegated to ToggleGroup via `$libs/selection`). `0` none · `1` single · `N` up to N · `UNLIMITED` · `[min, max]` range (`[1,1]` single-required, `[1,3]` pick 1–3). |
| `value` | `string[]` (bindable) | `[]` | Selected item values (0–1 in single mode). |
| `onValueChange` | `(value: string[]) => void` | — | Selection callback. |
| `whenFull` | `'reject' \| 'replace-oldest'` | `'reject'` | At a multi cap (`max > 1`): reject the pick (fires `commit-block`) or drop the oldest. |
| `disabled` | `boolean` | `false` | Disable selection + toggle for the whole group. |
| `depth` | `'raised' \| 'recessed' \| 'flush'` | `'raised'` | Canonical elevation (`data-depth`). |
| `size` | `ResponsiveProp<'sm' \| 'md' \| 'lg'>` | `'md'` | Padding / gap / title scale — **propagated to the cards**. |
| `minChildWidth` | `ResponsiveProp<number \| string>` | — | Fluid grid `repeat(auto-fill, minmax(MIN, 1fr))`. |
| `columns` | `ResponsiveProp<number>` | — | Fixed column count. Ignored when `minChildWidth` is set. |
| `motion` | `MotionPresetName` | — | Coordinated entrance cascade for the cards on toggle. |
| `staggerEach` | `number` | `60` | Stagger rhythm (ms) for the cascade. `0` = parallel. |
`aria-label` / `aria-labelledby` are forwarded; absent an explicit value the
group is labelled by the Title.
## Props (`.Item`)
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `string` | — | Selection identity. Required when the group is selectable. |
| `variant` | `CardVariant` | `'soft'` | Forwarded to the card recipe. |
| `color` | `CardColor` | `'neutral'` | Card accent. |
| `rounded` | `'sm' \| 'md' \| 'lg'` | `'md'` | Card corner radius. |
| `disabled` | `boolean` | `false` | Disable selecting this item. |
## Sema events
CardGroup declares **no own events** — its perceptual surface is composed:
| Event | From | Family · verb · intent | When |
| --- | --- | --- | --- |
| `expand` / `collapse` | Collapsible | `emerge` · expand/collapse | Title toggles the group. |
| `commit-toggle` | ToggleGroup | `commit` · toggle · neutral | A card is selected / unselected. |
| `commit-block` | ToggleGroup | `commit` · block · risk | A pick is rejected by the cardinality limit (at-max with `whenFull: 'reject'`, or below a required min). The card shakes (`card-group-block`) over the risk-tinted sound/haptic. |
## Accessibility
- **Group** — `role="group"` (from ToggleGroup, or the wrapper when not
selectable), labelled by the Title via `aria-labelledby`.
- **Selection** — each card is an `aria-pressed` toggle button
(toggle-button-group pattern) with roving focus + arrow-key navigation, all
from ToggleGroup.
- **Disclosure** — when toggleable the Title is a `Collapsible.Trigger`:
`aria-expanded` + `aria-controls`, Enter/Space toggles.
- **Reduced motion** — the cascade + the rejection shake are reduced-motion
aware.
## Reference comparison
| Library | Closest equivalent | Difference |
| --- | --- | --- |
| chakra-ui | `<CardGroup>` | Layout-only (shared size/variant). UIX adds disclosure (Collapsible) + selection (ToggleGroup). |
| ant design | `<Card.Grid>` | Static grid cells. UIX is a collapsible, selectable group of full cards. |
| radix / ark / bits | — | No CardGroup primitive; selection = a Radio/Toggle group, disclosure = Collapsible — exactly what UIX composes. |
| react-aria | `GridList` / `ListBox` | Selectable collections; cards are app-styled options. UIX promotes the pattern with the Card recipe reused via structural identity. |
| native HTML | `<fieldset><legend>` / `<details>` | The title-on-trigger + collapse idiom, here with a responsive card grid + coordinated motion. |
## Decisions
- **Composition over reinvention.** The disclosure and selection are NOT
re-implemented. An earlier draft shipped a bespoke `card-group` morfo + soma
provider (`pick` / `commit-block`) + a `$libs/selection` consumer; that
duplicated ToggleGroup + Collapsible and was removed. `$libs/selection` now
lives where it belongs — inside ToggleGroup.
- **The item carries structural identity** (`data-card`), reusing Card's recipe
wholesale — the `toggle-group ↔ toggle` pattern. Its `data-toggle` (from
ToggleGroup.Item) rides along harmlessly because the eidos Toggle CSS isn't
loaded when composing the soma ToggleGroup (cf. RadioCards ↔ RadioGroup).
- **The pressed state is read from the ToggleGroup provider** (`isItemPressed`,
reactive), not from the spread props — a `{@const}` over a stable
snippet-param proxy doesn't re-run.
- **Cardinality is ONE knob** (`enabledSelections: number | [min, max]`), not a
`mode` enum + min/max trio. `whenFull` governs the at-cap strategy; the mode
is derived from the ceiling.

@ -0,0 +1,48 @@
<script lang="ts">
/**
* Eidos `<CardGroup.Content>` — holds the responsive card grid.
* - toggleable → composes the canonical `Collapsible.Content` (which
* collapses via the `hidden` attribute) and puts the grid on a SEPARATE
* inner element so its `display: grid` never overrides `hidden`. The
* `data-collapsible-content` marker is stripped so the grid owns layout.
* - not toggleable → a plain always-visible grid.
*
* The inner grid is the `[data-stagger]` container — its direct Item children
* cascade off the group's open state (`data-state`).
*/
import * as Collapsible from '$soma/components/collapsible';
import { getCardGroupVisualContext } from './context';
import type { CardGroupContentProps } from './types';
let { children, ...rest }: CardGroupContentProps = $props();
const ctx = getCardGroupVisualContext();
const gridStyle = $derived(
[
ctx?.templateColumns ? `grid-template-columns:${ctx.templateColumns}` : '',
`--motion-stagger-each:${ctx?.staggerEach ?? 0}ms`
]
.filter(Boolean)
.join(';')
);
const gridState = $derived(ctx?.open === false ? 'closed' : 'open');
</script>
{#if ctx?.toggleable}
<Collapsible.Content>
{#snippet child({ props })}
<div {...props} data-collapsible-content={undefined} data-card-group-content="">
<div data-card-group-grid data-stagger data-state={gridState} style={gridStyle}>
{@render children?.()}
</div>
</div>
{/snippet}
</Collapsible.Content>
{:else}
<div data-card-group-content="" {...rest}>
<div data-card-group-grid data-stagger data-state="open" style={gridStyle}>
{@render children?.()}
</div>
</div>
{/if}

@ -0,0 +1,10 @@
<script lang="ts">
/** Eidos `<CardGroup.Description>` — secondary prose under the title. */
import type { CardGroupDescriptionProps } from './types';
let { children, ...rest }: CardGroupDescriptionProps = $props();
</script>
<div data-card-group-description="" {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,80 @@
<script lang="ts">
/**
* Eidos `<CardGroup.Item>` — the item IS a card (structural identity
* `data-card`, so it reuses the ENTIRE Card recipe) and, when the group is
* selectable, ALSO the selection control: it composes `ToggleGroup.Item`
* (value/cardinality, roving focus, keyboard, `aria-pressed`, the
* `commit-toggle` sema) and adds Card's `data-card` + variant/size/color +
* `data-selected`. This is the canonical system pattern — exactly how
* `toggle-group-item` reuses Toggle's recipe via `data-toggle`.
*
* The pressed state is read from the ToggleGroup provider (reactive) rather
* than from the spread props (a `{@const}` over a stable snippet-param proxy
* doesn't re-run). `data-toggle` rides along harmlessly — the eidos Toggle CSS
* isn't loaded when we compose the soma ToggleGroup (cf. RadioCards).
*
* The cascade rides `data-animation-style` (the group's `motion`); when the
* group owns the entrance, `data-no-emerge` suppresses the card's own mount.
*/
import * as ToggleGroup from '$soma/components/toggle-group';
import { ToggleGroupProvider } from '$soma/components/toggle-group/toggle-group-provider.svelte';
import { getCardGroupVisualContext } from './context';
import type { CardGroupItemProps } from './types';
let {
value,
variant = 'soft',
color = 'neutral',
rounded = 'md',
disabled = false,
children,
...rest
}: CardGroupItemProps = $props();
const ctx = getCardGroupVisualContext();
const selectable = $derived(!!ctx?.selectable);
const cascade = $derived(ctx?.cardMotion);
// Pressed state from the ToggleGroup provider (reactive `pressedSet`).
const tg = ToggleGroupProvider.get();
const selected = $derived(!!tg && tg.isItemPressed(value));
</script>
{#if selectable}
<ToggleGroup.Item {value} {disabled}>
{#snippet child({ props })}
<button
{...rest}
{...props}
data-card-group-item=""
data-card=""
data-interactive=""
data-variant={variant}
data-size={ctx?.cardSize}
data-color={color}
data-rounded={rounded}
data-selected={selected ? '' : undefined}
data-disabled={disabled ? '' : undefined}
data-animation-style={cascade}
data-no-emerge={cascade ? '' : undefined}
>
{@render children?.()}
</button>
{/snippet}
</ToggleGroup.Item>
{:else}
<div
{...rest}
data-card-group-item=""
data-card=""
data-variant={variant}
data-size={ctx?.cardSize}
data-color={color}
data-rounded={rounded}
data-disabled={disabled ? '' : undefined}
data-animation-style={cascade}
data-no-emerge={cascade ? '' : undefined}
>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,48 @@
<script lang="ts">
/**
* Eidos `<CardGroup.Title>` — the group heading.
* - toggleable → composes the canonical `Collapsible.Trigger` and renders
* the system `<Button>` (ghost) through its `child` slot, so the trigger
* is a real framework Button (cursor / hover / focus / press / aria) with
* the chevron in the Button `endIcon`. The `data-collapsible-trigger`
* marker is stripped so only the Button recipe paints it.
* - not toggleable → a plain static heading (no button, no chevron).
*
* Carries `id={ctx.titleId}` so the group's `aria-labelledby` resolves.
*/
import * as Collapsible from '$soma/components/collapsible';
import { Button } from '$uix/eidos/components/button';
import { getCardGroupVisualContext } from './context';
import type { CardGroupTitleProps } from './types';
let { children, ...rest }: CardGroupTitleProps = $props();
const ctx = getCardGroupVisualContext();
</script>
{#snippet chevron()}
<span data-card-group-title-chevron aria-hidden="true"></span>
{/snippet}
{#if ctx?.toggleable}
<Collapsible.Trigger id={ctx.titleId}>
{#snippet child({ props })}
<Button
{...props}
data-collapsible-trigger={undefined}
data-card-group-title=""
variant="ghost"
color="neutral"
block
size={ctx?.cardSize ?? 'md'}
endIcon={chevron}
>
<span data-card-group-title-text>{@render children?.()}</span>
</Button>
{/snippet}
</Collapsible.Trigger>
{:else}
<div id={ctx?.titleId} data-card-group-title="" data-static="" {...rest}>
<span data-card-group-title-text>{@render children?.()}</span>
</div>
{/if}

@ -0,0 +1,148 @@
/* CardGroup — responsive group of cards. Composition shell over ToggleGroup
* (selection) + Collapsible (disclosure); this recipe owns ONLY the genuinely
* CardGroup-level visual:
* - chrome via the DEPTH channel (`data-depth`: surface + border + shadow);
* - `inline-size: 100%` so collapsing changes only height (never recenters);
* - cards nest CONCENTRICALLY — `--shape-outer-radius = card-radius + inset`,
* and the items (each a `[data-card]` via structural identity) take that;
* - the responsive grid + the foundation `[data-stagger]` cascade.
* Card chrome (variant/size/color/selected ring/hover/press) is Card's recipe,
* reused by the items via `data-card`. The title is the system `<Button>`.
*/
[data-card-group] {
--_cg-pad: var(--space-4);
--_cg-card-radius: var(--radius-md);
--shape-outer-radius: calc(var(--_cg-card-radius) + var(--_cg-pad));
display: block;
box-sizing: border-box;
inline-size: 100%;
border-radius: var(--shape-outer-radius);
/* `data-depth` (foundation) supplies background + border + box-shadow. */
}
/* The composed Collapsible root is a transparent structural wrapper. */
[data-card-group] > [data-collapsible] {
display: block;
}
/* ── Title (disclosure trigger = system Button, ghost) ───────────────────────
* Button owns ALL chrome (bg / border / vertical padding / font / cursor /
* hover / focus ring / press). The recipe keeps ONLY the header-specific
* layout: full-bleed horizontal padding aligned to the grid (`--_cg-pad`) and
* the chevron pushed to the trailing edge via space-between. */
[data-card-group-title][data-button] {
justify-content: space-between;
padding-inline: var(--_cg-pad);
font-weight: var(--font-weight-semibold);
text-align: start;
}
/* Static (non-toggleable) heading — no button, plain header type. */
[data-card-group-title][data-static] {
display: block;
padding: var(--_cg-pad) var(--_cg-pad) var(--space-2);
color: var(--color-content-default);
font-family: var(--font-ui);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
line-height: var(--leading-ui);
}
[data-card-group-title] [data-card-group-title-text] {
min-inline-size: 0;
text-align: start;
}
/* Chevron — down at rest, up when the Collapsible is open. */
[data-card-group-title-chevron] {
inline-size: 0.5em;
block-size: 0.5em;
flex: 0 0 auto;
margin-block-start: -0.15em;
border-inline-end: var(--border-width-medium) solid currentColor;
border-block-end: var(--border-width-medium) solid currentColor;
transform: rotate(45deg);
transition: transform var(--duration-fast) var(--ease-out);
opacity: 0.6;
}
[data-card-group] [data-collapsible][data-state='open'] [data-card-group-title-chevron] {
transform: rotate(225deg);
}
/* ── Description ───────────────────────────────────────────────────────────── */
[data-card-group-description] {
margin: 0;
padding: 0 var(--_cg-pad) var(--space-2);
color: var(--color-content-muted);
font-size: var(--font-size-sm);
}
/* ── Content region + grid ─────────────────────────────────────────────────── */
/* No `display` rule on the content wrapper: the composed Collapsible.Content
* collapses via the `hidden` attribute (UA `display: none`); any author
* `display` would override it. The grid lives on a SEPARATE inner element. */
[data-card-group-grid] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
gap: var(--space-3);
padding: 0 var(--_cg-pad) var(--_cg-pad);
}
/* ── Concentric cards (§30: inner = outer − inset) ─────────────────────────── */
[data-card-group] [data-card] {
border-radius: var(--_cg-card-radius);
}
/* The item stretches to fill its grid cell so cards in a row match height. */
[data-card-group-item] {
block-size: 100%;
}
/* Rejection feedback — a pick blocked by the cardinality limit fires
* `commit-block` (ToggleGroup, via $libs/selection). Shake the blocked card
* during the sema hold, on top of the risk-tinted sound/haptic. */
[data-card-group-item][data-event='commit-block'][data-event-phase='active'] {
animation: card-group-block var(--duration-moderate) var(--ease-out);
}
@keyframes card-group-block {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-3px);
}
40%,
80% {
transform: translateX(3px);
}
}
@media (prefers-reduced-motion: reduce) {
[data-card-group-item][data-event='commit-block'][data-event-phase='active'] {
animation: none;
}
}
/* ── Size scale ────────────────────────────────────────────────────────────── */
[data-card-group][data-size='sm'] {
--_cg-pad: var(--space-3);
}
[data-card-group][data-size='sm'] [data-card-group-grid] {
gap: var(--space-2);
}
[data-card-group][data-size='lg'] {
--_cg-pad: var(--space-5);
}
[data-card-group][data-size='lg'] [data-card-group-grid] {
gap: var(--space-4);
}

@ -0,0 +1,129 @@
<script lang="ts">
/**
* Eidos `<CardGroup>` — a responsive, optionally-collapsible group of cards
* with optional selection. It is a COMPOSITION shell, not a new primitive:
*
* - selection → `ToggleGroup` (single / multiple). Cardinality, value,
* onValueChange, roving focus, keyboard and the `commit-toggle`
* sema all come from ToggleGroup. No selection engine here.
* - disclosure → `Collapsible` (Trigger = Button, Content collapses). No
* bespoke open/expand/collapse.
* - layout → the genuinely CardGroup-owned eidos: responsive grid,
* concentric cards, `data-depth` chrome, coordinated cascade.
*
* Mirrors how `RadioCards` skins `RadioGroup` ("share the machinery, layer a
* visual").
*/
import './card-group.css';
import { ActiveEidos } from '$uix/eidos';
import { Collapsible } from '$uix/eidos/components/collapsible';
import * as ToggleGroup from '$soma/components/toggle-group';
import { isSelectable } from '$libs/selection';
import { formatLayoutLength } from '$uix/eidos/lib/layout-helpers';
import { setCardGroupVisualContext } from './context';
import type { CardGroupProps } from './types';
let {
toggleable = false,
open = $bindable(true),
onOpenChange,
enabledSelections = 0,
value = $bindable([]),
onValueChange,
whenFull = 'reject',
disabled = false,
depth = 'raised',
size = 'md',
minChildWidth,
columns,
motion,
staggerEach = 60,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
...rest
}: CardGroupProps = $props();
const eidos = ActiveEidos.require();
const uid = $props.id();
const titleId = `${uid}-card-group-title`;
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const templateColumns = $derived.by(() => {
const min = formatLayoutLength(eidos.resolve(minChildWidth));
if (min) return `repeat(auto-fill, minmax(${min}, 1fr))`;
const cols = eidos.resolve(columns);
if (typeof cols === 'number' && cols > 0) return `repeat(${cols}, minmax(0, 1fr))`;
return undefined;
});
const selectable = $derived(isSelectable(enabledSelections));
const labelledby = $derived(ariaLabelledby ?? titleId);
setCardGroupVisualContext({
get selectable() {
return selectable;
},
get toggleable() {
return toggleable;
},
get titleId() {
return titleId;
},
get open() {
return open;
},
get templateColumns() {
return templateColumns;
},
get staggerEach() {
return staggerEach;
},
get cardMotion() {
return motion && motion !== 'none' ? motion : undefined;
},
get cardSize() {
return resolvedSize;
}
});
</script>
{#snippet body()}
{#if toggleable}
<Collapsible bind:open {onOpenChange}>
{@render children?.()}
</Collapsible>
{:else}
{@render children?.()}
{/if}
{/snippet}
{#if selectable}
<ToggleGroup.Provider
{enabledSelections}
{whenFull}
bind:value
{onValueChange}
{disabled}
data-card-group=""
data-depth={depth}
data-size={resolvedSize}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : labelledby}
{...rest}
>
{@render body()}
</ToggleGroup.Provider>
{:else}
<div
data-card-group=""
data-depth={depth}
data-size={resolvedSize}
role="group"
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : labelledby}
{...rest}
>
{@render body()}
</div>
{/if}

@ -0,0 +1,38 @@
import { getContext, setContext } from 'svelte';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type { CardGroupSize } from './types';
/**
* Eidos-only visual config the `<CardGroup>` root passes to its parts. Behaviour
* lives in the composed primitives (`ToggleGroup` selection, `Collapsible`
* disclosure); this only carries the eidos knobs (select mode, grid, cascade,
* motion, size) so Title / Content / Item render coherently.
*/
export interface CardGroupVisualContext {
/** Whether the group is selectable — Item renders as a ToggleGroup control. */
readonly selectable: boolean;
/** Whether the disclosure is toggleable — Title renders a trigger when true. */
readonly toggleable: boolean;
/** Stable id the Title applies to itself so the group's `aria-labelledby` resolves. */
readonly titleId: string;
/** Open state, mirrored so the inner grid can drive the stagger. */
readonly open: boolean;
/** Resolved CSS grid template, or `undefined` for the responsive default. */
readonly templateColumns: string | undefined;
/** Stagger rhythm in ms (0 = parallel). */
readonly staggerEach: number;
/** Cascade preset each Item cell plays on toggle. */
readonly cardMotion: MotionPresetName | undefined;
/** Resolved group size, propagated to each card so the group scales as one. */
readonly cardSize: CardGroupSize;
}
const KEY = Symbol('uix.card-group.visual');
export function setCardGroupVisualContext(ctx: CardGroupVisualContext): void {
setContext(KEY, ctx);
}
export function getCardGroupVisualContext(): CardGroupVisualContext | undefined {
return getContext<CardGroupVisualContext | undefined>(KEY);
}

@ -0,0 +1,57 @@
// CardGroup — responsive group of cards. A composition shell: selection is
// delegated to ToggleGroup, disclosure to Collapsible; the eidos layer owns the
// responsive grid, concentric cards, depth chrome and the coordinated cascade.
//
// import { CardGroup } from '$uix/eidos/components/card-group';
// import { Card } from '$uix/eidos/components/card';
//
// <!-- display group, collapsible -->
// <CardGroup toggleable bind:open minChildWidth={260} motion="scale-fade">
// <CardGroup.Title>Metrics</CardGroup.Title>
// <CardGroup.Content>
// <CardGroup.Description>Last 30 days</CardGroup.Description>
// <CardGroup.Item value="rev">
// <Card.Header><Card.Title>Revenue</Card.Title></Card.Header>
// <Card.Body>$48.2k</Card.Body>
// </CardGroup.Item>
// </CardGroup.Content>
// </CardGroup>
//
// <!-- selectable: each item IS a card AND a ToggleGroup control -->
// <CardGroup select="multiple" bind:value>
// <CardGroup.Content>
// <CardGroup.Item value="a" variant="soft">
// <Card.Body>Plan A</Card.Body>
// </CardGroup.Item>
// </CardGroup.Content>
// </CardGroup>
import CardGroupComponent from './card-group.svelte';
import Title from './card-group-title.svelte';
import Description from './card-group-description.svelte';
import Content from './card-group-content.svelte';
import Item from './card-group-item.svelte';
type CardGroupNamespace = typeof CardGroupComponent & {
Title: typeof Title;
Description: typeof Description;
Content: typeof Content;
Item: typeof Item;
};
const CardGroup = CardGroupComponent as CardGroupNamespace;
CardGroup.Title = Title;
CardGroup.Description = Description;
CardGroup.Content = Content;
CardGroup.Item = Item;
export { CardGroup };
export default CardGroup;
export type {
CardGroupProps,
CardGroupTitleProps,
CardGroupDescriptionProps,
CardGroupContentProps,
CardGroupItemProps,
CardGroupDepth,
CardGroupSize
} from './types';

@ -0,0 +1,102 @@
import type { Snippet } from 'svelte';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type { LayoutLengthValue } from '$uix/eidos/lib/layout-helpers';
import type { SelectionFullBehavior, SelectionLimit } from '$libs/selection';
import type { CardColor, CardRounded, CardVariant } from '../card/types';
/**
* CardGroup elevation — drives the canonical depth channel (`data-depth`:
* surface + border + shadow + halo), so the chrome is the system's.
* - `raised` — surface + border + soft shadow (default panel)
* - `recessed` — sunken surface + inset shadow
* - `flush` — no chrome (just layout)
*/
export type CardGroupDepth = 'raised' | 'recessed' | 'flush';
/** CardGroup size — padding / gap / title scale. Composed panel: `sm · md · lg`. */
export type CardGroupSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/** Props for `<CardGroup>` — disclosure (Collapsible) + selection (ToggleGroup) + eidos layout. */
export type CardGroupProps = {
/** Make the group collapsible — the Title becomes the disclosure trigger. @default false */
toggleable?: boolean;
/** Whether the group is expanded. Bindable. Only meaningful when toggleable. @default true */
open?: boolean;
/** Called when the open state changes. */
onOpenChange?: (open: boolean) => void;
/**
* Selection cardinality (delegated to ToggleGroup via `$libs/selection`):
* `number | [min, max]` — `0` none · `1` single · `N` up to N · `UNLIMITED` ·
* `[min, max]` range (e.g. `[1, 1]` single-required, `[1, 3]` pick 1–3).
* The single knob. @default 0 (none) */
enabledSelections?: SelectionLimit;
/** Selected item values. Bindable. `[]` when nothing selected. @default [] */
value?: string[];
/** Called when the selection changes. */
onValueChange?: (value: string[]) => void;
/**
* Behaviour when picking a new value at a multi cap (`max > 1`): `reject`
* (no-op, fires `commit-block`) or `replace-oldest`. @default 'reject'
*/
whenFull?: SelectionFullBehavior;
/** Disable selection + toggle for the whole group. @default false */
disabled?: boolean;
/** Elevation (canonical depth channel). @default 'raised' */
depth?: CardGroupDepth;
/** Sizing scale (padding / gap / title). Propagated to the cards. @default 'md' */
size?: ResponsiveProp<CardGroupSize>;
/** Fluid grid — `repeat(auto-fill, minmax(MIN, 1fr))`. The responsive default. */
minChildWidth?: ResponsiveProp<LayoutLengthValue>;
/** Fixed column count. Ignored when `minChildWidth` is set. */
columns?: ResponsiveProp<number>;
/** Coordinated entrance preset for the cards on toggle (the cascade). @default undefined */
motion?: MotionPresetName;
/** Stagger rhythm (ms) for the cascade. 0 = parallel. @default 60 */
staggerEach?: number;
/** Accessible name (when no Title is rendered). */
'aria-label'?: string;
/** Id of the element labelling the group. */
'aria-labelledby'?: string;
children?: Snippet;
[key: string]: unknown;
};
export type CardGroupTitleProps = {
children?: Snippet;
[key: string]: unknown;
};
export type CardGroupDescriptionProps = {
children?: Snippet;
[key: string]: unknown;
};
export type CardGroupContentProps = {
children?: Snippet;
[key: string]: unknown;
};
/**
* Item props. The item IS a card (structural identity `data-card`) and, when the
* group is selectable, also the selection control (composes `ToggleGroup.Item`).
* The Card visual knobs are forwarded to the card recipe.
*/
export type CardGroupItemProps = {
/** Selection identity. Required when the group is selectable. */
value: string;
/** Card visual treatment. @default 'soft' */
variant?: CardVariant;
/** Card accent palette. @default 'neutral' */
color?: CardColor;
/** Card corner radius. @default 'md' */
rounded?: CardRounded;
/** Disable selecting this item. @default false */
disabled?: boolean;
children?: Snippet;
[key: string]: unknown;
};

@ -33,6 +33,20 @@ export const toggleGroupMorfo = {
},
sequence: 'post'
}
},
{
// A pick blocked by the cardinality limit (at-max with `whenFull:
// 'reject'`, or below a required minimum). The perceptual "no, correct
// course" — corrigible, hence `risk`. Fired by the provider when
// `$libs/selection` rejects the pick.
name: 'commit-block',
semantic: {
family: 'commit',
verb: 'block',
target: v.partRef('item'),
intent: 'risk',
sequence: 'post'
}
}
],
parts: [

@ -27,6 +27,13 @@ export const toggleGroupSema: Sema = {
selector: onItem({ eventName: 'commit-toggle' }),
sound: soundTuning('form.toggle.silent'),
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{
// Rejection (cap reached / below required min). The `risk` intent owns
// the sound primitives (pitch/gain/contour); the rule only adds the
// tactile "blocked" character — never overrides the intent profile.
selector: onItem({ eventName: 'commit-block' }),
haptic: { kind: 'error', pattern: [40, 60, 40] }
}
]
};

@ -19,6 +19,8 @@
id = createId(uid, 'toggle-group'),
type = 'single',
deselectable = true,
enabledSelections,
whenFull = 'reject',
value = $bindable([]),
onValueChange = () => {},
disabled = false,
@ -54,6 +56,8 @@
),
type: readableActive(() => type),
deselectable: readableActive(() => deselectable),
enabledSelections: readableActive(() => enabledSelections),
whenFull: readableActive(() => whenFull),
value: writableActive(
() => value,
(v) => {

@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { SelectionLimit } from '$libs/selection';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
@ -58,6 +59,8 @@ function groupOpts(root = createRoot()) {
value: state<string[]>([]),
type: state<'single' | 'multiple'>('single'),
deselectable: state(true),
enabledSelections: state<SelectionLimit | undefined>(undefined),
whenFull: state<'reject' | 'replace-oldest'>('reject'),
disabled: state(false),
orientation: state<'horizontal' | 'vertical'>('horizontal'),
loop: state(true),

@ -8,6 +8,12 @@ import { toggleGroupMorfo } from '../../../morfo/components/toggle-group';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { ToggleGroupType } from './types';
import {
resolveSelection,
UNLIMITED,
type SelectionLimit,
type SelectionFullBehavior
} from '$libs/selection';
const attrs = createAttrs(toggleGroupMorfo);
@ -20,6 +26,14 @@ interface ToggleGroupOpts
ActiveProps<{
type: ToggleGroupType;
deselectable: boolean;
/**
* Canonical cardinality (`$libs/selection`): `number | [min, max]`.
* When set it OVERRIDES `type`/`deselectable` (single knob). Undefined
* → derive the limit from `type`/`deselectable` (backward compat).
*/
enabledSelections: SelectionLimit | undefined;
/** Behaviour when picking past a multi cap. @default 'reject' */
whenFull: SelectionFullBehavior;
disabled: boolean;
orientation: Orientation;
loop: boolean;
@ -68,28 +82,55 @@ export class ToggleGroupProvider {
return this.pressedSet.has(value);
}
toggleItem(value: string) {
if (this.opts.disabled.current) return;
/**
* The cardinality, as the canonical `$libs/selection` limit. `enabledSelections`
* wins when set; otherwise it is derived from `type`/`deselectable`:
* single + deselectable → `1` (one, clearable)
* single (required) → `[1,1]` (one, can't empty)
* multiple → `UNLIMITED`
*/
readonly limit = $derived.by<SelectionLimit>(() => {
const explicit = this.opts.enabledSelections.current;
if (explicit !== undefined) return explicit;
if (this.opts.type.current === 'single') {
return this.opts.deselectable.current ? 1 : [1, 1];
}
return UNLIMITED;
});
/** Resolve the DOM element for an item value (by data-value attr). */
private resolveItemEl(value: string): HTMLElement | null {
const root = this.opts.ref?.current;
if (!root) return null;
return root.querySelector<HTMLElement>(`[${attrs.item}][data-value="${CSS.escape(value)}"]`);
}
const current = this.opts.value.current;
const isPressed = current.includes(value);
/**
* Pick an item. Routes through `$libs/selection` so the cardinality limit
* applies (single replaces, multiple toggles, the cap rejects or replaces the
* oldest per `whenFull`). Fires `commit-toggle` when the selection changed, or
* `commit-block` when the pick was rejected by the limit (at-max / below-min).
*/
toggleItem(value: string, itemEl?: HTMLElement) {
if (this.opts.disabled.current) return;
let next: string[];
if (this.opts.type.current === 'single') {
if (isPressed) {
// Re-pressing the active item deselects it (→ empty) only when
// `deselectable`; otherwise it stays pressed (radio-like — a
// selection is required) and this is a no-op.
if (!this.opts.deselectable.current) return;
next = [];
} else {
next = [value];
}
} else {
next = isPressed ? current.filter((v) => v !== value) : [...current, value];
const result = resolveSelection(
this.opts.value.current,
value,
this.limit,
this.opts.whenFull.current
);
const target = itemEl ?? this.resolveItemEl(value);
if (result.changed) {
this.opts.value.current = [...result.next];
if (target) void this.runtime.trigger('commit-toggle', { fallbackTarget: target });
return;
}
this.opts.value.current = next;
if (result.rejected && result.rejected !== 'disabled' && target) {
void this.runtime.trigger('commit-block', { fallbackTarget: target });
}
}
/** Get all enabled item elements for keyboard navigation, scoped to this root. */
@ -174,15 +215,11 @@ export class ToggleGroupItemProvider {
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled) return;
this.provider.toggleItem(this.opts.value.current);
// Morfo declares `commit-toggle` on the `item` part. Pass the
// clicked button as `fallbackTarget` so the cascade selector
// `[data-toggle-group-item][data-event="commit-toggle"]` matches
// this exact item (the runtime would otherwise see N registered
// items under the same kebab and pick arbitrarily).
void this.provider.runtime.trigger('commit-toggle', {
fallbackTarget: e.currentTarget
});
// Selection + the perceptual event (commit-toggle on change, commit-block
// on a limit rejection) are centralised in `toggleItem`. Pass the clicked
// button so the cascade selector matches this exact item (the runtime
// would otherwise see N items under the same kebab and pick arbitrarily).
this.provider.toggleItem(this.opts.value.current, e.currentTarget);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {

@ -1,5 +1,6 @@
import type { WithChild, Without, OnChangeFn, Orientation, Direction } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
import type { SelectionLimit, SelectionFullBehavior } from '$libs/selection';
export type ToggleGroupType = 'single' | 'multiple';
@ -19,6 +20,18 @@ export type ToggleGroupProps = WithChild<{
* `multiple` mode. @default true
*/
deselectable?: boolean;
/**
* Canonical selection cardinality (`$libs/selection`): `number | [min, max]`
* (`0` none · `1` single · `N` up to N · `UNLIMITED` · `[min, max]` range).
* The single knob. When set it OVERRIDES `type`/`deselectable`. Leave unset to
* use the `type` sugar. @default undefined
*/
enabledSelections?: SelectionLimit;
/**
* What happens when a NEW value is picked at a multi cap (`max > 1`):
* `reject` (no-op, fires `commit-block`) or `replace-oldest`. @default 'reject'
*/
whenFull?: SelectionFullBehavior;
/** Array of pressed item values. Bindable. @default [] */
value?: string[];
/** Callback fired when value changes. */

@ -0,0 +1,477 @@
<script lang="ts">
import {
CardGroup,
type CardGroupDepth,
type CardGroupSize
} from '$uix/eidos/components/card-group';
import { Card } from '$uix/eidos/components/card';
import { UNLIMITED, isSelectable, type SelectionLimit } from '$libs/selection';
type Tab = 'live' | 'api' | 'composition' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ──────────────────────────────────────────────────────────
let toggleable = $state(true);
let open = $state(true);
let depth = $state<CardGroupDepth>('raised');
let size = $state<CardGroupSize>('md');
let motion = $state<'none' | 'scale-fade' | 'slide-fade' | 'fade'>('scale-fade');
let staggerEach = $state(60);
let layout = $state<'fluid' | 2 | 3 | 4>('fluid');
let minChildWidth = $state(240);
let whenFull = $state<'reject' | 'replace-oldest'>('reject');
let disabled = $state(false);
let value = $state<string[]>([]);
// Selection cardinality presets — the single `number | [min, max]` knob.
const selectionPresets: { label: string; value: SelectionLimit }[] = [
{ label: 'none · 0', value: 0 },
{ label: 'single · 1', value: 1 },
{ label: 'single req · [1,1]', value: [1, 1] },
{ label: 'up to 2 · 2', value: 2 },
{ label: 'range · [1,3]', value: [1, 3] },
{ label: 'multiple · ∞', value: UNLIMITED }
];
let selPreset = $state(3);
const enabledSelections = $derived(selectionPresets[selPreset].value);
const selectable = $derived(isSelectable(enabledSelections));
const capped = $derived(
(typeof enabledSelections === 'number' && enabledSelections > 1) ||
(Array.isArray(enabledSelections) && enabledSelections[1] > 1)
);
// Demo cards — different "types" the group is agnostic to.
type DemoCard = {
id: string;
kind: 'metric' | 'content' | 'product';
title: string;
sub?: string;
body?: string;
metric?: string;
delta?: string;
price?: string;
rating?: string;
};
const cards: DemoCard[] = [
{ id: 'rev', kind: 'metric', title: 'Revenue', metric: '$48.2k', delta: '▲ 12.4%' },
{ id: 'usr', kind: 'metric', title: 'Active users', metric: '8,931', delta: '▲ 3.1%' },
{
id: 'note',
kind: 'content',
title: 'Release notes',
sub: '2 min read',
body: 'CardGroup now composes ToggleGroup (selection) and Collapsible (disclosure) — no reinvented behaviour.'
},
{
id: 'guide',
kind: 'content',
title: 'Onboarding guide',
sub: 'Updated today',
body: 'A walkthrough of composing cards inside a toggleable, selectable group.'
},
{
id: 'pro',
kind: 'product',
title: 'Pro plan',
body: 'Everything in Starter, plus advanced analytics and priority support.',
price: '$29 / mo',
rating: '★ 4.8'
},
{
id: 'team',
kind: 'product',
title: 'Team plan',
body: 'Shared workspaces, roles, and audit logs for growing teams.',
price: '$79 / mo',
rating: '★ 4.9'
}
];
// ── Trace ─────────────────────────────────────────────────────────────
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
// ── Snippet ──────────────────────────────────────────────────────────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { CardGroup } from '$uix/eidos/components/card-group';",
" import { Card } from '$uix/eidos/components/card';",
selectable && ' let value = $state<string[]>([]);',
toggleable && ' let open = $state(true);',
'</' + 'script>',
'',
'<CardGroup',
toggleable && ' toggleable bind:open',
selectable &&
` enabledSelections={${Array.isArray(enabledSelections) ? `[${enabledSelections.join(', ')}]` : enabledSelections === UNLIMITED ? 'UNLIMITED' : enabledSelections}}`,
selectable && ' bind:value',
capped && whenFull !== 'reject' && ` whenFull="${whenFull}"`,
depth !== 'raised' && ` depth="${depth}"`,
size !== 'md' && ` size="${size}"`,
layout === 'fluid' ? ` minChildWidth={${minChildWidth}}` : ` columns={${layout}}`,
motion !== 'none' && ` motion="${motion}"`,
staggerEach !== 60 && ` staggerEach={${staggerEach}}`,
'>',
' <CardGroup.Title>Workspace</CardGroup.Title>',
' <CardGroup.Content>',
` <CardGroup.Item value="rev"${selectable ? '' : ''}>`,
' <Card.Header><Card.Title>Revenue</Card.Title></Card.Header>',
' <Card.Body>$48.2k</Card.Body>',
' </CardGroup.Item>',
' <!-- …more items… -->',
' </CardGroup.Content>',
'</CardGroup>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · CardGroup</div>
<h1 data-uix-page-title>CardGroup</h1>
<p data-uix-page-lede>
A responsive group of cards. CardGroup is a <strong>composition shell</strong>, not a new
primitive: selection is delegated to <code>ToggleGroup</code> (single / multiple),
disclosure to <code>Collapsible</code>, and the eidos layer owns the responsive grid,
concentric cards, <code>data-depth</code> chrome and the coordinated cascade. Each
selectable item IS a card (structural identity <code>data-card</code>) AND a ToggleGroup
control — the same pattern as <code>RadioCards</code> ↔ <code>RadioGroup</code>.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>composes</span>ToggleGroup · Collapsible</span>
<span data-uix-meta-pill><span data-uix-meta-key>select</span>3</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>3</span>
<span data-uix-meta-pill><span data-uix-meta-key>scope</span>eidos</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<CardGroup
{toggleable}
bind:open
{enabledSelections}
bind:value
{whenFull}
{depth}
{size}
{disabled}
minChildWidth={layout === 'fluid' ? minChildWidth : undefined}
columns={layout === 'fluid' ? undefined : layout}
motion={motion === 'none' ? undefined : motion}
{staggerEach}
>
<CardGroup.Title>Workspace</CardGroup.Title>
<CardGroup.Content>
<CardGroup.Description>Metrics, notes and plans — grouped.</CardGroup.Description>
{#each cards as card (card.id)}
<CardGroup.Item value={card.id}>
{#if card.kind === 'metric'}
<Card.Description>{card.title}</Card.Description>
<div class="metric-value">{card.metric}</div>
<div class="metric-delta">{card.delta}</div>
{:else if card.kind === 'product'}
<Card.Header>
<Card.Title>{card.title}</Card.Title>
</Card.Header>
<Card.Body>{card.body}</Card.Body>
<Card.Footer>
<strong>{card.price}</strong>
<span class="rating">{card.rating}</span>
</Card.Footer>
{:else}
<Card.Header>
<Card.Title>{card.title}</Card.Title>
<Card.Description>{card.sub}</Card.Description>
</Card.Header>
<Card.Body>{card.body}</Card.Body>
{/if}
</CardGroup.Item>
{/each}
</CardGroup.Content>
</CardGroup>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>toggle the group or select a card to see events</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>open</span> {String(open)} ·
<span data-uix-stage-trace-key>selected</span> {value.length ? value.join(', ') : '—'}
</span>
</div>
</div>
<!-- Tabs -->
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'composition'} onclick={() => (tab = 'composition')}>Composition</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<div data-uix-subsection-head>Disclosure · <code>Collapsible</code></div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>toggleable</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={toggleable} />
<span data-uix-switch-label>{toggleable ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>open <span data-uix-control-hint>bindable</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={open} disabled={!toggleable} />
<span data-uix-switch-label>{open ? 'open' : 'closed'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>Selection · <code>enabledSelections</code> <span data-uix-control-hint>delegated to ToggleGroup</span></div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>cardinality</span>
<span data-uix-chips role="radiogroup">
{#each selectionPresets as p, i}
<button data-uix-chip data-active={selPreset === i} onclick={() => { selPreset = i; value = []; }}>{p.label}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>whenFull <span data-uix-control-hint>at the cap → fires commit-block</span></span>
<span data-uix-chips role="radiogroup">
{#each ['reject', 'replace-oldest'] as w}
<button data-uix-chip data-active={whenFull === w} onclick={() => (whenFull = w as typeof whenFull)} disabled={!capped}>{w}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> visual + layout
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>depth</span>
<span data-uix-chips role="radiogroup">
{#each ['raised', 'recessed', 'flush'] as d}
<button data-uix-chip data-active={depth === d} onclick={() => (depth = d as CardGroupDepth)}>{d}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as CardGroupSize)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>layout</span>
<span data-uix-chips role="radiogroup">
{#each ['fluid', 2, 3, 4] as l}
<button data-uix-chip data-active={layout === l} onclick={() => (layout = l as typeof layout)}>{l === 'fluid' ? 'fluid' : `${l} cols`}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>minChildWidth <span data-uix-control-hint>fluid · {minChildWidth}px</span></span>
<input type="range" min="160" max="360" step="20" bind:value={minChildWidth} disabled={layout !== 'fluid'} />
</label>
</div>
<div data-uix-subsection-head>Motion · cascade</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>motion <span data-uix-control-hint>cascade on toggle</span></span>
<span data-uix-chips role="radiogroup">
{#each ['none', 'scale-fade', 'slide-fade', 'fade'] as m}
<button data-uix-chip data-active={motion === m} onclick={() => (motion = m as typeof motion)}>{m}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>staggerEach <span data-uix-control-hint>{staggerEach}ms</span></span>
<input type="range" min="0" max="150" step="10" bind:value={staggerEach} />
</label>
</div>
<div data-uix-code style="margin-top: var(--uix-space-4);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>composition · item = card via structural identity</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>Props on <code>&lt;CardGroup&gt;</code>. Selection forwards to <code>ToggleGroup</code>, disclosure to <code>Collapsible</code>.</p>
<div data-uix-subsection-head>CardGroup</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">toggleable</td><td class="type">boolean</td><td class="default">false</td><td>Make the group collapsible (composes Collapsible; Title becomes the trigger).</td></tr>
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">true</td><td>Whether expanded. Only meaningful when toggleable.</td></tr>
<tr><td class="name">enabledSelections</td><td class="type">{`number | [min, max]`}</td><td class="default">0</td><td>Selection cardinality (delegated to ToggleGroup via $libs/selection). 0 none · 1 single · N up to N · UNLIMITED · [min,max] range.</td></tr>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">string[]</td><td class="default">[]</td><td>Selected item values (0–1 in single mode).</td></tr>
<tr><td class="name">whenFull</td><td class="type">'reject' | 'replace-oldest'</td><td class="default">'reject'</td><td>At a multi cap (max &gt; 1): reject the pick (fires commit-block) or drop the oldest.</td></tr>
<tr><td class="name">depth</td><td class="type">'raised' | 'recessed' | 'flush'</td><td class="default">'raised'</td><td>Canonical elevation (data-depth): surface + border + shadow.</td></tr>
<tr><td class="name">size</td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>Padding / gap / title scale — propagated to the cards.</td></tr>
<tr><td class="name">minChildWidth</td><td class="type">ResponsiveProp&lt;number | string&gt;</td><td class="default empty">—</td><td>Fluid grid — repeat(auto-fill, minmax(MIN, 1fr)).</td></tr>
<tr><td class="name">columns</td><td class="type">ResponsiveProp&lt;number&gt;</td><td class="default empty">—</td><td>Fixed column count. Ignored when minChildWidth is set.</td></tr>
<tr><td class="name">motion</td><td class="type">MotionPresetName</td><td class="default empty">—</td><td>Coordinated entrance cascade for the cards on toggle.</td></tr>
<tr><td class="name">staggerEach</td><td class="type">number</td><td class="default">60</td><td>Stagger rhythm (ms) for the cascade. 0 = parallel.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>CardGroup.Item</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value</td><td class="type">string</td><td>Selection identity. Required when the group is selectable.</td></tr>
<tr><td class="name">variant</td><td class="type">CardVariant</td><td>Card visual treatment (forwarded to the card recipe). @default 'soft'</td></tr>
<tr><td class="name">color</td><td class="type">CardColor</td><td>Card accent palette. @default 'neutral'</td></tr>
<tr><td class="name">rounded</td><td class="type">'sm' | 'md' | 'lg'</td><td>Card corner radius. @default 'md'</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td>Disable selecting this item.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'composition'}
<section data-uix-section>
<h2 data-uix-section-title>Composition</h2>
<p data-uix-section-desc>
CardGroup owns no behavioural primitive of its own. It composes existing components
and adds only layout + chrome.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Owned by</th><th>What it provides</th></tr></thead>
<tbody>
<tr><td class="name">Selection</td><td><a href="/uix/components/toggle-group">ToggleGroup</a></td><td>Cardinality (single / multiple), value, onValueChange, roving focus, keyboard, aria-pressed, the <code>commit-toggle</code> sema.</td></tr>
<tr><td class="name">Disclosure</td><td><a href="/uix/components/collapsible">Collapsible</a></td><td>Open state, the <code>expand</code>/<code>collapse</code> sema, the content collapse, the Trigger button (Button) + aria.</td></tr>
<tr><td class="name">Card visual</td><td><a href="/uix/components/card">Card</a></td><td>The item IS a card (<code>data-card</code> structural identity): variant / size / color / selected ring / hover / press.</td></tr>
<tr><td class="name">Layout</td><td><span data-uix-layer-badge="eidos">eidos</span> CardGroup</td><td>Responsive grid, concentric cards, <code>data-depth</code> chrome, the coordinated cascade, size propagation.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr></thead>
<tbody>
<tr><td class="name">chakra-ui</td><td><code>&lt;CardGroup&gt;</code></td><td>Layout-only. UIX adds disclosure (Collapsible) + selection (ToggleGroup).</td></tr>
<tr><td class="name">ant design</td><td><code>&lt;Card.Grid&gt;</code></td><td>Static grid cells. UIX is a collapsible, selectable group.</td></tr>
<tr><td class="name">radix / ark / bits</td><td>—</td><td>No CardGroup; selection = ToggleGroup/Radio, disclosure = Collapsible — exactly what UIX composes.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Group</td><td><code>role="group"</code> (from ToggleGroup, or the wrapper when not selectable), labelled by the Title via <code>aria-labelledby</code>.</td></tr>
<tr><td class="name">Selection</td><td>Each card is a <code>aria-pressed</code> toggle button (toggle-button-group pattern) with roving focus + arrow-key navigation — all from ToggleGroup.</td></tr>
<tr><td class="name">Disclosure</td><td>When toggleable, the Title is a Collapsible.Trigger: <code>aria-expanded</code> + <code>aria-controls</code> to the content region, Enter/Space toggles.</td></tr>
<tr><td class="name">Reduced motion</td><td>The cascade is a foundation motion preset — reduced-motion-aware for free.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
/* The CardGroup is a full-width, top-anchored block. The shared stage area
* uses `place-items: center`, which makes the group jump to the vertical
* center when it collapses (its height drops below the stage min-height).
* Pin it to the top for this demo so collapsing keeps its position. */
:global([data-uix-stage-area]:has(> [data-card-group])) {
align-items: start;
}
.metric-value {
font-size: var(--uix-font-size-xl, 1.5rem);
font-weight: 700;
line-height: 1.1;
margin-block: var(--uix-space-1) var(--uix-space-1);
}
.metric-delta {
font-size: var(--uix-font-size-sm);
color: var(--uix-text-muted);
}
.rating {
margin-inline-start: auto;
color: var(--uix-text-muted);
font-size: var(--uix-font-size-sm);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.