diff --git a/src/uix/eidos/components/fab/fab.css b/src/uix/eidos/components/fab/fab.css index acabbb79e..67f64de6b 100644 --- a/src/uix/eidos/components/fab/fab.css +++ b/src/uix/eidos/components/fab/fab.css @@ -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); } diff --git a/src/uix/eidos/components/fab/types.ts b/src/uix/eidos/components/fab/types.ts index 7090dd3e2..19b99d51e 100644 --- a/src/uix/eidos/components/fab/types.ts +++ b/src/uix/eidos/components/fab/types.ts @@ -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 diff --git a/src/uix/eidos/components/menu-dial/README.md b/src/uix/eidos/components/menu-dial/README.md new file mode 100644 index 000000000..86cea6ce4 --- /dev/null +++ b/src/uix/eidos/components/menu-dial/README.md @@ -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 + + + + + + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + + +``` + +## Composition + +The dial is **compositional** (`` children, never a `model` +array) and composes the framework's primitives — zero re-invented chrome: + +- the **trigger IS a ``** (elevation, intent colour, the placement); +- each **action IS a ``** 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 + +**``** — `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`. + +**``** — `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 `` children, + the trigger a ``, each action a one-step-smaller `` (the mini FAB), and + each action's **name is a composed ``** — 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 `` (→ 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. diff --git a/src/uix/eidos/components/menu-dial/context.svelte.ts b/src/uix/eidos/components/menu-dial/context.svelte.ts new file mode 100644 index 000000000..e780266a7 --- /dev/null +++ b/src/uix/eidos/components/menu-dial/context.svelte.ts @@ -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 `` provides to its ``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 ``) 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); diff --git a/src/uix/eidos/components/menu-dial/index.ts b/src/uix/eidos/components/menu-dial/index.ts new file mode 100644 index 000000000..6cf9d4f25 --- /dev/null +++ b/src/uix/eidos/components/menu-dial/index.ts @@ -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'; +// +// +// {#snippet icon()}{/snippet} +// +// {#snippet icon()}{/snippet} +// +// +// +// +// The trigger is a composed ; each action a composed . 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'; diff --git a/src/uix/eidos/components/menu-dial/menu-dial-action.svelte b/src/uix/eidos/components/menu-dial/menu-dial-action.svelte new file mode 100644 index 000000000..a27ef965d --- /dev/null +++ b/src/uix/eidos/components/menu-dial/menu-dial-action.svelte @@ -0,0 +1,72 @@ + + +{#if dial} + + + {#snippet child({ props }: { props: Record })} + + + {/snippet} + + {#if showLabel} + + + {label} + + + {/if} + +{/if} + +{#snippet glyph()} + {#if icon}{@render icon()}{:else}{@render children?.()}{/if} +{/snippet} diff --git a/src/uix/eidos/components/menu-dial/menu-dial.css b/src/uix/eidos/components/menu-dial/menu-dial.css new file mode 100644 index 000000000..a74ddfa62 --- /dev/null +++ b/src/uix/eidos/components/menu-dial/menu-dial.css @@ -0,0 +1,331 @@ +/* + * MenuDial (speed dial) recipe. + * + * The trigger is a composed and each action a composed ; 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 '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 `` — 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 (`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; + } +} diff --git a/src/uix/eidos/components/menu-dial/menu-dial.svelte b/src/uix/eidos/components/menu-dial/menu-dial.svelte new file mode 100644 index 000000000..7ecf93027 --- /dev/null +++ b/src/uix/eidos/components/menu-dial/menu-dial.svelte @@ -0,0 +1,348 @@ + + +{#if backdrop && open} + +
closeMenu(false)}>
+{/if} + + +
+ + +
+ {@render children?.()} +
+
+ +{#snippet triggerIcon()} + {#if icon} + {@render (open && openIcon ? openIcon : icon)()} + {:else} + + {/if} +{/snippet} diff --git a/src/uix/eidos/components/menu-dial/types.ts b/src/uix/eidos/components/menu-dial/types.ts new file mode 100644 index 000000000..83d3f6d70 --- /dev/null +++ b/src/uix/eidos/components/menu-dial/types.ts @@ -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 `` — a speed dial. The trigger IS a `` that + * fans out a linear set of `` 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; + /** Which way the fan opens (`linear`); orients the arc on `static` placement. @default 'up' */ + direction?: ResponsiveProp; + /** + * 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; + /** FAB size scale — drives the trigger; actions are one step smaller. @default 'md' */ + size?: ResponsiveProp; + /** 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 ``s. */ + children?: Snippet; +} + +/** + * Props for `` — one mini-FAB in the fan. Composes `` + * 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; +} diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index 4e76178f5..eb439e34a 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -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, diff --git a/src/uix/langs/components/menu-dial.ts b/src/uix/langs/components/menu-dial.ts new file mode 100644 index 000000000..f51add3e1 --- /dev/null +++ b/src/uix/langs/components/menu-dial.ts @@ -0,0 +1,8 @@ +import type { LangNode } from '$libs/langs'; + +export const menuDialLangs = { + label: { + es: 'Acciones rápidas', + en: 'Quick actions' + } +} satisfies LangNode; diff --git a/src/uix/morfo/components/menu-dial.ts b/src/uix/morfo/components/menu-dial.ts new file mode 100644 index 000000000..65fa16767 --- /dev/null +++ b/src/uix/morfo/components/menu-dial.ts @@ -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 `` (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 ``; 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; diff --git a/src/uix/soma/components/menu-dial/index.ts b/src/uix/soma/components/menu-dial/index.ts new file mode 100644 index 000000000..8c30bf187 --- /dev/null +++ b/src/uix/soma/components/menu-dial/index.ts @@ -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'; diff --git a/src/uix/soma/components/menu-dial/menu-dial-nav.test.ts b/src/uix/soma/components/menu-dial/menu-dial-nav.test.ts new file mode 100644 index 000000000..e25c020a4 --- /dev/null +++ b/src/uix/soma/components/menu-dial/menu-dial-nav.test.ts @@ -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); + }); +}); diff --git a/src/uix/soma/components/menu-dial/menu-dial-nav.ts b/src/uix/soma/components/menu-dial/menu-dial-nav.ts new file mode 100644 index 000000000..0c943d2fe --- /dev/null +++ b/src/uix/soma/components/menu-dial/menu-dial-nav.ts @@ -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); +} diff --git a/src/uix/soma/components/menu-dial/menu-dial.svelte.ts b/src/uix/soma/components/menu-dial/menu-dial.svelte.ts new file mode 100644 index 000000000..68e501be1 --- /dev/null +++ b/src/uix/soma/components/menu-dial/menu-dial.svelte.ts @@ -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 }; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 5460ae163..8647ac9bc 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -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' }, diff --git a/web/routes/uix/components/menu-dial/+page.svelte b/web/routes/uix/components/menu-dial/+page.svelte new file mode 100644 index 000000000..fc7eefe04 --- /dev/null +++ b/web/routes/uix/components/menu-dial/+page.svelte @@ -0,0 +1,584 @@ + + +{#snippet iAdd()}{/snippet} +{#snippet iConfirm()}{/snippet} +{#snippet iMore()}{/snippet} +{#snippet iCollapse()}{/snippet} +{#snippet iRemove()}{/snippet} + +
+
+
Overlay · MenuDial
+

Menu dial

+

+ A speed dial: a FAB that fans out a linear set of action mini-FABs. + Compositional — the trigger is a <Fab>, each + <MenuDial.Action> a <Fab> (the Material "small FAB"). Flat + quick actions — the radial / nested case is OnionMenu's. The fan lays out by + direction in plain flex (no SVG). +

+
+ parts{compiled.parts.order.length} + events{events.length} + composesFab · Tooltip + + apg + menu-button ↗ + +
+
+ +
+
+ + (last = 'Add')} /> + (last = 'Confirm')} /> + (last = 'More')} /> + (last = 'Collapse')} /> + (last = 'Remove')} /> + +
+
+ trace + {#if trace.length === 0} + open the dial, choose an action, 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'} · last + {last} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Every public prop is a live control. The dial is static here; the floating + placement values fix it to the viewport. Events live in the + tab. +

+ +
+ eidos props · MenuDial (root) · layout +
+
+ + + + + +
+ +
+ eidos props · appearance +
+
+ + + + + + + +
+ +
+
+ eidos + compositional · MenuDial › MenuDial.Action + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ Two components. <MenuDial> holds the trigger + the actions; each + <MenuDial.Action> is a mini-FAB. +

+
MenuDial
+
+ + + + + + + + + + + + + + + + + +
PropTypeDefaultDescription
layout'linear' | 'arc''linear'Straight line, or a placement-driven arc (90° corner / 180° static).
direction'up' | 'down' | 'left' | 'right''up'Linear axis; orients the arc on static.
radiusstring— (size-derived)Arc radius override (CSS length).
openOn'click' | 'hover''click'Open on click (toggle) or hover.
placementMenuDialPlacement'bottom-end'One of 9 zones (corner / edge / centre) or static; sets the arc span (90/180/360°).
size'sm' | 'md' | 'lg''md'Trigger FAB size; actions are one step smaller.
labels'tooltip' | 'inline' | 'none''tooltip'How action names are shown.
backdropbooleanfalseScrim behind the open fan.
staggerbooleantrueStagger the actions in/out.
openbooleanfalseBindable open state.
variant / color / intentButton knobs—Trigger chrome.
icon / openIconSnippet— (+→×)Trigger glyph; omit for the default morph.
onOpenChange(open) => void—Open-state change.
+
+
MenuDial.Action
+
+ + + + + + + + + +
PropTypeDefaultDescription
labelstring—Name + tooltip / inline pill.
iconSnippet—The glyph.
onSelect() => void—Chosen (also closes the dial).
disabledbooleanfalseDisable + skip in nav.
variant / color / intentButton knobs—Per-action chrome.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo · declarative contract +

+

+ The morfo declares the menu-button contract: the trigger, the role="menu" list, + the menuitem actions, and the open / close / commit-select events. +

+
+ + + + + + + + + + +
FieldValue
name"{menuDialMorfo.name}"
kebab"{menuDialMorfo.kebab}"
scope[{menuDialMorfo.scope.map((s) => `"${s}"`).join(', ')}]
apgmenu-button ↗
parts.length{menuDialMorfo.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 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} +
{partAny.kebab}
+ {#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 menuDialMorfo.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 · list) and commit-select + (commit · affirm · action). Expression family-default. The soma layer fires + these on real interaction; here ▶ play emits onto the live target. +

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

Eidos recipe

+

+ The trigger + actions are composed FABs; menu-dial.css owns only the placement, + the fan layout per data-direction, the open/close + stagger and the glyph. +

+
+ + + + + + + + + +
SelectorSourcePurpose
[data-menu-dial][data-direction]eidosFan flex axis per direction.
[data-menu-dial][data-placement]eidosViewport-fixed corner.
[data-menu-dial-list][data-state]morfoOpen/close + stagger of the actions.
.menu-dial-action-labeleidosTooltip / inline pill.
[data-menu-dial][data-state='open'] .menu-dial-glypheidosRotate the + → × on open.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + +
ConcernContract
Trigger<Fab> with aria-haspopup="menu" + aria-expanded.
Fanrole="menu"; actions are menuitems; disabled actions expose aria-disabled.
Keyboard somaEnter/Space/↑↓←→ open · ↑↓/←→ move (wrap) · Home/End jump · Enter/Space activate · Esc close.
DismissOutside-click + Tab-out close (non-modal); focus returns to the trigger.
MotionThe fan stagger, the glyph rotation and the backdrop fade are suppressed under prefers-reduced-motion.
+
+
+ {/if} +