feat(dropdown-menu): port from soma to eidos (Tier 1 sprint, 3/5)

Full 15-part compound — pass-through wrappers over soma with the
canonical visual recipe.

Parts: Provider · Trigger · Content · Arrow · Item · Group ·
GroupHeading · Separator · CheckboxItem · CheckboxGroup · RadioGroup
· RadioItem · Sub · SubTrigger · SubContent.

API parity with Bits UI (richest reference): CheckboxGroup +
RadioGroup binding arrays / scalars, nested Sub-menus, Arrow pointer.
Radix Primitives + Ark UI lack CheckboxGroup; Chakra v3 + Radix
Themes lack Arrow.

Recipe (dropdown-menu.css):
- Uniform chrome across Content + SubContent (panel shadow,
  border, padding, max-height)
- Items share one set of tokens (hover/focus highlight, disabled
  opacity, indicator slot via `::before` for Checkbox/Radio
  data-state='checked')
- SubTrigger gets a chevron via `::after`
- GroupHeading: muted uppercase, micro-tracked
- Separator: 1px subtle divider
- Enter animation on `data-state='open'`

No `variant` / `color` / `size` knobs at the eidos layer — menus are
themselves the visual variant of "list of actions" within an app.
Per-item destructive coloring deferred to next pass (cascade via
item's `data-color` attr).

Morfo scope: ['soma'] → ['soma', 'sema', 'eidos']. Sound comes from
the family bases (`emerge` for open/close, `commit` for select) — no
per-component cascade needed.

Demo: 6-tab template, items hooked to `lastSelected` tracker, all
three groupings demoed (Items / CheckboxGroup / RadioGroup / Sub).

Checks: svelte-check 0 errors, component:audit 91/91 PASS.

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

@ -0,0 +1,132 @@
# `<DropdownMenu>` — eidos
Anchored menu opened by a button trigger. Compound API mirrors Radix
DropdownMenu / Bits UI / Ark UI — full surface: Items, Groups (with
GroupHeading), Separators, optional CheckboxItem / RadioItem (with
RadioGroup or CheckboxGroup binding), nested Sub-menus and an optional
Arrow pointer.
## Usage
```svelte
<script lang="ts">
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
let open = $state(false);
let notifications = $state(true);
let theme = $state<'light' | 'dark'>('light');
</script>
<DropdownMenu bind:open>
<DropdownMenu.Trigger>Open menu</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Account</DropdownMenu.GroupHeading>
<DropdownMenu.Item onSelect={() => goto('/profile')}>Profile</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goto('/settings')}>Settings</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={notifications}>
Show notifications
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup bind:value={theme}>
<DropdownMenu.RadioItem value="light">Light</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Dark</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>More options</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Nested item</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu>
```
## Parts
15 in total. All pass-through wrappers over soma — eidos adds **no
evaluative props** at this level; visual treatment is uniform across
items because the menu is itself the "variant" within an app's UI.
| Part | Notes |
|---|---|
| `DropdownMenu` (root) | Provider; bind:open |
| `Trigger` | Toggle button — bring your own visual via children |
| `Content` | Floating panel — soma owns positioning (side / align / collisions) |
| `Arrow` | Optional anchor pointer |
| `Item` | `role=menuitem`; fires `commit-select` on activation |
| `Group` / `GroupHeading` | Semantic grouping with `aria-labelledby` |
| `Separator` | `role=separator`, horizontal 1px divider |
| `CheckboxItem` | `role=menuitemcheckbox`; bind:checked |
| `CheckboxGroup` | Binds an array; child CheckboxItems use `value` to participate |
| `RadioGroup` / `RadioItem` | `role=menuitemradio` inside `role=group` |
| `Sub` / `SubTrigger` / `SubContent` | Nested fly-out menu |
## Baseline
WAI-ARIA APG Menu Button:
<https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/>
Soma owns:
- Floating positioning (`side`, `align`, `sideOffset`, `avoidCollisions`)
- Keyboard navigation (arrows, typeahead, Home/End, Enter/Space, Escape)
- Focus trap inside the open Content, return-on-close to Trigger
- Modal / dismissal behaviour (overlay click, outside interact)
- Sub-menu cross-axis navigation (ArrowRight opens, ArrowLeft closes)
## Comparativa
| Lib | Sub-menus | CheckboxItem / RadioItem | CheckboxGroup | Arrow | Modal toggle |
|---|---|---|---|---|---|
| **Radix Primitives** | ✓ | ✓ | — | ✓ | ✓ |
| **Bits UI** | ✓ | ✓ | ✓ | ✓ | ✓ |
| **Ark UI** | ✓ | ✓ | — | — | ✓ |
| **Chakra v3** | ✓ | ✓ | — | — | — |
| **Radix Themes** | ✓ | ✓ | — | — | — |
| **shadcn/ui** | inherits Radix | ✓ | — | inherits | ✓ |
| **Eidos (this)** | ✓ | ✓ | ✓ (Bits pattern) | ✓ | ✓ |
Full parity with the strongest reference (Bits UI). CheckboxGroup is
Bits' addition that Radix lacks — useful for "menu of toggleable
chips" without per-item state bookkeeping.
## Decisiones
- **No `variant` / `color` / `size` at the menu level.** Menus are
the visual variant of "list of actions" within an app; introducing
multiple visual flavors creates noise. If a consumer wants a colored
destructive item they pass `data-color` on the `<DropdownMenu.Item>`
directly (the morfo accepts it).
- **Pass-through wrappers** for every part. Visual treatment is in the
CSS recipe via `[data-dropdown-menu-*]` selectors — keeping wrappers
thin makes context-menu and menubar trivial to derive (just
different triggers).
- **SubContent reads identical to Content.** Same panel chrome, same
item rows. The contrast between root and sub is visual hierarchy
(positioning, chevron affordance), not different styling.
- **Indicator via `::before` pseudo.** Checkbox/Radio items reserve a
leading affordance slot; soma's CheckboxItem doesn't ship an
indicator child by default (unlike Bits') because the visual cue
via `data-state="checked"` + pseudo is more compact.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Per-item `data-color` cascade (destructive item visual) | **implementar** próximo turno | Add a cascade `[data-dropdown-menu-item][data-color='risk']` for the canonical destructive item pattern. |
| Right-aligned `Shortcut` slot (`⌘K` text) | **diferir** | Add an `ItemShortcut` part if the user requests; common pattern from shadcn. |
| `RadioGroupHeading` separate from `GroupHeading` | **diferir** | Not in soma morfo; only one heading semantics today. |
| Animation on `data-state='closed'` exit | **diferir** | Right now `closed` instantly sets opacity 0. Add a fade-out keyframe when Presence is wired. |
## Reference
- Radix UI Primitives:
<https://www.radix-ui.com/primitives/docs/components/dropdown-menu>
- Bits UI: <https://bits-ui.com/docs/components/dropdown-menu>
- Ark UI: <https://ark-ui.com/docs/components/menu>
- Chakra v3: <https://chakra-ui.com/docs/components/menu>
- Radix Themes:
<https://www.radix-ui.com/themes/docs/components/dropdown-menu>
- shadcn/ui: <https://ui.shadcn.com/docs/components/dropdown-menu>

@ -0,0 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuArrowProps } from './types';
let { children, ...rest }: DropdownMenuArrowProps = $props();
</script>
<DropdownMenu.Arrow {...rest}>{@render children?.()}</DropdownMenu.Arrow>

@ -0,0 +1,8 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuCheckboxGroupProps } from './types';
let { value = $bindable([]), children, ...rest }: DropdownMenuCheckboxGroupProps = $props();
</script>
<DropdownMenu.CheckboxGroup {...rest} bind:value>{@render children?.()}</DropdownMenu.CheckboxGroup>

