diff --git a/src/libs/selection/index.ts b/src/libs/selection/index.ts new file mode 100644 index 000000000..bc677fdfc --- /dev/null +++ b/src/libs/selection/index.ts @@ -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); +} diff --git a/src/libs/selection/selection.test.ts b/src/libs/selection/selection.test.ts new file mode 100644 index 000000000..e12f854d6 --- /dev/null +++ b/src/libs/selection/selection.test.ts @@ -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); + }); +}); diff --git a/src/uix/eidos/components/card-group/README.md b/src/uix/eidos/components/card-group/README.md new file mode 100644 index 000000000..2ec9a39ea --- /dev/null +++ b/src/uix/eidos/components/card-group/README.md @@ -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 ` + {/snippet} + +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/eidos/components/card-group/card-group-title.svelte b/src/uix/eidos/components/card-group/card-group-title.svelte new file mode 100644 index 000000000..386621c5d --- /dev/null +++ b/src/uix/eidos/components/card-group/card-group-title.svelte @@ -0,0 +1,48 @@ + + +{#snippet chevron()} + +{/snippet} + +{#if ctx?.toggleable} + + {#snippet child({ props })} + + {/snippet} + +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/eidos/components/card-group/card-group.css b/src/uix/eidos/components/card-group/card-group.css new file mode 100644 index 000000000..3909f25c2 --- /dev/null +++ b/src/uix/eidos/components/card-group/card-group.css @@ -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 ` + + + + + + {#if tab === 'live'} +
+

Controls

+ +
Disclosure · Collapsible
+
+ + +
+ +
Selection · enabledSelections delegated to ToggleGroup
+
+ + + +
+ +
+ eidos visual + layout +
+
+ + + + +
+ +
Motion · cascade
+
+ + +
+ +
+
+ eidos + composition · item = card via structural identity + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

Props on <CardGroup>. Selection forwards to ToggleGroup, disclosure to Collapsible.

+ +
CardGroup
+
+ + + + + + + + + + + + + + + +
PropTypeDefaultDescription
toggleablebooleanfalseMake the group collapsible (composes Collapsible; Title becomes the trigger).
open bindablebooleantrueWhether expanded. Only meaningful when toggleable.
enabledSelections{`number | [min, max]`}0Selection cardinality (delegated to ToggleGroup via $libs/selection). 0 none · 1 single · N up to N · UNLIMITED · [min,max] range.
value bindablestring[][]Selected item values (0–1 in single mode).
whenFull'reject' | 'replace-oldest''reject'At a multi cap (max > 1): reject the pick (fires commit-block) or drop the oldest.
depth'raised' | 'recessed' | 'flush''raised'Canonical elevation (data-depth): surface + border + shadow.
size'sm' | 'md' | 'lg''md'Padding / gap / title scale — propagated to the cards.
minChildWidthResponsiveProp<number | string>—Fluid grid — repeat(auto-fill, minmax(MIN, 1fr)).
columnsResponsiveProp<number>—Fixed column count. Ignored when minChildWidth is set.
motionMotionPresetName—Coordinated entrance cascade for the cards on toggle.
staggerEachnumber60Stagger rhythm (ms) for the cascade. 0 = parallel.
+
+ +
CardGroup.Item
+
+ + + + + + + + + +
PropTypeDescription
valuestringSelection identity. Required when the group is selectable.
variantCardVariantCard visual treatment (forwarded to the card recipe). @default 'soft'
colorCardColorCard accent palette. @default 'neutral'
rounded'sm' | 'md' | 'lg'Card corner radius. @default 'md'
disabledbooleanDisable selecting this item.
+
+
+ {/if} + + {#if tab === 'composition'} +
+

Composition

+

+ CardGroup owns no behavioural primitive of its own. It composes existing components + and adds only layout + chrome. +

+
+ + + + + + + + +
ConcernOwned byWhat it provides
SelectionToggleGroupCardinality (single / multiple), value, onValueChange, roving focus, keyboard, aria-pressed, the commit-toggle sema.
DisclosureCollapsibleOpen state, the expand/collapse sema, the content collapse, the Trigger button (Button) + aria.
Card visualCardThe item IS a card (data-card structural identity): variant / size / color / selected ring / hover / press.
Layouteidos CardGroupResponsive grid, concentric cards, data-depth chrome, the coordinated cascade, size propagation.
+
+
Reference comparison
+
+ + + + + + + +
LibraryClosest equivalentDifference
chakra-ui<CardGroup>Layout-only. UIX adds disclosure (Collapsible) + selection (ToggleGroup).
ant design<Card.Grid>Static grid cells. UIX is a collapsible, selectable group.
radix / ark / bits—No CardGroup; selection = ToggleGroup/Radio, disclosure = Collapsible — exactly what UIX composes.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + +
ConcernContract
Grouprole="group" (from ToggleGroup, or the wrapper when not selectable), labelled by the Title via aria-labelledby.
SelectionEach card is a aria-pressed toggle button (toggle-button-group pattern) with roving focus + arrow-key navigation — all from ToggleGroup.
DisclosureWhen toggleable, the Title is a Collapsible.Trigger: aria-expanded + aria-controls to the content region, Enter/Space toggles.
Reduced motionThe cascade is a foundation motion preset — reduced-motion-aware for free.
+
+
+ {/if} + + +