You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1019 lines
51 KiB
1019 lines
51 KiB
<script lang="ts">
|
|
import { ContextMenu } from '$uix/eidos/components/context-menu';
|
|
import { compileMorfo } from '$uix/morfo';
|
|
import { contextMenuMorfo } from '@/uix/morfo/components/context-menu';
|
|
import { getActiveUix } from '$active-uix';
|
|
|
|
const uix = getActiveUix();
|
|
|
|
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
|
let tab = $state<Tab>('live');
|
|
|
|
// ── Live state ────────────────────────────────────────────────────────
|
|
// Soma · Provider
|
|
let open = $state(false);
|
|
let dir = $state<'ltr' | 'rtl'>('ltr');
|
|
// Soma · Content (floating layer)
|
|
let side = $state<'top' | 'right' | 'bottom' | 'left'>('bottom');
|
|
let sideOffset = $state(0);
|
|
let align = $state<'start' | 'center' | 'end'>('start');
|
|
let alignOffset = $state(0);
|
|
let avoidCollisions = $state(true);
|
|
let loop = $state(false);
|
|
let trapFocus = $state(true);
|
|
let preventScroll = $state(false);
|
|
// Soma · Item-level
|
|
let disableDelete = $state(false);
|
|
let closeOnSelect = $state(true);
|
|
// Demo-driven state (bindings against menu items)
|
|
let showGrid = $state(true);
|
|
let snapToGrid = $state(false);
|
|
let format = $state<'png' | 'jpg' | 'webp'>('png');
|
|
let lastSelected = $state<string | null>(null);
|
|
// Composition (parts toggled in/out of the markup)
|
|
let showGroupHeading = $state(true);
|
|
let showSeparators = $state(true);
|
|
let showCheckboxes = $state(true);
|
|
let showSub = $state(true);
|
|
let showArrow = $state(false);
|
|
let showRadio = $state(true);
|
|
// Demo content
|
|
let triggerText = $state('Right-click anywhere in this area');
|
|
let subTriggerText = $state('Export as…');
|
|
|
|
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 = $derived(
|
|
[
|
|
"<script lang='ts'>",
|
|
" import * as ContextMenu from '$soma/components/context-menu';",
|
|
' let showGrid = $state(true);',
|
|
' let format = $state("png");',
|
|
'</' + 'script>',
|
|
'',
|
|
'<ContextMenu.Provider',
|
|
' bind:open',
|
|
dir !== 'ltr' && ` dir="${dir}"`,
|
|
'>',
|
|
` <ContextMenu.Trigger>${triggerText}</ContextMenu.Trigger>`,
|
|
' <ContextMenu.Content',
|
|
side !== 'bottom' && ` side="${side}"`,
|
|
sideOffset !== 0 && ` sideOffset={${sideOffset}}`,
|
|
align !== 'start' && ` align="${align}"`,
|
|
alignOffset !== 0 && ` alignOffset={${alignOffset}}`,
|
|
!avoidCollisions && ' avoidCollisions={false}',
|
|
loop && ' loop',
|
|
!trapFocus && ' trapFocus={false}',
|
|
preventScroll && ' preventScroll',
|
|
' >',
|
|
showGroupHeading && ' <ContextMenu.Group>',
|
|
showGroupHeading && ' <ContextMenu.GroupHeading>Edit</ContextMenu.GroupHeading>',
|
|
showGroupHeading && ' <ContextMenu.Item onSelect={() => …}>Copy</ContextMenu.Item>',
|
|
showGroupHeading && ' <ContextMenu.Item onSelect={() => …}>Paste</ContextMenu.Item>',
|
|
showGroupHeading && ' </ContextMenu.Group>',
|
|
!showGroupHeading && ' <ContextMenu.Item onSelect={() => …}>Copy</ContextMenu.Item>',
|
|
!showGroupHeading && ' <ContextMenu.Item onSelect={() => …}>Paste</ContextMenu.Item>',
|
|
showSeparators && ' <ContextMenu.Separator />',
|
|
showCheckboxes && ' <ContextMenu.CheckboxItem bind:checked={showGrid}>Show grid</ContextMenu.CheckboxItem>',
|
|
showCheckboxes && ' <ContextMenu.CheckboxItem bind:checked={snapToGrid}>Snap</ContextMenu.CheckboxItem>',
|
|
showSeparators && showCheckboxes && ' <ContextMenu.Separator />',
|
|
showSub && ' <ContextMenu.Sub>',
|
|
showSub && ` <ContextMenu.SubTrigger>${subTriggerText}</ContextMenu.SubTrigger>`,
|
|
showSub && ' <ContextMenu.SubContent>',
|
|
showSub && showRadio && ' <ContextMenu.RadioGroup bind:value={format}>',
|
|
showSub && showRadio && ' <ContextMenu.RadioItem value="png">PNG</ContextMenu.RadioItem>',
|
|
showSub && showRadio && ' <ContextMenu.RadioItem value="jpg">JPG</ContextMenu.RadioItem>',
|
|
showSub && showRadio && ' <ContextMenu.RadioItem value="webp">WebP</ContextMenu.RadioItem>',
|
|
showSub && showRadio && ' </ContextMenu.RadioGroup>',
|
|
showSub && !showRadio && ' <ContextMenu.Item>Bold</ContextMenu.Item>',
|
|
showSub && ' </ContextMenu.SubContent>',
|
|
showSub && ' </ContextMenu.Sub>',
|
|
showSeparators && ' <ContextMenu.Separator />',
|
|
` <ContextMenu.Item${disableDelete ? ' disabled' : ''}${!closeOnSelect ? ' closeOnSelect={false}' : ''} onSelect={() => …}>Delete</ContextMenu.Item>`,
|
|
showArrow && ' <ContextMenu.Arrow />',
|
|
' </ContextMenu.Content>',
|
|
'</ContextMenu.Provider>'
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
);
|
|
|
|
const eidosSnippet = $derived(
|
|
[
|
|
"<script lang='ts'>",
|
|
" import { ContextMenu } from '$uix/eidos/components/context-menu';",
|
|
' let showGrid = $state(true);',
|
|
' let format = $state("png");',
|
|
'</' + 'script>',
|
|
'',
|
|
'<ContextMenu',
|
|
' bind:open',
|
|
dir !== 'ltr' && ` dir="${dir}"`,
|
|
'>',
|
|
` <ContextMenu.Trigger>${triggerText}</ContextMenu.Trigger>`,
|
|
' <ContextMenu.Content',
|
|
side !== 'bottom' && ` side="${side}"`,
|
|
align !== 'start' && ` align="${align}"`,
|
|
!avoidCollisions && ' avoidCollisions={false}',
|
|
loop && ' loop',
|
|
' >',
|
|
showGroupHeading && ' <ContextMenu.Group>',
|
|
showGroupHeading && ' <ContextMenu.GroupHeading>Edit</ContextMenu.GroupHeading>',
|
|
showGroupHeading && ' <ContextMenu.Item onSelect={…}>Copy</ContextMenu.Item>',
|
|
showGroupHeading && ' <ContextMenu.Item onSelect={…}>Paste</ContextMenu.Item>',
|
|
showGroupHeading && ' <ContextMenu.Item onSelect={…}>Cut</ContextMenu.Item>',
|
|
showGroupHeading && ' </ContextMenu.Group>',
|
|
!showGroupHeading && ' <ContextMenu.Item onSelect={…}>Copy</ContextMenu.Item>',
|
|
!showGroupHeading && ' <ContextMenu.Item onSelect={…}>Paste</ContextMenu.Item>',
|
|
showSeparators && ' <ContextMenu.Separator />',
|
|
showCheckboxes && ' <ContextMenu.CheckboxItem bind:checked={showGrid}>Show grid</ContextMenu.CheckboxItem>',
|
|
showCheckboxes && ' <ContextMenu.CheckboxItem bind:checked={snapToGrid}>Snap to grid</ContextMenu.CheckboxItem>',
|
|
showSeparators && showCheckboxes && ' <ContextMenu.Separator />',
|
|
showSub && ' <ContextMenu.Sub>',
|
|
showSub && ` <ContextMenu.SubTrigger>${subTriggerText}</ContextMenu.SubTrigger>`,
|
|
showSub && ' <ContextMenu.SubContent>',
|
|
showSub && showRadio && ' <ContextMenu.RadioGroup bind:value={format}>',
|
|
showSub && showRadio && ' <ContextMenu.RadioItem value="png">PNG</ContextMenu.RadioItem>',
|
|
showSub && showRadio && ' <ContextMenu.RadioItem value="jpg">JPG</ContextMenu.RadioItem>',
|
|
showSub && showRadio && ' <ContextMenu.RadioItem value="webp">WebP</ContextMenu.RadioItem>',
|
|
showSub && showRadio && ' </ContextMenu.RadioGroup>',
|
|
showSub && !showRadio && ' <ContextMenu.Item>Bold</ContextMenu.Item>',
|
|
showSub && ' </ContextMenu.SubContent>',
|
|
showSub && ' </ContextMenu.Sub>',
|
|
showSeparators && ' <ContextMenu.Separator />',
|
|
` <ContextMenu.Item${disableDelete ? ' disabled' : ''} onSelect={…}>Delete</ContextMenu.Item>`,
|
|
showArrow && ' <ContextMenu.Arrow />',
|
|
' </ContextMenu.Content>',
|
|
'</ContextMenu>'
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
);
|
|
</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 / <span data-uix-kbd>Shift+F10</span> menu. Same visual recipe as
|
|
<code>DropdownMenu</code> — the only differences are the trigger surface
|
|
(<code>contextmenu</code> event on a wrapped region rather than a button)
|
|
and the anchoring (cursor position rather than trigger box). 14 parts,
|
|
3 semantic events, full Items / CheckboxItem / RadioGroup / Sub-menu
|
|
parity with the dropdown family.
|
|
</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>apg</span>
|
|
<a href={contextMenuMorfo.apg} target="_blank" rel="noreferrer">menu ↗</a>
|
|
</span>
|
|
{#if lastSelected}
|
|
<span data-uix-meta-pill>
|
|
<span data-uix-meta-key>last</span>{lastSelected}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Live preview always rendered -->
|
|
<div data-uix-stage>
|
|
<div data-uix-stage-area bind:this={stageRef}>
|
|
<ContextMenu bind:open {dir}>
|
|
<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;"
|
|
>
|
|
{triggerText}
|
|
</div>
|
|
</ContextMenu.Trigger>
|
|
<ContextMenu.Content
|
|
{side}
|
|
{sideOffset}
|
|
{align}
|
|
{alignOffset}
|
|
{avoidCollisions}
|
|
{loop}
|
|
{trapFocus}
|
|
{preventScroll}
|
|
>
|
|
{#if showGroupHeading}
|
|
<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>
|
|
{:else}
|
|
<ContextMenu.Item onSelect={pickItem('Copy')}>Copy</ContextMenu.Item>
|
|
<ContextMenu.Item onSelect={pickItem('Paste')}>Paste</ContextMenu.Item>
|
|
<ContextMenu.Item onSelect={pickItem('Cut')}>Cut</ContextMenu.Item>
|
|
{/if}
|
|
{#if showSeparators}<ContextMenu.Separator />{/if}
|
|
{#if showCheckboxes}
|
|
<ContextMenu.CheckboxItem bind:checked={showGrid}>Show grid</ContextMenu.CheckboxItem>
|
|
<ContextMenu.CheckboxItem bind:checked={snapToGrid}>Snap to grid</ContextMenu.CheckboxItem>
|
|
{/if}
|
|
{#if showSeparators && showCheckboxes}<ContextMenu.Separator />{/if}
|
|
{#if showSub}
|
|
<ContextMenu.Sub>
|
|
<ContextMenu.SubTrigger>{subTriggerText}</ContextMenu.SubTrigger>
|
|
<ContextMenu.SubContent>
|
|
{#if showRadio}
|
|
<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>
|
|
{:else}
|
|
<ContextMenu.Item onSelect={pickItem('Bold')}>Bold</ContextMenu.Item>
|
|
<ContextMenu.Item onSelect={pickItem('Italic')}>Italic</ContextMenu.Item>
|
|
{/if}
|
|
</ContextMenu.SubContent>
|
|
</ContextMenu.Sub>
|
|
{/if}
|
|
{#if showSeparators}<ContextMenu.Separator />{/if}
|
|
<ContextMenu.Item
|
|
disabled={disableDelete}
|
|
{closeOnSelect}
|
|
onSelect={pickItem('Delete')}
|
|
>
|
|
Delete
|
|
</ContextMenu.Item>
|
|
{#if showArrow}<ContextMenu.Arrow />{/if}
|
|
</ContextMenu.Content>
|
|
</ContextMenu>
|
|
</div>
|
|
<div data-uix-stage-trace>
|
|
<span data-uix-stage-trace-key>trace</span>
|
|
{#if trace.length === 0}
|
|
<span>right-click the area to summon the menu</span>
|
|
{:else}
|
|
{#each trace.slice(0, 3) as entry}
|
|
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
|
|
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
|
|
{/each}
|
|
{/if}
|
|
<span style="margin-inline-start: auto;">
|
|
<span data-uix-stage-trace-key>open</span> {String(open)}
|
|
{#if lastSelected}
|
|
<span data-uix-stage-trace-key style="margin-inline-start: 0.5rem;">last</span> {lastSelected}
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<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>
|
|
<span data-uix-tab-count>{events.length}</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>
|
|
<p data-uix-section-desc>
|
|
ContextMenu is a pure pass-through over <span data-uix-layer-badge="soma">soma</span>;
|
|
the eidos layer adds no evaluative props. Knobs are grouped by the
|
|
layer that owns the prop, plus a <em>composition</em> group for parts
|
|
toggled in / out of the markup (per the composition-over-visibility
|
|
norm). Semantic events live in the <a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
|
|
</p>
|
|
|
|
<!-- ── Soma · Provider ─────────────────────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
<span data-uix-layer-badge="soma">soma</span> Provider props
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>open <span data-uix-control-hint>bindable</span></span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={open} />
|
|
<span data-uix-switch-label>{open ? 'open' : 'closed'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>dir</span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['ltr', 'rtl'] as d (d)}
|
|
<button data-uix-chip data-active={dir === d} onclick={() => (dir = d as 'ltr' | 'rtl')}>{d}</button>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Soma · Content (floating layer) ─────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
<span data-uix-layer-badge="soma">soma</span> Content props
|
|
<span style="font-weight: normal; color: var(--uix-text-muted);">
|
|
· floating layer · cursor-anchored so side/align act as a bias hint
|
|
</span>
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>side</span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['top', 'right', 'bottom', 'left'] as s (s)}
|
|
<button data-uix-chip data-active={side === s} onclick={() => (side = s as typeof side)}>{s}</button>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>sideOffset <span data-uix-control-hint>{sideOffset}px</span></span>
|
|
<input type="range" min="-16" max="24" step="1" bind:value={sideOffset} />
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>align</span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['start', 'center', 'end'] as a (a)}
|
|
<button data-uix-chip data-active={align === a} onclick={() => (align = a as typeof align)}>{a}</button>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>alignOffset <span data-uix-control-hint>{alignOffset}px</span></span>
|
|
<input type="range" min="-24" max="24" step="1" bind:value={alignOffset} />
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>avoidCollisions</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={avoidCollisions} />
|
|
<span data-uix-switch-label>{avoidCollisions ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>loop</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={loop} />
|
|
<span data-uix-switch-label>{loop ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>trapFocus</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={trapFocus} />
|
|
<span data-uix-switch-label>{trapFocus ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>preventScroll</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={preventScroll} />
|
|
<span data-uix-switch-label>{preventScroll ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Soma · Item-level ───────────────────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
<span data-uix-layer-badge="soma">soma</span> Item props
|
|
<span style="font-weight: normal; color: var(--uix-text-muted);">
|
|
· applied to the Delete item below
|
|
</span>
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>disabled</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={disableDelete} />
|
|
<span data-uix-switch-label>{disableDelete ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>closeOnSelect</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={closeOnSelect} />
|
|
<span data-uix-switch-label>{closeOnSelect ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Composition (parts in / out) ────────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
Composition
|
|
<span style="font-weight: normal; color: var(--uix-text-muted);">
|
|
· toggle parts in / out of the markup (no visibility props)
|
|
</span>
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>Group + GroupHeading</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={showGroupHeading} />
|
|
<span data-uix-switch-label>{showGroupHeading ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>Separators</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={showSeparators} />
|
|
<span data-uix-switch-label>{showSeparators ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>CheckboxItems</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={showCheckboxes} />
|
|
<span data-uix-switch-label>{showCheckboxes ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>Sub menu</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={showSub} />
|
|
<span data-uix-switch-label>{showSub ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>RadioGroup inside Sub</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={showRadio} disabled={!showSub} />
|
|
<span data-uix-switch-label>{showRadio ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>Arrow</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={showArrow} />
|
|
<span data-uix-switch-label>{showArrow ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Bound state (read-only mirrors) ─────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
Bound state
|
|
<span style="font-weight: normal; color: var(--uix-text-muted);">
|
|
· mirrors of `bind:checked` / `bind:value` from the live menu items
|
|
</span>
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>showGrid <span data-uix-control-hint>CheckboxItem</span></span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={showGrid} />
|
|
<span data-uix-switch-label>{showGrid ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>snapToGrid <span data-uix-control-hint>CheckboxItem</span></span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={snapToGrid} />
|
|
<span data-uix-switch-label>{snapToGrid ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>format <span data-uix-control-hint>RadioGroup</span></span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['png', 'jpg', 'webp'] as f (f)}
|
|
<button data-uix-chip data-active={format === f} onclick={() => (format = f as typeof format)}>{f}</button>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Demo content (literal text) ─────────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
Demo content
|
|
<span style="font-weight: normal; color: var(--uix-text-muted);">
|
|
· literal labels rendered into the menu
|
|
</span>
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>Trigger area text</span>
|
|
<input type="text" bind:value={triggerText} />
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>SubTrigger text</span>
|
|
<input type="text" bind:value={subTriggerText} disabled={!showSub} />
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Code snippets per layer ─────────────────────────────────── -->
|
|
<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 · pure pass-through (no extra props at the visual layer)</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 parts, all forwarded verbatim from soma — eidos adds no
|
|
evaluative props. Item-family parts (Item / CheckboxItem /
|
|
RadioItem / SubTrigger) share <code>id</code>, <code>disabled</code>,
|
|
<code>textValue</code>, <code>closeOnSelect</code>; the other props
|
|
are listed per part below.
|
|
</p>
|
|
|
|
<div data-uix-subsection-head>ContextMenu (Provider)</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">id</td><td class="type">string</td><td class="default empty">auto</td><td>Stable identifier. Used to wire <code>aria-labelledby</code> / <code>aria-controls</code>.</td></tr>
|
|
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Whether the menu is open.</td></tr>
|
|
<tr><td class="name">onOpenChange</td><td class="type">(open: boolean) => void</td><td class="default empty">—</td><td>Callback on open state change.</td></tr>
|
|
<tr><td class="name">onOpenChangeComplete</td><td class="type">(open: boolean) => void</td><td class="default empty">—</td><td>Fires after the open/close animation settles.</td></tr>
|
|
<tr><td class="name">dir</td><td class="type">'ltr' | 'rtl'</td><td class="default">'ltr'</td><td>Direction used by keyboard navigation in horizontal sub-menus.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>Trigger</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">id</td><td class="type">string</td><td class="default empty">auto</td><td>—</td></tr>
|
|
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable opening. <code>contextmenu</code> bubbles to default browser menu.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>Content</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">forceMount</td><td class="type">boolean</td><td class="default">false</td><td>Keep in DOM when closed (CSS transitions).</td></tr>
|
|
<tr><td class="name">side</td><td class="type">'top' | 'right' | 'bottom' | 'left'</td><td class="default">'bottom'</td><td>Preferred side relative to the anchor (cursor on open).</td></tr>
|
|
<tr><td class="name">sideOffset</td><td class="type">number</td><td class="default">0</td><td>Pixels between content and anchor on the main axis.</td></tr>
|
|
<tr><td class="name">align</td><td class="type">'start' | 'center' | 'end'</td><td class="default">'start'</td><td>Cross-axis alignment.</td></tr>
|
|
<tr><td class="name">alignOffset</td><td class="type">number</td><td class="default">0</td><td>Pixels along the cross axis.</td></tr>
|
|
<tr><td class="name">arrowPadding</td><td class="type">number</td><td class="default">0</td><td>Distance from the panel corner allowed for the Arrow.</td></tr>
|
|
<tr><td class="name">avoidCollisions</td><td class="type">boolean</td><td class="default">true</td><td>Flip / shift when the preferred placement clips the viewport.</td></tr>
|
|
<tr><td class="name">collisionBoundary</td><td class="type">Element | Element[]</td><td class="default empty">viewport</td><td>Boundary the floating math respects.</td></tr>
|
|
<tr><td class="name">collisionPadding</td><td class="type">number | Partial<Record<Side, number>></td><td class="default">0</td><td>Inner inset from the boundary edges.</td></tr>
|
|
<tr><td class="name">sticky</td><td class="type">'partial' | 'always'</td><td class="default">'partial'</td><td>How the panel sticks while the anchor scrolls.</td></tr>
|
|
<tr><td class="name">hideWhenDetached</td><td class="type">boolean</td><td class="default">true</td><td>Hide if anchor scrolls fully out of view.</td></tr>
|
|
<tr><td class="name">updatePositionStrategy</td><td class="type">'optimized' | 'always'</td><td class="default">'optimized'</td><td>How often the floating math recomputes.</td></tr>
|
|
<tr><td class="name">strategy</td><td class="type">'fixed' | 'absolute'</td><td class="default">'fixed'</td><td>CSS positioning strategy.</td></tr>
|
|
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>ArrowDown / ArrowUp wrap at boundaries.</td></tr>
|
|
<tr><td class="name">trapFocus</td><td class="type">boolean</td><td class="default">true</td><td>Tab cycles inside Content.</td></tr>
|
|
<tr><td class="name">preventScroll</td><td class="type">boolean</td><td class="default">false</td><td>Body scroll lock. Context menus do NOT lock by default.</td></tr>
|
|
<tr><td class="name">escapeKeydownBehavior</td><td class="type">'close' | 'ignore' | 'defer-otherwise-close' | 'defer-otherwise-ignore'</td><td class="default">'close'</td><td>How Escape interacts with the dismissal layer.</td></tr>
|
|
<tr><td class="name">interactOutsideBehavior</td><td class="type">same as above</td><td class="default">'close'</td><td>Click / touch outside response.</td></tr>
|
|
<tr><td class="name">onOpenAutoFocus / onCloseAutoFocus</td><td class="type">(e: Event) => void</td><td class="default empty">—</td><td>Imperative focus hooks. Call <code>e.preventDefault()</code> to opt out.</td></tr>
|
|
<tr><td class="name">onEscapeKeydown / onInteractOutside / onFocusOutside</td><td class="type">handlers</td><td class="default empty">—</td><td>Cancellable dismissal events.</td></tr>
|
|
<tr><td class="name">dir</td><td class="type">'ltr' | 'rtl'</td><td class="default empty">inherits</td><td>Overrides Provider's dir for this panel.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>Item · CheckboxItem · RadioItem · SubTrigger</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Applies to</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>all</td><td>Skip in keyboard nav; <code>aria-disabled='true'</code>.</td></tr>
|
|
<tr><td class="name">textValue</td><td class="type">string</td><td class="default empty">—</td><td>all</td><td>Override the text used for typeahead search.</td></tr>
|
|
<tr><td class="name">onSelect</td><td class="type">() => void</td><td class="default empty">—</td><td>Item, CheckboxItem, RadioItem</td><td>Fires on activation (click / Enter / Space).</td></tr>
|
|
<tr><td class="name">closeOnSelect</td><td class="type">boolean</td><td class="default">true</td><td>Item, CheckboxItem, RadioItem</td><td>Close the menu after activation.</td></tr>
|
|
<tr><td class="name">checked <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>CheckboxItem</td><td>Whether the checkbox is checked.</td></tr>
|
|
<tr><td class="name">onCheckedChange</td><td class="type">(checked: boolean) => void</td><td class="default empty">—</td><td>CheckboxItem</td><td>Callback on check state change.</td></tr>
|
|
<tr><td class="name">value</td><td class="type">string (required)</td><td class="default empty">—</td><td>RadioItem</td><td>Identifies the radio inside the parent RadioGroup.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>RadioGroup</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">string</td><td class="default empty">—</td><td>Currently-selected RadioItem value.</td></tr>
|
|
<tr><td class="name">onValueChange</td><td class="type">(value: string) => void</td><td class="default empty">—</td><td>Callback on selection change.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>Sub · SubTrigger · SubContent</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Applies to</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Sub</td><td>Independent open state for the sub-menu.</td></tr>
|
|
<tr><td class="name">onOpenChange</td><td class="type">(open: boolean) => void</td><td class="default empty">—</td><td>Sub</td><td>—</td></tr>
|
|
<tr><td class="name">side</td><td class="type">'top' | 'right' | 'bottom' | 'left'</td><td class="default">'right'</td><td>SubContent</td><td>Default direction for the fly-out.</td></tr>
|
|
<tr><td class="name">align / alignOffset / sideOffset</td><td class="type">like Content</td><td class="default">—</td><td>SubContent</td><td>—</td></tr>
|
|
<tr><td class="name">avoidCollisions / collisionBoundary / collisionPadding</td><td class="type">like Content</td><td class="default">—</td><td>SubContent</td><td>—</td></tr>
|
|
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>SubContent</td><td>Wrap keyboard nav inside the fly-out.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>Group · GroupHeading · Separator · Arrow</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Part</th><th>Props</th><th>Notes</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">Group</td><td class="type">id</td><td>Wraps a logical block. <code>aria-labelledby</code> wires to GroupHeading.</td></tr>
|
|
<tr><td class="name">GroupHeading</td><td class="type">id</td><td>Non-interactive caption above grouped items.</td></tr>
|
|
<tr><td class="name">Separator</td><td class="type">id</td><td><code>role='separator'</code>, <code>aria-orientation='horizontal'</code>.</td></tr>
|
|
<tr><td class="name">Arrow</td><td class="type">width, height</td><td>Optional SVG pointer rendered inside Content.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>Reference parity</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Feature</th><th>Radix</th><th>bits-ui</th><th>ark-ui</th><th>Eidos (this)</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>Right-click + long-press</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
|
|
<tr><td>Shift+F10 keyboard summon</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
|
|
<tr><td>Cursor-anchored positioning</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
|
|
<tr><td>CheckboxItem / RadioGroup / RadioItem</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
|
|
<tr><td>Nested Sub menus</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
|
|
<tr><td>Group / GroupHeading</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
|
|
<tr><td>Arrow part</td><td>✓</td><td>✓</td><td>—</td><td>✓</td></tr>
|
|
<tr><td>closeOnSelect per item</td><td>✓</td><td>✓</td><td>—</td><td>✓</td></tr>
|
|
<tr><td>Typeahead search</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
|
|
<tr><td>commit-select semantic event</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'morfo'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>
|
|
<span data-uix-layer-badge="morfo">morfo</span>
|
|
· declarative contract
|
|
</h2>
|
|
<p data-uix-section-desc>
|
|
14 parts + 3 events. <code>open</code> / <code>close</code> emit on
|
|
Content (family <code>emerge</code>); <code>commit-select</code>
|
|
emits on Item with intent <code>affirm</code> (semantic sound from
|
|
the `commit` family base). Source: <code>src/uix/morfo/components/context-menu.ts</code>.
|
|
</p>
|
|
|
|
<!-- ── Component header ───────────────────────────────────────── -->
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">name</td><td class="type">"{contextMenuMorfo.name}"</td></tr>
|
|
<tr><td class="name">kebab</td><td class="type">"{contextMenuMorfo.kebab}"</td></tr>
|
|
<tr><td class="name">scope</td><td class="type">[{contextMenuMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
|
|
<tr><td class="name">apg</td><td><a href={contextMenuMorfo.apg} target="_blank" rel="noreferrer">{contextMenuMorfo.apg}</a></td></tr>
|
|
<tr><td class="name">parts.length</td><td class="default">{contextMenuMorfo.parts.length}</td></tr>
|
|
<tr><td class="name">events.length</td><td class="default">{contextMenuMorfo.events?.length ?? 0}</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- ── Parts overview ────────────────────────────────────────── -->
|
|
<div data-uix-subsection-head>Parts</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
|
|
<tbody>
|
|
{#each partsList as part (part.kebab)}
|
|
<tr>
|
|
<td class="name">{part.kebab}</td>
|
|
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
|
<td class="type"><{part.defaultElement}></td>
|
|
<td class="type">{part.role ?? '—'}</td>
|
|
<td class="default">{part.archetype ?? '—'}</td>
|
|
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
|
|
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- ── Per-part contracts (data + aria + keyboard) ────────────── -->
|
|
{#each contextMenuMorfo.parts as rawPart (rawPart.kebab)}
|
|
{@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }>; keyboard?: ReadonlyArray<{ key: string; action: string }> }}
|
|
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
|
|
{@const ariaAttrs = partAny.aria ?? []}
|
|
{@const keyboard = partAny.keyboard ?? []}
|
|
{#if dataAttrs.length || ariaAttrs.length || keyboard.length}
|
|
<div data-uix-subsection-head>{partAny.kebab}</div>
|
|
|
|
{#if dataAttrs.length}
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
|
|
<tbody>
|
|
{#each dataAttrs as attr (attr.attr)}
|
|
<tr>
|
|
<td class="name">{attr.attr}</td>
|
|
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
|
|
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if ariaAttrs.length}
|
|
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
|
|
<table data-uix-table>
|
|
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Condition</th><th>Severity</th></tr></thead>
|
|
<tbody>
|
|
{#each ariaAttrs as a (a.attr)}
|
|
<tr>
|
|
<td class="name">{a.attr}</td>
|
|
<td class="type">{a.value.kind}</td>
|
|
<td class="default">{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}` : 'always'}</td>
|
|
<td class="default">{a.severity ?? 'required'}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if keyboard.length}
|
|
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
|
|
<table data-uix-table>
|
|
<thead><tr><th>key</th><th>action</th></tr></thead>
|
|
<tbody>
|
|
{#each keyboard as kb (kb.key)}
|
|
<tr>
|
|
<td><span data-uix-kbd>{kb.key === ' ' ? 'Space' : kb.key}</span></td>
|
|
<td class="default">{kb.action}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- ── Events declaration ────────────────────────────────────── -->
|
|
<div data-uix-subsection-head>Events declaration</div>
|
|
<p data-uix-section-desc style="margin-top: 0.25rem;">
|
|
<code>open</code> / <code>close</code> fire <em>pre</em> the state
|
|
flip (so sema's perceptual signal precedes the DOM mutation),
|
|
<code>commit-select</code> fires <em>post</em> activation with
|
|
static <code>intent='affirm'</code>. Play any signal from the <a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
|
|
</p>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th><th>prewrite</th><th>commit</th></tr></thead>
|
|
<tbody>
|
|
{#each events as action (action.name)}
|
|
{@const sem = action.semantic}
|
|
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
|
|
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
|
|
<tr>
|
|
<td class="name">{action.name}</td>
|
|
<td class="type">{sem.family}</td>
|
|
<td>{sem.verb ?? '—'}</td>
|
|
<td>{sem.sequence ?? 'pre'}</td>
|
|
<td class="default">{intentStr}</td>
|
|
<td>{action.target}</td>
|
|
<td class="default">{action.prewrite.length ? action.prewrite.map((p) => `${p.attr}=${p.value}`).join(', ') : '—'}</td>
|
|
<td class="default">{action.commit ? `${action.commit.attr}=${action.commit.value}` : '—'}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'sema'}
|
|
<section data-uix-section id="sema">
|
|
<h2 data-uix-section-title>
|
|
<span data-uix-layer-badge="sema">sema</span>
|
|
· events + perceptual signature
|
|
</h2>
|
|
<p data-uix-section-desc>
|
|
Three events. <code>emerge·open</code> and <code>emerge·close</code>
|
|
share the floating-overlay perceptual signature with DropdownMenu /
|
|
Popover / Tooltip; <code>commit·select</code> with <code>intent='affirm'</code>
|
|
inherits the affirmative-commit signature. Press <strong>play</strong>
|
|
to fire the signal on the live menu — open the menu first so the
|
|
Content / Item targets exist in the DOM.
|
|
</p>
|
|
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Target part</th><th>Play</th></tr></thead>
|
|
<tbody>
|
|
{#each events as action (action.name)}
|
|
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
|
|
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
|
|
<tr>
|
|
<td class="name">{action.name}</td>
|
|
<td class="type">{action.semantic.family}</td>
|
|
<td>{action.semantic.verb ?? '—'}</td>
|
|
<td>{action.semantic.sequence ?? 'pre'}</td>
|
|
<td class="default">{effectiveIntent ?? '—'}</td>
|
|
<td class="default">{action.target}</td>
|
|
<td>
|
|
<button
|
|
data-uix-play
|
|
onclick={() => {
|
|
const targetSel =
|
|
action.target === 'item'
|
|
? '[data-context-menu-item]'
|
|
: action.target === 'content'
|
|
? '[data-context-menu-content]'
|
|
: '[data-context-menu-trigger]';
|
|
const target = (
|
|
stageRef?.querySelector(targetSel) ??
|
|
stageRef?.querySelector('[data-context-menu-trigger]') ??
|
|
stageRef
|
|
) as HTMLElement | null;
|
|
if (!target) return;
|
|
void uix.events?.emit({
|
|
name: action.name,
|
|
family: action.semantic.family,
|
|
target,
|
|
...(effectiveIntent ? { intent: effectiveIntent } : {})
|
|
});
|
|
}}
|
|
>▶ play</button>
|
|
</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>
|
|
Source: <code>src/uix/eidos/components/context-menu/context-menu.css</code>.
|
|
Visually identical to DropdownMenu — panel chrome, item rows,
|
|
indicator slots and submenu chevrons share the same vocabulary. The
|
|
only diverging rule is the Trigger: it renders inert by default and
|
|
gets a subtle dashed outline when <code>data-state='open'</code>
|
|
(courtesy affordance — "the menu came from this region"). All
|
|
selectors target morfo-emitted markers — eidos owns no extra
|
|
data-attrs.
|
|
</p>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Selector</th><th>Source</th><th>Concern</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">[data-context-menu-content], [data-context-menu-sub-content]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Panel chrome (border, radius, surface, shadow).</td></tr>
|
|
<tr><td class="name">[data-context-menu-content][data-state='open' | 'closed']</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Enter / exit transitions via state-driven keyframes.</td></tr>
|
|
<tr><td class="name">[data-context-menu-item], [data-context-menu-checkbox-item], [data-context-menu-radio-item], [data-context-menu-sub-trigger]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Row layout, padding, gap, hover background.</td></tr>
|
|
<tr><td class="name">[data-context-menu-item][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Disabled item opacity / cursor.</td></tr>
|
|
<tr><td class="name">[data-context-menu-checkbox-item][data-state='checked']::before</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Check indicator (rounded square).</td></tr>
|
|
<tr><td class="name">[data-context-menu-radio-item][data-state='checked']::before</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Radio indicator (circle).</td></tr>
|
|
<tr><td class="name">[data-context-menu-sub-trigger]::after</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Right-arrow chevron pure CSS — no part required.</td></tr>
|
|
<tr><td class="name">[data-context-menu-group-heading]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Uppercase micro-caption above grouped items.</td></tr>
|
|
<tr><td class="name">[data-context-menu-separator]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>1px subtle divider line.</td></tr>
|
|
<tr><td class="name">[data-context-menu-trigger][data-state='open']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Dashed outline cue while the menu is open.</td></tr>
|
|
<tr><td class="name">[data-context-menu-arrow]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>SVG fill matched to panel surface.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'a11y'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>Accessibility</h2>
|
|
<p data-uix-section-desc>
|
|
Follows the APG <a href={contextMenuMorfo.apg} target="_blank" rel="noreferrer">menu pattern</a>.
|
|
No native ARIA pattern exists for cursor-summoned menus; the
|
|
keyboard-summoned path uses <span data-uix-kbd>Shift+F10</span>
|
|
per WCAG 2.1 (keyboard parity for pointer-only triggers).
|
|
</p>
|
|
|
|
<div data-uix-subsection-head>Summon</div>
|
|
<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 position.</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><span data-uix-kbd>Shift+F10</span></td><td>Open the menu via keyboard, anchored at the focused trigger.</td></tr>
|
|
<tr><td><span data-uix-kbd>ContextMenu</span> key</td><td>Same (Windows / Linux keyboards).</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>Inside the menu</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Key</th><th>Action</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><span data-uix-kbd>ArrowDown</span> / <span data-uix-kbd>ArrowUp</span></td><td>Move focus to next / previous item.</td></tr>
|
|
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last item.</td></tr>
|
|
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Activate the focused item.</td></tr>
|
|
<tr><td><span data-uix-kbd>Escape</span></td><td>Close the menu; return focus to the trigger.</td></tr>
|
|
<tr><td><span data-uix-kbd>ArrowRight</span> on SubTrigger</td><td>Open the sub-menu (or <span data-uix-kbd>ArrowLeft</span> in RTL).</td></tr>
|
|
<tr><td><span data-uix-kbd>ArrowLeft</span> inside SubContent</td><td>Close sub-menu, return to parent (or <span data-uix-kbd>ArrowRight</span> in RTL).</td></tr>
|
|
<tr><td>Typeahead</td><td>Letters jump to the first item starting with that prefix (uses <code>textValue</code> when set).</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>ARIA contract</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
|
<tbody>
|
|
<tr><td class="name">trigger</td><td>aria-haspopup</td><td class="type">"menu"</td></tr>
|
|
<tr><td class="name">trigger</td><td>aria-expanded</td><td class="type">{`{open}`}</td></tr>
|
|
<tr><td class="name">trigger</td><td>aria-controls</td><td class="type">{`<content id>`}</td></tr>
|
|
<tr><td class="name">content</td><td>role</td><td class="type">"menu"</td></tr>
|
|
<tr><td class="name">content</td><td>aria-orientation</td><td class="type">"vertical"</td></tr>
|
|
<tr><td class="name">content</td><td>aria-labelledby</td><td class="type">{`<trigger id>`}</td></tr>
|
|
<tr><td class="name">item</td><td>role</td><td class="type">"menuitem"</td></tr>
|
|
<tr><td class="name">checkbox-item</td><td>role / aria-checked</td><td class="type">"menuitemcheckbox" / {`{checked}`}</td></tr>
|
|
<tr><td class="name">radio-item</td><td>role / aria-checked</td><td class="type">"menuitemradio" / {`{checked}`}</td></tr>
|
|
<tr><td class="name">sub-trigger</td><td>aria-haspopup / aria-expanded / aria-controls</td><td class="type">"menu" / {`{open}`} / {`<sub-content id>`}</td></tr>
|
|
<tr><td class="name">sub-content</td><td>role / aria-labelledby</td><td class="type">"menu" / {`<sub-trigger id>`}</td></tr>
|
|
<tr><td class="name">separator</td><td>role / aria-orientation</td><td class="type">"separator" / "horizontal"</td></tr>
|
|
<tr><td class="name">group</td><td>role / aria-labelledby</td><td class="type">"group" / {`<group-heading id>`}</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|