From 4e477e637b84c8f03f1c4b7272e14306aeace6ab Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 23:27:12 +0200 Subject: [PATCH] feat(dropdown-menu): port from soma to eidos (Tier 1 sprint, 3/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../eidos/components/dropdown-menu/README.md | 132 ++++++ .../dropdown-menu/dropdown-menu-arrow.svelte | 7 + .../dropdown-menu-checkbox-group.svelte | 8 + .../dropdown-menu-checkbox-item.svelte | 16 + .../dropdown-menu-content.svelte | 23 ++ .../dropdown-menu-group-heading.svelte | 7 + .../dropdown-menu/dropdown-menu-group.svelte | 7 + .../dropdown-menu/dropdown-menu-item.svelte | 7 + .../dropdown-menu-radio-group.svelte | 8 + .../dropdown-menu-radio-item.svelte | 12 + .../dropdown-menu-separator.svelte | 7 + .../dropdown-menu-sub-content.svelte | 12 + .../dropdown-menu-sub-trigger.svelte | 7 + .../dropdown-menu/dropdown-menu-sub.svelte | 8 + .../dropdown-menu-trigger.svelte | 7 + .../dropdown-menu/dropdown-menu.css | 189 +++++++++ .../dropdown-menu/dropdown-menu.svelte | 21 + .../eidos/components/dropdown-menu/index.ts | 101 +++++ .../eidos/components/dropdown-menu/types.ts | 48 +++ src/uix/eidos/index.css | 1 + src/uix/morfo/components/dropdown-menu.ts | 6 +- .../uix/components/dropdown-menu/+page.svelte | 385 ++++++++++++++++++ 22 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 src/uix/eidos/components/dropdown-menu/README.md create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-arrow.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-group.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-group-heading.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-group.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-group.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-separator.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-trigger.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-sub.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu-trigger.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu.css create mode 100644 src/uix/eidos/components/dropdown-menu/dropdown-menu.svelte create mode 100644 src/uix/eidos/components/dropdown-menu/index.ts create mode 100644 src/uix/eidos/components/dropdown-menu/types.ts create mode 100644 web/routes/uix/components/dropdown-menu/+page.svelte diff --git a/src/uix/eidos/components/dropdown-menu/README.md b/src/uix/eidos/components/dropdown-menu/README.md new file mode 100644 index 000000000..a9c68e237 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/README.md @@ -0,0 +1,132 @@ +# `` — 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 + + + + Open menu + + + Account + goto('/profile')}>Profile + goto('/settings')}>Settings + + + + Show notifications + + + + Light + Dark + + + + More options + + Nested item + + + + +``` + +## 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: + + +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 `` + 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: + +- Bits UI: +- Ark UI: +- Chakra v3: +- Radix Themes: + +- shadcn/ui: diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-arrow.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-arrow.svelte new file mode 100644 index 000000000..c91edc0fe --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-arrow.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-group.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 000000000..7a8d806c6 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 000000000..d66ef0528 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,16 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 000000000..85d0adddd --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,23 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-group-heading.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 000000000..0a76d0713 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-group.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 000000000..7e384e46a --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 000000000..c6796cb6c --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-group.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 000000000..05a8e0bac --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 000000000..949bc2ae9 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,12 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-separator.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 000000000..14b42d535 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 000000000..63a316eda --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,12 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 000000000..bec513e38 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 000000000..cd92c0473 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-trigger.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 000000000..3b096cd8a --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css new file mode 100644 index 000000000..a97bae72c --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css @@ -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; + } +} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 000000000..a296cbad9 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/dropdown-menu/index.ts b/src/uix/eidos/components/dropdown-menu/index.ts new file mode 100644 index 000000000..f6b723df6 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/index.ts @@ -0,0 +1,101 @@ +// DropdownMenu — eidos compound API. +// +// import { DropdownMenu } from '$uix/eidos/components/dropdown-menu'; +// +// +// Open menu +// +// +// Account +// Profile +// Settings +// +// +// Show notifications +// +// +// Light +// Dark +// +// +// +// More options → +// +// Nested +// +// +// +// +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'; diff --git a/src/uix/eidos/components/dropdown-menu/types.ts b/src/uix/eidos/components/dropdown-menu/types.ts new file mode 100644 index 000000000..e932bc733 --- /dev/null +++ b/src/uix/eidos/components/dropdown-menu/types.ts @@ -0,0 +1,48 @@ +// Eidos `` 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; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 4c4fd1505..ceffc24e2 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -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'; diff --git a/src/uix/morfo/components/dropdown-menu.ts b/src/uix/morfo/components/dropdown-menu.ts index 03e48e8f4..6e02e3d87 100644 --- a/src/uix/morfo/components/dropdown-menu.ts +++ b/src/uix/morfo/components/dropdown-menu.ts @@ -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' diff --git a/web/routes/uix/components/dropdown-menu/+page.svelte b/web/routes/uix/components/dropdown-menu/+page.svelte new file mode 100644 index 000000000..6a39eb7b5 --- /dev/null +++ b/web/routes/uix/components/dropdown-menu/+page.svelte @@ -0,0 +1,385 @@ + + +
+
+
Overlay · DropdownMenu
+

DropdownMenu

+

+ 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. + soma owns positioning, + keyboard navigation, focus trap, and dismissal — eidos paints + panel chrome and item rows uniformly across levels. +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + open{open ? 'true' : 'false'} + + {#if lastSelected} + + last{lastSelected} + + {/if} +
+
+ +
+
+ + Open menu + + + Account + Profile + Settings + + + + Show notifications + + + View + Ruler + Grid + Guides + + + + Theme + Light + Dark + System + + + + More options + + Export… + Import… + Sync (offline) + + + + Log out + + +
+
+ trace + {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} + {#if trace[0]} + · + last + {trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)} + {/if} +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+
+ soma props · positioning + behaviour +
+
+ + + +
+ +
+
+ soma + headless · positioning + keyboard + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · namespaced compound + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ 15 pass-through parts over soma/components/dropdown-menu. + No evaluative props at the eidos layer — visual treatment is + uniform across menu surfaces. +

+
+ + + + + + + + + + + + + + + +
PartNotes
DropdownMenu (root)Provider; bind:open, modal, dir.
TriggerToggle button — bring your own visual.
ContentFloating panel — side/align/collisions in soma.
ArrowOptional anchor pointer.
Itemrole=menuitem, onSelect, closeOnSelect.
Group / GroupHeadingSemantic grouping with aria-labelledby.
Separatorrole=separator, 1px divider.
CheckboxItemrole=menuitemcheckbox; bind:checked.
CheckboxGroupBinds an array; child CheckboxItems use value.
RadioGroup / RadioItemrole=menuitemradio inside role=group.
Sub / SubTrigger / SubContentNested fly-out menu (ArrowRight to open).
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+
+ + + + {#each partsList as part} + {@const partAny = part as unknown as Record} + + + + + + + {/each} + +
KebabRoleMarkerKind
{partAny.kebab}{partAny.role ?? '—'}{partAny.marker ?? '—'}{partAny.kind ?? '—'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+
+ + + + {#each events as action} + {@const sem = action.semantic} + + + + + + + {/each} + +
NameFamilyVerbSequence
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ dropdown-menu.css. Uniform chrome across Content + and SubContent; items share one set of tokens so a fly-out + reads identically to the root menu. +

+
+ {/if} + + {#if tab === 'a11y'} +
+

A11y

+

+ WAI-ARIA APG Menu Button. +

+
+ + + + + + + + + + + + + +
KeyEffect
Enter / Space (on Trigger)Open the menu.
ArrowDown (on Trigger)Open + focus first item.
ArrowUp / ArrowDownMove focus prev / next.
Home / EndFocus first / last.
TypeaheadFocus first item whose text matches.
Enter / SpaceActivate item; close menu by default.
EscapeClose menu; return focus to Trigger.
ArrowRight on SubTriggerOpen submenu; focus first item.
ArrowLeft in SubContentClose submenu; return focus.
+
+
+ {/if} +