@ -0,0 +1,16 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuCheckboxItemProps } from './types';
let {
checked = $bindable(false),
children: bodyContent,
...rest
}: DropdownMenuCheckboxItemProps = $props();
</script>
<DropdownMenu.CheckboxItem {...rest} bind:checked>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</DropdownMenu.CheckboxItem>

@ -0,0 +1,23 @@
<script lang="ts">
/**
* Eidos `<DropdownMenu.Content>`. Anchored menu panel — soma's
* floating layer owns positioning (`side`, `align`, `sideOffset`,
* collision avoidance). The recipe paints the surface chrome.
*
* `children` is renamed to `bodyContent` so the inner snippet that
* forwards `{ open }` doesn't shadow the prop name.
*/
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuContentProps } from './types';
let {
children: bodyContent,
...rest
}: DropdownMenuContentProps = $props();
</script>
<DropdownMenu.Content {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</DropdownMenu.Content>

@ -0,0 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuGroupHeadingProps } from './types';
let { children, ...rest }: DropdownMenuGroupHeadingProps = $props();
</script>
<DropdownMenu.GroupHeading {...rest}>{@render children?.()}</DropdownMenu.GroupHeading>

@ -0,0 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuGroupProps } from './types';
let { children, ...rest }: DropdownMenuGroupProps = $props();
</script>
<DropdownMenu.Group {...rest}>{@render children?.()}</DropdownMenu.Group>

