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