+
+ Controls · ButtonGroup
+ ButtonGroup
+
+ Presentational cluster of independent action buttons — role="group" plus shared
+ visual defaults (variant / size / color /
+ disabled) that propagate to the child <Button>s via context. Composes <Group> for the
+ segmented (attached) layout. Passive — 0 semantic events; each button fires its
+ own contact-activate.
+
+
+
+ parts{compiled.parts.order.length}
+
+
+ events0
+
+
+ composesGroup · Button
+
+
+ scopeeidos
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ trace
+ {#if trace.length === 0}
+ click a button to see its contact-activate
+ {:else}
+ {#each trace.slice(0, 3) as entry}
+ {entry.event} · {entry.family}{entry.intent
+ ? ' · ' + entry.intent
+ : ''}
+ {fmtTime(entry.at)}
+ {/each}
+ {/if}
+
+ attached
+ {String(attached)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if tab === 'live'}
+
+ {/if}
+
+ {#if tab === 'api'}
+
+ API reference
+
+ Single-root component — <ButtonGroup> takes real
+ <Button> children (no ButtonGroup.Item). The visual props
+ propagate as defaults to those children via an eidos context.
+
+
+
+ | Prop | Type | Default | Description |
+
+ | variant | 'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain' | 'solid' | Propagated to child Buttons as their default variant. |
+ | size | ResponsiveProp<'xs' | 'sm' | 'md' | 'lg' | 'xl'> | 'md' | Propagated to child Buttons. |
+ | color | 'primary' | 'secondary' | 'neutral' | 'primary' | Hierarchical color propagated to child Buttons. Per-button intent is set on the Button itself. |
+ | disabled | boolean | false | Disables every child Button at once. |
+ | orientation | 'horizontal' | 'vertical' | 'horizontal' | Layout axis → aria-orientation + data-orientation. |
+ | attached | boolean | true | Segmented: children share edges, inner corners squared, doubled borders collapsed. |
+ | block | boolean | false | Stretch the group to fill the container inline-size; children fill it equally. |
+ | equal | boolean | attached | Same button width. Vertical: each = the widest (content-sized). Horizontal: fills the container, split equally. Defaults ON when attached. |
+ | aria-label | string | — | Accessible name. Recommended — role="group" needs a name. |
+ | aria-labelledby | string | — | Accessible name by reference (id of a visible label). |
+
+
+
+
+ Reference comparison
+
+
+ | Library | Closest equivalent | Difference vs UIX |
+
+ | chakra-ui | <ButtonGroup> | Same shape — propagates size/variant/colorScheme + isAttached. Chakra doesn't set role="group"; UIX does (declared in morfo). |
+ | mui | <ButtonGroup> | MUI sets role="group" + propagates via context, attached by default — same model. UIX adds orientation → aria-orientation. |
+ | radix-themes | none (use <Flex>) | Radix has no ButtonGroup; SegmentedControl is a selection control. UIX keeps actions independent + groups them semantically. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'morfo'}
+
+
+ morfo · declarative contract
+
+
+ ButtonGroup is scope-eidos — there is no soma runtime. The morfo locks the ARIA grouping
+ contract (role="group" + aria-orientation + accessible name) and
+ the data-orientation / data-disabled attrs. The wrapper
+ materialises exactly what the morfo declares — nothing more. Source:
+ src/uix/morfo/components/button-group.ts.
+
+
+
+
+ | Field | Value |
+
+ | name | "{buttonGroupMorfo.name}" |
+ | kebab | "{buttonGroupMorfo.kebab}" |
+ | scope | [{buttonGroupMorfo.scope.map((s) => `"${s}"`).join(', ')}] |
+ | parts.length | {buttonGroupMorfo.parts.length} |
+ | events.length | 0 |
+
+
+
+
+ Parts
+
+
+ | Part | Marker | Element | Role | Archetype | States | Optional |
+
+ {#each partsList as part}
+
+ | {part.kebab} |
+ [{part.marker}] |
+ <{part.defaultElement}> |
+ {part.role ?? '—'} |
+ {part.archetype ?? '—'} |
+ {part.states.length ? part.states.join(' | ') : '—'} |
+ {part.optional ? 'yes' : 'no'} |
+
+ {/each}
+
+
+
+
+ {#each buttonGroupMorfo.parts as rawPart}
+ {@const partAny = rawPart as unknown as {
+ kebab: string;
+ aria?: ReadonlyArray<{
+ attr: string;
+ value: { kind: string };
+ condition?: { when: string; prop?: string };
+ severity?: string;
+ }>;
+ }}
+ {@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
+ {@const ariaAttrs = partAny.aria ?? []}
+ {#if dataAttrs.length || ariaAttrs.length}
+ {partAny.kebab}
+ {#if dataAttrs.length}
+
+
+ | data-attr | Values | Source |
+
+ {#each dataAttrs as attr}
+
+ | {attr.attr} |
+ {attr.values ? attr.values.join(' | ') : '—'} |
+ {'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'} |
+
+ {/each}
+
+
+
+ {/if}
+ {#if ariaAttrs.length}
+
+
+ | aria-attr | Source | Condition | Severity |
+
+ {#each ariaAttrs as a}
+
+ | {a.attr} |
+ {a.value.kind} |
+ {a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'} |
+ {a.severity ?? 'required'} |
+
+ {/each}
+
+
+
+ {/if}
+ {/if}
+ {/each}
+
+ {/if}
+
+ {#if tab === 'sema'}
+
+
+ sema · events
+
+
+ ButtonGroup declares no semantic events. As a structural frame it carries no perceptual
+ signal — per CANON §5 the signal belongs to the act, not the container. Each composed
+ <Button> fires its own
+ contact-activate (family contact); click a button in the stage
+ and watch the trace strip.
+
+ No Sema events declared.
+
+ {/if}
+
+ {#if tab === 'recipe'}
+
+ Eidos recipe
+
+ Layout (flex, gap, attached corners + border overlap, grow) is owned by the composed
+ <Group> ([data-group]). ButtonGroup's
+ own CSS at src/uix/eidos/components/button-group/button-group.css adds only the
+ button-specific concerns.
+
+
+
+ | Selector | Source | Purpose |
+
+ [data-button-group] | morfo | Provider marker. Tunes Group's --group-attached-overlap to the Button border. |
+ [data-button-group][data-orientation] | morfo | Layout axis (also drives Group's data-direction). |
+ [data-button-group][data-disabled] | morfo | Group-level disabled (propagated to each Button). |
+ [data-button-group][data-attached] [data-button]:focus-visible | eidos | Float the focused/active button above neighbours so the ring isn't clipped. |
+ [data-button-group][data-block] | eidos | Full-width stretch. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'a11y'}
+
+ Accessibility
+
+
+ | Concern | Contract |
+
+ | Role | role="group" — declared in the morfo, fixed. A cluster of independent actions. For toolbar semantics (roving focus, single tab stop) use <Toolbar> instead. |
+ | Label | Provide aria-label or aria-labelledby — a role="group" without a name is announced as an unnamed group. |
+ | Orientation | aria-orientation mirrors the orientation prop (horizontal / vertical). |
+ | Keyboard | Each button stays independently Tab-focusable — no roving. The group itself is not focusable; activation is the native button keyboard (Enter / Space) on each child. |
+ | Disabled | The disabled prop propagates to every child Button (each gets aria-disabled) and the group sets data-disabled. |
+
+
+
+
+ {/if}
+
diff --git a/web/routes/uix/components/fab/+page.svelte b/web/routes/uix/components/fab/+page.svelte
new file mode 100644
index 000000000..29f669935
--- /dev/null
+++ b/web/routes/uix/components/fab/+page.svelte
@@ -0,0 +1,416 @@
+
+
+
+
+ Controls · Fab
+ Fab
+
+ Floating Action Button — a prominent, elevated button for a screen's primary action. A
+ circular icon by default, or an extended pill with a label. It floats over
+ content (own placement, fixed) and lifts on hover. Pure composition over
+ <Button> — 0 own events, interactive by composition.
+
+
+
+ parts{compiled.parts.order.length}
+
+
+ events0
+
+
+ composesButton
+
+
+ scopeeidos
+
+
+
+
+
+
+
+
+ A page. The FAB floats in {placement === 'static' ? 'flow (static)' : `the ${placement} corner`} of this box.
+
+
(count += 1)}
+ >
+ {#snippet icon()}{/snippet}
+ {#if extended}{label}{/if}
+
+
+
+ trace
+ {#if trace.length === 0}
+ click the FAB to fire its action
+ {:else}
+ {#each trace.slice(0, 3) as entry}
+ {entry.event} · {entry.family}{entry.intent
+ ? ' · ' + entry.intent
+ : ''}
+ {fmtTime(entry.at)}
+ {/each}
+ {/if}
+
+ clicks
+ {count}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if tab === 'live'}
+
+ Controls
+
+ Fab is eidos-only — no soma split. The visual props
+ forward to the composed <Button>; the FAB owns
+ size, shape, elevation and placement.
+
+
+
+ eidos props · FAB treatment
+
+
+
+
+
+
+
+
+
+ eidos props · forwarded to Button
+
+
+
+
+
+
+
+
+
+
+ eidos
+ visual · composes Button
+ svelte
+
+
{eidosSnippet}
+
+
+ {/if}
+
+ {#if tab === 'api'}
+
+ API reference
+
+ FAB-specific props below; everything else (intent, color,
+ shape, loading, onclick, aria-label…)
+ forwards to Button. Button's size /
+ iconOnly / rounded / block are owned by the FAB.
+
+
+
+ | Prop | Type | Default | Description |
+
+ | extended | boolean | false | Pill with a visible label vs circular icon-only. |
+ | size | 'sm' | 'md' | 'lg' | 'md' | FAB scale (≈ 40 / 56 / 72px) — its own, larger than a control. |
+ | placement | 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | 'static' | 'bottom-end' | Where it floats (fixed + safe-area). static = consumer positions it. |
+ | offset | string | var(--fab-offset) | Distance from the edges when floating. |
+ | icon | Snippet | — | The glyph (visible in both forms). |
+ | variant | ButtonVariant | 'solid' | Forwarded to Button (FAB defaults to the prominent solid). |
+ | intent / color | like Button | neutral / primary | Forwarded — the screen's primary action. |
+ | aria-label | string | — | Accessible name. Required for the icon-only form. |
+
+
+
+
+ Reference comparison
+
+
+ | Library | Closest equivalent | Difference vs UIX |
+
+ | material-web | <md-fab> | Same model (regular + extended, sm/md/lg). UIX composes its own Button for chrome + intent palette. |
+ | mui | <Fab> + <SpeedDial> | UIX ships the FAB; speed-dial (fan-out menu) is a deferred follow-up. |
+ | ant-design | <FloatButton> | Both own placement. UIX drops badge/BackTop (compose Badge / out of scope). |
+ | radix / ark / chakra | — (none) | Headless libs have no FAB — you compose a button + positioning, which is exactly what UIX packages here. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'morfo'}
+
+
+ morfo · declarative contract
+
+
+ scope-eidos — no soma runtime. The morfo declares the FAB's own contract: the action role,
+ the accessible name, and the data-fab structural identity (over the composed
+ Button). role / disabled come from Button — referenced, never re-stamped. Source:
+ src/uix/morfo/components/fab.ts.
+
+
+
+ | Field | Value |
+
+ | name | "{fabMorfo.name}" |
+ | kebab | "{fabMorfo.kebab}" |
+ | scope | [{fabMorfo.scope.map((s) => `"${s}"`).join(', ')}] |
+ | apg | button ↗ |
+ | parts.length | {fabMorfo.parts.length} |
+ | events.length | 0 |
+
+
+
+
+ Parts
+
+
+ | Part | Marker | Element | Role | Archetype | States |
+
+ {#each partsList as part}
+
+ | {part.kebab} |
+ [{part.marker}] |
+ <{part.defaultElement}> |
+ {part.role ?? '—'} |
+ {part.archetype ?? '—'} |
+ {part.states.length ? part.states.join(' | ') : '—'} |
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if tab === 'sema'}
+
+
+ sema · events
+
+
+ Fab declares no events of its own — it is interactive by composition. The
+ press fires contact-activate on the composed Button. Click the FAB and watch
+ the trace strip.
+
+ No Sema events declared.
+
+ {/if}
+
+ {#if tab === 'recipe'}
+
+ Eidos recipe
+
+ The composed <Button> paints the chrome;
+ src/uix/eidos/components/fab/fab.css owns the FAB treatment — elevation,
+ size scale, circular/extended shape, and floating placement.
+
+
+
+ | Selector | Source | Purpose |
+
+ [data-fab] | eidos | Elevation tokens + rest shadow + transition. |
+ [data-fab]:hover:not([data-disabled]) | eidos | Hover lift via the translate property (composes with Button's press-squeeze). |
+ [data-button][data-fab][data-fab-size='…'] | eidos | FAB diameter + glyph size (out-specifies Button). |
+ [data-fab][data-extended] | eidos | Pill: auto width + label padding + gap. |
+ [data-fab][data-placement='…'] | eidos | Fixed positioning + safe-area inset. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'a11y'}
+
+ Accessibility
+
+
+ | Concern | Contract |
+
+ | Role | A native <button> (from Button) — role="button", focusable, Enter/Space activate. |
+ | Accessible name | Icon-only FAB has no visible text, so aria-label is required. Extended FAB names itself from the visible label (keep aria-label when the label is terse). |
+ | Disabled | disabled (from Button) removes it from the tab order + drops the elevation. |
+ | Motion | The hover lift is suppressed under prefers-reduced-motion. |
+ | Placement | Floating corners clear notches / home indicators via env(safe-area-inset-*). A fixed FAB shouldn't obscure focusable content — keep it clear of interactive bottom bars. |
+
+
+
+
+ {/if}
+
diff --git a/web/routes/uix/components/onion-menu/+page.svelte b/web/routes/uix/components/onion-menu/+page.svelte
new file mode 100644
index 000000000..0940d7f2c
--- /dev/null
+++ b/web/routes/uix/components/onion-menu/+page.svelte
@@ -0,0 +1,610 @@
+
+
+{#snippet iCreate()}
+
+ Overlay · OnionMenu
+ OnionMenu
+
+ A trigger-agnostic radial menu rendered as concentric onion rings.
+ Compositional: a root <OnionLayer> of
+ <OnionOption>s, each option nesting a <OnionLayer> for
+ its children. Each top option owns a palette colour; drilling derives the deeper rings and the
+ passed rings mute to a trail. The arc follows the placement (90 / 180 / 360). Built on two
+ pure engines — geometry + colour (OKLCH via uix.color).
+
+
+
parts{compiled.parts.order.length}
+
events{events.length}
+
placements9
+
composesuix.color · geometry
+
+ apg
+ menu-button ↗
+
+
+
+
+
+
+
+
+
+
+
+
+ (last = 'Blank')} />
+ (last = 'Template')} />
+ (last = 'Import')} />
+
+
+ (last = 'Folder')} />
+ (last = 'Board')} />
+ (last = 'Note')} />
+
+
+
+
+ (last = 'Link')} />
+ (last = 'Email')} />
+ (last = 'Embed')} />
+
+
+
+
+ (last = 'Cut')} />
+ (last = 'Copy')} />
+ (last = 'Paste')} />
+
+
+ (last = 'Red')} />
+ (last = 'Blue')} />
+ (last = 'Green')} />
+ (last = 'Gold')} />
+
+
+
+
+
+
+ (last = 'Zoom')} />
+ (last = 'Grid')} />
+
+
+
+
+
+
+ trace
+ {#if trace.length === 0}
+ open the menu, drill a branch, or fire a Sema event
+ {:else}
+ {#each trace.slice(0, 3) as entry}
+ {entry.event} · {entry.family}{entry.intent
+ ? ' · ' + entry.intent
+ : ''}
+ {fmtTime(entry.at)}
+ {/each}
+ {/if}
+
+ state
+ {open ? 'open' : 'closed'} · path[{drillPath.join(',')}]
+ · last
+ {last}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if tab === 'live'}
+
+ {/if}
+
+ {#if tab === 'api'}
+
+ API reference
+
+ Three components. <OnionMenu> holds the trigger + the root
+ <OnionLayer>; an <OnionOption> nests its children as a
+ <OnionLayer> composed inside it.
+
+ OnionMenu
+
+
+ | Prop | Type | Default | Description |
+
+ | placement | 'bottom-end' | … | 'center' | 'bottom-end' | Anchor → arc span (90/180/360) + orientation. |
+ | open bindable | boolean | false | Whether the rings show. |
+ | drillPath bindable | number[] | [] | Child indices of the active drill path. |
+ | selected bindable | number[] | null | null | Index path to the selected leaf. |
+ | innerRadius / ringWidth / gap / fontSize | number | 82 / 66 / 5 / 13 | Geometry knobs (a layer can override ringWidth/gap). |
+ | width / height | number | 680 / 620 | SVG viewBox size. |
+ | trigger | Snippet | — (+) | Center trigger content (any element). |
+ | onItemClick / onTriggerClick | callback | — | Notified on click (drill is owned internally). |
+
+
+
+ OnionLayer
+
+
+ | Prop | Type | Default | Description |
+
+ | textDisposition | 'angular' | 'horizontal' | 'vertical' | 'angular' | Label layout for this ring. |
+ | color | theme scale / role | — | Branch colour for this level — a theme scale ('teal') or role ('primary'). |
+ | display | 'icon' | 'text' | 'both' | 'both' | Default display for the level's options. |
+ | ringWidth / gap | number | — (root) | Override the ring thickness / gap. |
+
+
+
+ OnionOption
+
+
+ | Prop | Type | Default | Description |
+
+ | label | string | — | Visible label + accessible name. |
+ | icon | Snippet | — | Glyph (a framework <Svg*> / <Icon>). |
+ | color | theme scale / role | — | Branch colour (top) / per-option override — a theme scale or role. Auto-distinct if omitted. |
+ | display | 'icon' | 'text' | 'both' | — (layer) | Per-option display override. |
+ | disabled / onSelect | boolean / () => void | — | Disable / fire on a leaf choose. |
+ | children | <OnionLayer> | — | A nested layer = this option's children (makes it a branch). |
+
+
+
+
+ {/if}
+
+ {#if tab === 'morfo'}
+
+
+ morfo · declarative contract
+
+
+ The morfo declares the menu-button contract: the trigger, the role="menu"
+ surface, the menuitem sectors with data-depth /
+ data-active / data-muted, and the open / close / select events.
+
+
+
+ | Field | Value |
+
+ | name | "{onionMenuMorfo.name}" |
+ | kebab | "{onionMenuMorfo.kebab}" |
+ | scope | [{onionMenuMorfo.scope.map((s) => `"${s}"`).join(', ')}] |
+ | apg | menu-button ↗ |
+ | parts.length | {onionMenuMorfo.parts.length} |
+ | events.length | {events.length} |
+
+
+
+
+ Parts
+
+
+ | Part | Marker | Element | Role | Archetype | States |
+
+ {#each partsList as part}
+
+ | {part.kebab} |
+ [{part.marker}] |
+ <{part.defaultElement}> |
+ {part.role ?? '—'} |
+ {part.archetype ?? '—'} |
+ {part.states.length ? part.states.join(' | ') : '—'} |
+
+ {/each}
+
+
+
+
+ {#each onionMenuMorfo.parts as rawPart}
+ {@const partAny = rawPart as unknown as {
+ kebab: string;
+ aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }>;
+ keyboard?: ReadonlyArray<{ key: string; action: string }>;
+ }}
+ {@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
+ {@const ariaAttrs = partAny.aria ?? []}
+ {@const keys = partAny.keyboard ?? []}
+ {#if dataAttrs.length || ariaAttrs.length || keys.length}
+ {partAny.kebab}
+ {#if dataAttrs.length}
+
+
+ | data-attr | Values | Source |
+
+ {#each dataAttrs as attr}
+
+ | {attr.attr} |
+ {attr.values ? attr.values.join(' | ') : '—'} |
+ {'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'} |
+
+ {/each}
+
+
+
+ {/if}
+ {#if ariaAttrs.length}
+
+
+ | aria-attr | Source | Condition | Severity |
+
+ {#each ariaAttrs as a}
+
+ | {a.attr} |
+ {a.value.kind} |
+ {a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}` : 'always'} |
+ {a.severity ?? 'required'} |
+
+ {/each}
+
+
+
+ {/if}
+ {#if keys.length}
+
+
+ | key | action |
+
+ {#each keys as k}
+ | {k.key === ' ' ? 'Space' : k.key} | {k.action} |
+ {/each}
+
+
+
+ {/if}
+ {/if}
+ {/each}
+
+ Events
+
+
+ | name | family | verb | sequence | intent | target |
+
+ {#each onionMenuMorfo.events as ev}
+ {@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: string; target?: { kind: string; ref?: string } }}
+
+ | {ev.name} |
+ {sem.family} |
+ {sem.verb ?? '—'} |
+ {sem.sequence ?? '—'} |
+ {sem.intent ?? '—'} |
+ {sem.target?.ref ?? sem.target?.kind ?? '—'} |
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if tab === 'sema'}
+
+
+ sema · events
+
+
+ open / close (emerge · surface) and commit-select
+ (commit · affirm · item). Expression family-default — the family bases carry
+ the signal. The soma layer fires these on real interaction; here ▶ play emits onto the live
+ target.
+
+
+
+ | name | family | verb | sequence | intent | play |
+
+ {#each onionMenuMorfo.events as ev}
+ {@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: string; target?: { kind: string; ref?: string } }}
+ {@const part = sem.target?.ref ?? 'surface'}
+
+ | {ev.name} |
+ {sem.family} |
+ {sem.verb ?? '—'} |
+ {sem.sequence ?? '—'} |
+ {sem.intent ?? '—'} |
+
+
+ |
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if tab === 'recipe'}
+
+ Eidos recipe
+
+ Fills / text / stroke are computed per sector by the colour engine and applied inline.
+ onion-menu.css owns only the structural chrome.
+
+
+
+ | Selector | Source | Purpose |
+
+ [data-onion-menu-surface] | morfo | The SVG menu (role="menu"). |
+ .onion-menu-sector | eidos | Sector affordance + fade-in + focus ring. |
+ .onion-menu-label | eidos | Label typography (curved / flat / radial). |
+ .onion-menu-icon | eidos | Icon box inside the foreignObject. |
+ .onion-menu-trigger-glyph[data-state='open'] | eidos | Rotate the + → × on open. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'a11y'}
+
+ Accessibility
+
+
+ | Concern | Contract |
+
+ | Trigger | Any element; the soma stamps role="button" + aria-haspopup="menu" + aria-expanded. |
+ | Surface | role="menu"; menuitem sectors; branch items expose aria-haspopup + aria-expanded. |
+ | Keyboard soma | Enter/Space/↓ open · ←/→ rotate · Enter drill/activate · Backspace up · Esc close. |
+ | Motion | Sector fade-in + the trigger glyph rotation are suppressed under prefers-reduced-motion. |
+
+
+
+
+ {/if}
+
diff --git a/web/routes/uix/components/split-button/+page.svelte b/web/routes/uix/components/split-button/+page.svelte
new file mode 100644
index 000000000..05da13ec0
--- /dev/null
+++ b/web/routes/uix/components/split-button/+page.svelte
@@ -0,0 +1,519 @@
+
+
+
+
+ Controls · SplitButton
+ SplitButton
+
+ A primary action joined to a ▾ menu trigger — Save | ▾ → secondary actions.
+ Pure composition: <ButtonGroup attached> +
+ <Button> +
+ <DropdownMenu>. The ▾ trigger is
+ auto-rendered and inherits the group's variant / size / color; only the ▾ opens the menu.
+ 0 own events — interactive by composition.
+
+
+
+ parts{compiled.parts.order.length}
+
+
+ events0
+
+
+ composesButtonGroup · Button · DropdownMenu
+
+
+ scopeeidos
+
+
+
+
+
+
+
+
+ (lastAction = 'primary: Save')}>
+ {primaryLabel}
+
+
+ (lastAction = 'Save as…')}>Save as…
+ (lastAction = 'Save a copy')}
+ >Save a copy
+
+ (lastAction = 'Discard')}
+ >Discard
+
+
+
+
+ trace
+ {#if trace.length === 0}
+ click Save, open the ▾ menu, or pick an item
+ {:else}
+ {#each trace.slice(0, 3) as entry}
+ {entry.event} · {entry.family}{entry.intent
+ ? ' · ' + entry.intent
+ : ''}
+ {fmtTime(entry.at)}
+ {/each}
+ {/if}
+
+ menu
+ {open ? 'open' : 'closed'} · last
+ {lastAction}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if tab === 'live'}
+
+ {/if}
+
+ {#if tab === 'api'}
+
+ API reference
+
+ Root props propagate to both buttons; DropdownMenu provider props (open,
+ modal, onOpenChange…) pass through. The menu surface
+ (Menu / Item / Separator / Group /
+ CheckboxItem / RadioGroup / Sub…) is re-exported from
+ DropdownMenu.
+
+
+
+ | Prop | Type | Default | Description |
+
+ | open bindable | boolean | false | Menu open state. |
+ | variant | 'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain' | 'solid' | Propagated to both buttons. |
+ | size | ResponsiveProp<'xs' | 'sm' | 'md' | 'lg' | 'xl'> | 'md' | Propagated to both buttons. |
+ | color | 'primary' | 'secondary' | 'neutral' | 'primary' | Propagated to both buttons. |
+ | disabled | boolean | false | Disables both buttons. |
+ | icon | Snippet | — (chevron) | Override the ▾ glyph. The default chevron rotates while open. |
+ | menuLabel | string | 'More actions' | Accessible name for the ▾ trigger. |
+ | aria-label | string | — | Accessible name for the split group. |
+
+
+
+
+
+ <SplitButton.Menu> props · the float
+
+
+ The menu IS a DropdownMenu.Content with
+ split-button-aware defaults: it anchors to the whole group, matches its width, and sits a
+ gap below it. Every Content positioning prop (side, align,
+ collisionPadding…) still passes through.
+
+
+
+ | Prop | Type | Default | Description |
+
+ | matchAnchorWidth | ResponsiveProp<boolean> | true | Min width tracks the split surface (the anchor width); content can grow it. |
+ | sideOffset | number | canonical | Gap below the button. Unset → resolved from the size's --space-* step (density × scaling aware); pass a number to override. |
+ | size | ResponsiveProp<'xs'…'xl'> | split's size | Forwarded to DropdownMenu.Content's size (1:1 scale); defaults to the split's size, override to decouple. |
+ | align | 'start' | 'center' | 'end' | 'start' | Edge of the surface the menu aligns to. |
+
+
+
+
+ Reference comparison
+
+
+ | Library | Closest equivalent | Difference vs UIX |
+
+ | fluent-ui | <SplitButton> | Same model (primary + menu). UIX shares variant/size/color to both via the ButtonGroup context automatically. |
+ | ant-design | <Dropdown.Button> | Same split. UIX's menu is the full DropdownMenu (groups/checkbox/radio/submenus), not a flat list. |
+ | mui | ButtonGroup + Menu recipe | MUI documents it as a recipe; UIX ships it as a component with the chevron-rotates affordance. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'morfo'}
+
+
+ morfo · declarative contract
+
+
+ scope-eidos — no soma runtime. The morfo declares the split-specific contract: the
+ role="group" provider, the data-state that rotates the chevron,
+ and the Primary / Trigger parts (structural identities over the composed Button /
+ DropdownMenu.Trigger). Source: src/uix/morfo/components/split-button.ts.
+
+
+
+
+ | Field | Value |
+
+ | name | "{splitButtonMorfo.name}" |
+ | kebab | "{splitButtonMorfo.kebab}" |
+ | scope | [{splitButtonMorfo.scope.map((s) => `"${s}"`).join(', ')}] |
+ | apg | menu-button ↗ |
+ | parts.length | {splitButtonMorfo.parts.length} |
+ | events.length | 0 |
+
+
+
+
+ Parts
+
+
+ | Part | Marker | Element | Role | Archetype | States |
+
+ {#each partsList as part}
+
+ | {part.kebab} |
+ [{part.marker}] |
+ <{part.defaultElement}> |
+ {part.role ?? '—'} |
+ {part.archetype ?? '—'} |
+ {part.states.length ? part.states.join(' | ') : '—'} |
+
+ {/each}
+
+
+
+
+ {#each splitButtonMorfo.parts as rawPart}
+ {@const partAny = rawPart as unknown as {
+ kebab: string;
+ aria?: ReadonlyArray<{
+ attr: string;
+ value: { kind: string };
+ condition?: { when: string; prop?: string };
+ severity?: string;
+ }>;
+ }}
+ {@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
+ {@const ariaAttrs = partAny.aria ?? []}
+ {#if dataAttrs.length || ariaAttrs.length}
+ {partAny.kebab}
+ {#if dataAttrs.length}
+
+
+ | data-attr | Values | Source |
+
+ {#each dataAttrs as attr}
+
+ | {attr.attr} |
+ {attr.values ? attr.values.join(' | ') : '—'} |
+ {'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'} |
+
+ {/each}
+
+
+
+ {/if}
+ {#if ariaAttrs.length}
+
+
+ | aria-attr | Source | Condition | Severity |
+
+ {#each ariaAttrs as a}
+
+ | {a.attr} |
+ {a.value.kind} |
+ {a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'} |
+ {a.severity ?? 'required'} |
+
+ {/each}
+
+
+
+ {/if}
+ {/if}
+ {/each}
+
+ {/if}
+
+ {#if tab === 'sema'}
+
+
+ sema · events
+
+
+ SplitButton declares no events of its own — it is interactive by composition.
+ The primary fires contact-activate (Button), the ▾ fires
+ emerge.open/close (DropdownMenu), and each item fires
+ commit-select (DropdownMenu). Exercise the stage and watch the trace strip.
+
+ No Sema events declared.
+
+ {/if}
+
+ {#if tab === 'recipe'}
+
+ Eidos recipe
+
+ Layout + the segmented seam come from <ButtonGroup attached>;
+ Button + DropdownMenu paint the buttons. SplitButton's own CSS at
+ src/uix/eidos/components/split-button/split-button.css owns the
+ split-specific affordances: the ▾ rotates open and stays a slim tail, and the menu
+ tracks the surface width, scales with size, and sits a canonical gap below.
+
+
+
+ | Selector | Source | Purpose |
+
+ [data-split-button][data-state='open'] | morfo | Group open state (stamped from `open`). |
+ … [data-split-button-trigger] [data-svg='chevron'] | eidos | Rotate the ▾ chevron 180° while the menu is open. |
+ [data-split-button-trigger][data-icon-only] | eidos | Slim the ▾ to glyph + snug padding (not a square). |
+ [data-dropdown-menu-content][data-split-button-menu] | eidos | min-inline-size = the anchor (surface) width; content grows it. |
+ [data-split-button-menu][data-size='…'] | eidos | Gap step per size (font / height / padding scale come from DropdownMenu's own size axis). |
+ [data-split-button-menu][data-canonical-gap][data-side='…'] | eidos | Canonical separation: a --space-* margin on the placed side. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'a11y'}
+
+ Accessibility
+
+
+ | Concern | Contract |
+
+ | Group | role="group" (from ButtonGroup) + aria-label — names the split as a unit. |
+ | Primary | A native <button> (from Button) — its own action, independently Tab-focusable. |
+ | Menu trigger | Follows the APG Menu Button (from DropdownMenu): aria-haspopup="menu" + aria-expanded, opens with Enter/Space/ArrowDown, names itself via menuLabel. |
+ | Menu | role="menu" with arrow-key navigation, typeahead, Escape-to-close, focus return to the ▾ (all from DropdownMenu). |
+ | Keyboard | Tab moves between the primary and the ▾ (no roving); each opens / activates with its native keys. |
+
+
+
+
+ {/if}
+