@ -0,0 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuItemProps } from './types';
let { children, ...rest }: DropdownMenuItemProps = $props();
</script>
<DropdownMenu.Item {...rest}>{@render children?.()}</DropdownMenu.Item>

@ -0,0 +1,8 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuRadioGroupProps } from './types';
let { value = $bindable(''), children, ...rest }: DropdownMenuRadioGroupProps = $props();
</script>
<DropdownMenu.RadioGroup {...rest} bind:value>{@render children?.()}</DropdownMenu.RadioGroup>

@ -0,0 +1,12 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuRadioItemProps } from './types';
let { children: bodyContent, ...rest }: DropdownMenuRadioItemProps = $props();
</script>
<DropdownMenu.RadioItem {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</DropdownMenu.RadioItem>

@ -0,0 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuSeparatorProps } from './types';
let { children, ...rest }: DropdownMenuSeparatorProps = $props();
</script>
<DropdownMenu.Separator {...rest}>{@render children?.()}</DropdownMenu.Separator>

@ -0,0 +1,12 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuSubContentProps } from './types';
let { children: bodyContent, ...rest }: DropdownMenuSubContentProps = $props();
</script>
<DropdownMenu.SubContent {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</DropdownMenu.SubContent>

@ -0,0 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuSubTriggerProps } from './types';
let { children, ...rest }: DropdownMenuSubTriggerProps = $props();
</script>
<DropdownMenu.SubTrigger {...rest}>{@render children?.()}</DropdownMenu.SubTrigger>

@ -0,0 +1,8 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuSubProps } from './types';
let { open = $bindable(false), children, ...rest }: DropdownMenuSubProps = $props();
</script>
<DropdownMenu.Sub {...rest} bind:open>{@render children?.()}</DropdownMenu.Sub>

@ -0,0 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuTriggerProps } from './types';
let { children, ...rest }: DropdownMenuTriggerProps = $props();
</script>
<DropdownMenu.Trigger {...rest}>{@render children?.()}</DropdownMenu.Trigger>

