|
|
4 months ago | |
|---|---|---|
| .. | ||
| README.md | 4 months ago | |
| color.test.ts | 4 months ago | |
| color.ts | 4 months ago | |
| context.svelte.ts | 4 months ago | |
| geometry.test.ts | 4 months ago | |
| geometry.ts | 4 months ago | |
| index.ts | 4 months ago | |
| onion-layer.svelte | 4 months ago | |
| onion-menu.css | 4 months ago | |
| onion-menu.svelte | 4 months ago | |
| onion-option.svelte | 4 months ago | |
| types.ts | 4 months ago | |
README.md
OnionMenu
A trigger-agnostic radial ("onion") menu: any trigger opens a ring of options around a centre; an option with children drills into a new concentric ring. The active branch owns a distinct theme colour, the deeper rings derive lighter/darker tints by depth, and the already-passed inner rings mute to a trail — only the followed option stays lit.
<script>
import { OnionMenu, OnionLayer, OnionOption } from '$uix/eidos/components/onion-menu';
import { SvgPlus, SvgCheck } from '$uix/eidos/components/svg';
let open = $state(false);
</script>
<OnionMenu bind:open placement="bottom-end">
<OnionLayer textDisposition="angular">
<OnionOption label="Create" color="teal">
{#snippet icon()}<SvgPlus />{/snippet}
<OnionLayer textDisposition="vertical">
<OnionOption label="Document" onSelect={createDoc} />
<OnionOption label="Folder" onSelect={createFolder} />
</OnionLayer>
</OnionOption>
<OnionOption label="Share" onSelect={share}>
{#snippet icon()}<SvgCheck />{/snippet}
</OnionOption>
</OnionLayer>
</OnionMenu>
Compositional API
The menu is declared by composition, not a tree data prop. <OnionLayer> is a
ring; an option's children are a nested <OnionLayer> placed inside its
<OnionOption> (like DropdownMenu.Sub). The parts don't paint — they register
into a reactive node tree via context; the root <OnionMenu> reads that tree and
paints the SVG rings centrally. Parts declare structure, the root draws.
| Component | Role |
|---|---|
<OnionMenu> |
Root. Owns open / drillPath / selected, the trigger, render + behaviour. |
<OnionLayer> |
One ring. Root layer = ring 1; a layer inside an option = that option's children. |
<OnionOption> |
One sector. Nest an <OnionLayer> inside it to give it children. |
Key <OnionMenu> props: placement (anchor → arc span 90/180/360 + orientation),
open / drillPath / selected (bindable), innerRadius / ringWidth / gap /
fontSize / width / height (geometry), trigger (snippet — any element).
<OnionLayer>: textDisposition (angular/horizontal/vertical), color,
display, ringWidth, gap. <OnionOption>: label, icon (snippet), color,
display, disabled, onSelect.
Layers
- morfo (
$uix/morfo/components/onion-menu) — the contract: a Menu Button (APG),role="menu"surface,menuitemsectors, thefocuspolicy (non-modal: arrows navigate, Tab / outside-click exit + close, focus returns to the trigger), the keyboard map, and theopen/close(emerge) +commit-select(commit · affirm) events.expression: 'family-default'— no per-component sema pack. - soma (
$soma/components/onion-menu) — the headless behaviour bridge:createOnionMenuRuntimeemits the perceptual signals and dispatches the keyboard contract;onionNav*are the pure radial focus maths. The root drives focus / drill / dismiss against them. - eidos (here) — the SVG render + the two pure engines (geometry + colour) + the
recipe. The root owns
open/drillstate and paints. - sema — picks
open/closeup from theemergefamily base andcommit-selectfromcommit(no pack; family defaults).
Pure engines
geometry.ts— annular-sector maths: placement → arc, constant-pixel gaps (the angular pad scales1/r), curved labels via<textPath>(flipped on the lower arc), icon boxes. Output is geometry only — no colour, no DOM.color.ts— fully theme-driven colour viacolor-mix()of the theme's canonical scales (--scale-{name}-*) / roles (--primitive-{role}-*). A branch is a scale name; depth + muted recede the solid (step 9) toward the subtle step (step 3), which tracks the mode background (lightens in light mode, darkens in dark mode). Text auto-contrasts (--color-content-on-solidover solids, step 12 over tints). No invented colours — retinting the theme retints the menu.
Keyboard & a11y
Opened from the trigger (Enter / Space / ArrowDown), focus moves into the ring.
Within a ring: ArrowRight / ArrowLeft move (wrap around, skip disabled),
Enter / Space activate (drill a branch / choose a leaf), Backspace drills up (and
closes at the root), Escape closes. Closing restores focus to the trigger.
Outside-click and Tab-out dismiss (non-modal). Branch sectors expose
aria-haspopup="menu" + aria-expanded; the surface is a role="menu", sectors are
role="menuitem".
Recipe
The fills / text / stroke are computed per sector by the colour engine and applied
inline (they depend on the live branch + drill state). The recipe (onion-menu.css)
owns only structural chrome: the surface, the sector enter animation, the
hover / selection rings (--color-content-on-solid at full / --opacity-65), the
elevation (--shadow-raised sectors, --shadow-overlay trigger — single-layer tokens
drop straight into drop-shadow()), the label / icon typography, and the +→×
trigger glyph. All colours come through tokens; no raw hex in the recipe.
Decisions
- Compositional, not data-driven — parts register a node tree; the root paints. An
option's children nest inside the option (
DropdownMenu.Subshape). - Theme-driven colour —
color-mixof--scale-*, mode-adaptive recession. Thecolorprop is a theme scale / role name, never a raw colour. - Behaviour lives in the SVG root, not a parts provider — the menu is a single SVG renderer that already owns its state, so soma here is a behaviour bridge (runtime + nav maths) the root drives, rather than a compound provider tree.
- Focus on open transition, not mount — a controlled
open=trueat mount does not steal focus; user/programmatic open transitions move focus into the ring.