feat(eidos): port Menubar (desktop File/Edit/View bar)

#129. Eidos wrapper + types + recipe + canonical-depth demo. Soma owns
active-menu state, cross-menu ArrowLeft/Right navigation, hover-switch
when active, focus return on Escape, typeahead, RTL. Eidos paints the
bar surface and trigger states; per-menu Content reuses the
dropdown-menu recipe (Item / Sub / Checkbox / Radio chrome + the shared
menu-indicator partial from `a2709476`) because soma already
re-exports the DropdownMenu parts for those slots.

Reference parity:

| Feature | Radix | bits-ui | ark-ui | this port |
|---|---|---|---|---|
| Root provider w/ active-menu binding | ✓ | ✓ | ✗ (Menu only) | ✓ |
| Menu / Trigger / Content | ✓ | ✓ | ✓ | ✓ |
| Item / Group / GroupHeading / Separator | ✓ | ✓ | ✓ | ✓ |
| CheckboxItem / CheckboxGroup / RadioGroup / RadioItem | ✓ | ✓ | ✓ | ✓ |
| Sub / SubTrigger / SubContent | ✓ | ✓ | ✓ | ✓ |
| Cross-menu ArrowLeft/Right (with loop) | ✓ | ✓ | n/a | ✓ |
| Auto-open on hover when active | ✓ | ✓ | n/a | ✓ |
| Typeahead | ✓ | ✓ | ✓ | ✓ |
| Arrow part | ✓ | ✓ | ✓ | ⚠ dropped — menubar idiom anchors to bar edge |

Parts shipped (15): Provider, Menu, Trigger, Portal, Content, Item,
Group, GroupHeading, Separator, Arrow (re-exported), CheckboxItem,
CheckboxGroup, RadioGroup, RadioItem, Sub, SubTrigger, SubContent. The
inner 12 (Item onwards) are re-exports of the eidos DropdownMenu
wrappers so the menu-indicator partial applies automatically — no
recipe duplication.

Variant set: only `size: 'sm' | 'md' | 'lg'` (xs would crowd triggers,
xl unnecessary for a chrome bar). Size cascades from Menubar root to
per-menu Content via a Svelte context (`menubar-context.ts`,
`getMenubarSizeContext` / `setMenubarSizeContext`).

Cross-layer: eidos Menubar reuses eidos DropdownMenu internals — the
recipe doesn't redefine Item/Sub/Checkbox/Radio. Menubar.Content stamps
`data-size` AND inherits `data-dropdown-menu-content` (soma's
MenubarContent specializes DropdownMenuContent), so dropdown-menu.css
paints the panel chrome and our menubar.css only declares the bar
surface + trigger states.

Workaround note: menubar-content.svelte casts rest props through
`Record<string, unknown>` to bypass a TS "union too complex" depth
limit triggered by soma's inline-augmented MenuContentProps + HTML
attribute union. Runtime types are correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 0afcabee8b
commit 9def877dbd

