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>
5 months ago
< 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 }
fix(demos): eliminate each_key_duplicate crash in 22 component demos
The event-trace inspector in each demo keyed its `{#each}` by a
`Date.now()` timestamp (`entry.at` / `h.at`). A single interaction can
stamp `data-event` on several elements within the same millisecond, so
two trace entries share the key and Svelte throws `each_key_duplicate`
(reported on dropdown-menu).
The trace is an ephemeral, 3-item, text-only log with no transitions or
stateful children, so positional reconciliation is correct: drop the
timestamp key (the each becomes unkeyed). The `fmtTime(...at)` display is
left intact.
Swept all 22 demos carrying the pattern (21 keyed by `entry.at` + announce
by `h.at`): dropdown-menu, context-menu, menubar, navigation-menu, listbox,
grid-list, table, tree-view, tree-grid, feed, command, carousel, drag-drop,
clipboard, announce, alert-dialog, button, color-field, link-preview,
range-calendar, time-field, time-range-field.
Verified: `as \w+ (\w+.at)` -> 0 occurrences site-wide; `npm run check`
0 errors; browser repro on dropdown-menu + table (burst of events, no
each_key_duplicate, console clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
{ #each trace . slice ( 0 , 3 ) as entry }
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>
5 months ago
< 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) => 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< 'sm' | 'md' | 'lg'> < / 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) => 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 > < button> < / 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" > < { part . defaultElement } > </ 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 >