feat(eidos): port Command (⌘K palette)

Closes #130. Eidos wrapper + 13 part wrappers + 2 new sub-parts +
Dialog composition + recipe + canonical-depth demo. Soma owns scoring
(`computeFuzzyScore`), keyboard nav (Arrow + vim Ctrl+N/P/J/K +
typeahead + loop + grid columns + RTL), item visibility, and the
imperative `CommandApi`. Eidos adds size + Item.Icon / Item.Shortcut
slots + Dialog composition with optional shortcut binding.

Reference parity:

| Feature | cmdk | Mantine Spotlight | bits-ui Command | this port |
|---|---|---|---|---|
| Root provider w/ bind:value, bind:search | ✓ | ✓ | ✓ | ✓ |
| Input, List, Viewport, Empty | ✓ | ✓ | ✓ | ✓ |
| Group + GroupHeading + GroupItems | ✓ | ✓ | ✓ | ✓ |
| Item value, keywords, disabled, onSelect | ✓ | ✓ | ✓ | ✓ |
| LinkItem (anchor) | ✗ | ✗ | ✓ | ✓ |
| Separator, Loading (progress 0-100) | ✓ | partial | ✓ | ✓ |
| Custom filter / shouldFilter | ✓ | ✓ | ✓ | ✓ |
| Loop, vim bindings, grid columns | ✓ | partial | ✓ | ✓ |
| disablePointerSelection | ✓ | ✗ | ✓ | ✓ |
| Modal Dialog composition | ✓ | ✓ (Root is modal) | ✓ | ✓ via eidos Dialog |
| Built-in ⌘K shortcut binding | ✗ (BYO) | ✓ | ✗ (BYO) | ✓ optional |
| Item.Icon / Item.Shortcut slots | BYO | leftSection/rightSection | BYO | ✓ NEW |

Naming: `Command.Root` aliased as the default export (cmdk/bits-ui
parity, eidos convention). Inner parts as named sub-properties:
`Command.Input`, `Command.List`, `Command.Item`, `Command.Item.Icon`,
`Command.Item.Shortcut`, `Command.Dialog`, …

Variant set: only `size: 'sm' | 'md' | 'lg'` (drops xs/xl — palette UX
needs comfortable hit targets, matches air baseline).

Shortcut binding: cross-platform `mod` token (Cmd on macOS, Ctrl
elsewhere). Parses `'mod+k'`, `'ctrl+shift+p'`, etc. Window keydown
listener registered on mount, cleaned up on unmount. Pass `null`/
undefined to disable.

Recipe: `command.css` paints palette container, input row, scrolling
List/Viewport, item rows (with highlighted [data-selected] state),
icon + shortcut slot layout, group heading, separator, empty state.
Loading progress is a 2px bar under Input with `--_command-loading-
progress` for the fill. Inside Command.Dialog the panel chrome comes
from dialog.css.

Demo: 6 tabs (Live/API/Morfo/Sema/Recipe/A11y) — Live wires every
soma + eidos prop + Dialog mode with shortcut input. Two stages:
inline palette + dialog opener with shortcut hint. Three code
snippets (soma, eidos inline, eidos dialog).

