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

14-part compound — DropdownMenu surface minus CheckboxGroup. Same
visual recipe vocabulary; the only differences vs DropdownMenu are
the trigger surface (a `<span>` wrapping right-clickable content
rather than a `<button>` toggle) and cursor-anchored positioning
(soma's floating layer handles that).

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

Recipe (context-menu.css):
- Panel chrome + item rows identical to dropdown-menu
- Trigger gets a subtle `data-state='open'` dashed outline as a
  "this region summoned the menu" affordance
- Recipe duplication over aliasing — each component evolves freely
  without CSS cascade coupling

Sidebar nav: 'Context menu' added under Overlays.

Morfo scope: ['soma'] → ['soma', 'sema', 'eidos'].

Sprint progress: 4/5 (toggle-group, alert-dialog, dropdown-menu,
context-menu). Last is pin-input.

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 f19b5c7270
commit 21eb8a6264

@ -0,0 +1,108 @@
# `<ContextMenu>` — eidos
Right-click / long-press menu. Visually identical to
`<DropdownMenu>` — same panel chrome, same item rows. Differences vs
DropdownMenu:
| | DropdownMenu | ContextMenu |
|---|---|---|
| Trigger | `<button>` toggle | `<span>` wrap intercepting `contextmenu` |
| Open via | Click / Enter / Space / ArrowDown | Right-click / Ctrl+Click / Long-press / Shift+F10 |
| Anchored to | Trigger's bounding box | Cursor position |
| `CheckboxGroup` | ✓ | — (Bits parity gap; can be added later) |
## Usage
```svelte
<script lang="ts">
import { ContextMenu } from '$uix/eidos/components/context-menu';
let showGrid = $state(true);
</script>
<ContextMenu>
<ContextMenu.Trigger>
<div class="canvas">Right-click anywhere on the canvas…</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => copy()}>Copy</ContextMenu.Item>
<ContextMenu.Item onSelect={() => paste()}>Paste</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.CheckboxItem bind:checked={showGrid}>
Show grid
</ContextMenu.CheckboxItem>
<ContextMenu.Sub>
<ContextMenu.SubTrigger>Format</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
<ContextMenu.Item>Bold</ContextMenu.Item>
<ContextMenu.Item>Italic</ContextMenu.Item>
</ContextMenu.SubContent>
</ContextMenu.Sub>
</ContextMenu.Content>
</ContextMenu>
```
## Parts
14 pass-through wrappers — same vocabulary as DropdownMenu minus
`CheckboxGroup`.
`Provider · Trigger · Content · Arrow · Item · Group · GroupHeading ·
Separator · CheckboxItem · RadioGroup · RadioItem · Sub · SubTrigger
· SubContent`.
## Baseline
WAI-ARIA APG Menu pattern:
<https://www.w3.org/WAI/ARIA/apg/patterns/menu/>
Soma owns:
- `contextmenu` event interception, long-press detection, Shift+F10 handling
- Floating positioning anchored to the **cursor** (not the trigger box)
- Same keyboard navigation as DropdownMenu inside the open Content
- Focus management on open / close / sub navigation
## Comparativa
| Lib | Trigger source | Long-press (touch) | Shift+F10 | Sub-menus | Cursor anchor |
|---|---|---|---|---|---|
| **Radix Primitives** | `contextmenu` event | ✓ | ✓ | ✓ | ✓ |
| **Bits UI** | same | ✓ | ✓ | ✓ | ✓ |
| **Ark UI** | same | ✓ | ✓ | ✓ | ✓ |
| **Eidos (this)** | same | ✓ | ✓ | ✓ | ✓ |
Full functional parity. No styled API additions at the eidos level —
visual treatment identical to DropdownMenu (single recipe vocabulary
across the menu family).
## Decisiones
- **Recipe duplication, not aliasing.** The CSS uses separate
`[data-context-menu-*]` selectors rather than sharing
`[data-dropdown-menu-*]`. Reason: the morfo emits distinct markers
per component (correct contract behaviour), and aliasing the
recipe would couple two independent components in CSS even though
their *visual* vocabulary is identical. Cost: ~150 lines of CSS
duplication. Benefit: each component evolves freely without
cascade surprises.
- **Trigger gets a subtle `data-state='open'` outline.** Users
often forget which region they right-clicked; a 1px dashed border
while the menu is open provides a quiet "this is the source"
affordance without competing with the menu itself.
- **No `CheckboxGroup`.** Soma doesn't ship one for context-menu
(Bits doesn't either). Can be added later if needed.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `CheckboxGroup` (array binding, Bits' addition for DropdownMenu) | **diferir** | Soma doesn't ship one for context-menu yet. |
| Per-item `data-color` cascade for destructive items | **implementar** próximo turno | Same gap as DropdownMenu — would be the same cascade rule. Resolve once for both. |
| Touch UX testing | **diferir** | Long-press is wired in soma but visual + haptic feedback during the hold could be richer (Material-style ripple, etc.). |
## Reference
- Radix UI Primitives:
<https://www.radix-ui.com/primitives/docs/components/context-menu>
- Bits UI: <https://bits-ui.com/docs/components/context-menu>
- Ark UI: <https://ark-ui.com/docs/components/context-menu>
- shadcn/ui: <https://ui.shadcn.com/docs/components/context-menu>

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,12 @@
<script lang="ts">
/**
* Eidos `<ContextMenu.Trigger>` — the right-clickable / long-press
* region. Soma's Trigger is a `<span>` wrapper (not a button) that
* intercepts `contextmenu` events.
*/
import * as ContextMenu from '$soma/components/context-menu';
import type { ContextMenuTriggerProps } from './types';
let { children, ...rest }: ContextMenuTriggerProps = $props();
</script>
<ContextMenu.Trigger {...rest}>{@render children?.()}</ContextMenu.Trigger>

@ -0,0 +1,190 @@
/*
* ContextMenu recipe.
*
* Functionally identical to DropdownMenu — same panel chrome, same
* item row treatment. Only differences:
*
* - Trigger is a `<span>` wrapper (NOT a button) that intercepts
* `contextmenu`. The recipe gives it no chrome by default; the
* wrapper is meant to feel inert until invoked.
* - Anchoring is to the cursor position rather than the trigger's
* bounding box — soma's floating layer handles this. The visual
* panel reads identical to DropdownMenu's.
*
* To avoid recipe duplication we lean on attribute selectors that
* cover both `data-dropdown-menu-*` and `data-context-menu-*` for the
* shared parts (Content, Item, Group, Separator, etc.). The Trigger
* gets its own rule since its semantics differ.
*/
/* ── Panel (Content / SubContent) ──────────────────────────────────── */
[data-context-menu-content],
[data-context-menu-sub-content] {
min-inline-size: var(--context-menu-content-min-width, 12rem);
max-block-size: var(--context-menu-content-max-height, min(60vh, 22rem));
overflow-y: auto;
padding: var(--context-menu-content-padding, var(--space-1));
border: var(--context-menu-content-border-width, 1px) solid
var(--context-menu-content-border-color, var(--color-border-default));
border-radius: var(--context-menu-content-radius, var(--radius-md));
background: var(--context-menu-content-bg, var(--color-surface-overlay));
box-shadow: var(--context-menu-content-shadow, var(--shadow-floating));
font-family: var(--style-label-font-family, var(--font-ui));
font-size: var(--context-menu-content-font-size, var(--font-size-sm));
color: var(--color-content-primary);
}
[data-context-menu-content]:focus-visible,
[data-context-menu-sub-content]:focus-visible {
outline: none;
}
/* ── Item rows ─────────────────────────────────────────────────────── */
[data-context-menu-item],
[data-context-menu-checkbox-item],
[data-context-menu-radio-item],
[data-context-menu-sub-trigger] {
display: flex;
align-items: center;
gap: var(--context-menu-item-gap, var(--space-2));
min-block-size: var(--context-menu-item-height, 2rem);
padding-block: var(--context-menu-item-py, var(--space-1));
padding-inline: var(--context-menu-item-px, var(--space-2));
border-radius: var(--context-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-context-menu-item]:hover:not([data-disabled]),
[data-context-menu-checkbox-item]:hover:not([data-disabled]),
[data-context-menu-radio-item]:hover:not([data-disabled]),
[data-context-menu-sub-trigger]:hover:not([data-disabled]),
[data-context-menu-item]:focus-visible,
[data-context-menu-checkbox-item]:focus-visible,
[data-context-menu-radio-item]:focus-visible,
[data-context-menu-sub-trigger]:focus-visible,
[data-context-menu-sub-trigger][data-state='open'] {
outline: none;
background: var(--context-menu-item-bg-hover, var(--color-primary-element));
color: var(--context-menu-item-fg-hover, var(--color-content-primary));
}
[data-context-menu-item][data-disabled],
[data-context-menu-checkbox-item][data-disabled],
[data-context-menu-radio-item][data-disabled],
[data-context-menu-sub-trigger][data-disabled] {
cursor: default;
opacity: var(--context-menu-item-disabled-opacity, 0.55);
}
[data-context-menu-checkbox-item],
[data-context-menu-radio-item] {
padding-inline-start: var(--context-menu-item-indicator-inset, calc(var(--space-2) + 1rem));
position: relative;
}
[data-context-menu-checkbox-item][data-state='checked']::before,
[data-context-menu-radio-item][data-state='checked']::before {
content: '';
position: absolute;
inset-inline-start: var(--context-menu-item-px, var(--space-2));
inline-size: 0.875rem;
block-size: 0.875rem;
border-radius: 0.2rem;
background: var(--color-primary-solid);
}
[data-context-menu-radio-item][data-state='checked']::before {
border-radius: 50%;
}
[data-context-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-context-menu-group] {
display: contents;
}
[data-context-menu-group-heading] {
padding-block: var(--context-menu-heading-py, var(--space-1));
padding-inline: var(--context-menu-heading-px, var(--space-2));
color: var(--color-content-muted);
font-size: var(--context-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-context-menu-separator] {
margin-block: var(--context-menu-separator-my, var(--space-1));
block-size: 1px;
background: var(--context-menu-separator-bg, var(--color-border-subtle));
}
/* ── Trigger ──────────────────────────────────────────────────────── */
/*
* Default: no visual treatment. The Trigger is meant to feel inert
* until invoked. Consumers wrap whatever content they want
* right-clickable; the recipe stays out of the way.
*
* `data-state='open'` (set by soma when the menu is open) gets a
* subtle outline as a courtesy affordance — the user often forgets
* which area summoned the menu.
*/
[data-context-menu-trigger] {
display: inline-block;
}
[data-context-menu-trigger][data-state='open'] {
outline: 1px dashed var(--color-border-default);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Arrow ────────────────────────────────────────────────────────── */
[data-context-menu-arrow] {
fill: var(--context-menu-content-bg, var(--color-surface-overlay));
}
/* ── State transitions ────────────────────────────────────────────── */
[data-context-menu-content][data-state='closed'],
[data-context-menu-sub-content][data-state='closed'] {
opacity: 0;
pointer-events: none;
}
[data-context-menu-content][data-state='open'],
[data-context-menu-sub-content][data-state='open'] {
animation: context-menu-enter var(--duration-fast) var(--ease-out);
}
@keyframes context-menu-enter {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: none;
}
}

@ -0,0 +1,20 @@
<script lang="ts">
/**
* Eidos `<ContextMenu>` — root provider. Pass-through over soma.
*
* <ContextMenu>
* <ContextMenu.Trigger>Right-click me</ContextMenu.Trigger>
* <ContextMenu.Content>
* <ContextMenu.Item onSelect={...}>Copy</ContextMenu.Item>
* </ContextMenu.Content>
* </ContextMenu>
*/
import * as ContextMenu from '$soma/components/context-menu';
import type { ContextMenuProps } from './types';
let { open = $bindable(false), children, ...rest }: ContextMenuProps = $props();
</script>
<ContextMenu.Provider {...rest} bind:open>
{@render children?.()}
</ContextMenu.Provider>

@ -0,0 +1,90 @@
// ContextMenu — eidos compound API.
//
// import { ContextMenu } from '$uix/eidos/components/context-menu';
//
// <ContextMenu>
// <ContextMenu.Trigger>
// <div class="canvas">Right-click here</div>
// </ContextMenu.Trigger>
// <ContextMenu.Content>
// <ContextMenu.Item onSelect={...}>Copy</ContextMenu.Item>
// <ContextMenu.Item onSelect={...}>Paste</ContextMenu.Item>
// <ContextMenu.Separator />
// <ContextMenu.CheckboxItem bind:checked>Show grid</ContextMenu.CheckboxItem>
// <ContextMenu.Sub>
// <ContextMenu.SubTrigger>Format</ContextMenu.SubTrigger>
// <ContextMenu.SubContent>
// <ContextMenu.Item>Bold</ContextMenu.Item>
// </ContextMenu.SubContent>
// </ContextMenu.Sub>
// </ContextMenu.Content>
// </ContextMenu>
import ContextMenuComponent from './context-menu.svelte';
import Trigger from './context-menu-trigger.svelte';
import Content from './context-menu-content.svelte';
import Item from './context-menu-item.svelte';
import Group from './context-menu-group.svelte';
import GroupHeading from './context-menu-group-heading.svelte';
import Separator from './context-menu-separator.svelte';
import Arrow from './context-menu-arrow.svelte';
import CheckboxItem from './context-menu-checkbox-item.svelte';
import RadioGroup from './context-menu-radio-group.svelte';
import RadioItem from './context-menu-radio-item.svelte';
import Sub from './context-menu-sub.svelte';
import SubTrigger from './context-menu-sub-trigger.svelte';
import SubContent from './context-menu-sub-content.svelte';
import { Portal } from '$soma/components/internal';
type ContextMenuNamespace = typeof ContextMenuComponent & {
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;
RadioGroup: typeof RadioGroup;
RadioItem: typeof RadioItem;
Sub: typeof Sub;
SubTrigger: typeof SubTrigger;
SubContent: typeof SubContent;
};
const ContextMenu = ContextMenuComponent as ContextMenuNamespace;
ContextMenu.Trigger = Trigger;
ContextMenu.Portal = Portal;
ContextMenu.Content = Content;
ContextMenu.Item = Item;
ContextMenu.Group = Group;
ContextMenu.GroupHeading = GroupHeading;
ContextMenu.Separator = Separator;
ContextMenu.Arrow = Arrow;
ContextMenu.CheckboxItem = CheckboxItem;
ContextMenu.RadioGroup = RadioGroup;
ContextMenu.RadioItem = RadioItem;
ContextMenu.Sub = Sub;
ContextMenu.SubTrigger = SubTrigger;
ContextMenu.SubContent = SubContent;
export { ContextMenu };
export default ContextMenu;
export type {
ContextMenuProps,
ContextMenuTriggerProps as TriggerProps,
ContextMenuContentProps as ContentProps,
ContextMenuArrowProps as ArrowProps,
ContextMenuItemProps as ItemProps,
ContextMenuGroupProps as GroupProps,
ContextMenuGroupHeadingProps as GroupHeadingProps,
ContextMenuSeparatorProps as SeparatorProps,
ContextMenuCheckboxItemProps as CheckboxItemProps,
ContextMenuRadioGroupProps as RadioGroupProps,
ContextMenuRadioItemProps as RadioItemProps,
ContextMenuSubProps as SubProps,
ContextMenuSubTriggerProps as SubTriggerProps,
ContextMenuSubContentProps as SubContentProps
} from './types';

@ -0,0 +1,40 @@
// Eidos `<ContextMenu>` — visual wrapper over `soma/context-menu`. A
// menu opened by right-click / long-press / `contextmenu` keyboard
// shortcut on a Trigger region (the morfo's Trigger is a `<span>`
// wrapping arbitrary content rather than a `<button>` like
// DropdownMenu's). Positioning is anchored to the cursor instead of
// the trigger's bounding box.
//
// Otherwise functionally identical to DropdownMenu: same Items,
// Groups, Separators, Checkbox / Radio items, nested Sub-menus.
import type {
ProviderProps,
TriggerProps,
ContentProps,
ArrowProps,
ItemProps,
GroupProps,
GroupHeadingProps,
SeparatorProps,
CheckboxItemProps,
RadioGroupProps,
RadioItemProps,
SubProps,
SubTriggerProps,
SubContentProps
} from '$soma/components/context-menu';
export type ContextMenuProps = ProviderProps;
export type ContextMenuTriggerProps = TriggerProps;
export type ContextMenuContentProps = ContentProps;
export type ContextMenuArrowProps = ArrowProps;
export type ContextMenuItemProps = ItemProps;
export type ContextMenuGroupProps = GroupProps;
export type ContextMenuGroupHeadingProps = GroupHeadingProps;
export type ContextMenuSeparatorProps = SeparatorProps;
export type ContextMenuCheckboxItemProps = CheckboxItemProps;
export type ContextMenuRadioGroupProps = RadioGroupProps;
export type ContextMenuRadioItemProps = RadioItemProps;
export type ContextMenuSubProps = SubProps;
export type ContextMenuSubTriggerProps = SubTriggerProps;
export type ContextMenuSubContentProps = SubContentProps;

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

@ -4,7 +4,9 @@ import { v } from '../types';
export const contextMenuMorfo = {
name: 'ContextMenu',
kebab: 'context-menu',
scope: ['soma'],
// Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/context-menu/`).
// Same family/event vocabulary as dropdown-menu — sound from family bases.
scope: ['soma', 'sema', 'eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu/',
texts: {
label: '#?components.context-menu.label|Context menu'

@ -198,6 +198,7 @@
{ slug: '/uix/components/drawer', label: 'Drawer' },
{ slug: '/uix/components/popover', label: 'Popover' },
{ slug: '/uix/components/dropdown-menu', label: 'Dropdown menu' },
{ slug: '/uix/components/context-menu', label: 'Context menu' },
{ slug: '/uix/components/toast', label: 'Toast' },
{ slug: '/uix/components/tooltip', label: 'Tooltip' }
]

@ -0,0 +1,339 @@
<script lang="ts">
import { ContextMenu } from '$uix/eidos/components/context-menu';
import { compileMorfo } from '$uix/morfo';
import { contextMenuMorfo } from '@/uix/morfo/components/context-menu';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
let showGrid = $state(true);
let snapToGrid = $state(false);
let format = $state<'png' | 'jpg' | 'webp'>('png');
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(contextMenuMorfo);
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 = `<script lang='ts'>
import * as ContextMenu from '$soma/components/context-menu';
</${'script'}>
<ContextMenu.Provider>
<ContextMenu.Trigger><div>Right-click area</div></ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item onSelect={...}>Copy</ContextMenu.Item>
<ContextMenu.CheckboxItem bind:checked>Show grid</ContextMenu.CheckboxItem>
</ContextMenu.Content>
</ContextMenu.Provider>`;
const eidosSnippet = `<script lang='ts'>
import { ContextMenu } from '$uix/eidos/components/context-menu';
</${'script'}>
<ContextMenu>
<ContextMenu.Trigger><div class="canvas">Right-click anywhere</div></ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item onSelect={...}>Copy</ContextMenu.Item>
<ContextMenu.Item onSelect={...}>Paste</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.CheckboxItem bind:checked>Show grid</ContextMenu.CheckboxItem>
<ContextMenu.Sub>
<ContextMenu.SubTrigger>Format</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
<ContextMenu.RadioGroup bind:value>
<ContextMenu.RadioItem value="png">PNG</ContextMenu.RadioItem>
<ContextMenu.RadioItem value="jpg">JPG</ContextMenu.RadioItem>
</ContextMenu.RadioGroup>
</ContextMenu.SubContent>
</ContextMenu.Sub>
</ContextMenu.Content>
</ContextMenu>`;
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Overlay · ContextMenu</div>
<h1 data-uix-page-title>ContextMenu</h1>
<p data-uix-page-lede>
Right-click / long-press menu. Same visual recipe as
DropdownMenu — the only difference is the trigger surface
(<code>contextmenu</code> event on a wrapped region) and the
anchoring (cursor position rather than trigger box).
<span data-uix-layer-badge="soma">soma</span> handles the
interception (right-click, long-press, Shift+F10) and the
cursor-anchored floating math.
</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>
{#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}>
<ContextMenu>
<ContextMenu.Trigger>
<div
style="display: grid; place-items: center; inline-size: 100%; min-block-size: 12rem; padding: var(--space-6); border: 2px dashed var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default); color: var(--color-content-secondary); user-select: none;"
>
Right-click anywhere in this area
</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Group>
<ContextMenu.GroupHeading>Edit</ContextMenu.GroupHeading>
<ContextMenu.Item onSelect={pickItem('Copy')}>Copy</ContextMenu.Item>
<ContextMenu.Item onSelect={pickItem('Paste')}>Paste</ContextMenu.Item>
<ContextMenu.Item onSelect={pickItem('Cut')}>Cut</ContextMenu.Item>
</ContextMenu.Group>
<ContextMenu.Separator />
<ContextMenu.CheckboxItem bind:checked={showGrid}>Show grid</ContextMenu.CheckboxItem>
<ContextMenu.CheckboxItem bind:checked={snapToGrid}>Snap to grid</ContextMenu.CheckboxItem>
<ContextMenu.Separator />
<ContextMenu.Sub>
<ContextMenu.SubTrigger>Export as…</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
<ContextMenu.RadioGroup bind:value={format}>
<ContextMenu.RadioItem value="png">PNG</ContextMenu.RadioItem>
<ContextMenu.RadioItem value="jpg">JPG</ContextMenu.RadioItem>
<ContextMenu.RadioItem value="webp">WebP</ContextMenu.RadioItem>
</ContextMenu.RadioGroup>
</ContextMenu.SubContent>
</ContextMenu.Sub>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={pickItem('Delete')}>Delete</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
</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>14</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>Live state</h2>
<p data-uix-section-desc>
No knobs at the eidos level — visual treatment is uniform.
Interact with the demo's CheckboxItems / RadioGroup to see
state binding; the trigger area summons the menu via
right-click, Ctrl+Click on macOS, or long-press on touch.
</p>
<div data-uix-controls>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={showGrid} disabled /> showGrid (bound to demo)
</label>
<label data-uix-control data-control-kind="switch">
<input type="checkbox" bind:checked={snapToGrid} disabled /> snapToGrid (bound to demo)
</label>
<label data-uix-control>
<span data-uix-control-label>format (bound to demo)</span>
<input type="text" value={format} disabled />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · cursor-anchored, contextmenu interception</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>
14 pass-through parts (DropdownMenu surface minus
CheckboxGroup). No evaluative props at the eidos layer.
</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">ContextMenu (root)</td><td>Provider; bind:open, modal, dir.</td></tr>
<tr><td class="name">Trigger</td><td>`&lt;span&gt;` wrapping right-clickable content. Soma intercepts `contextmenu` / long-press / Shift+F10.</td></tr>
<tr><td class="name">Content</td><td>Floating panel anchored at the cursor position.</td></tr>
<tr><td class="name">Arrow</td><td>Optional 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.</td></tr>
<tr><td class="name">Separator</td><td><code>role=separator</code>.</td></tr>
<tr><td class="name">CheckboxItem</td><td><code>role=menuitemcheckbox</code>; bind:checked.</td></tr>
<tr><td class="name">RadioGroup / RadioItem</td><td><code>role=menuitemradio</code>.</td></tr>
<tr><td class="name">Sub / SubTrigger / SubContent</td><td>Nested fly-out.</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>context-menu.css</code>. Visually identical to
DropdownMenu — panel chrome, item rows, indicator slots and
submenu chevrons share the same vocabulary. The Trigger
gets a subtle <code>data-state='open'</code> dashed outline
as an affordance for "the menu came from this region".
</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/"
target="_blank"
rel="noopener">Menu</a
>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Input</th><th>Effect</th></tr></thead>
<tbody>
<tr><td>Right-click</td><td>Open the menu at cursor.</td></tr>
<tr><td>Ctrl+Click (macOS)</td><td>Same as right-click.</td></tr>
<tr><td>Long-press (touch)</td><td>Same after the platform's long-press delay.</td></tr>
<tr><td><kbd>Shift+F10</kbd></td><td>Open the menu via keyboard.</td></tr>
<tr><td><kbd>ArrowUp</kbd> / <kbd>ArrowDown</kbd></td><td>Navigate items.</td></tr>
<tr><td><kbd>Enter</kbd> / <kbd>Space</kbd></td><td>Activate item.</td></tr>
<tr><td><kbd>Escape</kbd></td><td>Close.</td></tr>
<tr><td><kbd>ArrowRight</kbd> on SubTrigger</td><td>Open submenu.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.