#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
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
|
||||||
|
};
|
||||||
@ -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) => 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>
|
||||||
Loading…
Reference in new issue