Gotcha note: command.svelte renames the children prop to `bodyContent`
on destructure to avoid shadowing the inner `{#snippet children}` — same
infinite-recursion trap noted in `feedback_button_consumer_pattern.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 9def877dbd
commit 8ab8f9dae1

@ -0,0 +1,80 @@
<script lang="ts">
/**
* Eidos `<Command.Dialog>` — modal palette built on top of the eidos
* `<Dialog>` wrapper (so panel chrome + overlay + sizing stay
* consistent with the rest of the system). Mirrors soma's
* CommandDialog API but routes through the eidos layer.
*
* <Command.Dialog bind:open shortcut="mod+k">
* <Command.Input placeholder="Search…" />
* <Command.List>…</Command.List>
* </Command.Dialog>
*
* Default `shortcut='mod+k'` opens the dialog on the canonical
* keyboard binding. Set `shortcut={null}` to disable.
*/
import { onMount } from 'svelte';
import { Dialog } from '$uix/eidos/components/dialog';
import CommandRoot from './command.svelte';
import type { CommandDialogProps } from './types';
import { bindCommandShortcut } from './command-shortcut.svelte';
let {
open = $bindable(false),
onOpenChange,
modal = true,
shortcut = 'mod+k',
size = 'md',
dialogSize = 'md',
// Command pass-through
label,
value = $bindable(''),
onValueChange,
search = $bindable(''),
onSearchChange,
shouldFilter = true,
filter,
loop = false,
vimBindings = true,
columns = 1,
disablePointerSelection = false,
overlay,
// Rename to avoid shadowing the inner snippet
children: bodyContent
}: CommandDialogProps = $props();
onMount(() => {
if (!shortcut) return undefined;
return bindCommandShortcut(shortcut, () => {
const next = !open;
open = next;
onOpenChange?.(next);
});
});
</script>
<Dialog bind:open {modal} onOpenChange={(v) => onOpenChange?.(v)}>
<Dialog.Portal>
{#if overlay}{@render overlay()}{:else}<Dialog.Overlay />{/if}
<Dialog.Content size={dialogSize}>
<CommandRoot
{label}
bind:value
onValueChange={(v) => onValueChange?.(v)}
bind:search
onSearchChange={(v) => onSearchChange?.(v)}
{shouldFilter}
{filter}
{loop}
{vimBindings}
{columns}
{disablePointerSelection}
{size}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</CommandRoot>
</Dialog.Content>
</Dialog.Portal>
</Dialog>

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

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

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

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

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

@ -0,0 +1,13 @@
<script lang="ts">
/**
* Eidos-only sub-part: leading icon inside `<Command.Item>`. Pure
* marker — the recipe handles flex layout via
* `[data-command-item-icon]`. Consumers wrap an `<Icon>` or emoji.
*/
import type { CommandItemIconProps } from './types';
let { children, id }: CommandItemIconProps = $props();
</script>
<span data-command-item-icon {id} aria-hidden="true">
{@render children?.()}
</span>

@ -0,0 +1,14 @@
<script lang="ts">
/**
* Eidos-only sub-part: trailing shortcut hint inside `<Command.Item>`.
* Pure marker — the recipe pushes it to the row's end via
* `[data-command-item-shortcut] { margin-inline-start: auto; }`.
* Wrap a string (`'⌘O'`) or a `<Kbd>` for richer styling.
*/
import type { CommandItemShortcutProps } from './types';
let { children, id }: CommandItemShortcutProps = $props();
</script>
<span data-command-item-shortcut {id}>
{@render children?.()}
</span>

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

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

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

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

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

@ -0,0 +1,74 @@
/**
* Cross-platform keyboard shortcut listener for `<Command.Root shortcut="mod+k">`.
* Mantine Spotlight-style convenience: bind once on mount, parse `mod` →
* Cmd on macOS / Ctrl elsewhere.
*
* Returns a cleanup function. Caller owns the open-state binding.
*/
import type { CommandShortcut } from './types';
function isMac(): boolean {
if (typeof navigator === 'undefined') return false;
return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
}
type ParsedKey = {
key: string;
meta?: boolean;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
};
function parseShortcut(spec: string): ParsedKey {
const parts = spec
.toLowerCase()
.split('+')
.map((p) => p.trim());
const out: ParsedKey = { key: '' };
const mac = isMac();
for (const p of parts) {
if (p === 'mod') {
if (mac) out.meta = true;
else out.ctrl = true;
} else if (p === 'meta' || p === 'cmd' || p === 'command') out.meta = true;
else if (p === 'ctrl' || p === 'control') out.ctrl = true;
else if (p === 'shift') out.shift = true;
else if (p === 'alt' || p === 'option') out.alt = true;
else out.key = p;
}
return out;
}
function matches(e: KeyboardEvent, parsed: ParsedKey): boolean {
if (e.key.toLowerCase() !== parsed.key) return false;
if (!!e.metaKey !== !!parsed.meta) return false;
if (!!e.ctrlKey !== !!parsed.ctrl) return false;
if (!!e.shiftKey !== !!parsed.shift) return false;
if (!!e.altKey !== !!parsed.alt) return false;
return true;
}
/**
* Bind a window-level keydown listener that calls `onTrigger` when any of
* the configured shortcuts fires. Pass `null`/`undefined` shortcut to no-op.
*/
export function bindCommandShortcut(
shortcut: CommandShortcut | null | undefined,
onTrigger: (e: KeyboardEvent) => void
): () => void {
if (!shortcut || typeof window === 'undefined') return () => {};
const specs = Array.isArray(shortcut) ? shortcut : [shortcut];
const parsed = specs.map(parseShortcut);
const handler = (e: KeyboardEvent) => {
for (const p of parsed) {
if (matches(e, p)) {
e.preventDefault();
onTrigger(e);
return;
}
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}

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

@ -0,0 +1,225 @@
/*
* Command palette recipe — ⌘K command bar.
*
* [data-command] → root container (input + list)
* [data-command-input] → search input
* [data-command-list] → scrolling listbox
* [data-command-viewport] → optional inner scroller (if List wraps it)
* [data-command-empty] → "no results" message
* [data-command-loading] → progress bar slot
* [data-command-group] → group container
* [data-command-group-heading]→ group label
* [data-command-group-items] → group items wrapper
* [data-command-item] → row (highlighted via data-selected)
* [data-command-link-item] → anchor-style row
* [data-command-separator] → 1px divider
* [data-command-item-icon] → leading glyph slot (eidos sub-part)
* [data-command-item-shortcut]→ trailing kbd hint slot (eidos sub-part)
*
* Sizes — sm | md | lg via `data-size` on [data-command].
* Inside `<Command.Dialog>` the panel chrome comes from dialog.css; this
* recipe only paints the inner palette.
*/
[data-command] {
--_command-input-height: var(--control-height-md);
--_command-input-px: var(--space-3);
--_command-input-font-size: var(--font-size-sm);
--_command-item-height: var(--control-height-md);
--_command-item-px: var(--space-3);
--_command-item-font-size: var(--font-size-sm);
display: flex;
flex-direction: column;
inline-size: 100%;
min-inline-size: 0;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-overlay);
box-shadow: var(--shadow-floating);
overflow: hidden;
outline: none;
}
[data-command][data-size='sm'] {
--_command-input-height: var(--control-height-sm);
--_command-input-px: var(--space-2);
--_command-input-font-size: var(--font-size-xs);
--_command-item-height: var(--control-height-sm);
--_command-item-px: var(--space-2);
--_command-item-font-size: var(--font-size-xs);
}
[data-command][data-size='lg'] {
--_command-input-height: var(--control-height-lg);
--_command-input-px: var(--space-4);
--_command-input-font-size: var(--font-size-md);
--_command-item-height: var(--control-height-lg);
--_command-item-px: var(--space-4);
--_command-item-font-size: var(--font-size-md);
}
/* ── Input ──────────────────────────────────────────────────────────── */
[data-command-input] {
box-sizing: border-box;
inline-size: 100%;
block-size: var(--_command-input-height);
padding-inline: var(--_command-input-px);
border: 0;
border-block-end: 1px solid var(--color-border-subtle);
background: transparent;
color: var(--color-content-primary);
font-family: var(--font-ui);
font-size: var(--_command-input-font-size);
line-height: var(--leading-ui);
outline: 0;
}
[data-command-input]::placeholder {
color: var(--color-content-muted);
}
/* ── Loading progress (slim bar under Input) ───────────────────────── */
[data-command-loading] {
display: block;
block-size: 2px;
background: linear-gradient(
to right,
var(--color-primary-solid) 0%,
var(--color-primary-solid) var(--_command-loading-progress, 0%),
transparent var(--_command-loading-progress, 0%)
);
}
/* ── List + Viewport ──────────────────────────────────────────────── */
[data-command-list] {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-block-size: min(60vh, 24rem);
padding: var(--space-1);
overflow: auto;
overscroll-behavior: contain;
}
[data-command-list]:has([data-command-viewport]) {
overflow: hidden;
padding: 0;
}
[data-command-viewport] {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-block-size: min(60vh, 24rem);
padding: var(--space-1);
overflow: auto;
overscroll-behavior: contain;
}
/* ── Group / GroupHeading / GroupItems ────────────────────────────── */
[data-command-group] {
display: grid;
gap: var(--space-1);
}
[data-command-group-heading] {
padding-block: var(--space-1);
padding-inline: var(--_command-item-px);
color: var(--color-content-muted);
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-command-group-items] {
display: grid;
gap: 1px;
}
/* ── Item / LinkItem ──────────────────────────────────────────────── */
[data-command-item],
[data-command-link-item] {
display: flex;
align-items: center;
gap: var(--space-2);
min-block-size: var(--_command-item-height);
padding-block: var(--space-1);
padding-inline: var(--_command-item-px);
border-radius: var(--radius-sm);
color: var(--color-content-primary);
font-size: var(--_command-item-font-size);
line-height: var(--leading-ui);
text-decoration: none;
cursor: pointer;
user-select: none;
transition:
background var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
[data-command-item][data-selected],
[data-command-link-item][data-selected] {
background: var(--color-primary-element);
color: var(--color-content-primary);
}
[data-command-item][data-disabled],
[data-command-link-item][data-disabled] {
cursor: default;
opacity: 0.55;
}
/* ── Item sub-parts (eidos-only) ──────────────────────────────────── */
[data-command-item-icon] {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
inline-size: 1.125em;
block-size: 1.125em;
color: var(--color-content-secondary);
}
[data-command-item-shortcut] {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
margin-inline-start: auto;
color: var(--color-content-muted);
font-size: 0.875em;
}
/* When the row is highlighted, lift the muted sub-parts to primary. */
[data-command-item][data-selected] [data-command-item-icon],
[data-command-item][data-selected] [data-command-item-shortcut],
[data-command-link-item][data-selected] [data-command-item-icon],
[data-command-link-item][data-selected] [data-command-item-shortcut] {
color: var(--color-content-primary);
}
/* ── Empty state ──────────────────────────────────────────────────── */
[data-command-empty] {
padding-block: var(--space-4);
padding-inline: var(--_command-item-px);
color: var(--color-content-muted);
font-size: var(--_command-item-font-size);
text-align: center;
}
/* ── Separator ────────────────────────────────────────────────────── */
[data-command-separator] {
block-size: 1px;
margin-block: var(--space-1);
background: var(--color-border-subtle);
}

@ -0,0 +1,69 @@
<script lang="ts">
/**
* Eidos `<Command>` — root of the ⌘K palette. Wraps Soma's
* `<Command.Provider>` adding:
*
* - `size` (sm/md/lg) → stamped as `data-size` on the root, drives
* input height + item height + font-size via the recipe.
* - optional `shortcut` (e.g. `'mod+k'`) → window keydown listener
* that toggles `open` when fired. Requires `bind:open` from the
* consumer. Mantine Spotlight-style sugar — purely convenience,
* consumer can still wire their own keydown handler.
*
* Compose:
*
* <Command bind:open shortcut="mod+k" size="md">
* <Command.Input placeholder="Search…" />
* <Command.List>
* <Command.Empty>No results.</Command.Empty>
* <Command.Group>
* <Command.GroupHeading>Files</Command.GroupHeading>
* <Command.GroupItems>
* <Command.Item value="open" onSelect={…}>
* <Command.Item.Icon>📁</Command.Item.Icon>
* Open file
* <Command.Item.Shortcut>⌘O</Command.Item.Shortcut>
* </Command.Item>
* </Command.GroupItems>
* </Command.Group>
* </Command.List>
* </Command>
*
* For a modal palette use `<Command.Dialog bind:open>` which wires the
* eidos Dialog wrapper for you.
*/
import { onMount } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import * as Command from '$soma/components/command';
import type { CommandProps } from './types';
import { bindCommandShortcut } from './command-shortcut.svelte';
let {
size = 'md',
shortcut,
open = $bindable(undefined as boolean | undefined),
onOpenChange,
value = $bindable(''),
search = $bindable(''),
children: bodyContent,
...rest
}: CommandProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
onMount(() => {
if (!shortcut) return undefined;
return bindCommandShortcut(shortcut, () => {
const next = !open;
open = next;
onOpenChange?.(next);
});
});
</script>
<Command.Provider {...rest} bind:value bind:search data-size={resolvedSize}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</Command.Provider>

@ -0,0 +1,115 @@
// Command — eidos compound API (⌘K palette).
//
// import { Command } from '$uix/eidos/components/command';
//
// Inline (non-modal) usage:
//
// <Command size="md">
// <Command.Input placeholder="Search…" />
// <Command.List>
// <Command.Empty>No results.</Command.Empty>
// <Command.Group>
// <Command.GroupHeading>Files</Command.GroupHeading>
// <Command.GroupItems>
// <Command.Item value="open" onSelect={…}>
// <Command.Item.Icon>📁</Command.Item.Icon>
// Open file
// <Command.Item.Shortcut>⌘O</Command.Item.Shortcut>
// </Command.Item>
// </Command.GroupItems>
// </Command.Group>
// </Command.List>
// </Command>
//
// Modal usage with built-in ⌘K binding:
//
// <Command.Dialog bind:open shortcut="mod+k">
// <Command.Input placeholder="Type a command…" />
// <Command.List>…</Command.List>
// </Command.Dialog>
//
// Soma owns scoring (computeFuzzyScore), keyboard nav (Arrow / vim
// bindings / typeahead / loop / grid columns / RTL), item visibility,
// and the imperative `CommandApi` snippet props. Eidos paints the
// palette chrome + size + Item sub-parts (Icon / Shortcut) + the
// Dialog composition.
import CommandRoot from './command.svelte';
import CommandDialog from './command-dialog.svelte';
import Input from './command-input.svelte';
import List from './command-list.svelte';
import Viewport from './command-viewport.svelte';
import Item from './command-item.svelte';
import LinkItem from './command-link-item.svelte';
import Group from './command-group.svelte';
import GroupHeading from './command-group-heading.svelte';
import GroupItems from './command-group-items.svelte';
import Empty from './command-empty.svelte';
import Loading from './command-loading.svelte';
import Separator from './command-separator.svelte';
import ItemIcon from './command-item-icon.svelte';
import ItemShortcut from './command-item-shortcut.svelte';
// Attach Icon / Shortcut as nested sub-parts of Item so the usage
// reads `<Command.Item.Icon>` / `<Command.Item.Shortcut>` — cmdk-style.
type ItemNamespace = typeof Item & {
Icon: typeof ItemIcon;
Shortcut: typeof ItemShortcut;
};
const ItemWithSubparts = Item as ItemNamespace;
ItemWithSubparts.Icon = ItemIcon;
ItemWithSubparts.Shortcut = ItemShortcut;
type CommandNamespace = typeof CommandRoot & {
Dialog: typeof CommandDialog;
Input: typeof Input;
List: typeof List;
Viewport: typeof Viewport;
Item: ItemNamespace;
LinkItem: typeof LinkItem;
Group: typeof Group;
GroupHeading: typeof GroupHeading;
GroupItems: typeof GroupItems;
Empty: typeof Empty;
Loading: typeof Loading;
Separator: typeof Separator;
};
const Command = CommandRoot as CommandNamespace;
Command.Dialog = CommandDialog;
Command.Input = Input;
Command.List = List;
Command.Viewport = Viewport;
Command.Item = ItemWithSubparts;
Command.LinkItem = LinkItem;
Command.Group = Group;
Command.GroupHeading = GroupHeading;
Command.GroupItems = GroupItems;
Command.Empty = Empty;
Command.Loading = Loading;
Command.Separator = Separator;
export { Command };
export default Command;
export type {
CommandProps,
CommandInputProps as InputProps,
CommandListProps as ListProps,
CommandViewportProps as ViewportProps,
CommandItemProps as ItemProps,
CommandLinkItemProps as LinkItemProps,
CommandGroupProps as GroupProps,
CommandGroupHeadingProps as GroupHeadingProps,
CommandGroupItemsProps as GroupItemsProps,
CommandEmptyProps as EmptyProps,
CommandLoadingProps as LoadingProps,
CommandSeparatorProps as SeparatorProps,
CommandItemIconProps as ItemIconProps,
CommandItemShortcutProps as ItemShortcutProps,
CommandDialogProps as DialogProps,
CommandSize,
CommandShortcut,
CommandFilterFn,
CommandApi,
CommandSnippetProps
} from './types';

@ -0,0 +1,123 @@
import type { Snippet } from 'svelte';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { DialogSize } from '$uix/eidos/components/dialog/types';
import type {
CommandFilterFn,
CommandApi,
CommandSnippetProps,
ProviderProps,
InputProps,
ListProps,
ViewportProps,
ItemProps,
LinkItemProps,
GroupProps,
GroupHeadingProps,
GroupItemsProps,
EmptyProps,
LoadingProps,
SeparatorProps
} from '$soma/components/command';
/**
* Command visual size — three-step subset matching the air baseline.
* `xs` would crowd item rows and `xl` is unnecessary for a palette
* UX; comfortable density is sm/md/lg.
*/
export type CommandSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/** Single shortcut spec — `'mod+k'`, `'ctrl+shift+p'`, etc. */
export type CommandShortcut = string | string[];
export type CommandProps = ProviderProps & {
/**
* Visual size preset — `'sm' | 'md' | 'lg'`. Resolved reactively via
* `Eidos.resolve(size)`. Maps to input height, item height, font-size.
*
* @default 'md'
*/
size?: ResponsiveProp<CommandSize>;
/**
* Optional keyboard shortcut(s) that toggle the palette open. Uses
* `mod` as a cross-platform meta/ctrl alias (cmd on macOS, ctrl elsewhere).
* Pass a single string (`'mod+k'`) or an array for multiple bindings.
* Requires `open` to be bindable.
*
* Mantine Spotlight-style convenience — pass undefined to skip and
* bind your own keydown handler.
*/
shortcut?: CommandShortcut;
/** Bindable open flag — required when `shortcut` is set. */
open?: boolean;
/** Callback when open toggles (via shortcut or other source). */
onOpenChange?: (open: boolean) => void;
};
export type CommandInputProps = InputProps;
export type CommandListProps = ListProps;
export type CommandViewportProps = ViewportProps;
export type CommandItemProps = ItemProps;
export type CommandLinkItemProps = LinkItemProps;
export type CommandGroupProps = GroupProps;
export type CommandGroupHeadingProps = GroupHeadingProps;
export type CommandGroupItemsProps = GroupItemsProps;
export type CommandEmptyProps = EmptyProps;
export type CommandLoadingProps = LoadingProps;
export type CommandSeparatorProps = SeparatorProps;
/**
* Eidos-only sub-parts that go INSIDE `<Command.Item>` to layout the
* three slots (leading icon / label / trailing shortcut hint) without
* the consumer having to handcraft the flex container. Pure markers —
* the recipe handles flex/gap.
*/
export type CommandItemIconProps = {
id?: string;
children?: Snippet;
};
export type CommandItemShortcutProps = {
id?: string;
children?: Snippet;
};
/**
* `<Command.Dialog>` — modal palette built on top of eidos Dialog.
* Mirrors soma's CommandDialog but uses the eidos Dialog wrappers so
* the panel chrome stays consistent with the rest of the system.
*/
export type CommandDialogProps = {
/** Bindable open flag. @default false */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Block outside interaction. @default true */
modal?: boolean;
/**
* Optional keyboard shortcut(s) that toggle the dialog open. Same
* format as `Command.Root.shortcut`. @default 'mod+k' (set to null/undefined to disable)
*/
shortcut?: CommandShortcut | null;
/** Visual size for the inner Command palette. @default 'md' */
size?: ResponsiveProp<CommandSize>;
/** Visual size of the Dialog panel itself. @default 'md' */
dialogSize?: ResponsiveProp<DialogSize>;
// Pass-through to inner Command
label?: string;
value?: string;
onValueChange?: (value: string) => void;
search?: string;
onSearchChange?: (value: string) => void;
shouldFilter?: boolean;
filter?: CommandFilterFn;
loop?: boolean;
vimBindings?: boolean;
columns?: number;
disablePointerSelection?: boolean;
/** Custom overlay — defaults to eidos Dialog.Overlay. */
overlay?: Snippet;
children?: Snippet<[CommandSnippetProps]>;
};
export type { CommandFilterFn, CommandApi, CommandSnippetProps };

@ -95,6 +95,7 @@
@import './components/dropdown-menu/dropdown-menu.css'; @import './components/dropdown-menu/dropdown-menu.css';
@import './components/context-menu/context-menu.css'; @import './components/context-menu/context-menu.css';
@import './components/menubar/menubar.css'; @import './components/menubar/menubar.css';
@import './components/command/command.css';
/* Shared menu-item indicator partial — must load AFTER the menu recipes /* Shared menu-item indicator partial — must load AFTER the menu recipes
so its `padding-inline-start` calc wins over the row rule's so its `padding-inline-start` calc wins over the row rule's
`padding-inline` shorthand at equal specificity. */ `padding-inline` shorthand at equal specificity. */

@ -0,0 +1,708 @@
<script lang="ts">
import {
Command,
type CommandSize
} from '$uix/eidos/components/command';
import { compileMorfo } from '$uix/morfo';
import { commandMorfo } from '@/uix/morfo/components/command';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
let size = $state<CommandSize>('md');
let search = $state('');
let value = $state('');
let loop = $state(false);
let vimBindings = $state(true);
let columns = $state(1);
let disablePointerSelection = $state(false);
let shouldFilter = $state(true);
// Dialog mode
let dialogOpen = $state(false);
let dialogShortcut = $state('mod+k');
// Track invocations
let lastInvoked = $state<string | null>(null);
function invoke(name: string) {
return () => {
lastInvoked = name;
};
}
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(commandMorfo);
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 Command from '$soma/components/command';",
' let value = $state("");',
' let search = $state("");',
'</' + 'script>',
'',
'<Command.Provider',
' bind:value',
' bind:search',
loop && ' loop',
!vimBindings && ' vimBindings={false}',
columns !== 1 && ` columns={${columns}}`,
disablePointerSelection && ' disablePointerSelection',
!shouldFilter && ' shouldFilter={false}',
'>',
' <Command.Input placeholder="Search…" />',
' <Command.List>',
' <Command.Empty>No results.</Command.Empty>',
' <Command.Group>',
' <Command.GroupHeading>Files</Command.GroupHeading>',
' <Command.GroupItems>',
' <Command.Item value="open" onSelect={…}>Open file</Command.Item>',
' </Command.GroupItems>',
' </Command.Group>',
' </Command.List>',
'</Command.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Command } from '$uix/eidos/components/command';",
' let value = $state("");',
' let search = $state("");',
'</' + 'script>',
'',
'<Command',
' bind:value',
' bind:search',
size !== 'md' && ` size="${size}"`,
loop && ' loop',
!vimBindings && ' vimBindings={false}',
columns !== 1 && ` columns={${columns}}`,
disablePointerSelection && ' disablePointerSelection',
!shouldFilter && ' shouldFilter={false}',
'>',
' <Command.Input placeholder="Search…" />',
' <Command.List>',
' <Command.Empty>No results.</Command.Empty>',
' <Command.Group>',
' <Command.GroupHeading>Files</Command.GroupHeading>',
' <Command.GroupItems>',
' <Command.Item value="open" onSelect={…}>',
' <Command.Item.Icon>📁</Command.Item.Icon>',
' Open file',
' <Command.Item.Shortcut>⌘O</Command.Item.Shortcut>',
' </Command.Item>',
' </Command.GroupItems>',
' </Command.Group>',
' </Command.List>',
'</Command>'
]
.filter(Boolean)
.join('\n')
);
const dialogSnippet = $derived(
[
"<script lang='ts'>",
" import { Command } from '$uix/eidos/components/command';",
' let open = $state(false);',
'</' + 'script>',
'',
`<Command.Dialog bind:open shortcut="${dialogShortcut}">`,
' <Command.Input placeholder="Type a command…" />',
' <Command.List>…</Command.List>',
'</Command.Dialog>'
].join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Overlays · Command</div>
<h1 data-uix-page-title>Command</h1>
<p data-uix-page-lede>
⌘K command palette. Soma owns fuzzy scoring
(<code>computeFuzzyScore</code>), keyboard nav (arrow + vim
bindings + typeahead + loop + grid columns + RTL), item
visibility and the imperative <code>CommandApi</code>. Eidos
paints the palette chrome, exposes <code>size</code>
(sm/md/lg), adds <code>Item.Icon</code> / <code>Item.Shortcut</code>
sub-parts, and provides a <code>&lt;Command.Dialog&gt;</code>
composition wired to the eidos Dialog with an optional
<code>mod+k</code> shortcut binding.
</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>sizes</span>3
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href="https://www.w3.org/WAI/ARIA/apg/patterns/combobox/" target="_blank" rel="noreferrer">combobox ↗</a>
</span>
{#if lastInvoked}
<span data-uix-meta-pill>
<span data-uix-meta-key>last</span>{lastInvoked}
</span>
{/if}
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="min-block-size: 20rem; padding: var(--uix-space-3); display: flex; justify-content: center;">
<div style="inline-size: min(28rem, 100%);">
<Command
bind:value
bind:search
{size}
{loop}
{vimBindings}
{columns}
{disablePointerSelection}
{shouldFilter}
>
<Command.Input placeholder="Search commands…" />
<Command.List>
<Command.Empty>No commands match.</Command.Empty>
<Command.Group>
<Command.GroupHeading>Files</Command.GroupHeading>
<Command.GroupItems>
<Command.Item value="new-file" onSelect={invoke('New file')}>
<Command.Item.Icon>📄</Command.Item.Icon>
New file
<Command.Item.Shortcut>⌘N</Command.Item.Shortcut>
</Command.Item>
<Command.Item value="open-file" onSelect={invoke('Open file')}>
<Command.Item.Icon>📁</Command.Item.Icon>
Open file…
<Command.Item.Shortcut>⌘O</Command.Item.Shortcut>
</Command.Item>
<Command.Item value="save-file" onSelect={invoke('Save file')}>
<Command.Item.Icon>💾</Command.Item.Icon>
Save file
<Command.Item.Shortcut>⌘S</Command.Item.Shortcut>
</Command.Item>
</Command.GroupItems>
</Command.Group>
<Command.Separator />
<Command.Group>
<Command.GroupHeading>Edit</Command.GroupHeading>
<Command.GroupItems>
<Command.Item value="undo" keywords={['ctrl-z', 'rollback']} onSelect={invoke('Undo')}>
<Command.Item.Icon>↶</Command.Item.Icon>
Undo
<Command.Item.Shortcut>⌘Z</Command.Item.Shortcut>
</Command.Item>
<Command.Item value="redo" keywords={['ctrl-y']} onSelect={invoke('Redo')}>
<Command.Item.Icon>↷</Command.Item.Icon>
Redo
<Command.Item.Shortcut>⇧⌘Z</Command.Item.Shortcut>
</Command.Item>
<Command.Item value="find" keywords={['search']} onSelect={invoke('Find')}>
<Command.Item.Icon>🔍</Command.Item.Icon>
Find in file
<Command.Item.Shortcut>⌘F</Command.Item.Shortcut>
</Command.Item>
</Command.GroupItems>
</Command.Group>
<Command.Separator />
<Command.Group>
<Command.GroupHeading>Help</Command.GroupHeading>
<Command.GroupItems>
<Command.LinkItem value="docs" href="https://example.com/docs" onSelect={invoke('Docs')}>
<Command.Item.Icon>📚</Command.Item.Icon>
Open documentation
<Command.Item.Shortcut>↗</Command.Item.Shortcut>
</Command.LinkItem>
<Command.Item value="about" disabled onSelect={invoke('About')}>
<Command.Item.Icon>ℹ︎</Command.Item.Icon>
About (disabled)
</Command.Item>
</Command.GroupItems>
</Command.Group>
</Command.List>
</Command>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>type or arrow-nav to highlight, Enter to invoke</span>
{:else}
{#each trace.slice(0, 3) as entry (entry.at)}
<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>value</span> {value || '—'}
</span>
</div>
</div>
<!-- Modal Dialog opener — separate stage row so consumers see both modes -->
<div data-uix-stage style="margin-top: var(--uix-space-3);">
<div data-uix-stage-area style="min-block-size: 6rem; display: flex; align-items: center; justify-content: center; gap: var(--uix-space-3);">
<button
onclick={() => (dialogOpen = !dialogOpen)}
style="padding: var(--uix-space-2) var(--uix-space-4); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-raised); cursor: pointer;"
>
{dialogOpen ? 'Close' : 'Open'} Command.Dialog (or press {dialogShortcut})
</button>
</div>
</div>
<Command.Dialog bind:open={dialogOpen} shortcut={dialogShortcut} {size}>
<Command.Input placeholder="Type a command or search…" />
<Command.List>
<Command.Empty>No matches.</Command.Empty>
<Command.Group>
<Command.GroupHeading>Quick actions</Command.GroupHeading>
<Command.GroupItems>
<Command.Item value="dlg-new" onSelect={invoke('Dialog · New')}>
<Command.Item.Icon>+</Command.Item.Icon>
New project
<Command.Item.Shortcut>⌘N</Command.Item.Shortcut>
</Command.Item>
<Command.Item value="dlg-search" onSelect={invoke('Dialog · Search')}>
<Command.Item.Icon>🔍</Command.Item.Icon>
Global search
<Command.Item.Shortcut>⌘P</Command.Item.Shortcut>
</Command.Item>
<Command.Item value="dlg-settings" onSelect={invoke('Dialog · Settings')}>
<Command.Item.Icon>⚙︎</Command.Item.Icon>
Open settings
<Command.Item.Shortcut>⌘,</Command.Item.Shortcut>
</Command.Item>
</Command.GroupItems>
</Command.Group>
</Command.List>
</Command.Dialog>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>15</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<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>
Soma owns scoring + keyboard nav. Eidos adds <code>size</code> on the root and the <code>Item.Icon</code> / <code>Item.Shortcut</code> layout sub-parts.
</p>
<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>search <span data-uix-control-hint>bindable</span></span>
<input type="text" bind:value={search} placeholder="(empty)" />
</label>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>highlighted item</span></span>
<input type="text" value={value || '—'} readonly />
</label>
<label data-uix-control>
<span data-uix-control-label>loop <span data-uix-control-hint>arrow nav wraps</span></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>vimBindings <span data-uix-control-hint>Ctrl+N/P/J/K</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={vimBindings} />
<span data-uix-switch-label>{vimBindings ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>columns <span data-uix-control-hint>{columns}</span></span>
<input type="range" min="1" max="4" step="1" bind:value={columns} />
</label>
<label data-uix-control>
<span data-uix-control-label>disablePointerSelection</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disablePointerSelection} />
<span data-uix-switch-label>{disablePointerSelection ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>shouldFilter</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={shouldFilter} />
<span data-uix-switch-label>{shouldFilter ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> visual size
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as CommandSize)}>{s}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> Dialog mode
<span style="font-weight: normal; color: var(--uix-text-muted);">
· built on top of &lt;Dialog&gt; with optional shortcut binding
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>shortcut <span data-uix-control-hint>e.g. mod+k, ctrl+shift+p</span></span>
<input type="text" bind:value={dialogShortcut} />
</label>
<label data-uix-control>
<span data-uix-control-label>dialog open <span data-uix-control-hint>bindable</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={dialogOpen} />
<span data-uix-switch-label>{dialogOpen ? 'open' : 'closed'}</span>
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · inline palette</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 · size + Item sub-parts</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</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>modal · Command.Dialog with mod+k binding</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{dialogSnippet}</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>
13 soma parts plus 3 eidos additions: <code>Command.Dialog</code>
(wraps eidos Dialog), <code>Command.Item.Icon</code> and
<code>Command.Item.Shortcut</code> (Item layout sub-parts).
</p>
<div data-uix-subsection-head>Provider (Command)</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">''</td><td>Currently highlighted item value.</td></tr>
<tr><td class="name">search <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">string</td><td class="default">''</td><td>Current search string.</td></tr>
<tr><td class="name">shouldFilter</td><td class="type">boolean</td><td class="default">true</td><td>Whether to run the scoring filter over items.</td></tr>
<tr><td class="name">filter</td><td class="type">CommandFilterFn</td><td class="default">computeFuzzyScore</td><td>Custom scoring function. Return 0 to exclude.</td></tr>
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>Loop keyboard navigation at the edges.</td></tr>
<tr><td class="name">vimBindings</td><td class="type">boolean</td><td class="default">true</td><td>Ctrl+N/J = next, Ctrl+P/K = previous.</td></tr>
<tr><td class="name">columns</td><td class="type">number</td><td class="default">1</td><td>Grid columns — arrows move ±1 (Right/Left) or ±columns (Down/Up).</td></tr>
<tr><td class="name">disablePointerSelection</td><td class="type">boolean</td><td class="default">false</td><td>Hovering items doesn't change the highlight.</td></tr>
<tr><td class="name">dir</td><td class="type">'ltr' | 'rtl'</td><td class="default empty">from soma config</td><td>Grid arrow inversion in RTL.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'sm' | 'md' | 'lg'&gt;</td><td class="default">'md'</td><td>Visual size — input height + item height + font.</td></tr>
<tr><td class="name">shortcut <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">string | string[]</td><td class="default empty">—</td><td>Optional keyboard shortcut (e.g. <code>'mod+k'</code>) that toggles open. Requires bindable open.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Item</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</td><td class="type">string</td><td class="default empty">required</td><td>Unique value used by the scorer.</td></tr>
<tr><td class="name">keywords</td><td class="type">readonly string[]</td><td class="default">[]</td><td>Text aliases the scorer also matches.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable activation. Still focusable.</td></tr>
<tr><td class="name">forceMount</td><td class="type">boolean</td><td class="default">false</td><td>Render even when filtered out.</td></tr>
<tr><td class="name">onSelect</td><td class="type">() =&gt; void</td><td class="default empty">—</td><td>Fired on Enter or click.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Item.Icon / Item.Shortcut <span data-uix-tag data-kind="eidos">eidos</span></div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
Sub-parts of <code>Item</code>. Both accept <code>children</code>; the recipe handles layout — Icon is a leading flex slot, Shortcut is pushed to row's end via <code>margin-inline-start: auto</code>. Pair with <code>&lt;Kbd&gt;</code> for richer shortcut styling.
</p>
<div data-uix-subsection-head>Command.Dialog <span data-uix-tag data-kind="eidos">eidos</span></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">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Dialog open state.</td></tr>
<tr><td class="name">shortcut</td><td class="type">string | string[] | null</td><td class="default">'mod+k'</td><td>Keyboard binding(s) to toggle. <code>null</code> disables.</td></tr>
<tr><td class="name">size</td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>Inner Command palette size.</td></tr>
<tr><td class="name">dialogSize</td><td class="type">DialogSize</td><td class="default">'md'</td><td>Eidos Dialog panel size (sm | md | lg | xl | full).</td></tr>
<tr><td class="name">modal · label · value · search · shouldFilter · filter · loop · …</td><td class="type">pass-through</td><td class="default empty">—</td><td>Forwarded to Dialog + Command.</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>Source: <code>src/uix/morfo/components/command.ts</code>.</p>
<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">"{commandMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{commandMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{commandMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts.length</td><td class="default">{commandMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
</div>
<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">&lt;{part.defaultElement}&gt;</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>
<div data-uix-subsection-head>Events declaration</div>
<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></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>
</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>
One event: <code>commit-invoke</code> fires when the user
activates a highlighted Item (Enter or click). Verb <code>submit</code>,
intent <code>fulfill</code>.
</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>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>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-command-item]') ?? 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>
Selectors at <code>src/uix/eidos/components/command/command.css</code>.
Inside <code>&lt;Command.Dialog&gt;</code> the dialog panel chrome
comes from <code>dialog.css</code> and the inner palette uses these
selectors.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-command]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Palette container — flex column, border, surface bg.</td></tr>
<tr><td class="name">[data-command][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Size variant — input + item heights, font-size.</td></tr>
<tr><td class="name">[data-command-input]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Search input row + divider.</td></tr>
<tr><td class="name">[data-command-list], [data-command-viewport]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Scrolling list.</td></tr>
<tr><td class="name">[data-command-item], [data-command-link-item]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Row — flex, padding, gap.</td></tr>
<tr><td class="name">[data-command-item][data-selected]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Highlighted row tint.</td></tr>
<tr><td class="name">[data-command-item-icon], [data-command-item-shortcut]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Item sub-part layout slots — icon fixed, shortcut auto-end.</td></tr>
<tr><td class="name">[data-command-group-heading]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Muted uppercase group label.</td></tr>
<tr><td class="name">[data-command-separator]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>1px divider between groups.</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="https://www.w3.org/WAI/ARIA/apg/patterns/combobox/" target="_blank" rel="noreferrer">combobox pattern</a> with a listbox popup. Input has <code>role="combobox"</code>; List is <code>role="listbox"</code>; each Item is <code>role="option"</code>.
</p>
<div data-uix-subsection-head>Keyboard</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 highlight ±1 (or ±columns if columns &gt; 1).</td></tr>
<tr><td><span data-uix-kbd>ArrowRight</span> / <span data-uix-kbd>ArrowLeft</span></td><td>In grid mode, move ±1 column (RTL inverts).</td></tr>
<tr><td><span data-uix-kbd>Ctrl+N</span> / <span data-uix-kbd>Ctrl+J</span></td><td>Next (vim binding, if <code>vimBindings=true</code>).</td></tr>
<tr><td><span data-uix-kbd>Ctrl+P</span> / <span data-uix-kbd>Ctrl+K</span></td><td>Previous (vim binding).</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last visible item.</td></tr>
<tr><td><span data-uix-kbd>Enter</span></td><td>Activate highlighted item — fires <code>onSelect</code> + <code>commit-invoke</code>.</td></tr>
<tr><td><span data-uix-kbd>Escape</span></td><td>Clear search (or close dialog if inside <code>Command.Dialog</code>).</td></tr>
<tr><td><span data-uix-kbd>mod+k</span></td><td>Toggle dialog open/close (if <code>shortcut</code> is configured).</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">input</td><td>role</td><td class="type">"combobox"</td></tr>
<tr><td class="name">input</td><td>aria-expanded</td><td class="type">"true" (always while palette is mounted)</td></tr>
<tr><td class="name">input</td><td>aria-controls</td><td class="type">{`<list id>`}</td></tr>
<tr><td class="name">input</td><td>aria-activedescendant</td><td class="type">{`<highlighted item id>`}</td></tr>
<tr><td class="name">list</td><td>role</td><td class="type">"listbox"</td></tr>
<tr><td class="name">item</td><td>role</td><td class="type">"option"</td></tr>
<tr><td class="name">item</td><td>aria-selected</td><td class="type">"true" (when highlighted)</td></tr>
<tr><td class="name">item</td><td>aria-disabled</td><td class="type">"true" (when disabled)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.