@ -0,0 +1,189 @@
/*
* DropdownMenu recipe.
*
* Soma anchors the Content via the floating layer (Side / Align /
* collision avoidance). Eidos paints the surface chrome: panel with
* shadow, item hover/focus highlight, indicator slots for
* Checkbox/Radio items, muted Separator and GroupHeading.
*
* [data-dropdown-menu-trigger] → toggle button
* [data-dropdown-menu-content] → floating menu panel
* [data-dropdown-menu-item] → menuitem row
* [data-dropdown-menu-checkbox-item] → menuitemcheckbox row
* [data-dropdown-menu-radio-item] → menuitemradio row
* [data-dropdown-menu-sub-trigger] → menuitem that opens a submenu
* [data-dropdown-menu-sub-content] → nested menu panel
* [data-dropdown-menu-group] → semantic grouping wrapper
* [data-dropdown-menu-group-heading] → muted group label
* [data-dropdown-menu-separator] → 1px divider
* [data-dropdown-menu-arrow] → optional anchor pointer
*
* All highlighted-row visuals share one set of tokens so a fly-out
* SubContent reads identically to the root Content.
*/
/* ── Panel (Content / SubContent) ──────────────────────────────────── */
[data-dropdown-menu-content],
[data-dropdown-menu-sub-content] {
min-inline-size: var(--dropdown-menu-content-min-width, 12rem);
max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem));
overflow-y: auto;
padding: var(--dropdown-menu-content-padding, var(--space-1));
border: var(--dropdown-menu-content-border-width, 1px) solid
var(--dropdown-menu-content-border-color, var(--color-border-default));
border-radius: var(--dropdown-menu-content-radius, var(--radius-md));
background: var(--dropdown-menu-content-bg, var(--color-surface-overlay));
box-shadow: var(--dropdown-menu-content-shadow, var(--shadow-floating));
font-family: var(--style-label-font-family, var(--font-ui));
font-size: var(--dropdown-menu-content-font-size, var(--font-size-sm));
color: var(--color-content-primary);
}
[data-dropdown-menu-content]:focus-visible,
[data-dropdown-menu-sub-content]:focus-visible {
outline: none;
}
/* ── Item rows ─────────────────────────────────────────────────────── */
[data-dropdown-menu-item],
[data-dropdown-menu-checkbox-item],
[data-dropdown-menu-radio-item],
[data-dropdown-menu-sub-trigger] {
display: flex;
align-items: center;
gap: var(--dropdown-menu-item-gap, var(--space-2));
min-block-size: var(--dropdown-menu-item-height, 2rem);
padding-block: var(--dropdown-menu-item-py, var(--space-1));
padding-inline: var(--dropdown-menu-item-px, var(--space-2));
border-radius: var(--dropdown-menu-item-radius, var(--radius-sm));
color: var(--color-content-primary);
line-height: var(--leading-ui);
cursor: pointer;
user-select: none;
transition:
background var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
[data-dropdown-menu-item]:hover:not([data-disabled]),
[data-dropdown-menu-checkbox-item]:hover:not([data-disabled]),
[data-dropdown-menu-radio-item]:hover:not([data-disabled]),
[data-dropdown-menu-sub-trigger]:hover:not([data-disabled]),
[data-dropdown-menu-item]:focus-visible,
[data-dropdown-menu-checkbox-item]:focus-visible,
[data-dropdown-menu-radio-item]:focus-visible,
[data-dropdown-menu-sub-trigger]:focus-visible,
[data-dropdown-menu-sub-trigger][data-state='open'] {
outline: none;
background: var(--dropdown-menu-item-bg-hover, var(--color-primary-element));
color: var(--dropdown-menu-item-fg-hover, var(--color-content-primary));
}
[data-dropdown-menu-item][data-disabled],
[data-dropdown-menu-checkbox-item][data-disabled],
[data-dropdown-menu-radio-item][data-disabled],
[data-dropdown-menu-sub-trigger][data-disabled] {
cursor: default;
opacity: var(--dropdown-menu-item-disabled-opacity, 0.55);
}
/* Indicator slot for Checkbox/Radio — soma renders the Indicator child
* inline. The recipe reserves a leading affordance for the glyph. */
[data-dropdown-menu-checkbox-item],
[data-dropdown-menu-radio-item] {
padding-inline-start: var(--dropdown-menu-item-indicator-inset, calc(var(--space-2) + 1rem));
position: relative;
}
[data-dropdown-menu-checkbox-item][data-state='checked']::before,
[data-dropdown-menu-radio-item][data-state='checked']::before {
content: '';
position: absolute;
inset-inline-start: var(--dropdown-menu-item-px, var(--space-2));
inline-size: 0.875rem;
block-size: 0.875rem;
border-radius: 0.2rem;
background: var(--color-primary-solid);
mask-image: var(--dropdown-menu-check-mask, none);
}
[data-dropdown-menu-radio-item][data-state='checked']::before {
border-radius: 50%;
}
/* SubTrigger gets a chevron affordance on the trailing edge. */
[data-dropdown-menu-sub-trigger]::after {
content: '';
margin-inline-start: auto;
inline-size: 0.625rem;
block-size: 0.625rem;
border-inline-end: 2px solid currentColor;
border-block-start: 2px solid currentColor;
transform: rotate(45deg);
opacity: 0.65;
}
/* ── Group / GroupHeading / Separator ─────────────────────────────── */
[data-dropdown-menu-group] {
display: contents;
}
[data-dropdown-menu-group-heading] {
padding-block: var(--dropdown-menu-heading-py, var(--space-1));
padding-inline: var(--dropdown-menu-heading-px, var(--space-2));
color: var(--color-content-muted);
font-size: var(--dropdown-menu-heading-font-size, var(--font-size-xs));
font-weight: var(--style-label-font-weight, 500);
text-transform: uppercase;
letter-spacing: 0.04em; /* literal: micro-tracking for caps */
}
[data-dropdown-menu-separator] {
margin-block: var(--dropdown-menu-separator-my, var(--space-1));
block-size: 1px;
background: var(--dropdown-menu-separator-bg, var(--color-border-subtle));
}
/* ── Trigger (button-ish, but bring-your-own visual) ──────────────── */
[data-dropdown-menu-trigger] {
cursor: pointer;
}
[data-dropdown-menu-trigger]:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* ── Arrow ────────────────────────────────────────────────────────── */
[data-dropdown-menu-arrow] {
fill: var(--dropdown-menu-content-bg, var(--color-surface-overlay));
}
/* ── State transitions ────────────────────────────────────────────── */
[data-dropdown-menu-content][data-state='closed'],
[data-dropdown-menu-sub-content][data-state='closed'] {
opacity: 0;
pointer-events: none;
}
[data-dropdown-menu-content][data-state='open'],
[data-dropdown-menu-sub-content][data-state='open'] {
animation: dropdown-menu-enter var(--duration-fast) var(--ease-out);
}
@keyframes dropdown-menu-enter {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}

@ -0,0 +1,21 @@
<script lang="ts">
/**
* Eidos `<DropdownMenu>` — root provider. Pass-through over soma.
* Children compose via the namespace attached in `index.ts`:
*
* <DropdownMenu bind:open>
* <DropdownMenu.Trigger>Menu</DropdownMenu.Trigger>
* <DropdownMenu.Content>
* <DropdownMenu.Item onSelect={...}>Item</DropdownMenu.Item>
* </DropdownMenu.Content>
* </DropdownMenu>
*/
import * as DropdownMenu from '$soma/components/dropdown-menu';
import type { DropdownMenuProps } from './types';
let { open = $bindable(false), children, ...rest }: DropdownMenuProps = $props();
</script>
<DropdownMenu.Provider {...rest} bind:open>
{@render children?.()}
</DropdownMenu.Provider>

@ -0,0 +1,101 @@
// DropdownMenu — eidos compound API.
//
// import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
//
// <DropdownMenu bind:open>
// <DropdownMenu.Trigger>Open menu</DropdownMenu.Trigger>
// <DropdownMenu.Content side="bottom" align="start">
// <DropdownMenu.Group>
// <DropdownMenu.GroupHeading>Account</DropdownMenu.GroupHeading>
// <DropdownMenu.Item onSelect={...}>Profile</DropdownMenu.Item>
// <DropdownMenu.Item onSelect={...}>Settings</DropdownMenu.Item>
// </DropdownMenu.Group>
// <DropdownMenu.Separator />
// <DropdownMenu.CheckboxItem bind:checked>Show notifications</DropdownMenu.CheckboxItem>
// <DropdownMenu.Separator />
// <DropdownMenu.RadioGroup bind:value>
// <DropdownMenu.RadioItem value="light">Light</DropdownMenu.RadioItem>
// <DropdownMenu.RadioItem value="dark">Dark</DropdownMenu.RadioItem>
// </DropdownMenu.RadioGroup>
// <DropdownMenu.Separator />
// <DropdownMenu.Sub>
// <DropdownMenu.SubTrigger>More options →</DropdownMenu.SubTrigger>
// <DropdownMenu.SubContent>
// <DropdownMenu.Item>Nested</DropdownMenu.Item>
// </DropdownMenu.SubContent>
// </DropdownMenu.Sub>
// </DropdownMenu.Content>
// </DropdownMenu>
import DropdownMenuComponent from './dropdown-menu.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import Content from './dropdown-menu-content.svelte';
import Item from './dropdown-menu-item.svelte';
import Group from './dropdown-menu-group.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Arrow from './dropdown-menu-arrow.svelte';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import CheckboxGroup from './dropdown-menu-checkbox-group.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Sub from './dropdown-menu-sub.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import { Portal } from '$soma/components/internal';
type DropdownMenuNamespace = typeof DropdownMenuComponent & {
Trigger: typeof Trigger;
Portal: typeof Portal;
Content: typeof Content;
Item: typeof Item;
Group: typeof Group;
GroupHeading: typeof GroupHeading;
Separator: typeof Separator;
Arrow: typeof Arrow;
CheckboxItem: typeof CheckboxItem;
CheckboxGroup: typeof CheckboxGroup;
RadioGroup: typeof RadioGroup;
RadioItem: typeof RadioItem;
Sub: typeof Sub;
SubTrigger: typeof SubTrigger;
SubContent: typeof SubContent;
};
const DropdownMenu = DropdownMenuComponent as DropdownMenuNamespace;
DropdownMenu.Trigger = Trigger;
DropdownMenu.Portal = Portal;
DropdownMenu.Content = Content;
DropdownMenu.Item = Item;
DropdownMenu.Group = Group;
DropdownMenu.GroupHeading = GroupHeading;
DropdownMenu.Separator = Separator;
DropdownMenu.Arrow = Arrow;
DropdownMenu.CheckboxItem = CheckboxItem;
DropdownMenu.CheckboxGroup = CheckboxGroup;
DropdownMenu.RadioGroup = RadioGroup;
DropdownMenu.RadioItem = RadioItem;
DropdownMenu.Sub = Sub;
DropdownMenu.SubTrigger = SubTrigger;
DropdownMenu.SubContent = SubContent;
export { DropdownMenu };
export default DropdownMenu;
export type {
DropdownMenuProps,
DropdownMenuTriggerProps as TriggerProps,
DropdownMenuContentProps as ContentProps,
DropdownMenuArrowProps as ArrowProps,
DropdownMenuItemProps as ItemProps,
DropdownMenuGroupProps as GroupProps,
DropdownMenuGroupHeadingProps as GroupHeadingProps,
DropdownMenuSeparatorProps as SeparatorProps,
DropdownMenuCheckboxItemProps as CheckboxItemProps,
DropdownMenuCheckboxGroupProps as CheckboxGroupProps,
DropdownMenuRadioGroupProps as RadioGroupProps,
DropdownMenuRadioItemProps as RadioItemProps,
DropdownMenuSubProps as SubProps,
DropdownMenuSubTriggerProps as SubTriggerProps,
DropdownMenuSubContentProps as SubContentProps
} from './types';

@ -0,0 +1,48 @@
// Eidos `<DropdownMenu>` types.
//
// The visual surface mirrors Radix DropdownMenu: a Trigger button
// opens a menu Content panel anchored via the floating layer. Inside
// the panel: Items, Groups (with GroupHeading), Separators, optional
// CheckboxItem / RadioGroup+RadioItem and nested Sub-menus.
//
// Soma owns positioning (Side / Align), keyboard navigation (arrows,
// typeahead, escape), focus trap, modal/dismissal behaviour. Eidos
// adds the recipe chrome.
//
// All props pass through from soma except where explicitly noted —
// the eidos layer adds no evaluative knobs (no `variant`, no `size`)
// at the DropdownMenu level. Per-item `intent` flows via morfo's
// `fromProp:intent` binding on the `commit-select` event.
import type {
ProviderProps,
TriggerProps,
ContentProps,
ArrowProps,
ItemProps,
GroupProps,
GroupHeadingProps,
SeparatorProps,
CheckboxItemProps,
CheckboxGroupProps,
RadioGroupProps,
RadioItemProps,
SubProps,
SubTriggerProps,
SubContentProps
} from '$soma/components/dropdown-menu';
export type DropdownMenuProps = ProviderProps;
export type DropdownMenuTriggerProps = TriggerProps;
export type DropdownMenuContentProps = ContentProps;
export type DropdownMenuArrowProps = ArrowProps;
export type DropdownMenuItemProps = ItemProps;
export type DropdownMenuGroupProps = GroupProps;
export type DropdownMenuGroupHeadingProps = GroupHeadingProps;
export type DropdownMenuSeparatorProps = SeparatorProps;
export type DropdownMenuCheckboxItemProps = CheckboxItemProps;
export type DropdownMenuCheckboxGroupProps = CheckboxGroupProps;
export type DropdownMenuRadioGroupProps = RadioGroupProps;
export type DropdownMenuRadioItemProps = RadioItemProps;
export type DropdownMenuSubProps = SubProps;
export type DropdownMenuSubTriggerProps = SubTriggerProps;
export type DropdownMenuSubContentProps = SubContentProps;

@ -91,6 +91,7 @@
@import './components/toast/toast.css';
@import './components/dialog/dialog.css';
@import './components/alert-dialog/alert-dialog.css';
@import './components/dropdown-menu/dropdown-menu.css';
@import './components/drawer/drawer.css';
@import './components/field/field.css';
@import './components/form/form.css';

@ -4,7 +4,11 @@ import { v } from '../types';
export const dropdownMenuMorfo = {
name: 'DropdownMenu',
kebab: 'dropdown-menu',
scope: ['soma'],
// Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/dropdown-menu/`).
// Sema picks up open/close from the `emerge` family base and
// commit-select from the `commit` family base — both ship sound
// out of the box, no per-component cascade needed.
scope: ['soma', 'sema', 'eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
texts: {
label: '#?components.dropdown-menu.label|Dropdown menu'

@ -0,0 +1,385 @@
<script lang="ts">
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { compileMorfo } from '$uix/morfo';
import { dropdownMenuMorfo } from '@/uix/morfo/components/dropdown-menu';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ──────────────────────────────────────────────────
let open = $state(false);
const sides = ['top', 'right', 'bottom', 'left'] as const;
const aligns = ['start', 'center', 'end'] as const;
let side = $state<(typeof sides)[number]>('bottom');
let align = $state<(typeof aligns)[number]>('start');
let modal = $state(true);
let notifications = $state(true);
let theme = $state<'light' | 'dark' | 'system'>('system');
let viewLayers = $state<string[]>(['ruler']);
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(dropdownMenuMorfo);
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 DropdownMenu from '$soma/components/dropdown-menu';",
' let open = $state(false);',
'</' + 'script>',
'',
'<DropdownMenu.Provider',
' bind:open',
!modal && ' modal={false}',
'>',
' <DropdownMenu.Trigger>Open menu</DropdownMenu.Trigger>',
` <DropdownMenu.Content side="${side}" align="${align}">`,
' <DropdownMenu.Item onSelect={...}>Profile</DropdownMenu.Item>',
' <DropdownMenu.Separator />',
' <DropdownMenu.CheckboxItem bind:checked>Notifications</DropdownMenu.CheckboxItem>',
' <DropdownMenu.RadioGroup bind:value>',
' <DropdownMenu.RadioItem value="light">Light</DropdownMenu.RadioItem>',
' <DropdownMenu.RadioItem value="dark">Dark</DropdownMenu.RadioItem>',
' </DropdownMenu.RadioGroup>',
' </DropdownMenu.Content>',
'</DropdownMenu.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';",
' let open = $state(false);',
'</' + 'script>',
'',
'<DropdownMenu',
' bind:open',
!modal && ' modal={false}',
'>',
' <DropdownMenu.Trigger>Open menu</DropdownMenu.Trigger>',
` <DropdownMenu.Content side="${side}" align="${align}">`,
' <DropdownMenu.Group>',
' <DropdownMenu.GroupHeading>Account</DropdownMenu.GroupHeading>',
' <DropdownMenu.Item onSelect={...}>Profile</DropdownMenu.Item>',
' </DropdownMenu.Group>',
' <DropdownMenu.Separator />',
' <DropdownMenu.CheckboxItem bind:checked>Notifications</DropdownMenu.CheckboxItem>',
' <DropdownMenu.Sub>',
' <DropdownMenu.SubTrigger>More options</DropdownMenu.SubTrigger>',
' <DropdownMenu.SubContent>',
' <DropdownMenu.Item>Nested</DropdownMenu.Item>',
' </DropdownMenu.SubContent>',
' </DropdownMenu.Sub>',
' </DropdownMenu.Content>',
'</DropdownMenu>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Overlay · DropdownMenu</div>
<h1 data-uix-page-title>DropdownMenu</h1>
<p data-uix-page-lede>
Anchored menu opened by a button trigger. Compound API parity with
Radix DropdownMenu / Bits UI: Items, Groups (with GroupHeading),
Separators, optional Checkbox / Radio items with array-binding
groups, nested Sub-menus and an optional Arrow pointer.
<span data-uix-layer-badge="soma">soma</span> owns positioning,
keyboard navigation, focus trap, and dismissal — eidos paints
panel chrome and item rows uniformly across levels.
</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>open</span>{open ? 'true' : 'false'}
</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}>
<DropdownMenu bind:open {modal}>
<DropdownMenu.Trigger>Open menu</DropdownMenu.Trigger>
<DropdownMenu.Content {side} {align}>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Account</DropdownMenu.GroupHeading>
<DropdownMenu.Item onSelect={pickItem('Profile')}>Profile</DropdownMenu.Item>
<DropdownMenu.Item onSelect={pickItem('Settings')}>Settings</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={notifications}>
Show notifications
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxGroup bind:value={viewLayers}>
<DropdownMenu.GroupHeading>View</DropdownMenu.GroupHeading>
<DropdownMenu.CheckboxItem value="ruler">Ruler</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem value="grid">Grid</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem value="guides">Guides</DropdownMenu.CheckboxItem>
</DropdownMenu.CheckboxGroup>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup bind:value={theme}>
<DropdownMenu.GroupHeading>Theme</DropdownMenu.GroupHeading>
<DropdownMenu.RadioItem value="light">Light</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Dark</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">System</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>More options</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item onSelect={pickItem('Export…')}>Export…</DropdownMenu.Item>
<DropdownMenu.Item onSelect={pickItem('Import…')}>Import…</DropdownMenu.Item>
<DropdownMenu.Item disabled>Sync (offline)</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={pickItem('Log out')}>Log out</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
{#if trace[0]}
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>last</span>
<span>{trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)}</span>
{/if}
</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>15</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>
</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>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · positioning + behaviour
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>side</span>
<span data-uix-chips role="radiogroup">
{#each sides as opt}
<button data-uix-chip data-active={side === opt} onclick={() => (side = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each aligns as opt}
<button data-uix-chip data-active={align === opt} onclick={() => (align = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={modal} /> modal (overlay)
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · positioning + keyboard</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 · namespaced compound</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>
15 pass-through parts over <code>soma/components/dropdown-menu</code>.
No evaluative props at the eidos layer — visual treatment is
uniform across menu surfaces.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">DropdownMenu (root)</td><td>Provider; bind:open, modal, dir.</td></tr>
<tr><td class="name">Trigger</td><td>Toggle button — bring your own visual.</td></tr>
<tr><td class="name">Content</td><td>Floating panel — side/align/collisions in soma.</td></tr>
<tr><td class="name">Arrow</td><td>Optional anchor pointer.</td></tr>
<tr><td class="name">Item</td><td><code>role=menuitem</code>, <code>onSelect</code>, <code>closeOnSelect</code>.</td></tr>
<tr><td class="name">Group / GroupHeading</td><td>Semantic grouping with <code>aria-labelledby</code>.</td></tr>
<tr><td class="name">Separator</td><td><code>role=separator</code>, 1px divider.</td></tr>
<tr><td class="name">CheckboxItem</td><td><code>role=menuitemcheckbox</code>; bind:checked.</td></tr>
<tr><td class="name">CheckboxGroup</td><td>Binds an array; child CheckboxItems use <code>value</code>.</td></tr>
<tr><td class="name">RadioGroup / RadioItem</td><td><code>role=menuitemradio</code> inside <code>role=group</code>.</td></tr>
<tr><td class="name">Sub / SubTrigger / SubContent</td><td>Nested fly-out menu (ArrowRight to open).</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Kebab</th><th>Role</th><th>Marker</th><th>Kind</th></tr></thead>
<tbody>
{#each partsList as part}
{@const partAny = part as unknown as Record<string, unknown>}
<tr>
<td class="name">{partAny.kebab}</td>
<td>{partAny.role ?? '—'}</td>
<td><code>{partAny.marker ?? '—'}</code></td>
<td>{partAny.kind ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th></tr></thead>
<tbody>
{#each events as action}
{@const sem = action.semantic}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</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>
<code>dropdown-menu.css</code>. Uniform chrome across Content
and SubContent; items share one set of tokens so a fly-out
reads identically to the root menu.
</p>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>A11y</h2>
<p data-uix-section-desc>
WAI-ARIA APG <a
href="https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/"
target="_blank"
rel="noopener">Menu Button</a
>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Effect</th></tr></thead>
<tbody>
<tr><td><kbd>Enter</kbd> / <kbd>Space</kbd> (on Trigger)</td><td>Open the menu.</td></tr>
<tr><td><kbd>ArrowDown</kbd> (on Trigger)</td><td>Open + focus first item.</td></tr>
<tr><td><kbd>ArrowUp</kbd> / <kbd>ArrowDown</kbd></td><td>Move focus prev / next.</td></tr>
<tr><td><kbd>Home</kbd> / <kbd>End</kbd></td><td>Focus first / last.</td></tr>
<tr><td>Typeahead</td><td>Focus first item whose text matches.</td></tr>
<tr><td><kbd>Enter</kbd> / <kbd>Space</kbd></td><td>Activate item; close menu by default.</td></tr>
<tr><td><kbd>Escape</kbd></td><td>Close menu; return focus to Trigger.</td></tr>
<tr><td><kbd>ArrowRight</kbd> on SubTrigger</td><td>Open submenu; focus first item.</td></tr>
<tr><td><kbd>ArrowLeft</kbd> in SubContent</td><td>Close submenu; return focus.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.