@ -0,0 +1,106 @@
// Menubar — eidos compound API.
//
// import { Menubar } from '$uix/eidos/components/menubar';
//
// <Menubar bind:value>
// <Menubar.Menu value="file">
// <Menubar.Trigger>File</Menubar.Trigger>
// <Menubar.Portal>
// <Menubar.Content>
// <Menubar.Item onSelect={…}>New</Menubar.Item>
// <Menubar.Item onSelect={…}>Open…</Menubar.Item>
// <Menubar.Separator />
// <Menubar.Sub>
// <Menubar.SubTrigger>Open recent</Menubar.SubTrigger>
// <Menubar.SubContent>
// <Menubar.Item>project-1.svelte</Menubar.Item>
// </Menubar.SubContent>
// </Menubar.Sub>
// </Menubar.Content>
// </Menubar.Portal>
// </Menubar.Menu>
// <Menubar.Menu value="edit">…</Menubar.Menu>
// </Menubar>
//
// Soma owns the active-menu state, cross-menu arrow navigation, hover-
// switch when active, focus return, typeahead, RTL. Eidos paints the
// bar surface + trigger states; per-menu Content reuses the
// dropdown-menu recipe (Item / Sub / Checkbox / Radio / Separator
// chrome) since soma re-exports those DropdownMenu parts.
//
// Drops `Arrow` part — the menubar idiom anchors panels to the bar
// edge; an arrow chrome doesn't fit. If you need one, render
// `<DropdownMenu.Arrow>` inside Content (it'll work, just visually
// awkward against the bar).
import MenubarComponent from './menubar.svelte';
import Menu from './menubar-menu.svelte';
import Trigger from './menubar-trigger.svelte';
import Content from './menubar-content.svelte';
// Per-menu inner parts: re-use the eidos DropdownMenu wrappers so the
// menu-indicator shared partial (commit a2709476) applies and we don't
// duplicate Item/Sub/Checkbox/Radio recipes.
import {
DropdownMenu as _DropdownMenu
} from '$uix/eidos/components/dropdown-menu';
import { Portal } from '$soma/components/internal';
type MenubarNamespace = typeof MenubarComponent & {
Menu: typeof Menu;
Trigger: typeof Trigger;
Portal: typeof Portal;
Content: typeof Content;
Item: typeof _DropdownMenu.Item;
Group: typeof _DropdownMenu.Group;
GroupHeading: typeof _DropdownMenu.GroupHeading;
Separator: typeof _DropdownMenu.Separator;
Arrow: typeof _DropdownMenu.Arrow;
CheckboxItem: typeof _DropdownMenu.CheckboxItem;
CheckboxGroup: typeof _DropdownMenu.CheckboxGroup;
RadioGroup: typeof _DropdownMenu.RadioGroup;
RadioItem: typeof _DropdownMenu.RadioItem;
Sub: typeof _DropdownMenu.Sub;
SubTrigger: typeof _DropdownMenu.SubTrigger;
SubContent: typeof _DropdownMenu.SubContent;
};
const Menubar = MenubarComponent as MenubarNamespace;
Menubar.Menu = Menu;
Menubar.Trigger = Trigger;
Menubar.Portal = Portal;
Menubar.Content = Content;
Menubar.Item = _DropdownMenu.Item;
Menubar.Group = _DropdownMenu.Group;
Menubar.GroupHeading = _DropdownMenu.GroupHeading;
Menubar.Separator = _DropdownMenu.Separator;
Menubar.Arrow = _DropdownMenu.Arrow;
Menubar.CheckboxItem = _DropdownMenu.CheckboxItem;
Menubar.CheckboxGroup = _DropdownMenu.CheckboxGroup;
Menubar.RadioGroup = _DropdownMenu.RadioGroup;
Menubar.RadioItem = _DropdownMenu.RadioItem;
Menubar.Sub = _DropdownMenu.Sub;
Menubar.SubTrigger = _DropdownMenu.SubTrigger;
Menubar.SubContent = _DropdownMenu.SubContent;
export { Menubar };
export default Menubar;
export type {
MenubarProps,
MenubarMenuProps as MenuProps,
MenubarTriggerProps as TriggerProps,
MenubarContentProps as ContentProps,
MenubarArrowProps as ArrowProps,
MenubarItemProps as ItemProps,
MenubarGroupProps as GroupProps,
MenubarGroupHeadingProps as GroupHeadingProps,
MenubarSeparatorProps as SeparatorProps,
MenubarCheckboxItemProps as CheckboxItemProps,
MenubarCheckboxGroupProps as CheckboxGroupProps,
MenubarRadioGroupProps as RadioGroupProps,
MenubarRadioItemProps as RadioItemProps,
MenubarSubProps as SubProps,
MenubarSubTriggerProps as SubTriggerProps,
MenubarSubContentProps as SubContentProps,
MenubarSize
} from './types';

@ -0,0 +1,30 @@
<script lang="ts">
/**
* Eidos `Menubar.Content` — wraps soma's menubar Content (which adds
* cross-menu ArrowLeft/Right navigation on top of DropdownMenu.Content).
* Stamps `data-size` from the Menubar root's size context so each
* per-menu panel inherits the bar's chosen density without consumers
* needing to repeat the prop.
*/
import * as Menubar from '$soma/components/menubar';
import type { MenubarContentProps } from './types';
import { getMenubarSizeContext } from './menubar-context';
let { children: bodyContent, ...rest }: MenubarContentProps = $props();
const ctx = getMenubarSizeContext();
const resolvedSize = $derived(ctx?.getSize() ?? 'md');
// Spread cast: soma's MenubarContent declares its props as
// `MenuContentProps & { onkeydown?: ... }` inline (not exported),
// so the resulting union with HTMLDivElement's full attr set
// exceeds TS's representable-union depth limit. The runtime types
// are correct — the cast bypasses the TS-only ceiling.
const passthrough = $derived(rest as Record<string, unknown>);
</script>
<Menubar.Content {...passthrough} data-size={resolvedSize}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</Menubar.Content>

