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
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;
|
||||
@ -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…
Reference in new issue