feat(toggle-group): port from soma to eidos (Tier 1 sprint, 1/5)

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 <Toggle>
- 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 <Toggle> and a <ToggleGroup.Item> 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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 4324cb489d
commit 61ffbade46

@ -0,0 +1,121 @@
# `<ToggleGroup>` — 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
<script lang="ts">
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
let value = $state<string[]>(['bold']);
</script>
<ToggleGroup
type="single"
variant="outline"
size="md"
attached
bind:value
aria-label="Text format"
>
<ToggleGroup.Item value="bold">B</ToggleGroup.Item>
<ToggleGroup.Item value="italic">I</ToggleGroup.Item>
<ToggleGroup.Item value="underline">U</ToggleGroup.Item>
</ToggleGroup>
```
## Eidos-layer props
| Prop | Type | Default | Notes |
|---|---|---|---|
| `variant` | `'solid' \| 'outline' \| 'ghost'` | `'solid'` | Visual treatment. Same vocabulary as `<Toggle>`. |
| `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
`<Toggle>` 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: <https://www.w3.org/WAI/ARIA/apg/patterns/button/>
(`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 `<Toggle>`'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 `<Toggle>`
next to a `<ToggleGroup>` 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: <https://www.radix-ui.com/primitives/docs/components/toggle-group>
- Bits UI: <https://bits-ui.com/docs/components/toggle-group>
- Ark UI: <https://ark-ui.com/docs/components/toggle-group>
- Chakra v3: <https://chakra-ui.com/docs/components/toggle-group>
- Mantine: <https://mantine.dev/core/segmented-control/>

@ -0,0 +1,32 @@
// ToggleGroup — eidos compound API, disciplined option C.
//
// import { ToggleGroup } from '$uix/eidos/components/toggle-group';
//
// <ToggleGroup type="single" variant="outline" size="md" attached
// bind:value aria-label="Text format">
// <ToggleGroup.Item value="bold">B</ToggleGroup.Item>
// <ToggleGroup.Item value="italic">I</ToggleGroup.Item>
// <ToggleGroup.Item value="underline">U</ToggleGroup.Item>
// </ToggleGroup>
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';

@ -0,0 +1,19 @@
<script lang="ts">
/**
* Eidos `<ToggleGroup.Item>`. Visual styling for the item lives in
* `toggle-group.css` and reads `data-variant` / `data-color` /
* `data-size` from the ancestor root (set by `<ToggleGroup>`).
*
* The morfo emits `data-state="on"|"off"`, `data-disabled` and
* `data-value` on the item — the recipe consumes those for the
* pressed-state visuals.
*/
import * as ToggleGroup from '$soma/components/toggle-group';
import type { ToggleGroupItemProps } from './types';
let { iconOnly = false, children, ...rest }: ToggleGroupItemProps = $props();
</script>
<ToggleGroup.Item {...rest} data-icon-only={iconOnly ? '' : undefined}>
{@render children?.()}
</ToggleGroup.Item>

@ -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;
}

@ -0,0 +1,46 @@
<script lang="ts">
/**
* Eidos `<ToggleGroup>` — root.
*
* <ToggleGroup type="single" variant="outline" size="md" attached
* bind:value aria-label="Text format">
* <ToggleGroup.Item value="bold">B</ToggleGroup.Item>
* <ToggleGroup.Item value="italic">I</ToggleGroup.Item>
* </ToggleGroup>
*
* Soma owns: selection type, value, keyboard nav, disabled cascade,
* `commit-toggle` perceptual event. Eidos adds: variant / color /
* size / attached / block recipe knobs as `data-*` on the root, which
* the CSS recipe consumes to style the children via attribute
* descendant selectors.
*/
import { ActiveEidos } from '$uix/eidos';
import * as ToggleGroup from '$soma/components/toggle-group';
import type { ToggleGroupProps } from './types';
let {
variant = 'solid',
color = 'neutral',
size = 'md',
attached = false,
block = false,
value = $bindable([]),
children,
...rest
}: ToggleGroupProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<ToggleGroup.Provider
{...rest}
bind:value
data-variant={variant}
data-color={color}
data-size={resolvedSize}
data-attached={attached ? '' : undefined}
data-block={block ? '' : undefined}
>
{@render children?.()}
</ToggleGroup.Provider>

@ -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 `<ToggleGroup>` — visual wrapper over the headless soma
* primitive. Compound shape mirrors Radix `ToggleGroup`:
*
* <ToggleGroup type="single" bind:value aria-label="Text format">
* <ToggleGroup.Item value="bold">B</ToggleGroup.Item>
* <ToggleGroup.Item value="italic">I</ToggleGroup.Item>
* <ToggleGroup.Item value="underline">U</ToggleGroup.Item>
* </ToggleGroup>
*
* 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<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
/** 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<ToggleGroupSize>;
/**
* 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;
};

@ -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';

@ -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'
},

@ -0,0 +1,622 @@
<script lang="ts">
import {
ToggleGroup,
type ToggleGroupSize,
type ToggleGroupVariant,
type ToggleGroupColor,
type ToggleGroupType
} from '$uix/eidos/components/toggle-group';
import type { Orientation } from '$soma/types';
import { compileMorfo } from '$uix/morfo';
import { toggleGroupMorfo } from '@/uix/morfo/components/toggle-group';
import { getActiveUix } from '$active-uix';
import { page } from '$app/state';
const uix = getActiveUix();
const params = page.url.searchParams;
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ──────────────────────────────────────────────────
const types: ToggleGroupType[] = ['single', 'multiple'];
const variants: ToggleGroupVariant[] = ['solid', 'outline', 'ghost'];
const colors: ToggleGroupColor[] = [
'primary',
'neutral',
'affirm',
'risk',
'threat',
'absent',
'unknown'
];
const sizes: ToggleGroupSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const orientations: Orientation[] = ['horizontal', 'vertical'];
let type = $state<ToggleGroupType>('single');
let value = $state<string[]>(['bold']);
let variant = $state<ToggleGroupVariant>('outline');
let color = $state<ToggleGroupColor>('neutral');
let size = $state<ToggleGroupSize>('md');
let orientation = $state<Orientation>('horizontal');
let attached = $state(true);
let block = $state(false);
let disabled = $state(params.get('perm.disabled') === 'true');
let loop = $state(true);
let iconOnly = $state(false);
let italicDisabled = $state(false);
let ariaLabel = $state('Text format');
const items = $derived([
{ value: 'bold', label: 'Bold', glyph: 'B' },
{ value: 'italic', label: 'Italic', glyph: 'I', disabled: italicDisabled },
{ value: 'underline', label: 'Underline', glyph: 'U' },
{ value: 'strike', label: 'Strikethrough', glyph: 'S' }
]);
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();
});
const compiled = compileMorfo(toggleGroupMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
// ── Snippets ────────────────────────────────────────────────────
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as ToggleGroup from '$soma/components/toggle-group';",
' let value = $state<string[]>([\'bold\']);',
'</' + 'script>',
'',
'<ToggleGroup.Provider',
type !== 'single' && ` type="${type}"`,
' bind:value',
disabled && ' disabled',
!loop && ' loop={false}',
orientation !== 'horizontal' && ` orientation="${orientation}"`,
` aria-label="${ariaLabel}"`,
'>',
...items.map(
(it) =>
` <ToggleGroup.Item value="${it.value}"${it.disabled ? ' disabled' : ''}>${it.glyph}</ToggleGroup.Item>`
),
'</ToggleGroup.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { ToggleGroup } from '$uix/eidos/components/toggle-group';",
' let value = $state<string[]>([\'bold\']);',
'</' + 'script>',
'',
'<ToggleGroup',
type !== 'single' && ` type="${type}"`,
' bind:value',
variant !== 'solid' && ` variant="${variant}"`,
color !== 'neutral' && ` color="${color}"`,
size !== 'md' && ` size="${size}"`,
orientation !== 'horizontal' && ` orientation="${orientation}"`,
attached && ' attached',
block && ' block',
disabled && ' disabled',
!loop && ' loop={false}',
` aria-label="${ariaLabel}"`,
'>',
...items.map(
(it) =>
` <ToggleGroup.Item value="${it.value}"${it.disabled ? ' disabled' : ''}${iconOnly ? ' iconOnly' : ''}>${it.glyph}</ToggleGroup.Item>`
),
'</ToggleGroup>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Form · ToggleGroup</div>
<h1 data-uix-page-title>ToggleGroup</h1>
<p data-uix-page-lede>
Multiple toggle buttons sharing one selection model.
<code>type="single"</code> behaves radio-like (deselectable);
<code>type="multiple"</code> behaves checkbox-like. Headless
<span data-uix-layer-badge="soma">soma</span>
owns selection state, keyboard navigation, and the
<code>commit-toggle</code> perceptual event; Eidos paints chrome —
<code>variant</code>, <code>color</code>, <code>size</code>,
<code>attached</code> (segmented control affordance) and
<code>block</code> (stretch to fill).
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>type</span>{type}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>selected</span>{value.length}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<ToggleGroup
{type}
bind:value
{variant}
{color}
{size}
{orientation}
{attached}
{block}
{disabled}
{loop}
aria-label={ariaLabel}
>
{#each items as item (item.value)}
<ToggleGroup.Item value={item.value} disabled={item.disabled} {iconOnly}>
{item.glyph}
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
{#if trace[0]}
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>last</span>
<span>{trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)}</span>
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>value</span>
[{value.map((v) => `"${v}"`).join(', ')}]
</span>
</div>
</div>
<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 <span data-uix-tab-count>2</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</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>
<p data-uix-section-desc>
Knobs split by layer — <span data-uix-layer-badge="soma">soma</span> owns
selection/keyboard/disabled, <span data-uix-layer-badge="eidos">eidos</span> owns chrome
(variant/color/size/attached/block/iconOnly).
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · selection + a11y
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>type</span>
<span data-uix-chips role="radiogroup">
{#each types as opt}
<button data-uix-chip data-active={type === opt} onclick={() => (type = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each orientations as opt}
<button data-uix-chip data-active={orientation === opt} onclick={() => (orientation = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={disabled} /> disabled
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={loop} /> loop arrow keys
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={italicDisabled} /> disable "Italic" item
</label>
<label data-uix-control>
<span data-uix-control-label>aria-label</span>
<input type="text" bind:value={ariaLabel} />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · chrome
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as opt}
<button data-uix-chip data-active={variant === opt} onclick={() => (variant = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as opt}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as opt}
<button data-uix-chip data-active={size === opt} onclick={() => (size = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={attached} /> attached (segmented)
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={block} /> block (stretch)
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={iconOnly} /> iconOnly (square)
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · selection + keyboard</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · variant + size + attached + block</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>
Two parts — <code>ToggleGroup</code> (provider/container) and
<code>ToggleGroup.Item</code> (button). Soma drives selection,
eidos drives visual treatment.
</p>
<div data-uix-subsection-head>ToggleGroup (root)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">type <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">'single' | 'multiple'</td>
<td class="default">'single'</td>
<td>Selection mode.</td>
</tr>
<tr>
<td class="name">value <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">string[]</td>
<td class="default">[]</td>
<td>Bindable array of selected item values.</td>
</tr>
<tr>
<td class="name">disabled <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Disables all items.</td>
</tr>
<tr>
<td class="name">orientation <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">'horizontal' | 'vertical'</td>
<td class="default">'horizontal'</td>
<td>Arrow-key direction.</td>
</tr>
<tr>
<td class="name">loop <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">boolean</td>
<td class="default">true</td>
<td>Wrap arrow-key navigation at boundaries.</td>
</tr>
<tr>
<td class="name">aria-label <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">string</td>
<td class="default empty">—</td>
<td>
<strong>Required</strong> unless <code>aria-labelledby</code> is set.
</td>
</tr>
<tr>
<td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">'solid' | 'outline' | 'ghost'</td>
<td class="default">'solid'</td>
<td>Visual treatment. Mirrors Toggle.</td>
</tr>
<tr>
<td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">primary | neutral | affirm | risk | threat | absent | unknown</td>
<td class="default">'neutral'</td>
<td>Propagates to items via <code>data-color</code>.</td>
</tr>
<tr>
<td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td>
<td class="default">'md'</td>
<td>T-shirt scale, responsive.</td>
</tr>
<tr>
<td class="name">attached <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Segmented control — shared borders.</td>
</tr>
<tr>
<td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Stretch items to fill the container.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ToggleGroup.Item</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">value <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">string</td>
<td class="default empty">—</td>
<td><strong>Required</strong>. Item identifier.</td>
</tr>
<tr>
<td class="name">disabled <span data-uix-tag data-kind="soma">soma</span></td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Disables this item only.</td>
</tr>
<tr>
<td class="name">iconOnly <span data-uix-tag data-kind="eidos">eidos</span></td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Square aspect, label sr-only.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
Compiled from <code>src/uix/morfo/components/toggle-group.ts</code>.
</p>
<div data-uix-subsection-head>Parts ({partsList.length})</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Kebab</th><th>Role</th><th>Marker</th><th>States</th></tr></thead>
<tbody>
{#each partsList as part}
{@const partAny = part as unknown as Record<string, unknown>}
<tr>
<td class="name">{partAny.kebab}</td>
<td>{partAny.role ?? '—'}</td>
<td><code>{partAny.marker ?? '—'}</code></td>
<td>
{#if Array.isArray(partAny.states) && partAny.states.length}
{(partAny.states as string[]).join(', ')}
{:else}—{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events + perceptual signature
</h2>
<p data-uix-section-desc>
One event: <code>commit-toggle</code> from the
<code>commit</code> family. Each item press fires it with
sequence <em>post</em> — the perceptual signal lands after
the state has flipped. Intent is per-item via
<code>fromProp:intent</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#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}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = stageRef?.querySelector(
'[data-toggle-group-item]'
) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: sem.family,
target,
...(effectiveIntent
? { intent: effectiveIntent as 'neutral' | 'affirm' | 'risk' | 'threat' }
: {})
});
}}>▶ play</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe at <code>src/uix/eidos/components/toggle-group/toggle-group.css</code>.
Items consume the Toggle palette tokens so visuals stay
coherent across <code>&lt;Toggle&gt;</code> and
<code>&lt;ToggleGroup.Item&gt;</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-toggle-group]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Flex container, orientation-aware direction.</td>
</tr>
<tr>
<td class="name"><code>[data-toggle-group][data-attached]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Collapsed gap + shared borders (segmented control).</td>
</tr>
<tr>
<td class="name"><code>[data-toggle-group][data-block]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Items <code>flex: 1</code>; group <code>inline-size: 100%</code>.</td>
</tr>
<tr>
<td class="name"><code>[data-toggle-group-item]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Button — inherits Toggle's height/padding/radius/font tokens.</td>
</tr>
<tr>
<td class="name"><code>[data-toggle-group-item][data-state='on']</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Pressed visual — switches to <code>--_toggle-on-*</code> palette slice.</td>
</tr>
<tr>
<td class="name"
><code>[data-toggle-group][data-variant='outline'] [data-toggle-group-item]</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Cascades the variant slice from root to items.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>A11y contract</h2>
<p data-uix-section-desc>
Group exposes <code>role="group"</code> with the required
accessible name (warns in dev when missing). Items are
<code>&lt;button&gt;</code> with
<code>aria-pressed</code> and arrow-key navigation.
Pattern: WAI-ARIA <a
href="https://www.w3.org/WAI/ARIA/apg/patterns/button/"
target="_blank"
rel="noopener">Button</a
>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Effect</th></tr></thead>
<tbody>
<tr><td><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></td><td>Move focus to next item.</td></tr>
<tr><td><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></td><td>Move focus to previous item.</td></tr>
<tr><td><kbd>Home</kbd></td><td>Move focus to first item.</td></tr>
<tr><td><kbd>End</kbd></td><td>Move focus to last item.</td></tr>
<tr><td><kbd>Space</kbd> / <kbd>Enter</kbd></td><td>Toggle the focused item.</td></tr>
<tr><td><kbd>Tab</kbd></td><td>Move focus out of the group.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.