@ -0,0 +1,21 @@
import { getContext, setContext } from 'svelte';
import type { MenubarSize } from './types';
/**
* Menubar size context — set by `<Menubar>` (Provider), read by
* `<Menubar.Content>` so the size variant cascades to every per-menu
* panel without consumers needing to repeat the prop. Air did the same
* thing via `AIR_MENUBAR_CTX.getSize`; we just use Svelte's native
* `getContext`.
*/
const MENUBAR_SIZE_KEY = Symbol('eidos.menubar.size');
export type MenubarSizeContext = { getSize: () => MenubarSize };
export function setMenubarSizeContext(getSize: () => MenubarSize): void {
setContext<MenubarSizeContext>(MENUBAR_SIZE_KEY, { getSize });
}
export function getMenubarSizeContext(): MenubarSizeContext | undefined {
return getContext<MenubarSizeContext | undefined>(MENUBAR_SIZE_KEY);
}

@ -0,0 +1,15 @@
<script lang="ts">
/**
* Eidos `Menubar.Menu` — pass-through over soma. One menu per
* Menubar (e.g. File, Edit, View). The `value` prop identifies the
* menu inside the Menubar's `value` binding.
*/
import * as Menubar from '$soma/components/menubar';
import type { MenubarMenuProps } from './types';
let { children, ...rest }: MenubarMenuProps = $props();
</script>
<Menubar.Menu {...rest}>
{@render children?.()}
</Menubar.Menu>

@ -0,0 +1,17 @@
<script lang="ts">
/**
* Eidos `Menubar.Trigger` — pass-through over soma. Unlike
* `DropdownMenu.Trigger`, the menubar trigger is part of the bar
* surface (e.g. "File", "Edit") — NOT a standalone Button. The
* recipe paints the trigger directly: transparent bg, hover/open
* tint, focus ring. No Button composition.
*/
import * as Menubar from '$soma/components/menubar';
import type { MenubarTriggerProps } from './types';
let { children, ...rest }: MenubarTriggerProps = $props();
</script>
<Menubar.Trigger {...rest}>
{@render children?.()}
</Menubar.Trigger>

