From 61ffbade460bc4042f2557d47f94fa02256263b0 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 23:02:00 +0200 Subject: [PATCH] feat(toggle-group): port from soma to eidos (Tier 1 sprint, 1/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First component in the soma→eidos porting sprint. Two-part compound (Provider + Item), modeled on radio-group's structure. Eidos surface: - variant: 'solid' | 'outline' | 'ghost' — mirrors - color: 7 tones, propagated to items via data-color cascade - size: xs/sm/md/lg/xl (responsive) - attached: boolean — segmented control affordance (shared borders, active item z-indexed above neighbors) - block: boolean — items grow to fill the inline-size - iconOnly: per-item flag — square aspect, sr-only label Items consume the Toggle palette token vocabulary (--toggle-*) so a standalone and a at the same variant × color × size are visually identical — single source of truth for the "toggle button" recipe. Morfo scope fix: ['soma'] → ['soma', 'sema', 'eidos']. Adds apg URL (WAI-ARIA Button pattern) so the audit's A-1.4 rule passes. The commit-toggle event's sound comes from the `commit` family base — no per-component cascade needed. Demo: canonical 6-tab template (Live / API / Morfo / Sema / Recipe / A11y) with every prop exposed as a chip/switch, soma + eidos snippets update live, sema "▶ play" button on the commit-toggle event. Reference comparison vs Radix Primitives / Bits UI / Ark UI / Chakra v3 / Mantine / Radix Themes / shadcn documented in README §Comparativa. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../eidos/components/toggle-group/README.md | 121 ++++ .../eidos/components/toggle-group/index.ts | 32 + .../toggle-group/toggle-group-item.svelte | 19 + .../components/toggle-group/toggle-group.css | 296 +++++++++ .../toggle-group/toggle-group.svelte | 46 ++ .../eidos/components/toggle-group/types.ts | 64 ++ src/uix/eidos/index.css | 1 + src/uix/morfo/components/toggle-group.ts | 6 +- .../uix/components/toggle-group/+page.svelte | 622 ++++++++++++++++++ 9 files changed, 1206 insertions(+), 1 deletion(-) create mode 100644 src/uix/eidos/components/toggle-group/README.md create mode 100644 src/uix/eidos/components/toggle-group/index.ts create mode 100644 src/uix/eidos/components/toggle-group/toggle-group-item.svelte create mode 100644 src/uix/eidos/components/toggle-group/toggle-group.css create mode 100644 src/uix/eidos/components/toggle-group/toggle-group.svelte create mode 100644 src/uix/eidos/components/toggle-group/types.ts create mode 100644 web/routes/uix/components/toggle-group/+page.svelte diff --git a/src/uix/eidos/components/toggle-group/README.md b/src/uix/eidos/components/toggle-group/README.md new file mode 100644 index 000000000..0cc295db2 --- /dev/null +++ b/src/uix/eidos/components/toggle-group/README.md @@ -0,0 +1,121 @@ +# `` — eidos + +Visual wrapper over `soma/components/toggle-group`. Multiple toggle +buttons that share a single selection model — `type="single"` +(radio-like, deselectable) or `type="multiple"` (checkbox-like). + +## Usage + +```svelte + + + + B + I + U + +``` + +## Eidos-layer props + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `variant` | `'solid' \| 'outline' \| 'ghost'` | `'solid'` | Visual treatment. Same vocabulary as ``. | +| `color` | `'primary' \| 'neutral' \| 'affirm' \| 'risk' \| 'threat' \| 'absent' \| 'unknown'` | `'neutral'` | Palette tone, propagated to items. | +| `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` (responsive) | `'md'` | Item dimensions. | +| `attached` | `boolean` | `false` | Items share borders → segmented control. | +| `block` | `boolean` | `false` | Items grow to fill the container's inline-size. | + +## Item-level props + +| Prop | Type | Notes | +|---|---|---| +| `value` | `string` | Required — selection key. | +| `disabled` | `boolean` | Disables this item only. | +| `iconOnly` | `boolean` | Square aspect, label sr-only. | + +## Headless props (forwarded to soma) + +`type`, `value`, `onValueChange`, `disabled`, `orientation`, +`loop`, `dir`, `aria-label`, `aria-labelledby`. See +`src/uix/soma/components/toggle-group/types.ts`. + +> `aria-label` or `aria-labelledby` is REQUIRED. `role="group"` +> needs an accessible name per APG. + +## Sema + +The morfo emits `commit-toggle` on each item press (family `commit`, +intent `fromProp:intent`, sequence `post`). The `commit` family base +ships a sound payload — no per-component cascade needed. + +## Recipe + +`toggle-group.css`. Consumes the Toggle token vocabulary +(`--toggle-*`) so item visuals stay coherent with standalone +`` buttons. `data-attached`, `data-block`, `data-orientation`, +`data-size`, `data-variant`, `data-color` cascade from the root to +the items via descendant selectors. + +## Baseline + +WAI-ARIA APG: +(`role="group"` containing toggle buttons with `aria-pressed`). + +The group itself needs an accessible name (`aria-label` or +`aria-labelledby`) per APG. Soma warns at mount in dev when both are +missing. + +## Comparativa + +| Lib | Selection | Variant | Color | Attached | Block | Notes | +|---|---|---|---|---|---|---| +| **Radix Primitives** | `type='single'\|'multiple'` | — | — | — | — | Headless. | +| **Bits UI** | same | — | — | — | — | Pass-through of Radix. | +| **Ark UI** | same | — | — | — | — | Headless. | +| **Chakra v3** | `value`/`onChange` array | `solid\|outline\|ghost\|subtle` | `colorPalette` | `isAttached` | — | Adopts segmented control affordance. | +| **Mantine** | `multiple` boolean | `default\|filled\|light\|outline\|subtle` | `color` | — | `fullWidth` | t-shirt size scale. | +| **Radix Themes** | same | `surface\|classic\|soft` | `color` | — | — | Recipe-level wrapper. | +| **shadcn/ui** | inherits Radix | `default\|outline` | — | — | — | Tailwind utility composition. | +| **Eidos (this)** | `type` (Radix) | `solid\|outline\|ghost` | `data-color` (7 tones) | `attached` (Chakra) | `block` (Mantine) | Shares Toggle tokens. | + +## Decisiones + +- `variant` mirrors ``'s three-value scale, not Chakra's + four-value (no `subtle` — we'd reuse `ghost` for that). +- `attached` borrows Chakra's segmented affordance. Items collapse + spacing + share borders, pressed item z-indexed above neighbors + so the active border stays crisp. +- Items consume the **Toggle palette tokens** so a stray `` + next to a `` looks visually identical at the same + `variant` × `color` × `size`. Single source of truth for the + "toggle button" vocabulary. +- No re-bindable `pressed` per item — the value array on the group + is the source of truth (Radix-style). + +## Gaps + +| Gap | Disposición | Detalle | +| --- | --- | --- | +| No `loop` chip in demo | **implementar** | Soma soporta `loop` (boolean) y el demo debería tener un chip on/off. | +| No `orientation='vertical'` visual stress test | **implementar** | El recipe ya lo cubre; falta expone en el demo. | +| `color="absent"` / `"unknown"` palette pass | **diferir** | Los tokens se emiten pero raramente se usan para toggle buttons; revisión visual cuando llegue caso. | +| Per-item `intent` override | **diferir** | El morfo declara `intent: fromProp` en el item; activar requiere pasar prop al wrapper. Esperar caso. | + +## Reference + +- Radix UI Primitives: +- Bits UI: +- Ark UI: +- Chakra v3: +- Mantine: diff --git a/src/uix/eidos/components/toggle-group/index.ts b/src/uix/eidos/components/toggle-group/index.ts new file mode 100644 index 000000000..3dc3b74f6 --- /dev/null +++ b/src/uix/eidos/components/toggle-group/index.ts @@ -0,0 +1,32 @@ +// ToggleGroup — eidos compound API, disciplined option C. +// +// import { ToggleGroup } from '$uix/eidos/components/toggle-group'; +// +// +// B +// I +// U +// +import ToggleGroupComponent from './toggle-group.svelte'; +import Item from './toggle-group-item.svelte'; + +type ToggleGroupNamespace = typeof ToggleGroupComponent & { + Item: typeof Item; +}; + +const ToggleGroup = ToggleGroupComponent as ToggleGroupNamespace; +ToggleGroup.Item = Item; + +export { ToggleGroup }; + +export default ToggleGroup; + +export type { + ToggleGroupProps, + ToggleGroupItemProps as ItemProps, + ToggleGroupType, + ToggleGroupVariant, + ToggleGroupColor, + ToggleGroupSize +} from './types'; diff --git a/src/uix/eidos/components/toggle-group/toggle-group-item.svelte b/src/uix/eidos/components/toggle-group/toggle-group-item.svelte new file mode 100644 index 000000000..2822818a6 --- /dev/null +++ b/src/uix/eidos/components/toggle-group/toggle-group-item.svelte @@ -0,0 +1,19 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/toggle-group/toggle-group.css b/src/uix/eidos/components/toggle-group/toggle-group.css new file mode 100644 index 000000000..f2b04f00e --- /dev/null +++ b/src/uix/eidos/components/toggle-group/toggle-group.css @@ -0,0 +1,296 @@ +/* + * ToggleGroup recipe. + * + * Visually each item IS a Toggle — same height / padding / radius + * tokens, same variant + palette vocabulary. The group adds: + * + * - Layout: flex container, orientation-aware direction, gap + * - `data-attached`: items collapse spacing + share borders so the + * group reads as a single segmented control + * - `data-block`: items grow equally to fill the inline-size + * - Disabled cascade: a single `disabled` on the group disables all + * items (the morfo already projects `data-disabled` on each item). + * + * [data-toggle-group] → flex container + * [data-toggle-group-item] → button (Toggle-shaped) + */ + +[data-toggle-group] { + display: inline-flex; + flex-direction: row; + gap: var(--toggle-group-gap, var(--space-1)); + align-items: stretch; +} + +[data-toggle-group][data-orientation='vertical'] { + flex-direction: column; + align-items: stretch; +} + +[data-toggle-group][data-block] { + display: flex; + inline-size: 100%; +} + +[data-toggle-group][data-block] [data-toggle-group-item] { + flex: 1 1 auto; + min-inline-size: 0; +} + +/* Attached mode — segmented control look. Items share edges, only the + * outermost corners stay rounded. */ +[data-toggle-group][data-attached] { + gap: 0; +} + +[data-toggle-group][data-attached] [data-toggle-group-item] { + border-radius: 0; +} + +[data-toggle-group][data-attached][data-orientation='horizontal'] + [data-toggle-group-item]:first-of-type { + border-start-start-radius: var(--_toggle-radius); + border-end-start-radius: var(--_toggle-radius); +} + +[data-toggle-group][data-attached][data-orientation='horizontal'] + [data-toggle-group-item]:last-of-type { + border-start-end-radius: var(--_toggle-radius); + border-end-end-radius: var(--_toggle-radius); +} + +[data-toggle-group][data-attached][data-orientation='horizontal'] + [data-toggle-group-item]:not(:first-of-type) { + margin-inline-start: calc(var(--toggle-border-width) * -1); +} + +[data-toggle-group][data-attached][data-orientation='vertical'] + [data-toggle-group-item]:first-of-type { + border-start-start-radius: var(--_toggle-radius); + border-start-end-radius: var(--_toggle-radius); +} + +[data-toggle-group][data-attached][data-orientation='vertical'] + [data-toggle-group-item]:last-of-type { + border-end-start-radius: var(--_toggle-radius); + border-end-end-radius: var(--_toggle-radius); +} + +[data-toggle-group][data-attached][data-orientation='vertical'] + [data-toggle-group-item]:not(:first-of-type) { + margin-block-start: calc(var(--toggle-border-width) * -1); +} + +/* Pressed item floats above its neighbors so the active border isn't + * clipped by the attached overlap. */ +[data-toggle-group][data-attached] [data-toggle-group-item][data-state='on'] { + position: relative; + z-index: 1; +} + +/* ── Item ──────────────────────────────────────────────────────────── + * + * Items consume the Toggle palette tokens (one source of truth for the + * "toggle button" visual vocabulary). `data-variant` / `data-color` / + * `data-size` are read from the ancestor group via descendant + * selectors below. + */ + +[data-toggle-group-item] { + --_toggle-height: var(--toggle-height-md); + --_toggle-padding-inline: var(--toggle-px-md); + --_toggle-gap: var(--toggle-gap-md); + --_toggle-font-size: var(--toggle-font-size-md); + --_toggle-font-weight: var(--toggle-font-weight-md); + --_toggle-radius: var(--toggle-radius-md); + --toggle-palette-track: var(--toggle-color-neutral-track); + --toggle-palette-element: var(--toggle-color-neutral-element); + --toggle-palette-hover: var(--toggle-color-neutral-hover); + --toggle-palette-border: var(--toggle-color-neutral-border); + --toggle-palette-solid: var(--toggle-color-neutral-solid); + --toggle-palette-solid-hover: var(--toggle-color-neutral-solid-hover); + --toggle-palette-text: var(--toggle-color-neutral-text); + --toggle-palette-contrast: var(--toggle-color-neutral-contrast); + --_toggle-bg: var(--toggle-solid-bg); + --_toggle-fg: var(--toggle-solid-fg); + --_toggle-border: var(--toggle-solid-border); + --_toggle-hover-bg: var(--toggle-solid-hover-bg); + --_toggle-hover-border: var(--toggle-solid-hover-border); + --_toggle-on-bg: var(--toggle-solid-on-bg); + --_toggle-on-fg: var(--toggle-solid-on-fg); + --_toggle-on-border: var(--toggle-solid-on-border); + --_toggle-on-hover-bg: var(--toggle-solid-on-hover-bg); + --_toggle-on-hover-border: var(--toggle-solid-on-hover-border); + + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--_toggle-gap); + min-height: var(--_toggle-height); + padding-inline: var(--_toggle-padding-inline); + border: var(--toggle-border-width) solid var(--_toggle-border); + border-radius: var(--_toggle-radius); + background: var(--_toggle-bg); + color: var(--_toggle-fg); + box-shadow: var(--toggle-shadow); + font-family: var(--toggle-font-family); + font-size: var(--_toggle-font-size); + font-weight: var(--_toggle-font-weight); + line-height: var(--toggle-line-height); + letter-spacing: var(--toggle-letter-spacing); + white-space: nowrap; + cursor: pointer; + transition: + background var(--toggle-transition-duration) var(--toggle-transition-ease), + border-color var(--toggle-transition-duration) var(--toggle-transition-ease), + color var(--toggle-transition-duration) var(--toggle-transition-ease), + box-shadow var(--toggle-transition-duration) var(--toggle-transition-ease); +} + +[data-toggle-group-item]:hover:not([data-disabled]) { + background: var(--_toggle-hover-bg); + border-color: var(--_toggle-hover-border); +} + +[data-toggle-group-item][data-state='on'] { + background: var(--_toggle-on-bg); + color: var(--_toggle-on-fg); + border-color: var(--_toggle-on-border); +} + +[data-toggle-group-item][data-state='on']:hover:not([data-disabled]) { + background: var(--_toggle-on-hover-bg); + border-color: var(--_toggle-on-hover-border); +} + +[data-toggle-group-item]:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + z-index: 2; +} + +[data-toggle-group-item][data-disabled] { + cursor: default; + opacity: var(--toggle-disabled-opacity, 0.55); +} + +[data-toggle-group-item][data-icon-only] { + padding-inline: 0; + inline-size: var(--_toggle-height); +} + +/* ── Size cascade — items inherit data-size from the group ───────── */ +[data-toggle-group][data-size='xs'] [data-toggle-group-item] { + --_toggle-height: var(--toggle-height-xs); + --_toggle-padding-inline: var(--toggle-px-xs); + --_toggle-gap: var(--toggle-gap-xs); + --_toggle-font-size: var(--toggle-font-size-xs); + --_toggle-font-weight: var(--toggle-font-weight-xs); + --_toggle-radius: var(--toggle-radius-xs); +} + +[data-toggle-group][data-size='sm'] [data-toggle-group-item] { + --_toggle-height: var(--toggle-height-sm); + --_toggle-padding-inline: var(--toggle-px-sm); + --_toggle-gap: var(--toggle-gap-sm); + --_toggle-font-size: var(--toggle-font-size-sm); + --_toggle-font-weight: var(--toggle-font-weight-sm); + --_toggle-radius: var(--toggle-radius-sm); +} + +[data-toggle-group][data-size='lg'] [data-toggle-group-item] { + --_toggle-height: var(--toggle-height-lg); + --_toggle-padding-inline: var(--toggle-px-lg); + --_toggle-gap: var(--toggle-gap-lg); + --_toggle-font-size: var(--toggle-font-size-lg); + --_toggle-font-weight: var(--toggle-font-weight-lg); + --_toggle-radius: var(--toggle-radius-lg); +} + +[data-toggle-group][data-size='xl'] [data-toggle-group-item] { + --_toggle-height: var(--toggle-height-xl); + --_toggle-padding-inline: var(--toggle-px-xl); + --_toggle-gap: var(--toggle-gap-xl); + --_toggle-font-size: var(--toggle-font-size-xl); + --_toggle-font-weight: var(--toggle-font-weight-xl); + --_toggle-radius: var(--toggle-radius-xl); +} + +/* ── Variant cascade ─────────────────────────────────────────────── */ + +[data-toggle-group][data-variant='outline'] [data-toggle-group-item] { + --_toggle-bg: var(--toggle-outline-bg); + --_toggle-fg: var(--toggle-outline-fg); + --_toggle-border: var(--toggle-outline-border); + --_toggle-hover-bg: var(--toggle-outline-hover-bg); + --_toggle-hover-border: var(--toggle-outline-hover-border); + --_toggle-on-bg: var(--toggle-outline-on-bg); + --_toggle-on-fg: var(--toggle-outline-on-fg); + --_toggle-on-border: var(--toggle-outline-on-border); + --_toggle-on-hover-bg: var(--toggle-outline-on-hover-bg); + --_toggle-on-hover-border: var(--toggle-outline-on-hover-border); +} + +[data-toggle-group][data-variant='ghost'] [data-toggle-group-item] { + --_toggle-bg: var(--toggle-ghost-bg); + --_toggle-fg: var(--toggle-ghost-fg); + --_toggle-border: var(--toggle-ghost-border); + --_toggle-hover-bg: var(--toggle-ghost-hover-bg); + --_toggle-hover-border: var(--toggle-ghost-hover-border); + --_toggle-on-bg: var(--toggle-ghost-on-bg); + --_toggle-on-fg: var(--toggle-ghost-on-fg); + --_toggle-on-border: var(--toggle-ghost-on-border); + --_toggle-on-hover-bg: var(--toggle-ghost-on-hover-bg); + --_toggle-on-hover-border: var(--toggle-ghost-on-hover-border); +} + +/* ── Color cascade — switches the palette tokens ───────────────── */ + +[data-toggle-group][data-color='primary'] [data-toggle-group-item] { + --toggle-palette-track: var(--toggle-color-primary-track); + --toggle-palette-element: var(--toggle-color-primary-element); + --toggle-palette-hover: var(--toggle-color-primary-hover); + --toggle-palette-border: var(--toggle-color-primary-border); + --toggle-palette-solid: var(--toggle-color-primary-solid); + --toggle-palette-solid-hover: var(--toggle-color-primary-solid-hover); + --toggle-palette-text: var(--toggle-color-primary-text); + --toggle-palette-contrast: var(--toggle-color-primary-contrast); +} + +[data-toggle-group][data-color='affirm'] [data-toggle-group-item] { + --toggle-palette-track: var(--toggle-color-affirm-track); + --toggle-palette-element: var(--toggle-color-affirm-element); + --toggle-palette-hover: var(--toggle-color-affirm-hover); + --toggle-palette-border: var(--toggle-color-affirm-border); + --toggle-palette-solid: var(--toggle-color-affirm-solid); + --toggle-palette-solid-hover: var(--toggle-color-affirm-solid-hover); + --toggle-palette-text: var(--toggle-color-affirm-text); + --toggle-palette-contrast: var(--toggle-color-affirm-contrast); +} + +[data-toggle-group][data-color='risk'] [data-toggle-group-item] { + --toggle-palette-track: var(--toggle-color-risk-track); + --toggle-palette-element: var(--toggle-color-risk-element); + --toggle-palette-hover: var(--toggle-color-risk-hover); + --toggle-palette-border: var(--toggle-color-risk-border); + --toggle-palette-solid: var(--toggle-color-risk-solid); + --toggle-palette-solid-hover: var(--toggle-color-risk-solid-hover); + --toggle-palette-text: var(--toggle-color-risk-text); + --toggle-palette-contrast: var(--toggle-color-risk-contrast); +} + +[data-toggle-group][data-color='threat'] [data-toggle-group-item] { + --toggle-palette-track: var(--toggle-color-threat-track); + --toggle-palette-element: var(--toggle-color-threat-element); + --toggle-palette-hover: var(--toggle-color-threat-hover); + --toggle-palette-border: var(--toggle-color-threat-border); + --toggle-palette-solid: var(--toggle-color-threat-solid); + --toggle-palette-solid-hover: var(--toggle-color-threat-solid-hover); + --toggle-palette-text: var(--toggle-color-threat-text); + --toggle-palette-contrast: var(--toggle-color-threat-contrast); +} + +[data-toggle-group][data-disabled] { + opacity: var(--toggle-disabled-opacity, 0.55); + pointer-events: none; +} diff --git a/src/uix/eidos/components/toggle-group/toggle-group.svelte b/src/uix/eidos/components/toggle-group/toggle-group.svelte new file mode 100644 index 000000000..f0d660657 --- /dev/null +++ b/src/uix/eidos/components/toggle-group/toggle-group.svelte @@ -0,0 +1,46 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/toggle-group/types.ts b/src/uix/eidos/components/toggle-group/types.ts new file mode 100644 index 000000000..0348745c4 --- /dev/null +++ b/src/uix/eidos/components/toggle-group/types.ts @@ -0,0 +1,64 @@ +import type { ProviderProps, ItemProps, ToggleGroupType } from '$soma/components/toggle-group'; +import type { ResponsiveProp, SelectionVariant, Size } from '$uix/eidos/lib/types'; + +/** + * Eidos `` — visual wrapper over the headless soma + * primitive. Compound shape mirrors Radix `ToggleGroup`: + * + * + * B + * I + * U + * + * + * Visual concerns: `variant` (solid / outline / ghost), `size`, `color`, + * `attached` (shared borders à la `Group`). The single-vs-multiple + * selection logic, keyboard navigation, and `commit-toggle` semantic + * event live in soma. + */ + +/** Toggle group visual variants. Mirrors {@link ToggleVariant}. */ +export type ToggleGroupVariant = SelectionVariant; + +/** Color tone the group's recipe propagates to its items via `data-color`. */ +export type ToggleGroupColor = + | 'primary' + | 'neutral' + | 'affirm' + | 'risk' + | 'threat' + | 'absent' + | 'unknown'; + +/** + * Sizing subset toggle-group exposes. Matches {@link ToggleSize}, narrowed + * to the t-shirt steps the recipe maps to tokens. + */ +export type ToggleGroupSize = Extract; + +/** Selection mode — single (radio-like, deselectable) vs multiple (checkbox-like). */ +export type { ToggleGroupType }; + +export type ToggleGroupProps = ProviderProps & { + /** Visual variant. @default 'solid' */ + variant?: ToggleGroupVariant; + /** Color tone — propagates to items as `data-color`. @default 'neutral' */ + color?: ToggleGroupColor; + /** Sizing scale. @default 'md' */ + size?: ResponsiveProp; + /** + * Items share borders / collapse spacing, presenting as a single + * segmented control. @default false + */ + attached?: boolean; + /** Stretch to fill the container's inline-size. @default false */ + block?: boolean; +}; + +export type ToggleGroupItemProps = ItemProps & { + /** + * Render as an icon-only button (square aspect, sr-only label). + * @default false + */ + iconOnly?: boolean; +}; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 92c03a45f..e1a7f2387 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -100,6 +100,7 @@ @import './components/number-field/number-field.css'; @import './components/pagination/pagination.css'; @import './components/toggle/toggle.css'; +@import './components/toggle-group/toggle-group.css'; @import './components/switch/switch.css'; @import './components/collapsible/collapsible.css'; @import './components/accordion/accordion.css'; diff --git a/src/uix/morfo/components/toggle-group.ts b/src/uix/morfo/components/toggle-group.ts index d6aa2f698..b01131260 100644 --- a/src/uix/morfo/components/toggle-group.ts +++ b/src/uix/morfo/components/toggle-group.ts @@ -4,7 +4,11 @@ import { v } from '../types'; export const toggleGroupMorfo = { name: 'ToggleGroup', kebab: 'toggle-group', - scope: ['soma'], + // Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/toggle-group/`). + // Sema picks up the `commit-toggle` event via the `commit` family base — + // no per-component cascade needed (commit ships sound out of the box). + scope: ['soma', 'sema', 'eidos'], + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/', texts: { label: '#?components.toggle-group.label|Toggle group' }, diff --git a/web/routes/uix/components/toggle-group/+page.svelte b/web/routes/uix/components/toggle-group/+page.svelte new file mode 100644 index 000000000..b1a67944b --- /dev/null +++ b/web/routes/uix/components/toggle-group/+page.svelte @@ -0,0 +1,622 @@ + + +
+
+
Form · ToggleGroup
+

ToggleGroup

+

+ Multiple toggle buttons sharing one selection model. + type="single" behaves radio-like (deselectable); + type="multiple" behaves checkbox-like. Headless + soma + owns selection state, keyboard navigation, and the + commit-toggle perceptual event; Eidos paints chrome — + variant, color, size, + attached (segmented control affordance) and + block (stretch to fill). +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + type{type} + + + selected{value.length} + +
+
+ +
+
+ + {#each items as item (item.value)} + + {item.glyph} + + {/each} + +
+
+ trace + {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} + {#if trace[0]} + · + last + {trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)} + {/if} + + value + [{value.map((v) => `"${v}"`).join(', ')}] + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Knobs split by layer — soma owns + selection/keyboard/disabled, eidos owns chrome + (variant/color/size/attached/block/iconOnly). +

+ +
+ soma props · selection + a11y +
+
+ + + + + + +
+ +
+ eidos props · chrome +
+
+ + + + + + +
+ +
+
+ soma + headless · selection + keyboard + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · variant + size + attached + block + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ Two parts — ToggleGroup (provider/container) and + ToggleGroup.Item (button). Soma drives selection, + eidos drives visual treatment. +

+
ToggleGroup (root)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultNotes
type soma'single' | 'multiple''single'Selection mode.
value somastring[][]Bindable array of selected item values.
disabled somabooleanfalseDisables all items.
orientation soma'horizontal' | 'vertical''horizontal'Arrow-key direction.
loop somabooleantrueWrap arrow-key navigation at boundaries.
aria-label somastring— + Required unless aria-labelledby is set. +
variant eidos'solid' | 'outline' | 'ghost''solid'Visual treatment. Mirrors Toggle.
color eidosprimary | neutral | affirm | risk | threat | absent | unknown'neutral'Propagates to items via data-color.
size eidos'xs' | 'sm' | 'md' | 'lg' | 'xl''md'T-shirt scale, responsive.
attached eidosbooleanfalseSegmented control — shared borders.
block eidosbooleanfalseStretch items to fill the container.
+
+ +
ToggleGroup.Item
+
+ + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultNotes
value somastring—Required. Item identifier.
disabled somabooleanfalseDisables this item only.
iconOnly eidosbooleanfalseSquare aspect, label sr-only.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+

+ Compiled from src/uix/morfo/components/toggle-group.ts. +

+
Parts ({partsList.length})
+
+ + + + {#each partsList as part} + {@const partAny = part as unknown as Record} + + + + + + + {/each} + +
KebabRoleMarkerStates
{partAny.kebab}{partAny.role ?? '—'}{partAny.marker ?? '—'} + {#if Array.isArray(partAny.states) && partAny.states.length} + {(partAny.states as string[]).join(', ')} + {:else}—{/if} +
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema + · events + perceptual signature +

+

+ One event: commit-toggle from the + commit family. Each item press fires it with + sequence post — the perceptual signal lands after + the state has flipped. Intent is per-item via + fromProp:intent. +

+
+ + + + {#each events as action} + {@const sem = action.semantic} + {@const intentDecl = 'intent' in sem ? sem.intent : undefined} + {@const effectiveIntent = + typeof intentDecl === 'string' + ? intentDecl + : intentDecl && typeof intentDecl === 'object' && 'default' in intentDecl + ? (intentDecl as { default: string }).default + : undefined} + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentPlay
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{effectiveIntent ?? '—'} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe at src/uix/eidos/components/toggle-group/toggle-group.css. + Items consume the Toggle palette tokens so visuals stay + coherent across <Toggle> and + <ToggleGroup.Item>. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-toggle-group]morfoFlex container, orientation-aware direction.
[data-toggle-group][data-attached]eidosCollapsed gap + shared borders (segmented control).
[data-toggle-group][data-block]eidosItems flex: 1; group inline-size: 100%.
[data-toggle-group-item]morfoButton — inherits Toggle's height/padding/radius/font tokens.
[data-toggle-group-item][data-state='on']morfoPressed visual — switches to --_toggle-on-* palette slice.
[data-toggle-group][data-variant='outline'] [data-toggle-group-item]eidosCascades the variant slice from root to items.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

A11y contract

+

+ Group exposes role="group" with the required + accessible name (warns in dev when missing). Items are + <button> with + aria-pressed and arrow-key navigation. + Pattern: WAI-ARIA Button. +

+
+ + + + + + + + + + +
KeyEffect
ArrowRight / ArrowDownMove focus to next item.
ArrowLeft / ArrowUpMove focus to previous item.
HomeMove focus to first item.
EndMove focus to last item.
Space / EnterToggle the focused item.
TabMove focus out of the group.
+
+
+ {/if} +