feat(eidos): menu-dial speed dial — compositional, arc + 9 zones

- MenuDial: a FAB trigger that fans a line OR a placement-driven arc of
  mini-FAB actions. 9 zones mirror OnionMenu's onionArc (90 corner / 180
  edge / 360 centre); radius auto-scales so the actions never overlap.
- Each action's NAME composes <Tooltip> (Floating-UI positioned, portaled,
  collision-corrected) — tooltip on hover/focus, inline = forced open. No
  hand-rolled label.
- Hover-open close delay (cross-gap); action size adapts one FAB step below
  the trigger.
- Completes the FAB `xs` (30px) mini step: fab.css [data-fab-size='xs']
  cascade + FabSize union (the recipe tokens already shipped with timeline).
- morfo + langs + soma (linear nav maths + runtime) + eidos + demo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent c1effca1b4
commit a0da99aa15

@ -54,6 +54,12 @@
/* ── Size (diameter + glyph) ─────────────────────────────────────────── */
[data-button][data-fab][data-fab-size='xs'] {
block-size: var(--fab-size-xs);
/* Below the Button's touch-target floor — the mini FAB owns its diameter. */
min-block-size: var(--fab-size-xs);
font-size: var(--fab-icon-xs);
}
[data-button][data-fab][data-fab-size='sm'] {
block-size: var(--fab-size-sm);
font-size: var(--fab-icon-sm);
@ -70,6 +76,10 @@
/* Circular icon-only form: width = height. Overrides Button's iconOnly
* `inline-size: var(--_button-height)` (which would size to the control
* height, not the FAB diameter). */
[data-button][data-fab][data-fab-size='xs']:not([data-extended]) {
inline-size: var(--fab-size-xs);
min-inline-size: var(--fab-size-xs);
}
[data-button][data-fab][data-fab-size='sm']:not([data-extended]) {
inline-size: var(--fab-size-sm);
}

@ -3,11 +3,11 @@ import type { ButtonProps } from '$uix/eidos/components/button';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/**
* FAB size scale. Deliberately its OWN 3-step scale (not Button's `xs…xl`): a
* FAB is a prominent target, larger than an inline control — `sm` ≈ 40px, `md`
* ≈ 56px (the canonical FAB), `lg` ≈ 72px.
* FAB size scale. Deliberately its OWN scale (not Button's `xs…xl`): a FAB is a
* prominent target — `xs` ≈ 30px (a mini FAB, e.g. speed-dial actions), `sm` ≈
* 40px (the small FAB), `md` ≈ 56px (the canonical FAB), `lg` ≈ 72px.
*/
export type FabSize = 'sm' | 'md' | 'lg';
export type FabSize = 'xs' | 'sm' | 'md' | 'lg';
/**
* Floating placement around the viewport. `static` opts out of the fixed

@ -0,0 +1,93 @@
# MenuDial
A **speed dial**: a FAB that fans out a line — or a placement-driven arc — of
action mini-FABs. The exact feature `fabMorfo` defers to. Flat (no nesting) — for
a few quick actions; the concentric / nested radial case is **OnionMenu**'s. By
APG it's a Menu Button.
```svelte
<script>
import { MenuDial } from '$uix/eidos/components/menu-dial';
import { SvgShare, SvgPencil, SvgTrash } from '$uix/eidos/components/svg';
</script>
<MenuDial direction="up" labels="tooltip" aria-label="Quick actions">
<!-- no `icon` → the default `+`→`×` morph glyph -->
<MenuDial.Action label="Share" onSelect={share}>
{#snippet icon()}<SvgShare />{/snippet}
</MenuDial.Action>
<MenuDial.Action label="Edit" onSelect={edit}>
{#snippet icon()}<SvgPencil />{/snippet}
</MenuDial.Action>
<MenuDial.Action label="Delete" intent="threat" onSelect={remove}>
{#snippet icon()}<SvgTrash />{/snippet}
</MenuDial.Action>
</MenuDial>
```
## Composition
The dial is **compositional** (`<MenuDial.Action>` children, never a `model`
array) and composes the framework's primitives — zero re-invented chrome:
- the **trigger IS a `<Fab>`** (elevation, intent colour, the placement);
- each **action IS a `<Fab>`** too — one size smaller (the mini FAB), to which the
dial adds the `menuitem` role and the label.
The actions render themselves (no SVG geometry, unlike OnionMenu — `linear` is
HTML flex, `arc` is absolute placement via CSS `cos()`/`sin()`) and read the dial
config via context; the root owns the open state, lays the fan out by `layout` +
`direction` + `placement`, and drives the behaviour by querying the action elements.
## Props
**`<MenuDial>`** — `layout` (`linear`/`arc`) · `direction` (`up`/`down`/`left`/`right`
— the linear axis, and the arc opening on `static`) · `radius` (arc radius override) ·
`openOn` (`click`/`hover`) · `placement` (one of the **9 zones** — corner / edge /
centre — or `static`; sets the arc span 90/180/360°) ·
`size` (FAB size — actions are one step smaller) · `labels`
(`tooltip`/`inline`/`none`) · `backdrop` · `stagger` · `open` (bindable) ·
`variant`/`color`/`intent` (trigger) · `icon`/`openIcon` (snippets — omit for the
default `+`→`×` morph) · `aria-label` · `onOpenChange`.
**`<MenuDial.Action>`** — `label` (the name + tooltip/pill) · `icon` · `onSelect`
· `disabled` · `variant`/`color`/`intent`.
## Layers
- **morfo** (`$uix/morfo/components/menu-dial`) — Menu Button (APG); parts
`provider`/`trigger`/`list`/`action`; events `open`/`close` (emerge) +
`commit-select` (commit · affirm); focus policy (non-modal, return to trigger).
- **soma** (`$soma/components/menu-dial`) — `createMenuDialRuntime` (emission +
keyboard dispatch) + the pure linear `dialNav*` maths (testable apart).
- **eidos** (here) — composes Fab (trigger + actions) + Tooltip (each action's
name); the recipe owns the placement, the fan layout (linear + arc), the
open/close + stagger transition and the glyph (NOT the label — Tooltip owns it).
## Keyboard & a11y
Opened from the trigger (`Enter`/`Space`/any Arrow), focus moves into the fan.
Within it: `↑↓`/`←→` move (wrap, skip disabled), `Home`/`End` jump,
`Enter`/`Space` activate (native button), `Escape` closes. Outside-click and
Tab-out dismiss (non-modal); focus returns to the trigger. The fan is `role="menu"`,
the actions `role="menuitem"`, the trigger `aria-haspopup="menu"` + `aria-expanded`.
## Decisions
- **Two layouts** — `linear` (plain flex) and `arc`. The arc mirrors OnionMenu's
`onionArc` over the **9 zones**: **90°** in a corner, **180°** on an edge, **360°**
in the centre (a full ring), opening into the free space; one ring only (the
concentric / nested radial case stays OnionMenu's). The radius auto-scales with
the action count + span so the mini-FABs never overlap.
- **Compositional, compose primitives** — actions are `<MenuDial.Action>` children,
the trigger a `<Fab>`, each action a one-step-smaller `<Fab>` (the mini FAB), and
each action's **name is a composed `<Tooltip>`** — Floating-UI positioned +
portaled, so it's collision-corrected and always on top (never a hand-rolled
label). The `side` is computed radial-outward per action.
- **`labels` is a prop** — `tooltip` (default, the Tooltip floats on hover/focus),
`inline` (the Tooltip forced open via `open`), or `none` (sr-only name only).
- **Glyph state via the FRAME** — the trigger button owns its own `data-state`
(Button press/loading), so the `+`→`×` rotation keys off `[data-menu-dial][data-state='open']`.
- **Double signal, noted** — composing `<Fab>` (→ Button) means an action click emits
the button's `contact-activate` (tap) AND the dial's `commit-select` (affirm). If
the layered feedback is too much, a sema cascade can mute the action's contact.

@ -0,0 +1,39 @@
import { getContext, setContext } from 'svelte';
import type { FabSize } from '$uix/eidos/components/fab';
import type { MenuDialDirection, MenuDialLabels } from './types';
/**
* Context the root `<MenuDial>` provides to its `<MenuDial.Action>`s. The actions
* render themselves (HTML mini-FABs) — unlike OnionMenu's central SVG paint — so
* they only need the shared config (direction / labels / size / stagger order)
* plus the `select` callback. The root drives focus + keyboard by querying the
* action elements in the DOM, so no registration tree is needed.
*/
export interface MenuDialContext {
/** Which way the fan opens (drives the action's label side + slide axis). */
readonly direction: MenuDialDirection;
/** How action names are shown. */
readonly labels: MenuDialLabels;
/** FAB size for the action mini-FABs — one FAB step below the trigger. */
readonly actionSize: FabSize;
/** Whether actions stagger in/out. */
readonly stagger: boolean;
/** Monotonic index assigned per action at init — drives the stagger order. */
nextIndex(): number;
/**
* The Floating-UI `side` the action's label (a composed `<Tooltip>`) should
* prefer — radial-outward in `arc`, perpendicular to the fan in `linear`. The
* Tooltip's own `avoidCollisions` corrects it against the viewport.
*/
labelSide(index: number): 'top' | 'right' | 'bottom' | 'left';
/** Choose an action: emit `commit-select` on its element, run `onSelect`, close. */
select(el: HTMLElement | null, onSelect?: () => void): void;
}
const KEY = Symbol('uix.menu-dial');
export const setMenuDialContext = (ctx: MenuDialContext): void => {
setContext(KEY, ctx);
};
export const getMenuDialContext = (): MenuDialContext | undefined => getContext(KEY);

@ -0,0 +1,35 @@
// MenuDial — eidos speed dial. A FAB that fans out a line or arc of actions.
//
// import { MenuDial } from '$uix/eidos/components/menu-dial';
//
// <MenuDial direction="up" labels="tooltip" aria-label="Quick actions">
// {#snippet icon()}<SvgPlus />{/snippet}
// <MenuDial.Action label="Share" onSelect={…}>
// {#snippet icon()}<SvgShare />{/snippet}
// </MenuDial.Action>
// <MenuDial.Action label="Edit" onSelect={…} intent="risk" />
// </MenuDial>
//
// The trigger is a composed <Fab>; each action a composed <IconButton>. The root
// owns open + the linear keyboard nav / focus / dismiss / perceptual emission;
// the actions render themselves and read the dial config via context.
import MenuDialRoot from './menu-dial.svelte';
import Action from './menu-dial-action.svelte';
type MenuDialComponent = typeof MenuDialRoot & { Action: typeof Action };
const MenuDial = MenuDialRoot as MenuDialComponent;
MenuDial.Action = Action;
export { MenuDial };
export { MenuDial as MenuDialRoot, Action as MenuDialAction };
export type {
MenuDialProps,
MenuDialActionProps,
MenuDialDirection,
MenuDialLayout,
MenuDialLabels,
MenuDialOpenOn,
MenuDialPlacement
} from './types';

@ -0,0 +1,72 @@
<script lang="ts">
/**
* `<MenuDial.Action>` — one action in the fan. It IS a `<Fab>` (the Material
* "small FAB"); its NAME is a composed `<Tooltip>` (Floating-UI positioned,
* portaled → always on top, collision-aware) — `tooltip` reveals it on
* hover/focus, `inline` forces it open. The dial adds the `menuitem` role and
* the stagger order, and selects via the root's context.
*/
import { Fab } from '$uix/eidos/components/fab';
import { Tooltip } from '$uix/eidos/components/tooltip';
import { getMenuDialContext } from './context.svelte';
import type { MenuDialActionProps } from './types';
let {
label,
icon,
onSelect,
disabled = false,
variant,
color,
intent,
children
}: MenuDialActionProps = $props();
const dial = getMenuDialContext();
// Stagger index captured at init (declaration order).
const index = dial ? dial.nextIndex() : 0;
const showLabel = $derived(!!dial && dial.labels !== 'none' && !!label);
const inline = $derived(dial?.labels === 'inline');
</script>
{#if dial}
<Tooltip open={inline ? true : undefined}>
<Tooltip.Trigger>
{#snippet child({ props }: { props: Record<string, unknown> })}
<!-- The trigger IS the positioned action wrapper (no extra element). -->
<div class="menu-dial-action" style="--menu-dial-i: {index};" {...props}>
<Fab
data-menu-dial-action=""
data-disabled={disabled ? '' : undefined}
role="menuitem"
tabindex={-1}
aria-label={label ?? ''}
aria-disabled={disabled ? 'true' : undefined}
{disabled}
{variant}
{color}
{intent}
size={dial.actionSize}
placement="static"
icon={glyph}
onclick={(e: MouseEvent) => {
if (!disabled) dial.select(e.currentTarget as HTMLElement, onSelect);
}}
/>
</div>
{/snippet}
</Tooltip.Trigger>
{#if showLabel}
<Tooltip.Portal>
<Tooltip.Content side={dial.labelSide(index)} avoidCollisions collisionPadding={8}>
{label}
</Tooltip.Content>
</Tooltip.Portal>
{/if}
</Tooltip>
{/if}
{#snippet glyph()}
{#if icon}{@render icon()}{:else}{@render children?.()}{/if}
{/snippet}

@ -0,0 +1,331 @@
/*
* MenuDial (speed dial) recipe.
*
* The trigger is a composed <Fab> and each action a composed <IconButton>; this
* recipe owns only the dial-specific chrome: the floating placement, the fan
* layout (`linear` plain-flex per `data-direction`, or `arc` — actions placed on
* a circle via cos/sin, the span + opening following `data-placement`), the
* open/close + stagger transition, the action elevation, the labels (tooltip /
* inline) and the `+`→`×` trigger glyph. No SVG.
*
* [data-menu-dial] → frame (placement + fan layout)
* [data-menu-dial-trigger] → the trigger <Fab>'s button
* [data-menu-dial-list] → the actions container (role="menu")
* [data-menu-dial-action] → an action mini-FAB (role="menuitem")
*/
[data-menu-dial] {
display: inline-flex;
align-items: center;
gap: var(--menu-dial-gap, var(--space-3));
/* Floating offset from the viewport edges (mirrors the FAB). */
--menu-dial-offset: var(--space-5);
/* Per-action stagger step. */
--menu-dial-stagger-step: 28ms;
/* Scrim behind the open fan. */
--menu-dial-backdrop-bg: color-mix(in oklab, var(--primitive-neutral-12) 40%, transparent);
}
/* ── Fan layout per direction ────────────────────────────────────────── */
[data-menu-dial][data-direction='up'],
[data-menu-dial][data-direction='up'] [data-menu-dial-list] {
flex-direction: column-reverse;
}
[data-menu-dial][data-direction='down'],
[data-menu-dial][data-direction='down'] [data-menu-dial-list] {
flex-direction: column;
}
[data-menu-dial][data-direction='left'],
[data-menu-dial][data-direction='left'] [data-menu-dial-list] {
flex-direction: row-reverse;
}
[data-menu-dial][data-direction='right'],
[data-menu-dial][data-direction='right'] [data-menu-dial-list] {
flex-direction: row;
}
[data-menu-dial-list] {
display: inline-flex;
align-items: center;
gap: var(--menu-dial-gap, var(--space-3));
}
/* ── Floating placement (fixed to the viewport) — the 9 zones ──────────── */
[data-menu-dial][data-placement='bottom-end'],
[data-menu-dial][data-placement='bottom-start'],
[data-menu-dial][data-placement='bottom-center'],
[data-menu-dial][data-placement='top-end'],
[data-menu-dial][data-placement='top-start'],
[data-menu-dial][data-placement='top-center'],
[data-menu-dial][data-placement='left-center'],
[data-menu-dial][data-placement='right-center'],
[data-menu-dial][data-placement='center'] {
position: fixed;
z-index: var(--z-index-sticky, 1100);
}
/* Edges — centred on the free axis. Centre — fully centred. */
[data-menu-dial][data-placement='top-center'] {
inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-start: 50%;
translate: -50% 0;
}
[data-menu-dial][data-placement='bottom-center'] {
inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-start: 50%;
translate: -50% 0;
}
[data-menu-dial][data-placement='left-center'] {
inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
inset-block-start: 50%;
translate: 0 -50%;
}
[data-menu-dial][data-placement='right-center'] {
inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
inset-block-start: 50%;
translate: 0 -50%;
}
[data-menu-dial][data-placement='center'] {
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
}
[data-menu-dial][data-placement='bottom-end'] {
inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
}
[data-menu-dial][data-placement='bottom-start'] {
inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
}
[data-menu-dial][data-placement='top-end'] {
inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
}
[data-menu-dial][data-placement='top-start'] {
inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
}
/* ── Actions: open / close + stagger ─────────────────────────────────── */
/* The action IS a `<Fab>` — elevation, circular shape and hover lift come from
the FAB recipe; this recipe only owns the wrapper layout + the open/close. */
.menu-dial-action {
/* Wraps ONLY the button so it stays on the trigger's centre axis; the label
is absolutely positioned beside it (both tooltip AND inline), so it never
pushes the button off-centre. */
position: relative;
display: inline-flex;
transition:
opacity var(--duration-fast) var(--ease-out, ease-out),
transform var(--duration-fast) var(--ease-out, ease-out);
}
[data-menu-dial-list][data-state='closed'] .menu-dial-action {
opacity: 0;
transform: scale(0.6);
visibility: hidden;
pointer-events: none;
/* hide only AFTER the out-transition has played */
transition:
opacity var(--duration-fast) var(--ease-out, ease-out),
transform var(--duration-fast) var(--ease-out, ease-out),
visibility 0s linear var(--duration-fast, 150ms);
}
[data-menu-dial-list][data-state='open'] .menu-dial-action {
opacity: 1;
transform: none;
visibility: visible;
}
/* Stagger — each action offset from the trigger by its index. */
[data-menu-dial][data-stagger] [data-menu-dial-list][data-state='open'] .menu-dial-action {
transition-delay: calc(var(--menu-dial-i, 0) * var(--menu-dial-stagger-step));
}
/* Labels — the action's name is a composed <Tooltip> (`menu-dial-action.svelte`),
positioned + collision-corrected by Floating UI and portaled (always on top).
This recipe no longer styles or positions any label. */
/* ── Arc layout (single arc, opening per placement) ──────────────────────
The list overlays the trigger; each action is absolutely placed on a circle
of radius `--menu-dial-radius` at an angle spread across the arc. Polar frame:
0° = up, clockwise — mirrors the OnionMenu geometry (`onionArc` / `polar`). */
/* Containing block for the absolute list (floating placements are already `fixed`). */
[data-menu-dial][data-layout='arc'][data-placement='static'] {
position: relative;
}
[data-menu-dial][data-layout='arc'] [data-menu-dial-list] {
position: absolute;
inset: 0;
display: block;
/* The list overlays the trigger — let clicks pass through to it; only the
open actions (below) re-capture the pointer. */
pointer-events: none;
}
[data-menu-dial][data-layout='arc'] [data-menu-dial-list][data-state='open'] .menu-dial-action {
pointer-events: auto;
}
/* Per-size diameters (trigger + its one-step-smaller action) feed the radius. */
[data-menu-dial][data-layout='arc'][data-size='xs'] {
--_dial-trigger-size: var(--fab-size-xs);
--_dial-action-size: var(--fab-size-xs);
}
[data-menu-dial][data-layout='arc'][data-size='sm'] {
--_dial-trigger-size: var(--fab-size-sm);
--_dial-action-size: var(--fab-size-xs);
}
[data-menu-dial][data-layout='arc'][data-size='md'] {
--_dial-trigger-size: var(--fab-size-md);
--_dial-action-size: var(--fab-size-sm);
}
[data-menu-dial][data-layout='arc'][data-size='lg'] {
--_dial-trigger-size: var(--fab-size-lg);
--_dial-action-size: var(--fab-size-md);
}
/* Arc opening per placement — span + start (deg) into the free space.
Corners → quarter (90°); static → a half (180°) oriented by `direction`. */
[data-menu-dial][data-layout='arc'][data-placement='bottom-end'] {
--menu-dial-arc-start: 270deg; /* up-left */
--menu-dial-arc-span: 90deg;
}
[data-menu-dial][data-layout='arc'][data-placement='bottom-start'] {
--menu-dial-arc-start: 0deg; /* up-right */
--menu-dial-arc-span: 90deg;
}
[data-menu-dial][data-layout='arc'][data-placement='top-end'] {
--menu-dial-arc-start: 180deg; /* down-left */
--menu-dial-arc-span: 90deg;
}
[data-menu-dial][data-layout='arc'][data-placement='top-start'] {
--menu-dial-arc-start: 90deg; /* down-right */
--menu-dial-arc-span: 90deg;
}
/* edges → half (180°) opening away from the anchored edge. */
[data-menu-dial][data-layout='arc'][data-placement='top-center'] {
--menu-dial-arc-start: 90deg; /* opens down */
--menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='bottom-center'] {
--menu-dial-arc-start: 270deg; /* opens up */
--menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='left-center'] {
--menu-dial-arc-start: 0deg; /* opens right */
--menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='right-center'] {
--menu-dial-arc-start: 180deg; /* opens left */
--menu-dial-arc-span: 180deg;
}
/* centre → full ring (360°): the actions distribute around the whole circle. */
[data-menu-dial][data-layout='arc'][data-placement='center'] {
--menu-dial-arc-start: 0deg;
--menu-dial-arc-span: 360deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='up'] {
--menu-dial-arc-start: 270deg; /* opens up */
--menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='down'] {
--menu-dial-arc-start: 90deg; /* opens down */
--menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='left'] {
--menu-dial-arc-start: 180deg; /* opens left */
--menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='right'] {
--menu-dial-arc-start: 0deg; /* opens right */
--menu-dial-arc-span: 180deg;
}
/* Radius: clear the trigger, AND spread far enough that adjacent actions
(chord = 2R·sin(span/2N)) don't overlap — whichever is larger. */
[data-menu-dial][data-layout='arc'] {
--_dial-n: max(var(--menu-dial-count, 1), 1);
--_dial-r-clear: calc((var(--_dial-trigger-size) + var(--_dial-action-size)) / 2 + var(--space-3));
--_dial-r-spread: calc(
(var(--_dial-action-size) + var(--space-2)) /
(2 * sin(var(--menu-dial-arc-span, 90deg) / (2 * max(var(--_dial-n), 2))))
);
--menu-dial-radius: max(var(--_dial-r-clear), var(--_dial-r-spread));
}
/* Place each action on the arc. angle_i = start + span·(i + 0.5)/N; the polar
frame (0° = up) maps to the math frame by −90°, then cos/sin to x/y. */
[data-menu-dial][data-layout='arc'] .menu-dial-action {
position: absolute;
inset-inline-start: 50%;
inset-block-start: 50%;
--_a: calc(
var(--menu-dial-arc-start, 270deg) +
var(--menu-dial-arc-span, 90deg) * (var(--menu-dial-i, 0) + 0.5) / var(--_dial-n)
);
--_ac: calc(var(--_a) - 90deg);
translate: calc(-50% + var(--menu-dial-radius) * cos(var(--_ac)))
calc(-50% + var(--menu-dial-radius) * sin(var(--_ac)));
}
/* ── Trigger glyph (`+`→`×`) ──────────────────────────────────────────── */
.menu-dial-glyph {
position: relative;
inline-size: 1em;
block-size: 1em;
transition: transform var(--duration-normal, 300ms) var(--ease-default);
}
.menu-dial-glyph::before,
.menu-dial-glyph::after {
content: '';
position: absolute;
inset-inline: 0;
inset-block-start: 50%;
block-size: 2px;
margin-block-start: -1px;
border-radius: var(--radius-full, 1px);
background: currentColor;
}
.menu-dial-glyph::after {
transform: rotate(90deg);
}
/* Use the FRAME's state, not the trigger button's — Button owns its own
`data-state` (press / loading), which would shadow ours on the trigger. */
[data-menu-dial][data-state='open'] .menu-dial-glyph {
transform: rotate(45deg);
}
/* ── Backdrop ─────────────────────────────────────────────────────────── */
[data-menu-dial-backdrop] {
position: fixed;
inset: 0;
z-index: calc(var(--z-index-sticky, 1100) - 1);
background: var(--menu-dial-backdrop-bg);
animation: menu-dial-backdrop-in var(--duration-fast, 150ms) var(--ease-out, ease-out) both;
}
@keyframes menu-dial-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.menu-dial-action,
.menu-dial-glyph,
[data-menu-dial-backdrop] {
transition: none;
animation: none;
}
}

@ -0,0 +1,348 @@
<script lang="ts">
import './menu-dial.css';
/**
* `<MenuDial>` — a speed dial. The trigger IS a `<Fab>` that fans out a line —
* or a placement-driven arc — of `<MenuDial.Action>` mini-FABs. Compositional:
* the actions are children that render themselves and read the dial config via
* context; the root owns the open state, lays the fan out by `layout` +
* `direction` + `placement` (plain flex or a CSS cos/sin arc, no SVG), and
* drives the soma behaviour — keyboard nav, focus, dismiss, the perceptual
* emission — by querying the action elements.
*/
import { ActiveEidos } from '$uix/eidos';
import { Fab } from '$uix/eidos/components/fab';
import {
createMenuDialRuntime,
dialNavNext,
dialNavPrev,
dialNavFirst,
dialNavLast
} from '$soma/components/menu-dial';
import { setMenuDialContext } from './context.svelte';
import type { FabSize } from '$uix/eidos/components/fab';
import type { MenuDialProps, MenuDialPlacement, MenuDialDirection } from './types';
let {
layout = 'linear',
direction = 'up',
radius,
openOn = 'click',
placement = 'bottom-end',
size = 'md',
labels = 'tooltip',
backdrop = false,
stagger = true,
open = $bindable(false),
variant = 'solid',
color,
intent,
icon,
openIcon,
'aria-label': ariaLabel,
onOpenChange,
children
}: MenuDialProps = $props();
const eidos = ActiveEidos.require();
const resolvedLayout = $derived(eidos.resolve(layout, 'linear'));
const resolvedDirection = $derived(eidos.resolve(direction, 'up'));
const resolvedPlacement = $derived(eidos.resolve(placement, 'bottom-end'));
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// Actions are one step below the trigger in the FAB's OWN size scale, so they
// scale with it and keep the trigger-dominant hierarchy. `xs` (30px) is the
// FAB's mini step — only FabSize steps, no invented dimensions.
const ACTION_SIZE: Record<FabSize, FabSize> = { xs: 'xs', sm: 'xs', md: 'sm', lg: 'md' };
const actionSize = $derived(ACTION_SIZE[resolvedSize]);
// ── Label side per action (its composed <Tooltip>'s preferred side) ──────
// Mirrors the CSS arc mapping (placement → start/span, 0° = up, clockwise) so
// the label points radially OUTWARD; the Tooltip's own `avoidCollisions`
// corrects it against the viewport. Linear → perpendicular to the fan.
let actionCount = $state(0);
type DialSide = 'top' | 'right' | 'bottom' | 'left';
const ARC: Record<MenuDialPlacement, { start: number; span: number } | null> = {
'bottom-end': { start: 270, span: 90 },
'bottom-start': { start: 0, span: 90 },
'top-end': { start: 180, span: 90 },
'top-start': { start: 90, span: 90 },
'top-center': { start: 90, span: 180 },
'bottom-center': { start: 270, span: 180 },
'left-center': { start: 0, span: 180 },
'right-center': { start: 180, span: 180 },
center: { start: 0, span: 360 },
static: null
};
const STATIC_ARC: Record<MenuDialDirection, { start: number; span: number }> = {
up: { start: 270, span: 180 },
down: { start: 90, span: 180 },
left: { start: 180, span: 180 },
right: { start: 0, span: 180 }
};
function quantizeSide(deg: number): DialSide {
const a = ((deg % 360) + 360) % 360;
if (a < 45 || a >= 315) return 'top';
if (a < 135) return 'right';
if (a < 225) return 'bottom';
return 'left';
}
function labelSide(index: number): DialSide {
if (resolvedLayout === 'linear') {
return resolvedDirection === 'up' || resolvedDirection === 'down' ? 'left' : 'top';
}
const arc = ARC[resolvedPlacement] ?? STATIC_ARC[resolvedDirection];
const n = Math.max(actionCount, 1);
return quantizeSide(arc.start + (arc.span * (index + 0.5)) / n);
}
// ── DOM refs ───────────────────────────────────────────────────────────
let frameEl = $state<HTMLDivElement | null>(null);
let listEl = $state<HTMLDivElement | null>(null);
const triggerEl = $derived(
frameEl?.querySelector<HTMLButtonElement>('[data-menu-dial-trigger]') ?? null
);
let focusedIndex = $state(0);
let pendingFocus = $state<number | null>(null);
let seq = 0;
const asTarget = (el: Element | null | undefined): HTMLElement | undefined =>
(el ?? undefined) as HTMLElement | undefined;
// ── Soma behaviour bridge ────────────────────────────────────────────────
const runtime = createMenuDialRuntime({
dom: eidos.dom,
eventEngine: eidos.uix?.events,
announce: eidos.uix ? (m, p) => eidos.uix!.announce(m, p) : undefined,
actions: {
open: (e) => {
e.preventDefault();
openMenu();
},
close: (e) => {
e.preventDefault();
closeMenu();
},
'next-item': (e) => {
e.preventDefault();
moveFocus(1);
},
'prev-item': (e) => {
e.preventDefault();
moveFocus(-1);
},
'first-item': (e) => {
e.preventDefault();
focusEnd('first');
},
'last-item': (e) => {
e.preventDefault();
focusEnd('last');
}
}
});
// ── Action element queries (focus + nav state) ───────────────────────────
function actionEls(): HTMLElement[] {
return [...(listEl?.querySelectorAll<HTMLElement>('[data-menu-dial-action]') ?? [])];
}
function navState() {
const els = actionEls();
const disabled: number[] = [];
els.forEach((el, i) => el.hasAttribute('data-disabled') && disabled.push(i));
return { count: els.length, disabled };
}
function focusActionAt(i: number) {
if (i < 0) return;
focusedIndex = i;
actionEls()[i]?.focus();
}
// ── Open / close ─────────────────────────────────────────────────────────
function openMenu() {
if (open) return;
open = true;
onOpenChange?.(true);
runtime.trigger('open', { fallbackTarget: asTarget(listEl) });
pendingFocus = dialNavFirst(navState());
}
function closeMenu(restoreFocus = true) {
if (!open) return;
open = false;
onOpenChange?.(false);
focusedIndex = 0;
pendingFocus = null;
runtime.trigger('close', { fallbackTarget: asTarget(listEl) });
if (restoreFocus) triggerEl?.focus();
}
function toggle() {
if (open) closeMenu();
else openMenu();
}
function moveFocus(dir: 1 | -1) {
const nav = navState();
const next = dir > 0 ? dialNavNext(focusedIndex, nav) : dialNavPrev(focusedIndex, nav);
focusActionAt(next);
}
function focusEnd(which: 'first' | 'last') {
const nav = navState();
focusActionAt(which === 'first' ? dialNavFirst(nav) : dialNavLast(nav));
}
// Chosen by an action (click or native Enter/Space) — emit + close.
function select(el: HTMLElement | null, onSelect?: () => void) {
onSelect?.();
runtime.trigger('commit-select', { fallbackTarget: asTarget(el ?? listEl) });
closeMenu();
}
setMenuDialContext({
get direction() {
return resolvedDirection;
},
get labels() {
return labels;
},
get actionSize() {
return actionSize;
},
get stagger() {
return stagger;
},
nextIndex: () => seq++,
labelSide,
select
});
// ── Keyboard routing ─────────────────────────────────────────────────────
function onTriggerKeydown(e: KeyboardEvent) {
runtime.keydown('trigger', e);
}
function onListKeydown(e: KeyboardEvent) {
runtime.keydown('list', e);
}
// Hover open (optional). The close is DELAYED: moving from the trigger to an
// action crosses an empty gap (which fires `pointerleave`), so an immediate
// close would shut the fan before the pointer reaches the option. The delay
// lets re-entering the frame (the actions are descendants) cancel it.
let hoverCloseTimer: ReturnType<typeof eidos.timers.schedule> | undefined;
function cancelHoverClose() {
hoverCloseTimer?.cancel();
hoverCloseTimer = undefined;
}
function onPointerEnter() {
if (openOn !== 'hover') return;
cancelHoverClose();
openMenu();
}
function onPointerLeave() {
if (openOn !== 'hover') return;
cancelHoverClose();
if (!eidos.uix) {
closeMenu(false);
return;
}
hoverCloseTimer = eidos.timers.schedule(null, 260, () => {
hoverCloseTimer = undefined;
closeMenu(false);
});
}
// Non-modal dismiss: focus leaving the frame (Tab out) closes.
function onFocusout(e: FocusEvent) {
const next = e.relatedTarget as Node | null;
if (open && next && frameEl && !frameEl.contains(next)) closeMenu(false);
}
// Publish the action count so the arc layout can distribute the angles in CSS
// (`angle_i = start + span·(i + 0.5)/count`). Re-reads when the list mounts.
$effect(() => {
if (!frameEl || !listEl) return;
void open;
const n = actionEls().length;
actionCount = n;
frameEl.style.setProperty('--menu-dial-count', String(n));
});
// Focus the first/queued action once the fan is visible.
$effect(() => {
void open;
if (pendingFocus == null) return;
const el = actionEls()[pendingFocus];
if (el) {
el.focus();
focusedIndex = pendingFocus;
pendingFocus = null;
}
});
// Outside-click dismiss while open.
$effect(() => {
if (!open || !frameEl) return;
const doc = eidos.dom.getDocument(frameEl);
const onDown = (e: Event) => {
const t = e.target as Node | null;
if (frameEl && t && !frameEl.contains(t)) closeMenu(false);
};
doc.addEventListener('pointerdown', onDown, true);
return () => doc.removeEventListener('pointerdown', onDown, true);
});
</script>
{#if backdrop && open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div data-menu-dial-backdrop onclick={() => closeMenu(false)}></div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_static_element_interactions -->
<div
bind:this={frameEl}
data-menu-dial
data-layout={resolvedLayout}
data-direction={resolvedDirection}
data-placement={resolvedPlacement}
data-size={resolvedSize}
data-state={open ? 'open' : 'closed'}
data-stagger={stagger ? '' : undefined}
style={radius ? `--menu-dial-radius: ${radius};` : undefined}
onpointerenter={onPointerEnter}
onpointerleave={onPointerLeave}
onfocusout={onFocusout}
>
<Fab
{variant}
{color}
{intent}
size={resolvedSize}
placement="static"
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
data-menu-dial-trigger=""
onclick={toggle}
onkeydown={onTriggerKeydown}
icon={triggerIcon}
/>
<div
bind:this={listEl}
data-menu-dial-list
role="menu"
tabindex="-1"
data-state={open ? 'open' : 'closed'}
aria-label={ariaLabel}
onkeydown={onListKeydown}
>
{@render children?.()}
</div>
</div>
{#snippet triggerIcon()}
{#if icon}
{@render (open && openIcon ? openIcon : icon)()}
{:else}
<span class="menu-dial-glyph" aria-hidden="true"></span>
{/if}
{/snippet}

@ -0,0 +1,117 @@
import type { Snippet } from 'svelte';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { FabSize, FabProps } from '$uix/eidos/components/fab';
/**
* Where the dial sits — the 9 zones (mirroring OnionMenu): the 4 corners, the 4
* edge-middles and the centre, plus `static` (no fixed positioning — you place
* it). In `layout="arc"` the zone also sets the arc span + opening: 90° in a
* corner, 180° on an edge, **360° in the centre** (the actions ring the trigger).
*/
export type MenuDialPlacement =
| 'top-start'
| 'top-center'
| 'top-end'
| 'left-center'
| 'center'
| 'right-center'
| 'bottom-start'
| 'bottom-center'
| 'bottom-end'
| 'static';
/** The trigger / action chrome props, reused from the FAB (which reuses Button). */
type DialVariant = FabProps['variant'];
type DialColor = FabProps['color'];
type DialIntent = FabProps['intent'];
/** Which way the fan opens from the trigger. */
export type MenuDialDirection = 'up' | 'down' | 'left' | 'right';
/**
* Fan geometry. `linear` stacks the actions in a straight line (per `direction`).
* `arc` fans them on a single arc whose span + opening follow the `placement`
* (90° in a corner, 180° on an edge / static), mirroring the OnionMenu's radial
* arc — the actions splay into the free space around the trigger.
*/
export type MenuDialLayout = 'linear' | 'arc';
/** How each action's name is shown. `tooltip` floats it on hover/focus; `inline`
* is an always-visible pill beside the action; `none` keeps it sr-only. */
export type MenuDialLabels = 'tooltip' | 'inline' | 'none';
/** How the dial opens. */
export type MenuDialOpenOn = 'click' | 'hover';
/**
* Props for the root `<MenuDial>` — a speed dial. The trigger IS a `<Fab>` that
* fans out a linear set of `<MenuDial.Action>` mini-FABs. Compositional (the
* actions are children, never a `model` array). The root owns the open state,
* the keyboard / focus / dismiss behaviour and the perceptual emission.
*/
export interface MenuDialProps {
/** Fan geometry — a straight line or a placement-driven arc. @default 'linear' */
layout?: ResponsiveProp<MenuDialLayout>;
/** Which way the fan opens (`linear`); orients the arc on `static` placement. @default 'up' */
direction?: ResponsiveProp<MenuDialDirection>;
/**
* Arc radius (any CSS length) — distance from the trigger centre to the
* actions in `layout="arc"`. Defaults to a size-derived value that clears
* the trigger + action.
*/
radius?: string;
/** Open on click (toggle) or on hover. @default 'click' */
openOn?: MenuDialOpenOn;
/** Which of the 9 zones the dial sits in (fixed), or `static`. Drives the arc
* span in `layout="arc"` (90° corner · 180° edge · 360° centre). @default 'bottom-end' */
placement?: ResponsiveProp<MenuDialPlacement>;
/** FAB size scale — drives the trigger; actions are one step smaller. @default 'md' */
size?: ResponsiveProp<FabSize>;
/** How action names are shown. @default 'tooltip' */
labels?: MenuDialLabels;
/** Dim the page behind the open fan with a scrim. @default false */
backdrop?: boolean;
/** Stagger the actions in/out (each offset from the trigger). @default true */
stagger?: boolean;
/** Whether the fan is open. Bindable. @default false */
open?: boolean;
/** Trigger fill variant. @default 'solid' */
variant?: DialVariant;
/** Trigger colour role. */
color?: DialColor;
/** Trigger evaluative intent. */
intent?: DialIntent;
/** Trigger resting glyph (morphs to `openIcon` when open). Defaults to a `+`. */
icon?: Snippet;
/** Trigger open glyph. Defaults to rotating the resting `+` into a `×`. */
openIcon?: Snippet;
/** Accessible name for the trigger (the dial's purpose). */
'aria-label'?: string;
/** Called when the open state changes. */
onOpenChange?: (open: boolean) => void;
/** The `<MenuDial.Action>`s. */
children?: Snippet;
}
/**
* Props for `<MenuDial.Action>` — one mini-FAB in the fan. Composes `<IconButton>`
* for the chrome; the dial adds the `menuitem` role, the elevation and the label.
*/
export interface MenuDialActionProps {
/** Visible label (tooltip / inline pill) + the accessible name. */
label?: string;
/** The glyph. */
icon?: Snippet;
/** Fired when the action is chosen (also closes the dial). */
onSelect?: () => void;
/** Disable the action. @default false */
disabled?: boolean;
/** Per-action fill variant (else the dial's). */
variant?: DialVariant;
/** Per-action colour role. */
color?: DialColor;
/** Per-action evaluative intent. */
intent?: DialIntent;
/** A nested glyph slot, when `icon` isn't used. */
children?: Snippet;
}

@ -41,6 +41,7 @@ import { linkPreviewLangs } from './link-preview';
import { listboxLangs } from './listbox';
import { menubarLangs } from './menubar';
import { meterLangs } from './meter';
import { menuDialLangs } from './menu-dial';
import { monthGridLangs } from './month-grid';
import { navigationMenuLangs } from './navigation-menu';
import { numberFieldLangs } from './number-field';
@ -132,6 +133,7 @@ export const componentLangs = {
'link-preview': linkPreviewLangs,
listbox: listboxLangs,
menubar: menubarLangs,
'menu-dial': menuDialLangs,
meter: meterLangs,
'month-grid': monthGridLangs,
'navigation-menu': navigationMenuLangs,

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const menuDialLangs = {
label: {
es: 'Acciones rápidas',
en: 'Quick actions'
}
} satisfies LangNode;

@ -0,0 +1,151 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* MenuDial — a "speed dial": a FAB that fans out a LINEAR set of action buttons
* (each a mini-FAB with a label). The exact feature `fabMorfo` defers to here.
* Flat (no nesting) — for a few quick actions; the radial / nested case is
* OnionMenu's. By APG it's a Menu Button: the trigger (`aria-haspopup="menu"`)
* opens a `role="menu"` list of `menuitem` actions.
*
* scope: ['soma','sema','eidos'] — soma owns open + the linear keyboard nav,
* focus and dismiss; eidos composes `<Fab>` (trigger) + `IconButton` (actions)
* and lays the fan out by `direction` (no SVG — plain flex); sema picks up
* open/close from `emerge` and commit-select from `commit` (family defaults).
*/
export const menuDialMorfo = {
name: 'MenuDial',
kebab: 'menu-dial',
scope: ['soma', 'sema', 'eidos'],
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
texts: {
label: '#?components.menu-dial.label|Quick actions'
},
focus: {
initial: 'first-focusable',
// Non-modal: arrows move along the fan, but Tab / outside-click exit and
// close. The trigger gets focus back on close.
trap: false,
return: 'trigger',
restore: true
},
events: [
{
name: 'open',
semantic: { family: 'emerge', verb: 'open', target: v.partRef('list'), sequence: 'pre' },
commits: { part: v.partRef('list'), attr: 'data-state', value: 'open' }
},
{
name: 'close',
semantic: { family: 'emerge', verb: 'close', target: v.partRef('list'), sequence: 'pre' },
commits: { part: v.partRef('list'), attr: 'data-state', value: 'closed' }
},
{
name: 'commit-select',
semantic: {
family: 'commit',
verb: 'select',
target: v.partRef('action'),
intent: 'affirm',
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'virtual',
defaultElement: 'none',
optional: false,
data: [],
aria: []
},
{
// The trigger IS a `<Fab>`; the soma/eidos stamp this contract onto it.
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
aria: [
{ attr: 'aria-haspopup', value: v.literal('menu') },
{ attr: 'aria-expanded', value: v.stateRef('open') },
{
attr: 'aria-controls',
value: v.partRef('list'),
condition: { when: 'part-present', part: 'list' },
severity: 'recommended'
}
],
keyboard: [
{ key: 'Enter', action: 'open' },
{ key: ' ', action: 'open' },
{ key: 'ArrowUp', action: 'open' },
{ key: 'ArrowDown', action: 'open' },
{ key: 'ArrowLeft', action: 'open' },
{ key: 'ArrowRight', action: 'open' }
]
},
{
// The fan container (role="menu"). `data-direction` drives the layout.
name: 'List',
kebab: 'list',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
role: 'menu',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('trigger'),
condition: { when: 'part-present', part: 'trigger' },
severity: 'recommended'
}
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'ArrowDown', action: 'next-item' },
{ key: 'ArrowRight', action: 'next-item' },
{ key: 'ArrowUp', action: 'prev-item' },
{ key: 'ArrowLeft', action: 'prev-item' },
{ key: 'Home', action: 'first-item' },
{ key: 'End', action: 'last-item' },
{ key: 'Enter', action: 'activate' },
{ key: ' ', action: 'activate' }
]
},
{
// One action = one mini-FAB (IconButton). `data-disabled` when disabled.
name: 'Action',
kebab: 'action',
archetype: 'item',
kind: 'public',
defaultElement: 'button',
role: 'menuitem',
optional: false,
states: [],
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-disabled',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,15 @@
// MenuDial soma layer — the headless behaviour bridge for the speed dial.
//
// import { createMenuDialRuntime, dialNavNext } from '$soma/components/menu-dial';
//
// The eidos root owns the open state + the fan render; this layer owns the
// perceptual emission + keyboard-action dispatch (`createMenuDialRuntime`) and
// the pure linear focus maths (`dialNav*`).
export { createMenuDialRuntime, menuDialMorfo } from './menu-dial.svelte';
export {
dialNavNext,
dialNavPrev,
dialNavFirst,
dialNavLast,
type DialNav
} from './menu-dial-nav';

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { dialNavNext, dialNavPrev, dialNavFirst, dialNavLast } from './menu-dial-nav';
describe('menu-dial nav — wraps along the fan', () => {
const nav = { count: 4 };
it('next advances and wraps past the last back to the first', () => {
expect(dialNavNext(0, nav)).toBe(1);
expect(dialNavNext(3, nav)).toBe(0);
});
it('prev retreats and wraps past the first back to the last', () => {
expect(dialNavPrev(1, nav)).toBe(0);
expect(dialNavPrev(0, nav)).toBe(3);
});
it('first / last hit the ends', () => {
expect(dialNavFirst(nav)).toBe(0);
expect(dialNavLast(nav)).toBe(3);
});
});
describe('menu-dial nav — skips disabled actions', () => {
const nav = { count: 4, disabled: [1, 2] };
it('next / prev jump over the disabled run', () => {
expect(dialNavNext(0, nav)).toBe(3);
expect(dialNavPrev(3, nav)).toBe(0);
});
it('first / last skip disabled ends', () => {
expect(dialNavFirst({ count: 3, disabled: [0] })).toBe(1);
expect(dialNavLast({ count: 3, disabled: [2] })).toBe(1);
});
});
describe('menu-dial nav — degenerate', () => {
it('-1 when empty or all disabled', () => {
expect(dialNavFirst({ count: 0 })).toBe(-1);
expect(dialNavNext(0, { count: 2, disabled: [0, 1] })).toBe(-1);
});
it('a single enabled action is its own neighbour', () => {
expect(dialNavNext(0, { count: 1 })).toBe(0);
expect(dialNavLast({ count: 1 })).toBe(0);
});
});

@ -0,0 +1,47 @@
/**
* MenuDial keyboard navigation — pure index transitions along the linear fan.
* Arrows wrap (a small dial of quick actions reads better looping than dead-
* ending), Home/End jump to the ends, and disabled actions are skipped. The
* eidos root owns the open state + render; these helpers just answer "where
* does focus go?" so the movement rule is testable in isolation.
*/
export interface DialNav {
/** Number of actions in the fan. */
readonly count: number;
/** Indices that are disabled (skipped by keyboard nav). */
readonly disabled?: readonly number[];
}
/** Walk `dir` steps from `from`, wrapping, skipping disabled. -1 if none focusable. */
function step(from: number, dir: 1 | -1, nav: DialNav): number {
const { count } = nav;
if (count <= 0) return -1;
const disabled = new Set(nav.disabled ?? []);
if (disabled.size >= count) return -1;
for (let i = 1; i <= count; i++) {
const idx = (((from + dir * i) % count) + count) % count;
if (!disabled.has(idx)) return idx;
}
return -1;
}
/** Next focusable index after `from` (wraps; skips disabled). -1 if none. */
export function dialNavNext(from: number, nav: DialNav): number {
return step(from, 1, nav);
}
/** Previous focusable index before `from` (wraps; skips disabled). -1 if none. */
export function dialNavPrev(from: number, nav: DialNav): number {
return step(from, -1, nav);
}
/** First focusable index (skips leading disabled). -1 if none. */
export function dialNavFirst(nav: DialNav): number {
return step(-1, 1, nav);
}
/** Last focusable index (skips trailing disabled). -1 if none. */
export function dialNavLast(nav: DialNav): number {
return step(0, -1, nav);
}

@ -0,0 +1,19 @@
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { menuDialMorfo } from '$uix/morfo/components/menu-dial';
/**
* The MenuDial soma runtime — the headless behaviour bridge. Like OnionMenu, the
* eidos root owns the open state + render, so soma here is:
*
* - `keydown('trigger' | 'list', event)` → the morfo keyboard contract
* (open from the trigger; Arrow / Home / End / Enter / Escape on the list);
* - `trigger('open' | 'close' | 'commit-select', { fallbackTarget })` → the
* perceptual signal (sound / haptic) + the a11y live region.
*
* Linear focus maths live in {@link './menu-dial-nav'} (testable apart).
*/
export function createMenuDialRuntime(sources: SomaRuntimeSources): SomaRuntime {
return createSomaRuntime(menuDialMorfo, sources);
}
export { menuDialMorfo };

@ -153,7 +153,11 @@
{ slug: '/uix/components/collapsible', label: 'Collapsible' },
{ slug: '/uix/components/accordion', label: 'Accordion' },
{ slug: '/uix/components/tabs', label: 'Tabs' },
{ slug: '/uix/components/toolbar', label: 'Toolbar' }
{ slug: '/uix/components/toolbar', label: 'Toolbar' },
{ slug: '/uix/components/button', label: 'Button' },
{ slug: '/uix/components/button-group', label: 'Button group' },
{ slug: '/uix/components/split-button', label: 'Split button' },
{ slug: '/uix/components/fab', label: 'Fab' }
]
},
{
@ -267,6 +271,8 @@
{ slug: '/uix/components/dropdown-menu', label: 'Dropdown menu' },
{ slug: '/uix/components/context-menu', label: 'Context menu' },
{ slug: '/uix/components/menubar', label: 'Menubar' },
{ slug: '/uix/components/onion-menu', label: 'Onion menu' },
{ slug: '/uix/components/menu-dial', label: 'Menu dial' },
{ slug: '/uix/components/command', label: 'Command' },
{ slug: '/uix/components/link-preview', label: 'Link preview' },
{ slug: '/uix/components/toast', label: 'Toast' },

@ -0,0 +1,584 @@
<script lang="ts">
import { MenuDial } from '$uix/eidos/components/menu-dial';
import type {
MenuDialDirection,
MenuDialLabels,
MenuDialLayout,
MenuDialOpenOn,
MenuDialPlacement
} from '$uix/eidos/components/menu-dial';
import type { FabSize } from '$uix/eidos/components/fab';
import { SvgPlus, SvgCheck, SvgDot, SvgMinus, SvgChevron } from '$uix/eidos/components/svg';
import { compileMorfo } from '$uix/morfo';
import { menuDialMorfo } from '@/uix/morfo/components/menu-dial';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Dial state ───────────────────────────────────────────────────────
let open = $state(true);
let last = $state('—');
// ── Live props — every public knob is a control ────────────────────────
let layout = $state<MenuDialLayout>('linear');
let direction = $state<MenuDialDirection>('up');
let openOn = $state<MenuDialOpenOn>('click');
let size = $state<FabSize>('md');
let placement = $state<MenuDialPlacement>('center');
let labels = $state<MenuDialLabels>('tooltip');
let variant = $state<'solid' | 'soft' | 'surface' | 'outline' | 'ghost'>('solid');
let color = $state<'primary' | 'secondary' | 'neutral' | undefined>(undefined);
let intent = $state<'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss' | undefined>(undefined);
let backdrop = $state(false);
let stagger = $state(true);
const layouts: MenuDialLayout[] = ['linear', 'arc'];
const directions: MenuDialDirection[] = ['up', 'down', 'left', 'right'];
const openOns: MenuDialOpenOn[] = ['click', 'hover'];
const sizes: FabSize[] = ['sm', 'md', 'lg'];
// Zone grid (like OnionMenu) — the 9 zones: corners (90°), edges (180°) and the
// centre (360°). A separate `static` chip below opts out of fixed positioning.
const placementGrid: MenuDialPlacement[][] = [
['top-start', 'top-center', 'top-end'],
['left-center', 'center', 'right-center'],
['bottom-start', 'bottom-center', 'bottom-end']
];
const labelModes: MenuDialLabels[] = ['tooltip', 'inline', 'none'];
const variants = ['solid', 'soft', 'surface', 'outline', 'ghost'] as const;
const colors = [undefined, 'primary', 'secondary', 'neutral'] as const;
const intents = [undefined, 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const;
const compiled = compileMorfo(menuDialMorfo);
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')}`;
}
$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 eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { MenuDial } from '$uix/eidos/components/menu-dial';",
'</' + 'script>',
'',
'<MenuDial',
layout !== 'linear' && ` layout="${layout}"`,
direction !== 'up' && ` direction="${direction}"`,
openOn !== 'click' && ` openOn="${openOn}"`,
size !== 'md' && ` size="${size}"`,
placement !== 'bottom-end' && ` placement="${placement}"`,
labels !== 'tooltip' && ` labels="${labels}"`,
variant !== 'solid' && ` variant="${variant}"`,
color && ` color="${color}"`,
backdrop && ' backdrop',
!stagger && ' stagger={false}',
' aria-label="Quick actions"',
'>',
' <MenuDial.Action label="Add" onSelect={add}>',
' {#snippet icon()}<SvgPlus />{/snippet}',
' </MenuDial.Action>',
' <MenuDial.Action label="Confirm" intent="affirm" onSelect={ok} />',
' <MenuDial.Action label="Delete" intent="threat" onSelect={remove} />',
'</MenuDial>'
]
.filter(Boolean)
.join('\n')
);
function playTarget(part: string): HTMLElement | null {
return (stageRef?.querySelector(`[data-menu-dial-${part}]`) ??
stageRef?.querySelector('[data-menu-dial-list]') ??
stageRef) as HTMLElement | null;
}
</script>
{#snippet iAdd()}<SvgPlus />{/snippet}
{#snippet iConfirm()}<SvgCheck />{/snippet}
{#snippet iMore()}<SvgDot />{/snippet}
{#snippet iCollapse()}<SvgChevron direction="down" />{/snippet}
{#snippet iRemove()}<SvgMinus />{/snippet}
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Overlay · MenuDial</div>
<h1 data-uix-page-title>Menu dial</h1>
<p data-uix-page-lede>
A <strong>speed dial</strong>: a FAB that fans out a linear set of action mini-FABs.
<strong>Compositional</strong> — the trigger is a <code>&lt;Fab&gt;</code>, each
<code>&lt;MenuDial.Action&gt;</code> a <code>&lt;Fab&gt;</code> (the Material "small FAB"). Flat
quick actions — the radial / nested case is <strong>OnionMenu</strong>'s. The fan lays out by
<code>direction</code> in plain flex (no SVG).
</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>composes</span>Fab · Tooltip</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={menuDialMorfo.apg} target="_blank" rel="noreferrer">menu-button ↗</a>
</span>
</div>
</header>
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
style="position: relative; contain: layout; min-block-size: 24rem; display: grid; place-items: center; overflow: hidden; border-radius: var(--uix-radius-lg);"
>
<MenuDial
bind:open
{layout}
{direction}
{openOn}
{size}
{placement}
{labels}
{variant}
{color}
{intent}
{backdrop}
{stagger}
aria-label="Quick actions"
>
<MenuDial.Action label="Add" icon={iAdd} onSelect={() => (last = 'Add')} />
<MenuDial.Action label="Confirm" intent="affirm" icon={iConfirm} onSelect={() => (last = 'Confirm')} />
<MenuDial.Action label="More" icon={iMore} onSelect={() => (last = 'More')} />
<MenuDial.Action label="Collapse" icon={iCollapse} onSelect={() => (last = 'Collapse')} />
<MenuDial.Action label="Remove" intent="threat" disabled icon={iRemove} onSelect={() => (last = 'Remove')} />
</MenuDial>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>open the dial, choose an action, or fire a Sema event</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>state</span>
{open ? 'open' : 'closed'} · <span data-uix-stage-trace-key>last</span>
{last}
</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>
Every public prop is a live control. The dial is <code>static</code> here; the floating
<code>placement</code> values fix it to the viewport. Events live in the
<button data-uix-inline-link onclick={() => (tab = 'sema')}>Sema</button> tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · MenuDial (root) · layout
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>layout <span data-uix-control-hint>straight line vs placement-driven arc</span></span>
<span data-uix-chips role="radiogroup">
{#each layouts as l}
<button data-uix-chip data-active={layout === l} onclick={() => (layout = l)}>{l}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>direction <span data-uix-control-hint>linear axis · arc opening on static</span></span>
<span data-uix-chips role="radiogroup">
{#each directions as d}
<button data-uix-chip data-active={direction === d} onclick={() => (direction = d)}>{d}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>placement <span data-uix-control-hint>9 zones · arc 90° corner / 180° edge / 360° centre</span></span>
<div style="display:flex; align-items:center; gap:10px;">
<div style="display:grid; grid-template-columns:repeat(3,auto); gap:6px; width:max-content;">
{#each placementGrid as row}
{#each row as p}
<button
data-uix-chip
data-active={placement === p}
title={p}
style="min-width:30px;"
onclick={() => (placement = p)}>{p === 'center' ? '○' : '·'}</button>
{/each}
{/each}
</div>
<button data-uix-chip data-active={placement === 'static'} title="static (place it yourself)" onclick={() => (placement = 'static')}>static</button>
</div>
</label>
<label data-uix-control>
<span data-uix-control-label>openOn</span>
<span data-uix-chips role="radiogroup">
{#each openOns as o}
<button data-uix-chip data-active={openOn === o} onclick={() => (openOn = o)}>{o}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>open</span>
<span data-uix-switch><input type="checkbox" bind:checked={open} /><span data-uix-switch-label>{open ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · appearance
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size <span data-uix-control-hint>actions one step smaller</span></span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>labels</span>
<span data-uix-chips role="radiogroup">
{#each labelModes as l}
<button data-uix-chip data-active={labels === l} onclick={() => (labels = l)}>{l}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant <span data-uix-control-hint>trigger</span></span>
<span data-uix-chips role="radiogroup">
{#each variants as v}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color <span data-uix-control-hint>role · applies when intent=neutral</span></span>
<span data-uix-chips role="radiogroup">
{#each colors as c}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c ?? 'default'}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>intent <span data-uix-control-hint>evaluative · wins over color</span></span>
<span data-uix-chips role="radiogroup">
{#each intents as i}
<button data-uix-chip data-active={intent === i} onclick={() => (intent = i)}>{i ?? 'neutral'}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>backdrop <span data-uix-control-hint>full-screen scrim</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={backdrop} /><span data-uix-switch-label>{backdrop ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>stagger</span>
<span data-uix-switch><input type="checkbox" bind:checked={stagger} /><span data-uix-switch-label>{stagger ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>compositional · MenuDial › MenuDial.Action</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 components. <code>&lt;MenuDial&gt;</code> holds the trigger + the actions; each
<code>&lt;MenuDial.Action&gt;</code> is a mini-FAB.
</p>
<div data-uix-subsection-head>MenuDial</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">layout</td><td class="type">'linear' | 'arc'</td><td class="default">'linear'</td><td>Straight line, or a placement-driven arc (90° corner / 180° static).</td></tr>
<tr><td class="name">direction</td><td class="type">'up' | 'down' | 'left' | 'right'</td><td class="default">'up'</td><td>Linear axis; orients the arc on <code>static</code>.</td></tr>
<tr><td class="name">radius</td><td class="type">string</td><td class="default empty">— (size-derived)</td><td>Arc radius override (CSS length).</td></tr>
<tr><td class="name">openOn</td><td class="type">'click' | 'hover'</td><td class="default">'click'</td><td>Open on click (toggle) or hover.</td></tr>
<tr><td class="name">placement</td><td class="type">MenuDialPlacement</td><td class="default">'bottom-end'</td><td>One of 9 zones (corner / edge / centre) or <code>static</code>; sets the arc span (90/180/360°).</td></tr>
<tr><td class="name">size</td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>Trigger FAB size; actions are one step smaller.</td></tr>
<tr><td class="name">labels</td><td class="type">'tooltip' | 'inline' | 'none'</td><td class="default">'tooltip'</td><td>How action names are shown.</td></tr>
<tr><td class="name">backdrop</td><td class="type">boolean</td><td class="default">false</td><td>Scrim behind the open fan.</td></tr>
<tr><td class="name">stagger</td><td class="type">boolean</td><td class="default">true</td><td>Stagger the actions in/out.</td></tr>
<tr><td class="name">open</td><td class="type">boolean</td><td class="default">false</td><td>Bindable open state.</td></tr>
<tr><td class="name">variant / color / intent</td><td class="type">Button knobs</td><td class="default empty">—</td><td>Trigger chrome.</td></tr>
<tr><td class="name">icon / openIcon</td><td class="type">Snippet</td><td class="default empty">— (+→×)</td><td>Trigger glyph; omit for the default morph.</td></tr>
<tr><td class="name">onOpenChange</td><td class="type">(open) =&gt; void</td><td class="default empty">—</td><td>Open-state change.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>MenuDial.Action</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">label</td><td class="type">string</td><td class="default empty">—</td><td>Name + tooltip / inline pill.</td></tr>
<tr><td class="name">icon</td><td class="type">Snippet</td><td class="default empty">—</td><td>The glyph.</td></tr>
<tr><td class="name">onSelect</td><td class="type">() =&gt; void</td><td class="default empty">—</td><td>Chosen (also closes the dial).</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable + skip in nav.</td></tr>
<tr><td class="name">variant / color / intent</td><td class="type">Button knobs</td><td class="default empty">—</td><td>Per-action chrome.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
The morfo declares the menu-button contract: the trigger, the <code>role="menu"</code> list,
the <code>menuitem</code> actions, and the open / close / commit-select events.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{menuDialMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{menuDialMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{menuDialMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={menuDialMorfo.apg} target="_blank" rel="noreferrer">menu-button ↗</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{menuDialMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each menuDialMorfo.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}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if ariaAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source</th><th>Condition</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}` : 'always'}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if keys.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>key</th><th>action</th></tr></thead>
<tbody>
{#each keys as k}
<tr><td><span data-uix-kbd>{k.key === ' ' ? 'Space' : k.key}</span></td><td class="default">{k.action}</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<div data-uix-subsection-head>Events</div>
<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>target</th></tr></thead>
<tbody>
{#each menuDialMorfo.events as ev}
{@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: string; target?: { kind: string; ref?: string } }}
<tr>
<td class="name">{ev.name}</td>
<td class="type">{sem.family}</td>
<td class="default">{sem.verb ?? '—'}</td>
<td class="default">{sem.sequence ?? '—'}</td>
<td class="default">{sem.intent ?? '—'}</td>
<td class="default">{sem.target?.ref ?? sem.target?.kind ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
<code>open</code> / <code>close</code> (emerge · list) and <code>commit-select</code>
(commit · affirm · action). Expression <code>family-default</code>. The soma layer fires
these on real interaction; here ▶ play emits onto the live target.
</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 menuDialMorfo.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 ?? 'list'}
<tr>
<td class="name">{ev.name}</td>
<td class="type">{sem.family}</td>
<td class="default">{sem.verb ?? '—'}</td>
<td class="default">{sem.sequence ?? '—'}</td>
<td class="default">{sem.intent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = playTarget(part);
if (!target) return;
void uix.events?.emit({
name: ev.name,
family: sem.family as never,
target,
...(sem.intent ? { intent: sem.intent as never } : {})
});
}}>▶ 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>
The trigger + actions are composed FABs; <code>menu-dial.css</code> owns only the placement,
the fan layout per <code>data-direction</code>, the open/close + stagger and the glyph.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-menu-dial][data-direction]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Fan flex axis per direction.</td></tr>
<tr><td class="name"><code>[data-menu-dial][data-placement]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Viewport-fixed corner.</td></tr>
<tr><td class="name"><code>[data-menu-dial-list][data-state]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Open/close + stagger of the actions.</td></tr>
<tr><td class="name"><code>.menu-dial-action-label</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Tooltip / inline pill.</td></tr>
<tr><td class="name"><code>[data-menu-dial][data-state='open'] .menu-dial-glyph</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Rotate the + → × on open.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Trigger</td><td><code>&lt;Fab&gt;</code> with <code>aria-haspopup="menu"</code> + <code>aria-expanded</code>.</td></tr>
<tr><td class="name">Fan</td><td><code>role="menu"</code>; actions are <code>menuitem</code>s; disabled actions expose <code>aria-disabled</code>.</td></tr>
<tr><td class="name">Keyboard <span data-uix-tag data-kind="soma">soma</span></td><td><span data-uix-kbd>Enter</span>/<span data-uix-kbd>Space</span>/<span data-uix-kbd>↑↓←→</span> open · <span data-uix-kbd>↑↓</span>/<span data-uix-kbd>←→</span> move (wrap) · <span data-uix-kbd>Home</span>/<span data-uix-kbd>End</span> jump · <span data-uix-kbd>Enter</span>/<span data-uix-kbd>Space</span> activate · <span data-uix-kbd>Esc</span> close.</td></tr>
<tr><td class="name">Dismiss</td><td>Outside-click + Tab-out close (non-modal); focus returns to the trigger.</td></tr>
<tr><td class="name">Motion</td><td>The fan stagger, the glyph rotation and the backdrop fade are suppressed under <span data-uix-kbd>prefers-reduced-motion</span>.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.