@ -0,0 +1,121 @@
/*
* Menubar recipe — desktop File / Edit / View bar.
*
* Scope: provider bar surface + per-menu trigger states. Per-menu
* Content / Item / Sub-tree / Checkbox / Radio chrome is reused from
* dropdown-menu.css since soma's Menubar literally re-exports the
* DropdownMenu parts for those slots (parts emit the same
* `data-dropdown-menu-*` attrs and the shared menu-indicator partial
* picks them up automatically).
*
* What's menubar-specific:
*
* [data-menubar] → bar surface
* [data-menubar-trigger] → in-bar trigger ("File", "Edit", …)
* [data-menubar-content] → per-menu panel (same chrome as
* [data-dropdown-menu-content] via
* the dropdown-menu recipe; we only
* add size variants here)
*
* Sizes — sm | md | lg (xs and xl out of band for a desktop chrome bar).
*/
/* ── Provider — the bar itself ──────────────────────────────────────── */
[data-menubar] {
--_menubar-height: var(--control-height-md);
--_menubar-padding: var(--space-1);
--_menubar-gap: var(--space-1);
--_menubar-radius: var(--radius-md);
--_menubar-trigger-px: var(--space-3);
--_menubar-font-size: var(--font-size-sm);
display: inline-flex;
align-items: center;
gap: var(--_menubar-gap);
padding: var(--_menubar-padding);
border: 1px solid var(--color-border-default);
border-radius: var(--_menubar-radius);
background: var(--color-surface-raised);
}
[data-menubar][data-size='sm'] {
--_menubar-height: var(--control-height-sm);
--_menubar-padding: calc(var(--space-1) * 0.5);
--_menubar-gap: calc(var(--space-1) * 0.5);
--_menubar-radius: var(--radius-sm);
--_menubar-trigger-px: var(--space-2);
--_menubar-font-size: var(--font-size-xs);
}
[data-menubar][data-size='lg'] {
--_menubar-height: var(--control-height-lg);
--_menubar-padding: var(--space-2);
--_menubar-gap: var(--space-2);
--_menubar-radius: var(--radius-lg);
--_menubar-trigger-px: var(--space-4);
--_menubar-font-size: var(--font-size-md);
}
/* ── Trigger — in-bar menu opener ───────────────────────────────────── */
[data-menubar-trigger] {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: calc(var(--_menubar-height) - var(--_menubar-padding) * 2 - 2px);
padding-inline: var(--_menubar-trigger-px);
border: none;
border-radius: calc(var(--_menubar-radius) - var(--_menubar-padding));
background: transparent;
color: var(--color-content-primary);
font-family: var(--font-ui);
font-size: var(--_menubar-font-size);
font-weight: 500;
line-height: var(--leading-ui);
white-space: nowrap;
cursor: pointer;
outline: none;
transition:
background var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
[data-menubar-trigger]:hover:not([data-disabled]),
[data-menubar-trigger][data-highlighted]:not([data-disabled]) {
background: var(--color-surface-overlay);
}
[data-menubar-trigger][data-state='open'],
[data-menubar-trigger][data-state='open']:hover,
[data-menubar-trigger][data-state='open'][data-highlighted] {
background: var(--color-primary-element);
color: var(--color-content-primary);
}
[data-menubar-trigger]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 1px;
position: relative;
z-index: 1;
}
[data-menubar-trigger][data-disabled] {
color: var(--color-content-muted);
pointer-events: none;
cursor: default;
}
/* ── Content — per-menu panel ───────────────────────────────────────────
* Chrome (border, bg, shadow, padding, item rows, separator, sub-trigger
* chevron, group heading) comes from `dropdown-menu.css` via the
* `[data-dropdown-menu-*]` selectors that the Menu/Item/Sub parts emit
* (soma re-exports the DropdownMenu parts for the per-menu inner slots).
*
* We add only the menubar-specific marker selector here. The
* `[data-menubar-content]` selector matches the panel that wraps the
* Item rows — it's the same element that ALSO carries
* `[data-dropdown-menu-content]` because soma's MenubarContent
* specializes DropdownMenuContent. So the dropdown-menu panel rules
* already paint it; we don't repeat them.
*/

@ -0,0 +1,48 @@
<script lang="ts">
/**
* Eidos `<Menubar>` — desktop File / Edit / View bar. Wraps soma's
* `<Menubar.Provider>` and stamps `data-size` on the bar element.
* Size also propagates to per-menu Content via context.
*
* <Menubar bind:value size="md">
* <Menubar.Menu value="file">
* <Menubar.Trigger>File</Menubar.Trigger>
* <Menubar.Portal>
* <Menubar.Content>
* <Menubar.Item onSelect={…}>New</Menubar.Item>
* <Menubar.Item onSelect={…}>Open…</Menubar.Item>
* <Menubar.Separator />
* <Menubar.Item onSelect={…}>Quit</Menubar.Item>
* </Menubar.Content>
* </Menubar.Portal>
* </Menubar.Menu>
* …
* </Menubar>
*
* Soma owns the active-menu state, cross-menu arrow navigation, hover-
* switching when active, focus return on Escape, typeahead, RTL. Eidos
* paints the bar surface + trigger states; per-menu Content reuses
* the dropdown-menu recipe.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Menubar from '$soma/components/menubar';
import type { MenubarProps } from './types';
import { setMenubarSizeContext } from './menubar-context';
let {
value = $bindable(''),
size = 'md',
children,
...rest
}: MenubarProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// Publish size for Content/SubContent to read via context.
setMenubarSizeContext(() => resolvedSize);
</script>
<Menubar.Provider {...rest} bind:value data-size={resolvedSize}>
{@render children?.()}
</Menubar.Provider>

@ -0,0 +1,78 @@
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type {
ProviderProps,
MenuProps,
TriggerProps,
ContentProps,
ArrowProps,
ItemProps,
GroupProps,
GroupHeadingProps,
SeparatorProps,
CheckboxItemProps,
CheckboxGroupProps,
RadioGroupProps,
RadioItemProps,
SubProps,
SubTriggerProps,
SubContentProps
} from '$soma/components/menubar';
/**
* Menubar visual size — three-step subset of the canonical `Size`
* scale, matching the air baseline. A menubar is a desktop affordance
* (File / Edit / View / …); the comfortable-density range is sm..lg,
* xs would crowd the triggers and xl is unnecessary for a chrome bar.
*/
export type MenubarSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type MenubarProps = ProviderProps & {
/**
* Visual size preset — `'sm' | 'md' | 'lg'`. Resolved reactively via
* `Eidos.resolve(size)`. Propagated to Content via context so the
* size variant cascades to per-menu panels.
*
* @default 'md'
*/
size?: ResponsiveProp<MenubarSize>;
};
export type MenubarMenuProps = MenuProps;
export type MenubarTriggerProps = TriggerProps;
export type MenubarContentProps = ContentProps;
// Re-exported from DropdownMenu — Menubar's per-menu inner parts
// (Item, Group, Separator, Checkbox/RadioItem, Sub-tree) are literally
// DropdownMenu parts. Eidos re-exports the dropdown-menu wrappers so
// the menu-indicator shared partial (commit a2709476) applies
// automatically.
export type {
DropdownMenuArrowProps as MenubarArrowProps,
DropdownMenuItemProps as MenubarItemProps,
DropdownMenuGroupProps as MenubarGroupProps,
DropdownMenuGroupHeadingProps as MenubarGroupHeadingProps,
DropdownMenuSeparatorProps as MenubarSeparatorProps,
DropdownMenuCheckboxItemProps as MenubarCheckboxItemProps,
DropdownMenuCheckboxGroupProps as MenubarCheckboxGroupProps,
DropdownMenuRadioGroupProps as MenubarRadioGroupProps,
DropdownMenuRadioItemProps as MenubarRadioItemProps,
DropdownMenuSubProps as MenubarSubProps,
DropdownMenuSubTriggerProps as MenubarSubTriggerProps,
DropdownMenuSubContentProps as MenubarSubContentProps
} from '$uix/eidos/components/dropdown-menu/types';
// Re-export for callers that don't want to dive into soma types
export type {
ArrowProps,
ItemProps,
GroupProps,
GroupHeadingProps,
SeparatorProps,
CheckboxItemProps,
CheckboxGroupProps,
RadioGroupProps,
RadioItemProps,
SubProps,
SubTriggerProps,
SubContentProps
};

