From c00bac13450b6a30004fe6dfcee1a665e7d0cf16 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 22 Jun 2026 21:31:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20action=20components=20=E2=80=94?= =?UTF-8?q?=20fab,=20onion-menu,=20button-group,=20split-button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the four action-cluster components. Per the audit the user requested, the hardcoded magic numbers in their CSS move to recipes (base.ts), so the values live in one place and pass the recipe-CSS contract: - fab: the off-ladder Material size / icon / lift scale → `fab` recipe (named tokens — the canonical control-height / icon-size ladders genuinely can't express 40/56/72 + 20/24/28; documented as the FAB's own scale). - onion-menu: the radial-chrome magics (sector ring widths, glyph bar, trail opacities) → `onion-menu` recipe; the centre trigger's lift + shadow REFERENCE `--fab-*` (it IS a FAB — the two can't drift apart). - button-group: the segmented-divider strength → `button-group` recipe. - split-button: the slim chevron tail's inline padding → `split-button` recipe; DropdownMenu gains `customAnchor` so the menu spans the whole split surface. All four compose the framework primitives (Group / Button / Icon / DropdownMenu), declare a morfo, ship langs + a demo, and pass the recipe-CSS contract test (the only flagged component left is palabras, an active dev track). check 0; onion-menu engine tests 31/31. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eidos/components/button-group/README.md | 156 +++++ .../components/button-group/button-group.css | 107 +++ .../button-group/button-group.svelte | 80 +++ .../eidos/components/button-group/context.ts | 39 ++ .../eidos/components/button-group/index.ts | 26 + .../eidos/components/button-group/types.ts | 89 +++ src/uix/eidos/components/fab/README.md | 120 ++++ src/uix/eidos/components/fab/fab.css | 131 ++++ src/uix/eidos/components/fab/fab.svelte | 57 ++ src/uix/eidos/components/fab/index.ts | 23 + src/uix/eidos/components/fab/types.ts | 58 ++ src/uix/eidos/components/onion-menu/README.md | 111 ++++ .../eidos/components/onion-menu/color.test.ts | 77 +++ src/uix/eidos/components/onion-menu/color.ts | 107 +++ .../components/onion-menu/context.svelte.ts | 96 +++ .../components/onion-menu/geometry.test.ts | 163 +++++ .../eidos/components/onion-menu/geometry.ts | 283 ++++++++ src/uix/eidos/components/onion-menu/index.ts | 38 ++ .../components/onion-menu/onion-layer.svelte | 48 ++ .../components/onion-menu/onion-menu.css | 219 +++++++ .../components/onion-menu/onion-menu.svelte | 515 +++++++++++++++ .../components/onion-menu/onion-option.svelte | 56 ++ src/uix/eidos/components/onion-menu/types.ts | 91 +++ .../eidos/components/split-button/README.md | 150 +++++ .../eidos/components/split-button/context.ts | 32 + .../eidos/components/split-button/index.ts | 68 ++ .../split-button/split-button-menu.svelte | 77 +++ .../split-button/split-button-primary.svelte | 16 + .../components/split-button/split-button.css | 115 ++++ .../split-button/split-button.svelte | 95 +++ .../eidos/components/split-button/types.ts | 93 +++ src/uix/eidos/generated/base.css | 20 + src/uix/eidos/lib/recipes/base.ts | 51 ++ src/uix/langs/components/button-group.ts | 8 + src/uix/langs/components/fab.ts | 8 + src/uix/langs/components/index.ts | 8 + src/uix/langs/components/onion-menu.ts | 8 + src/uix/langs/components/split-button.ts | 12 + src/uix/morfo/components/button-group.ts | 71 ++ src/uix/morfo/components/fab.ts | 62 ++ src/uix/morfo/components/onion-menu.ts | 167 +++++ src/uix/morfo/components/split-button.ts | 99 +++ .../dropdown-menu-provider.svelte.ts | 6 +- src/uix/soma/components/onion-menu/index.ts | 9 + .../onion-menu/onion-menu-nav.test.ts | 48 ++ .../components/onion-menu/onion-menu-nav.ts | 42 ++ .../onion-menu/onion-menu.svelte.ts | 23 + .../uix/components/button-group/+page.svelte | 496 ++++++++++++++ web/routes/uix/components/fab/+page.svelte | 416 ++++++++++++ .../uix/components/onion-menu/+page.svelte | 610 ++++++++++++++++++ .../uix/components/split-button/+page.svelte | 519 +++++++++++++++ 51 files changed, 6018 insertions(+), 1 deletion(-) create mode 100644 src/uix/eidos/components/button-group/README.md create mode 100644 src/uix/eidos/components/button-group/button-group.css create mode 100644 src/uix/eidos/components/button-group/button-group.svelte create mode 100644 src/uix/eidos/components/button-group/context.ts create mode 100644 src/uix/eidos/components/button-group/index.ts create mode 100644 src/uix/eidos/components/button-group/types.ts create mode 100644 src/uix/eidos/components/fab/README.md create mode 100644 src/uix/eidos/components/fab/fab.css create mode 100644 src/uix/eidos/components/fab/fab.svelte create mode 100644 src/uix/eidos/components/fab/index.ts create mode 100644 src/uix/eidos/components/fab/types.ts create mode 100644 src/uix/eidos/components/onion-menu/README.md create mode 100644 src/uix/eidos/components/onion-menu/color.test.ts create mode 100644 src/uix/eidos/components/onion-menu/color.ts create mode 100644 src/uix/eidos/components/onion-menu/context.svelte.ts create mode 100644 src/uix/eidos/components/onion-menu/geometry.test.ts create mode 100644 src/uix/eidos/components/onion-menu/geometry.ts create mode 100644 src/uix/eidos/components/onion-menu/index.ts create mode 100644 src/uix/eidos/components/onion-menu/onion-layer.svelte create mode 100644 src/uix/eidos/components/onion-menu/onion-menu.css create mode 100644 src/uix/eidos/components/onion-menu/onion-menu.svelte create mode 100644 src/uix/eidos/components/onion-menu/onion-option.svelte create mode 100644 src/uix/eidos/components/onion-menu/types.ts create mode 100644 src/uix/eidos/components/split-button/README.md create mode 100644 src/uix/eidos/components/split-button/context.ts create mode 100644 src/uix/eidos/components/split-button/index.ts create mode 100644 src/uix/eidos/components/split-button/split-button-menu.svelte create mode 100644 src/uix/eidos/components/split-button/split-button-primary.svelte create mode 100644 src/uix/eidos/components/split-button/split-button.css create mode 100644 src/uix/eidos/components/split-button/split-button.svelte create mode 100644 src/uix/eidos/components/split-button/types.ts create mode 100644 src/uix/langs/components/button-group.ts create mode 100644 src/uix/langs/components/fab.ts create mode 100644 src/uix/langs/components/onion-menu.ts create mode 100644 src/uix/langs/components/split-button.ts create mode 100644 src/uix/morfo/components/button-group.ts create mode 100644 src/uix/morfo/components/fab.ts create mode 100644 src/uix/morfo/components/onion-menu.ts create mode 100644 src/uix/morfo/components/split-button.ts create mode 100644 src/uix/soma/components/onion-menu/index.ts create mode 100644 src/uix/soma/components/onion-menu/onion-menu-nav.test.ts create mode 100644 src/uix/soma/components/onion-menu/onion-menu-nav.ts create mode 100644 src/uix/soma/components/onion-menu/onion-menu.svelte.ts create mode 100644 web/routes/uix/components/button-group/+page.svelte create mode 100644 web/routes/uix/components/fab/+page.svelte create mode 100644 web/routes/uix/components/onion-menu/+page.svelte create mode 100644 web/routes/uix/components/split-button/+page.svelte diff --git a/src/uix/eidos/components/button-group/README.md b/src/uix/eidos/components/button-group/README.md new file mode 100644 index 000000000..1064003b0 --- /dev/null +++ b/src/uix/eidos/components/button-group/README.md @@ -0,0 +1,156 @@ +# ButtonGroup + +Presentational cluster of independent action **buttons**. `role="group"` + +shared visual defaults that propagate to the child Buttons + segmented +(attached) or spaced layout. + +```svelte + + + + + + + + + + + + + + +``` + +The children are real ` +// +// +// +// +// Single-root component — the default IS the component. Children are real +// diff --git a/src/uix/eidos/components/fab/index.ts b/src/uix/eidos/components/fab/index.ts new file mode 100644 index 000000000..c0aba4b75 --- /dev/null +++ b/src/uix/eidos/components/fab/index.ts @@ -0,0 +1,23 @@ +// Fab — eidos Floating Action Button. +// +// import { Fab } from '$uix/eidos/components/fab'; +// +// +// {#snippet icon()}{/snippet} +// +// +// +// +// {#snippet icon()}{/snippet} +// Compose +// +// +// Composes {/snippet} +// +// +// +// +// +// +// +// +// +// +// The parts REGISTER into a reactive tree (via context); the root paints the SVG +// rings centrally from the pure geometry + colour engines. An option's children +// are a nested composed inside it (like DropdownMenu.Sub). +import OnionMenu from './onion-menu.svelte'; +import OnionLayer from './onion-layer.svelte'; +import OnionOption from './onion-option.svelte'; + +export { OnionMenu, OnionLayer, OnionOption }; + +export type { + OnionMenuProps, + OnionLayerProps, + OnionOptionProps, + OnionColor, + OnionPlacement, + OnionDisposition, + OnionDisplay +} from './types'; + +// Pure engines — exposed for advanced consumers + tests. +export { computeOnionGeometry, onionArc, ICON_SIZE } from './geometry'; +export { onionBaseColor, deriveOnionShade, deriveOnionMuted, deriveOnionTrigger } from './color'; diff --git a/src/uix/eidos/components/onion-menu/onion-layer.svelte b/src/uix/eidos/components/onion-menu/onion-layer.svelte new file mode 100644 index 000000000..388dd6493 --- /dev/null +++ b/src/uix/eidos/components/onion-menu/onion-layer.svelte @@ -0,0 +1,48 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/onion-menu/onion-menu.css b/src/uix/eidos/components/onion-menu/onion-menu.css new file mode 100644 index 000000000..d4f6b0794 --- /dev/null +++ b/src/uix/eidos/components/onion-menu/onion-menu.css @@ -0,0 +1,219 @@ +/* + * OnionMenu recipe. + * + * The fills / text / stroke are computed per sector by the colour engine and + * applied inline (they depend on the live branch + drill state). This recipe + * owns only the structural chrome: the surface, the sector affordances + enter + * animation, the label / icon typography, and the trigger `+`→`×` glyph. + * + * [data-onion-menu] → provider container + * [data-onion-menu-surface] → the SVG menu (role="menu") + * [data-onion-menu-item] → an option sector (role="menuitem") + * [data-onion-menu-trigger] → the centre trigger + */ + +[data-onion-menu-frame] { + display: inline-flex; + position: relative; + line-height: 0; +} + +[data-onion-menu-surface] { + overflow: visible; +} + +/* The surface is a container, never an outlined target — kill the default focus + ring on plain `:focus` too (click-focusing the SVG would otherwise draw a + black box). Item focus shows via the sector ring, not an outline. */ +[data-onion-menu-surface]:focus, +[data-onion-menu-surface] :focus { + outline: none; +} + +/* ── Sectors ─────────────────────────────────────────────────────────── */ + +.onion-menu-sector { + filter: drop-shadow(var(--onion-menu-sector-shadow)); + transition: + fill var(--duration-fast) var(--ease-default), + opacity var(--duration-fast) var(--ease-default), + filter var(--duration-fast) var(--ease-default); + transform-box: fill-box; + transform-origin: center; + animation: onion-sector-in var(--duration-fast, 150ms) var(--ease-out, ease-out) both; +} + +.onion-menu-sector[data-disabled] { + opacity: var(--opacity-muted, 0.55); +} + +.onion-menu-sector:focus-visible { + outline: none; + stroke: var(--focus-ring-color, currentColor); + stroke-width: var(--onion-menu-sector-ring-width); +} + +@keyframes onion-sector-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* ── Sector group — scopes the per-sector hover affordance ────────────── */ + +.onion-menu-sector-group { + cursor: pointer; +} + +.onion-menu-sector-group.is-disabled { + cursor: default; +} + +/* Passed-trail (muted) sectors dim their label + icon; the faded FILL is + already produced by the colour engine. */ +.onion-menu-sector-group.is-muted .onion-menu-label, +.onion-menu-sector-group.is-muted .onion-menu-icon { + opacity: var(--onion-menu-muted-opacity); +} + +/* Selection / active ring — the followed branch + the chosen leaf (the morfo's + active / selected state). Uses the on-solid token, not a raw colour. */ +.onion-menu-sector-group.is-highlight .onion-menu-sector { + stroke: var(--color-content-on-solid); + stroke-width: var(--onion-menu-sector-ring-width); +} + +/* Hover: lift the fill and bring the label / icon back to full strength so + the option reads as fully selectable (matters most on the muted trail). */ +.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-sector { + filter: drop-shadow(var(--onion-menu-sector-shadow)) brightness(1.1) saturate(1.05); +} + +/* A translucent ring is the primary "this one" signal — it reads on any fill + lightness, where brightness alone is too subtle. Skipped on highlighted + (active / selected) sectors: they already carry a solid ring we mustn't thin. + Uses the on-solid token at reduced opacity — the same colour as the selection + ring, kept lighter so hover reads as "candidate", not "chosen". */ +.onion-menu-sector-group:not(.is-disabled):not(.is-highlight):hover .onion-menu-sector { + stroke: var(--color-content-on-solid); + stroke-width: var(--onion-menu-sector-hover-ring-width); + stroke-opacity: var(--onion-menu-hover-ring-opacity); +} + +.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-label, +.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-icon { + opacity: 1; +} + +/* ── Labels + icons ──────────────────────────────────────────────────── */ + +.onion-menu-label { + font-family: var(--style-label-font-family, var(--font-ui)); + font-weight: var(--style-label-font-weight, 500); + pointer-events: none; + user-select: none; + transition: opacity var(--duration-fast) var(--ease-default); +} + +.onion-menu-icon { + inline-size: 100%; + block-size: 100%; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + /* clicks pass through the icon to the sector path beneath it */ + pointer-events: none; + transition: opacity var(--duration-fast) var(--ease-default); +} + +/* The glyph fills a proportion of its geometry box (ICON_SIZE) — a layout ratio, + not a fixed px, so it scales with the ring sizing. */ +.onion-menu-icon :global(svg) { + inline-size: 70%; + block-size: 70%; +} + +/* ── Trigger ─────────────────────────────────────────────────────────── */ + +/* A real HTML button overlaid at the SVG centre (positioned by inline left/top + %, centred by the transform). Its elevation is EXACTLY the FAB's — the recipe + wires `--onion-menu-trigger-shadow: var(--fab-shadow)` + `--onion-menu-trigger-lift: + var(--fab-lift)`, so the trigger shares the FAB's shadow + hover lift BY REFERENCE + (they can't drift). */ +.onion-menu-trigger { + position: absolute; + transform: translate(-50%, -50%); + aspect-ratio: 1; + display: grid; + place-items: center; + border: 0; + padding: 0; + border-radius: var(--radius-full, 50%); + cursor: pointer; + color: var(--color-content-on-solid); + background: var(--_onion-trigger-fill); + box-shadow: var(--onion-menu-trigger-shadow); + /* FAB-style hover lift — `translate` composes with the centring `transform`. */ + transition: + translate var(--duration-fast) var(--ease-out, ease-out), + box-shadow var(--duration-fast) var(--ease-default), + background var(--duration-fast) var(--ease-default); +} + +.onion-menu-trigger:hover { + translate: 0 calc(-1 * var(--onion-menu-trigger-lift)); +} + +.onion-menu-trigger:focus-visible { + outline: 2px solid var(--focus-ring-color, currentColor); + outline-offset: 2px; +} + +/* The `+`→`×` glyph — two bars built from pseudo-elements; the span rotates 45° + on open to cross them. */ +.onion-menu-trigger-glyph { + position: relative; + inline-size: 42%; + block-size: 42%; + transition: transform var(--duration-normal, 300ms) var(--ease-default); +} + +.onion-menu-trigger-glyph::before, +.onion-menu-trigger-glyph::after { + content: ''; + position: absolute; + inset-inline: 0; + inset-block-start: 50%; + block-size: var(--onion-menu-glyph-bar-thickness); + margin-block-start: calc(var(--onion-menu-glyph-bar-thickness) / -2); + border-radius: var(--radius-full, 1px); + background: currentColor; +} + +.onion-menu-trigger-glyph::after { + transform: rotate(90deg); +} + +.onion-menu-trigger[data-state='open'] .onion-menu-trigger-glyph { + transform: rotate(45deg); +} + +@media (prefers-reduced-motion: reduce) { + .onion-menu-sector { + animation: none; + transition: none; + } + .onion-menu-label, + .onion-menu-icon, + .onion-menu-trigger, + .onion-menu-trigger-glyph { + transition: none; + } + .onion-menu-trigger:hover { + translate: none; + } +} diff --git a/src/uix/eidos/components/onion-menu/onion-menu.svelte b/src/uix/eidos/components/onion-menu/onion-menu.svelte new file mode 100644 index 000000000..5d4a6894c --- /dev/null +++ b/src/uix/eidos/components/onion-menu/onion-menu.svelte @@ -0,0 +1,515 @@ + + + +
+ + + +
+ + + + {#each sectors as v (sid(v))} + {#if v.label && v.label.text && v.label.geom.disposition === 'angular'} + + {/if} + {/each} + + + {#if open} + {#each sectors as v (sid(v))} + + + + + clickItem(v.ring, v.index)} + /> + + {#if v.label && v.label.text} + {@const g = v.label.geom} + {#if g.disposition === 'angular'} + + {v.label.text} + + {:else} + {v.label.text} + {/if} + {/if} + + {#if v.icon} + + +
+ {@render v.opt.icon?.()} +
+
+ {/if} +
+ {/each} + {/if} + +
+ + + +
+
diff --git a/src/uix/eidos/components/onion-menu/onion-option.svelte b/src/uix/eidos/components/onion-menu/onion-option.svelte new file mode 100644 index 000000000..b45ff2a9d --- /dev/null +++ b/src/uix/eidos/components/onion-menu/onion-option.svelte @@ -0,0 +1,56 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/onion-menu/types.ts b/src/uix/eidos/components/onion-menu/types.ts new file mode 100644 index 000000000..86d8137dc --- /dev/null +++ b/src/uix/eidos/components/onion-menu/types.ts @@ -0,0 +1,91 @@ +import type { Snippet } from 'svelte'; +import type { OnionPlacement, OnionDisposition, OnionDisplay } from './geometry'; + +export type { OnionPlacement, OnionDisposition, OnionDisplay }; + +/** + * A branch / option colour — a canonical THEME colour name: a Radix-style scale + * (`'teal'`, `'indigo'`, `'orange'`, …) or a role (`'primary'`, `'affirm'`, …). + * The render resolves it to the theme's `--scale-*` / `--primitive-*` tokens, so + * the menu always tracks the active theme. Omit it to auto-assign a distinct + * default per top-level branch. + */ +export type OnionColor = string; + +/** + * Props for the root ``. The menu is COMPOSITIONAL: it holds the + * trigger + one root ``; options nest their children by composing a + * `` inside an `` (like `DropdownMenu.Sub`). The root + * owns the open + drill state, collects the composed tree via context, and + * paints the SVG rings centrally from the geometry + colour engines. + */ +export interface OnionMenuProps { + /** Anchor → arc span (90 / 180 / 360) + orientation. @default 'bottom-end' */ + placement?: OnionPlacement; + /** Whether the rings show. Bindable. @default false */ + open?: boolean; + /** Child indices of the active drill path. Bindable. @default [] */ + drillPath?: number[]; + /** Index path to the currently-selected leaf, or null. Bindable. */ + selected?: number[] | null; + /** Inner radius where ring 1 begins (just outside the trigger). @default 82 */ + innerRadius?: number; + /** Default radial thickness per ring (a layer can override). @default 66 */ + ringWidth?: number; + /** Default fixed pixel gap between segments (a layer can override). @default 5 */ + gap?: number; + /** Label font size in SVG px (≥ sm). @default 13 */ + fontSize?: number; + /** SVG viewBox width. @default 680 */ + width?: number; + /** SVG viewBox height. @default 620 */ + height?: number; + /** Center trigger content (any element). Defaults to a + that rotates to ×. */ + trigger?: Snippet; + /** Clicked the option at ring `ring` (0-based) / sector `index`. */ + onItemClick?: (ring: number, index: number) => void; + /** Clicked the center trigger. */ + onTriggerClick?: () => void; + /** The root `` (+ any registration-only descendants). */ + children?: Snippet; +} + +/** + * Props for `` — one concentric ring / level. The root layer is the + * first ring; a layer composed inside an `` is that option's + * children. Its config applies to all the options it holds. + */ +export interface OnionLayerProps { + /** How this level's labels are laid out. @default 'angular' */ + textDisposition?: OnionDisposition; + /** Base colour when this level is a drilled branch (else inherited/derived). */ + color?: OnionColor; + /** Default display for the level's options. @default 'both' */ + display?: OnionDisplay; + /** Radial thickness of this ring. */ + ringWidth?: number; + /** Fixed pixel gap for this ring. */ + gap?: number; + /** The ``s of this level. */ + children?: Snippet; +} + +/** + * Props for `` — one option (sector). If it has children, compose a + * `` inside it (the children pass through `children`). + */ +export interface OnionOptionProps { + /** Visible label (and accessible name). */ + label?: string; + /** Glyph snippet (rendered via a framework `` / ``). */ + icon?: Snippet; + /** Branch base (top option) or per-option override. Auto-distinct if omitted. */ + color?: OnionColor; + /** Per-option display override. */ + display?: OnionDisplay; + disabled?: boolean; + /** Fired when this LEAF option is chosen. */ + onSelect?: () => void; + /** A nested `` = this option's children (makes it a branch). */ + children?: Snippet; +} diff --git a/src/uix/eidos/components/split-button/README.md b/src/uix/eidos/components/split-button/README.md new file mode 100644 index 000000000..a7b6ec0df --- /dev/null +++ b/src/uix/eidos/components/split-button/README.md @@ -0,0 +1,150 @@ +# SplitButton + +A primary action joined to a ▾ menu trigger that opens secondary actions — +`Save | ▾` → Save as…, Save a copy…. The primary runs its own `onclick`; **only +the ▾ opens the menu**. + +```svelte + + + + Save + + Save as… + Save a copy + + Discard + + +``` + +## Baseline + +No Air baseline. SplitButton is **pure composition** of four existing +components — nothing reinvented: + +- **``** — the segmented group (role=group + label, the + one-border-per-seam segmenting, and the variant/size/color propagation + context we built for it). +- **` + + + + +
+ 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'} +
+

Controls

+

+ ButtonGroup is eidos-only — no soma split. The + visual props propagate as defaults to the child Buttons; an explicit prop on a child + always wins (toggle override middle to see it). +

+ +
+ eidos props · visual treatment + layout +
+
+ + + + + + + + + + +
+ +
+
+ eidos + visual · composes Group + Button, propagates defaults + svelte +
+
{eidosSnippet}
+
+
+ {/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. +

+
+ + + + + + + + + + + + + + +
PropTypeDefaultDescription
variant'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain''solid'Propagated to child Buttons as their default variant.
sizeResponsiveProp<'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.
disabledbooleanfalseDisables every child Button at once.
orientation'horizontal' | 'vertical''horizontal'Layout axis → aria-orientation + data-orientation.
attachedbooleantrueSegmented: children share edges, inner corners squared, doubled borders collapsed.
blockbooleanfalseStretch the group to fill the container inline-size; children fill it equally.
equalbooleanattachedSame button width. Vertical: each = the widest (content-sized). Horizontal: fills the container, split equally. Defaults ON when attached.
aria-labelstring—Accessible name. Recommended — role="group" needs a name.
aria-labelledbystring—Accessible name by reference (id of a visible label).
+
+ +
Reference comparison
+
+ + + + + + + +
LibraryClosest equivalentDifference 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-themesnone (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. +

+ +
+ + + + + + + + + +
FieldValue
name"{buttonGroupMorfo.name}"
kebab"{buttonGroupMorfo.kebab}"
scope[{buttonGroupMorfo.scope.map((s) => `"${s}"`).join(', ')}]
parts.length{buttonGroupMorfo.parts.length}
events.length0
+
+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStatesOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}{part.optional ? 'yes' : 'no'}
+
+ + {#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} +
+ + + + {#each dataAttrs as attr} + + + + + + {/each} + +
data-attrValuesSource
{attr.attr}{attr.values ? attr.values.join(' | ') : '—'}{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
+
+ {/if} + {#if ariaAttrs.length} +
+ + + + {#each ariaAttrs as a} + + + + + + + {/each} + +
aria-attrSourceConditionSeverity
{a.attr}{a.value.kind}{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'}{a.severity ?? 'required'}
+
+ {/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. +

+
+ + + + + + + + + +
SelectorSourcePurpose
[data-button-group]morfoProvider marker. Tunes Group's --group-attached-overlap to the Button border.
[data-button-group][data-orientation]morfoLayout axis (also drives Group's data-direction).
[data-button-group][data-disabled]morfoGroup-level disabled (propagated to each Button).
[data-button-group][data-attached] [data-button]:focus-visibleeidosFloat the focused/active button above neighbours so the ring isn't clipped.
[data-button-group][data-block]eidosFull-width stretch.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + +
ConcernContract
Rolerole="group" — declared in the morfo, fixed. A cluster of independent actions. For toolbar semantics (roving focus, single tab stop) use <Toolbar> instead.
LabelProvide aria-label or aria-labelledby — a role="group" without a name is announced as an unnamed group.
Orientationaria-orientation mirrors the orientation prop (horizontal / vertical).
KeyboardEach button stays independently Tab-focusable — no roving. The group itself is not focusable; activation is the native button keyboard (Enter / Space) on each child.
DisabledThe 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. +

+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
extendedbooleanfalsePill 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.
offsetstringvar(--fab-offset)Distance from the edges when floating.
iconSnippet—The glyph (visible in both forms).
variantButtonVariant'solid'Forwarded to Button (FAB defaults to the prominent solid).
intent / colorlike Buttonneutral / primaryForwarded — the screen's primary action.
aria-labelstring—Accessible name. Required for the icon-only form.
+
+ +
Reference comparison
+
+ + + + + + + + +
LibraryClosest equivalentDifference 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. +

+
+ + + + + + + + + + +
FieldValue
name"{fabMorfo.name}"
kebab"{fabMorfo.kebab}"
scope[{fabMorfo.scope.map((s) => `"${s}"`).join(', ')}]
apgbutton ↗
parts.length{fabMorfo.parts.length}
events.length0
+
+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStates
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}
+
+
+ {/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. +

+
+ + + + + + + + + +
SelectorSourcePurpose
[data-fab]eidosElevation tokens + rest shadow + transition.
[data-fab]:hover:not([data-disabled])eidosHover lift via the translate property (composes with Button's press-squeeze).
[data-button][data-fab][data-fab-size='…']eidosFAB diameter + glyph size (out-specifies Button).
[data-fab][data-extended]eidosPill: auto width + label padding + gap.
[data-fab][data-placement='…']eidosFixed positioning + safe-area inset.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + +
ConcernContract
RoleA native <button> (from Button) — role="button", focusable, Enter/Space activate.
Accessible nameIcon-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).
Disableddisabled (from Button) removes it from the tab order + drops the elevation.
MotionThe hover lift is suppressed under prefers-reduced-motion.
PlacementFloating 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()}{/snippet} +{#snippet iShare()}{/snippet} +{#snippet iEdit()}{/snippet} +{#snippet iDoc()}{/snippet} +{#snippet iDot()}{/snippet} +{#snippet iCheck()}{/snippet} +{#snippet iX()}{/snippet} +{#snippet iMinus()}{/snippet} +{#snippet triggerSnippet()}Go{/snippet} + +
+
+
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'} +
+

Controls

+

+ OnionMenu is eidos render — the geometry + colour engines react to these props live. + Events live in the tab. +

+ +
+ eidos props · OnionMenu (root) · layout + placement +
+
+ + + + + + +
+ +
+ eidos props · OnionLayer / OnionOption · content +
+
+ + + + + +
+ +
+
+ eidos + compositional · OnionMenu › OnionLayer › OnionOption + svelte +
+
{eidosSnippet}
+
+
+ {/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
+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
placement'bottom-end' | … | 'center''bottom-end'Anchor → arc span (90/180/360) + orientation.
open bindablebooleanfalseWhether the rings show.
drillPath bindablenumber[][]Child indices of the active drill path.
selected bindablenumber[] | nullnullIndex path to the selected leaf.
innerRadius / ringWidth / gap / fontSizenumber82 / 66 / 5 / 13Geometry knobs (a layer can override ringWidth/gap).
width / heightnumber680 / 620SVG viewBox size.
triggerSnippet— (+)Center trigger content (any element).
onItemClick / onTriggerClickcallback—Notified on click (drill is owned internally).
+
+
OnionLayer
+
+ + + + + + + + +
PropTypeDefaultDescription
textDisposition'angular' | 'horizontal' | 'vertical''angular'Label layout for this ring.
colortheme 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 / gapnumber— (root)Override the ring thickness / gap.
+
+
OnionOption
+
+ + + + + + + + + + +
PropTypeDefaultDescription
labelstring—Visible label + accessible name.
iconSnippet—Glyph (a framework <Svg*> / <Icon>).
colortheme 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 / onSelectboolean / () => 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. +

+
+ + + + + + + + + + +
FieldValue
name"{onionMenuMorfo.name}"
kebab"{onionMenuMorfo.kebab}"
scope[{onionMenuMorfo.scope.map((s) => `"${s}"`).join(', ')}]
apgmenu-button ↗
parts.length{onionMenuMorfo.parts.length}
events.length{events.length}
+
+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStates
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}
+
+ + {#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} +
+ + + + {#each dataAttrs as attr} + + + + + + {/each} + +
data-attrValuesSource
{attr.attr}{attr.values ? attr.values.join(' | ') : '—'}{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
+
+ {/if} + {#if ariaAttrs.length} +
+ + + + {#each ariaAttrs as a} + + + + + + + {/each} + +
aria-attrSourceConditionSeverity
{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'}
+
+ {/if} + {#if keys.length} +
+ + + + {#each keys as k} + + {/each} + +
keyaction
{k.key === ' ' ? 'Space' : k.key}{k.action}
+
+ {/if} + {/if} + {/each} + +
Events
+
+ + + + {#each onionMenuMorfo.events as ev} + {@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: string; target?: { kind: string; ref?: string } }} + + + + + + + + + {/each} + +
namefamilyverbsequenceintenttarget
{ev.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? '—'}{sem.intent ?? '—'}{sem.target?.ref ?? sem.target?.kind ?? '—'}
+
+
+ {/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. +

+
+ + + + {#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'} + + + + + + + + + {/each} + +
namefamilyverbsequenceintentplay
{ev.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? '—'}{sem.intent ?? '—'} + +
+
+
+ {/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. +

+
+ + + + + + + + + +
SelectorSourcePurpose
[data-onion-menu-surface]morfoThe SVG menu (role="menu").
.onion-menu-sectoreidosSector affordance + fade-in + focus ring.
.onion-menu-labeleidosLabel typography (curved / flat / radial).
.onion-menu-iconeidosIcon box inside the foreignObject.
.onion-menu-trigger-glyph[data-state='open']eidosRotate the + → × on open.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + +
ConcernContract
TriggerAny element; the soma stamps role="button" + aria-haspopup="menu" + aria-expanded.
Surfacerole="menu"; menuitem sectors; branch items expose aria-haspopup + aria-expanded.
Keyboard somaEnter/Space/↓ open · ←/→ rotate · Enter drill/activate · Backspace up · Esc close.
MotionSector 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'} +
+

Controls

+

+ SplitButton is eidos-only — no soma split. The + visual props propagate to BOTH the primary and the ▾ trigger via the ButtonGroup context. +

+ +
+ eidos props · visual treatment +
+
+ + + + + +
+ +
+ eidos props · menu (float) · + <SplitButton.Menu> +
+
+ + + {#if !menuOffsetAuto} + + {/if} +
+ +
Demo content · primary label
+
+ +
+ +
+
+ eidos + visual · composes ButtonGroup + Button + DropdownMenu + svelte +
+
{eidosSnippet}
+
+
+ {/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. +

+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
open bindablebooleanfalseMenu open state.
variant'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain''solid'Propagated to both buttons.
sizeResponsiveProp<'xs' | 'sm' | 'md' | 'lg' | 'xl'>'md'Propagated to both buttons.
color'primary' | 'secondary' | 'neutral''primary'Propagated to both buttons.
disabledbooleanfalseDisables both buttons.
iconSnippet— (chevron)Override the ▾ glyph. The default chevron rotates while open.
menuLabelstring'More actions'Accessible name for the ▾ trigger.
aria-labelstring—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. +

+
+ + + + + + + + +
PropTypeDefaultDescription
matchAnchorWidthResponsiveProp<boolean>trueMin width tracks the split surface (the anchor width); content can grow it.
sideOffsetnumbercanonicalGap below the button. Unset → resolved from the size's --space-* step (density × scaling aware); pass a number to override.
sizeResponsiveProp<'xs'…'xl'>split's sizeForwarded 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
+
+ + + + + + + +
LibraryClosest equivalentDifference 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.
muiButtonGroup + Menu recipeMUI 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. +

+ +
+ + + + + + + + + + +
FieldValue
name"{splitButtonMorfo.name}"
kebab"{splitButtonMorfo.kebab}"
scope[{splitButtonMorfo.scope.map((s) => `"${s}"`).join(', ')}]
apgmenu-button ↗
parts.length{splitButtonMorfo.parts.length}
events.length0
+
+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStates
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}
+
+ + {#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} +
+ + + + {#each dataAttrs as attr} + + + + + + {/each} + +
data-attrValuesSource
{attr.attr}{attr.values ? attr.values.join(' | ') : '—'}{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
+
+ {/if} + {#if ariaAttrs.length} +
+ + + + {#each ariaAttrs as a} + + + + + + + {/each} + +
aria-attrSourceConditionSeverity
{a.attr}{a.value.kind}{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'}{a.severity ?? 'required'}
+
+ {/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. +

+
+ + + + + + + + + + +
SelectorSourcePurpose
[data-split-button][data-state='open']morfoGroup open state (stamped from `open`).
… [data-split-button-trigger] [data-svg='chevron']eidosRotate the ▾ chevron 180° while the menu is open.
[data-split-button-trigger][data-icon-only]eidosSlim the ▾ to glyph + snug padding (not a square).
[data-dropdown-menu-content][data-split-button-menu]eidosmin-inline-size = the anchor (surface) width; content grows it.
[data-split-button-menu][data-size='…']eidosGap step per size (font / height / padding scale come from DropdownMenu's own size axis).
[data-split-button-menu][data-canonical-gap][data-side='…']eidosCanonical separation: a --space-* margin on the placed side.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + +
ConcernContract
Grouprole="group" (from ButtonGroup) + aria-label — names the split as a unit.
PrimaryA native <button> (from Button) — its own action, independently Tab-focusable.
Menu triggerFollows the APG Menu Button (from DropdownMenu): aria-haspopup="menu" + aria-expanded, opens with Enter/Space/ArrowDown, names itself via menuLabel.
Menurole="menu" with arrow-key navigation, typeahead, Escape-to-close, focus return to the ▾ (all from DropdownMenu).
KeyboardTab moves between the primary and the ▾ (no roving); each opens / activates with its native keys.
+
+
+ {/if} +