@ -94,6 +94,7 @@
@import './components/alert-dialog/alert-dialog.css';
@import './components/dropdown-menu/dropdown-menu.css';
@import './components/context-menu/context-menu.css';
@import './components/menubar/menubar.css';
/* Shared menu-item indicator partial — must load AFTER the menu recipes
so its `padding-inline-start` calc wins over the row rule's
`padding-inline` shorthand at equal specificity. */

@ -0,0 +1,647 @@
<script lang="ts">
import {
Menubar,
type MenubarSize
} from '$uix/eidos/components/menubar';
import { compileMorfo } from '$uix/morfo';
import { menubarMorfo } from '@/uix/morfo/components/menubar';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
let activeMenu = $state(''); // which menu is open
let size = $state<MenubarSize>('md');
let dir = $state<'ltr' | 'rtl'>('ltr');
let loop = $state(true);
// Bound state from inner items
let showStatusBar = $state(true);
let theme = $state<'light' | 'dark' | 'system'>('system');
let viewLayers = $state<string[]>(['ruler', 'grid']);
let lastSelected = $state<string | null>(null);
function pickItem(label: string) {
return () => {
lastSelected = label;
};
}
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$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 compiled = compileMorfo(menubarMorfo);
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')}`;
}
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as Menubar from '$soma/components/menubar';",
' let value = $state("");',
'</' + 'script>',
'',
'<Menubar.Provider',
' bind:value',
dir !== 'ltr' && ` dir="${dir}"`,
!loop && ' loop={false}',
'>',
' <Menubar.Menu value="file">',
' <Menubar.Trigger>File</Menubar.Trigger>',
' <Menubar.Content>',
' <Menubar.Item onSelect={…}>New</Menubar.Item>',
' <Menubar.Separator />',
' <Menubar.Item onSelect={…}>Quit</Menubar.Item>',
' </Menubar.Content>',
' </Menubar.Menu>',
'</Menubar.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Menubar } from '$uix/eidos/components/menubar';",
' let value = $state("");',
'</' + 'script>',
'',
'<Menubar',
' bind:value',
size !== 'md' && ` size="${size}"`,
dir !== 'ltr' && ` dir="${dir}"`,
!loop && ' loop={false}',
'>',
' <Menubar.Menu value="file">',
' <Menubar.Trigger>File</Menubar.Trigger>',
' <Menubar.Content>',
' <Menubar.Item onSelect={…}>New</Menubar.Item>',
' <Menubar.Separator />',
' <Menubar.Item onSelect={…}>Quit</Menubar.Item>',
' </Menubar.Content>',
' </Menubar.Menu>',
'</Menubar>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Navigation · Menubar</div>
<h1 data-uix-page-title>Menubar</h1>
<p data-uix-page-lede>
Desktop File / Edit / View bar. Exactly one menu open at a time;
hover a sibling trigger while a menu is open to switch without an
intermediate click. Soma owns active-menu state + cross-menu
ArrowLeft/Right + typeahead + focus return on Escape. Per-menu
Item / Sub / Checkbox / Radio chrome is shared with
<code>DropdownMenu</code> — same parts, same recipe, same
menu-indicator (checkbox/radio glyphs match the standalone
<code>Checkbox</code> / <code>RadioGroup</code>).
</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>sizes</span>3
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={menubarMorfo.apg} target="_blank" rel="noreferrer">menubar ↗</a>
</span>
{#if lastSelected}
<span data-uix-meta-pill>
<span data-uix-meta-key>last</span>{lastSelected}
</span>
{/if}
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="min-block-size: 14rem; display: flex; flex-direction: column; align-items: flex-start; gap: var(--uix-space-3); padding: var(--uix-space-4);">
<Menubar bind:value={activeMenu} {size} {dir} {loop}>
<Menubar.Menu value="file">
<Menubar.Trigger>File</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item onSelect={pickItem('New tab')}>New tab</Menubar.Item>
<Menubar.Item onSelect={pickItem('New window')}>New window</Menubar.Item>
<Menubar.Item disabled>New incognito (offline)</Menubar.Item>
<Menubar.Separator />
<Menubar.Sub>
<Menubar.SubTrigger>Open recent</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.Item onSelect={pickItem('project.svelte')}>project.svelte</Menubar.Item>
<Menubar.Item onSelect={pickItem('readme.md')}>readme.md</Menubar.Item>
<Menubar.Item onSelect={pickItem('package.json')}>package.json</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onSelect={pickItem('Clear recent')}>Clear recent</Menubar.Item>
</Menubar.SubContent>
</Menubar.Sub>
<Menubar.Separator />
<Menubar.Item onSelect={pickItem('Print…')}>Print…</Menubar.Item>
<Menubar.Item onSelect={pickItem('Quit')}>Quit</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="edit">
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item onSelect={pickItem('Undo')}>Undo</Menubar.Item>
<Menubar.Item onSelect={pickItem('Redo')}>Redo</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onSelect={pickItem('Cut')}>Cut</Menubar.Item>
<Menubar.Item onSelect={pickItem('Copy')}>Copy</Menubar.Item>
<Menubar.Item onSelect={pickItem('Paste')}>Paste</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onSelect={pickItem('Find…')}>Find…</Menubar.Item>
<Menubar.Item onSelect={pickItem('Replace…')}>Replace…</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="view">
<Menubar.Trigger>View</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.CheckboxItem bind:checked={showStatusBar}>
Show status bar
</Menubar.CheckboxItem>
<Menubar.Separator />
<Menubar.Group>
<Menubar.GroupHeading>Layers</Menubar.GroupHeading>
<Menubar.CheckboxGroup bind:value={viewLayers}>
<Menubar.CheckboxItem value="ruler">Ruler</Menubar.CheckboxItem>
<Menubar.CheckboxItem value="grid">Grid</Menubar.CheckboxItem>
<Menubar.CheckboxItem value="guides">Guides</Menubar.CheckboxItem>
</Menubar.CheckboxGroup>
</Menubar.Group>
<Menubar.Separator />
<Menubar.Group>
<Menubar.GroupHeading>Theme</Menubar.GroupHeading>
<Menubar.RadioGroup bind:value={theme}>
<Menubar.RadioItem value="light">Light</Menubar.RadioItem>
<Menubar.RadioItem value="dark">Dark</Menubar.RadioItem>
<Menubar.RadioItem value="system">System</Menubar.RadioItem>
</Menubar.RadioGroup>
</Menubar.Group>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="help">
<Menubar.Trigger>Help</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item onSelect={pickItem('Docs')}>Documentation</Menubar.Item>
<Menubar.Item onSelect={pickItem('Shortcuts')}>Keyboard shortcuts</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onSelect={pickItem('About')}>About UIX</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
</Menubar>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click a trigger or arrow-nav between menus</span>
{:else}
{#each trace.slice(0, 3) as entry (entry.at)}
<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>active</span> {activeMenu || '—'}
</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>17</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>
Soma owns active-menu state + keyboard nav across menus. Eidos
adds <code>size</code> on the root and propagates it to per-menu Content via context.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Provider props
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>open menu id</span></span>
<input type="text" bind:value={activeMenu} placeholder="(none open)" />
</label>
<label data-uix-control>
<span data-uix-control-label>dir</span>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as d (d)}
<button data-uix-chip data-active={dir === d} onclick={() => (dir = d as typeof dir)}>{d}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>loop <span data-uix-control-hint>cross-menu arrow wraps</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={loop} />
<span data-uix-switch-label>{loop ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> visual size
<span style="font-weight: normal; color: var(--uix-text-muted);">
· bar height + trigger padding + font-size + per-menu density
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as MenubarSize)}>{s}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
Bound state
<span style="font-weight: normal; color: var(--uix-text-muted);">
· mirrors of CheckboxItem / CheckboxGroup / RadioGroup
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>showStatusBar <span data-uix-control-hint>CheckboxItem</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showStatusBar} />
<span data-uix-switch-label>{showStatusBar ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>viewLayers <span data-uix-control-hint>CheckboxGroup array</span></span>
<input type="text" value={viewLayers.join(', ') || '(empty)'} readonly />
</label>
<label data-uix-control>
<span data-uix-control-label>theme <span data-uix-control-hint>RadioGroup</span></span>
<span data-uix-chips role="radiogroup">
{#each ['light', 'dark', 'system'] as t (t)}
<button data-uix-chip data-active={theme === t} onclick={() => (theme = t as typeof theme)}>{t}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · active-menu state + cross-menu nav</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · bar surface + trigger states + size context</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>
Three menubar-specific parts (Provider, Menu, Trigger, Content)
plus DropdownMenu's full inner surface for Item / Sub /
Checkbox / Radio. The inner parts share recipes with
<a href="/uix/components/dropdown-menu">DropdownMenu</a> — the
menu-indicator shared partial (checkbox/radio glyphs) applies
automatically.
</p>
<div data-uix-subsection-head>Provider</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">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">string</td><td class="default">''</td><td>Identifier of the currently open menu. Empty = none open.</td></tr>
<tr><td class="name">onValueChange</td><td class="type">(value: string) =&gt; void</td><td class="default empty">—</td><td>Fires when the active menu changes.</td></tr>
<tr><td class="name">dir</td><td class="type">'ltr' | 'rtl'</td><td class="default">'ltr'</td><td>Direction for cross-menu arrow nav.</td></tr>
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">true</td><td>Wrap cross-menu arrow at edges.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'sm' | 'md' | 'lg'&gt;</td><td class="default">'md'</td><td>Visual size — bar height + trigger padding + per-menu density (via context).</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Menu</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">value</td><td class="type">string</td><td class="default empty">—</td><td>Unique identifier for this menu inside the menubar.</td></tr>
<tr><td class="name">onOpenChange / onOpenChangeComplete</td><td class="type">(open: boolean) =&gt; void</td><td class="default empty">—</td><td>Per-menu open/close + animation-settle callbacks.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Trigger</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">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable opening this menu via this trigger.</td></tr>
<tr><td class="name">…native button attrs</td><td class="type">PrimitiveButtonAttributes</td><td class="default empty">—</td><td>Renders a native <code>&lt;button&gt;</code>; all HTML button attrs flow through.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Content</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">side · sideOffset · align · alignOffset · avoidCollisions · …</td><td class="type">FloatingLayerProps</td><td class="default empty">—</td><td>Same surface as <a href="/uix/components/dropdown-menu">DropdownMenu.Content</a>.</td></tr>
<tr><td class="name">data-size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">auto</td><td class="default empty">from Menubar root</td><td>Stamped via context — no need to pass per Content.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inner parts (shared with DropdownMenu)</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
<code>Item</code>, <code>Group</code>, <code>GroupHeading</code>,
<code>Separator</code>, <code>Arrow</code>, <code>CheckboxItem</code>,
<code>CheckboxGroup</code>, <code>RadioGroup</code>, <code>RadioItem</code>,
<code>Sub</code>, <code>SubTrigger</code>, <code>SubContent</code> —
identical API to <a href="/uix/components/dropdown-menu">DropdownMenu</a>.
Eidos re-uses those wrappers so the recipe (and the
menu-indicator shared partial) is one source of truth.
</p>
</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>
Source: <code>src/uix/morfo/components/menubar.ts</code>.
</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">"{menubarMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{menubarMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{menubarMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={menubarMorfo.apg} target="_blank" rel="noreferrer">{menubarMorfo.apg}</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{menubarMorfo.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><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part (part.kebab)}
<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>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events declaration</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 events as action (action.name)}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{intentStr}</td>
<td>{action.target}</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 + perceptual signature
</h2>
<p data-uix-section-desc>
One event — <code>commit-select</code> — fires when an Item is
activated. Open/close of individual menus rides on
DropdownMenu's events via the re-exported parts.
</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 events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-menubar-trigger]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ 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>
Selectors at <code>src/uix/eidos/components/menubar/menubar.css</code>
(bar + trigger only). Per-menu Content / Item / Sub / Checkbox / Radio
chrome is reused from <code>dropdown-menu.css</code> + the shared
<code>menu-indicator.css</code> partial because soma's Menubar re-exports
the DropdownMenu parts for those slots.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-menubar]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>The bar surface — flex row, border, bg.</td></tr>
<tr><td class="name">[data-menubar][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Size variant (sm/md/lg) — height + padding + gap + font.</td></tr>
<tr><td class="name">[data-menubar-trigger]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>In-bar trigger: transparent bg, hover/open tint, focus ring.</td></tr>
<tr><td class="name">[data-menubar-trigger][data-state='open']</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Open menu's trigger lights up with primary tint.</td></tr>
<tr><td class="name">[data-dropdown-menu-content], [data-dropdown-menu-item], …</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Inner panel + items — shared with DropdownMenu.</td></tr>
<tr><td class="name">[data-dropdown-menu-checkbox-item]::before, ::after</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Checkbox indicator — outlined box / filled + ✓ via shared menu-indicator partial.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
Follows the APG <a href={menubarMorfo.apg} target="_blank" rel="noreferrer">menubar pattern</a>.
Bar exposes <code>role="menubar"</code>; each per-menu Content is
<code>role="menu"</code>. Trigger has <code>aria-haspopup="menu"</code>,
<code>aria-expanded</code> and <code>aria-controls</code>.
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>ArrowRight</span> / <span data-uix-kbd>ArrowLeft</span> on Trigger</td><td>Move focus to next/previous trigger (wraps if <code>loop=true</code>; RTL inverts).</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span> / <span data-uix-kbd>ArrowDown</span> on Trigger</td><td>Open the menu, focus first item.</td></tr>
<tr><td><span data-uix-kbd>ArrowUp</span> / <span data-uix-kbd>ArrowDown</span> on Item</td><td>Move within the menu.</td></tr>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowRight</span> on Item</td><td>Close current menu, focus + open prev/next menu.</td></tr>
<tr><td><span data-uix-kbd>Escape</span></td><td>Close the menu, return focus to its Trigger.</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>Jump to first / last item.</td></tr>
<tr><td>Letter</td><td>Typeahead — focus next item starting with that letter.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Pointer</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Gesture</th><th>Behavior</th></tr></thead>
<tbody>
<tr><td>Click trigger</td><td>Opens its menu (or closes if already open).</td></tr>
<tr><td>Hover sibling trigger while a menu is open</td><td>Switches to the hovered menu without an intermediate click.</td></tr>
<tr><td>Click outside</td><td>Closes the active menu.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">menubar</td><td>role</td><td class="type">"menubar"</td></tr>
<tr><td class="name">trigger</td><td>aria-haspopup</td><td class="type">"menu"</td></tr>
<tr><td class="name">trigger</td><td>aria-expanded</td><td class="type">{`{open}`}</td></tr>
<tr><td class="name">trigger</td><td>aria-controls</td><td class="type">{`<content id>`}</td></tr>
<tr><td class="name">trigger</td><td>data-state</td><td class="type">"open" | "closed"</td></tr>
<tr><td class="name">content</td><td>role</td><td class="type">"menu"</td></tr>
<tr><td class="name">content</td><td>aria-labelledby</td><td class="type">{`<trigger id>`}</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.