From 80b21f63ba544a3928a239ffeb201cdaf4605d28 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 17:56:05 +0200 Subject: [PATCH] feat(eidos): port Listbox (embedded selection list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #131. Eidos wrapper + types + recipe + canonical-depth demo. Soma owns single/multi selection (value as string[]), roving-tabindex keyboard nav (Arrow + Home/End + typeahead + Ctrl+A), Field-merged flags (disabled/readonly/required/invalid), hidden form inputs. Eidos adds the visual surface and exposes variant + size + color. Reference parity (no air baseline — port directly from soma): | Feature | soma | react-aria | Headless UI | bits-ui | this port | |---|---|---|---|---|---| | Single + multiple selection | ✓ | ✓ | ✓ | ✓ | ✓ | | bind:value (controlled) | ✓ | ✓ | ✓ | ✓ | ✓ | | Typeahead + Arrow / Home / End | ✓ | ✓ | ✓ | ✓ | ✓ | | Loop nav | ✓ | ✗ | ✗ | ✗ | ✓ | | Section / Group + label | ✓ | ✓ | ✗ | ✓ | ✓ | | Ctrl+A select-all (multi) | ✓ | ✓ | ✗ | ✗ | ✓ | | Form participation (hidden inputs) | ✓ | ✗ | ✓ | ✗ | ✓ | | Field.Provider merge (disabled/invalid/…) | ✓ | ✗ | ✗ | ✗ | ✓ | | Horizontal orientation | ✓ | ✓ | ✓ | ✓ | ✓ | | Item indicator (✓ glyph) | ✓ | ✓ | ✗ | ✓ | ✓ shared partial | | Drag-to-reorder | ✗ | ✓ | ✗ | ✗ | ⚠ deferred | | Async / infinite loading | ✗ | ✓ | ✗ | ✗ | ⚠ deferred | | selectionBehavior (toggle/replace) | ⚠ implicit | ✓ | ✗ | ✓ | ⚠ deferred | | PageUp/Down nav | ✗ | ✓ | ✗ | ✗ | ⚠ deferred | Parts (5): Listbox (Root, replaces soma's Provider name), Item, ItemIndicator, Group, GroupLabel. Variant set: - variant: ControlVariant (`'surface' | 'outline' | 'ghost'`), default 'surface' - size: xs..xl (5-step canonical form-control scale) - color: ColorRole (primary/secondary/neutral/affirm/fulfill/risk/threat/loss) - block: stretch to container width Cross-component reuse: - `ItemIndicator` shares the menu-indicator visual (`a2709476`) — ✓ glyph identical to standalone Checkbox and Dropdown/Context Menu CheckboxItems. CSS-only difference: shown only inside `[data-listbox-item][data-selected]`. - Item row hover / highlight tokens mirror Combobox.Item / Command.Item — the form-control surface family stays coherent. Disambiguation note (in index.ts comment): - Listbox: atomic options, text rows, no inner interactive elements (ARIA forbids interactive descendants inside role="listbox") - GridList: rows with buttons / links / checkboxes inside (2D nav) - Select / Combobox: closed picker surface (Listbox-like dropdown) Gotcha: listbox.svelte and listbox-item.svelte rename `children` to `bodyContent` on destructure to avoid shadowing the inner `{#snippet children}` block (same recursion trap documented in `feedback_button_consumer_pattern.md` Rule 3). Demo: 6 tabs at canonical depth — Live wires all soma + eidos props, all 3 ControlVariant chips, 5 size chips, 8 ColorRole chips, single + multi mode toggle, RTL/horizontal toggle. Sidebar entry added under Forms (between Combobox and Calendar). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/eidos/components/listbox/index.ts | 69 ++ .../listbox/listbox-group-label.svelte | 7 + .../components/listbox/listbox-group.svelte | 7 + .../listbox/listbox-item-indicator.svelte | 7 + .../components/listbox/listbox-item.svelte | 14 + src/uix/eidos/components/listbox/listbox.css | 211 ++++++ .../eidos/components/listbox/listbox.svelte | 53 ++ src/uix/eidos/components/listbox/types.ts | 42 ++ src/uix/eidos/index.css | 1 + web/routes/uix/+layout@.svelte | 1 + .../uix/components/listbox/+page.svelte | 601 ++++++++++++++++++ 11 files changed, 1013 insertions(+) create mode 100644 src/uix/eidos/components/listbox/index.ts create mode 100644 src/uix/eidos/components/listbox/listbox-group-label.svelte create mode 100644 src/uix/eidos/components/listbox/listbox-group.svelte create mode 100644 src/uix/eidos/components/listbox/listbox-item-indicator.svelte create mode 100644 src/uix/eidos/components/listbox/listbox-item.svelte create mode 100644 src/uix/eidos/components/listbox/listbox.css create mode 100644 src/uix/eidos/components/listbox/listbox.svelte create mode 100644 src/uix/eidos/components/listbox/types.ts create mode 100644 web/routes/uix/components/listbox/+page.svelte diff --git a/src/uix/eidos/components/listbox/index.ts b/src/uix/eidos/components/listbox/index.ts new file mode 100644 index 000000000..9f5fde4f0 --- /dev/null +++ b/src/uix/eidos/components/listbox/index.ts @@ -0,0 +1,69 @@ +// Listbox — eidos compound API. +// +// import { Listbox } from '$uix/eidos/components/listbox'; +// +// +// +// +// Apple +// +// +// +// Banana +// +// +// Citrus +// +// +// Orange +// +// +// +// +// Soma owns single/multi selection (value as string[]), roving-tabindex +// keyboard nav (Arrow + Home/End + typeahead + Ctrl+A), Field +// integration (disabled/readonly/required/invalid OR-merged), and +// hidden form inputs. Eidos paints the visual surface and exposes +// `variant` (ControlVariant — surface/outline/ghost), `size` (xs..xl), +// `color` (ColorRole). The ItemIndicator ✓ glyph mirrors the shared +// menu-indicator partial used by Dropdown/Context Menu CheckboxItems. +// +// Disambiguation: use Listbox for atomic options (text rows, no inner +// interactive elements). For row-with-button/link/checkbox patterns +// use `GridList` instead (ARIA forbids interactive descendants inside +// role="listbox"). +import ListboxRoot from './listbox.svelte'; +import Item from './listbox-item.svelte'; +import ItemIndicator from './listbox-item-indicator.svelte'; +import Group from './listbox-group.svelte'; +import GroupLabel from './listbox-group-label.svelte'; + +type ListboxNamespace = typeof ListboxRoot & { + Item: typeof Item; + ItemIndicator: typeof ItemIndicator; + Group: typeof Group; + GroupLabel: typeof GroupLabel; +}; + +const Listbox = ListboxRoot as ListboxNamespace; +Listbox.Item = Item; +Listbox.ItemIndicator = ItemIndicator; +Listbox.Group = Group; +Listbox.GroupLabel = GroupLabel; + +export { Listbox }; +export default Listbox; + +export type { + ListboxProps, + ListboxItemProps as ItemProps, + ListboxItemIndicatorProps as ItemIndicatorProps, + ListboxGroupProps as GroupProps, + ListboxGroupLabelProps as GroupLabelProps, + ListboxSize, + ListboxVariant, + ListboxColor, + ListboxSelectionMode, + ListboxProviderSnippetProps, + ListboxItemSnippetProps +} from './types'; diff --git a/src/uix/eidos/components/listbox/listbox-group-label.svelte b/src/uix/eidos/components/listbox/listbox-group-label.svelte new file mode 100644 index 000000000..47c583ed9 --- /dev/null +++ b/src/uix/eidos/components/listbox/listbox-group-label.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/listbox/listbox-group.svelte b/src/uix/eidos/components/listbox/listbox-group.svelte new file mode 100644 index 000000000..368d2277f --- /dev/null +++ b/src/uix/eidos/components/listbox/listbox-group.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/listbox/listbox-item-indicator.svelte b/src/uix/eidos/components/listbox/listbox-item-indicator.svelte new file mode 100644 index 000000000..8eb002922 --- /dev/null +++ b/src/uix/eidos/components/listbox/listbox-item-indicator.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/listbox/listbox-item.svelte b/src/uix/eidos/components/listbox/listbox-item.svelte new file mode 100644 index 000000000..ae669d90a --- /dev/null +++ b/src/uix/eidos/components/listbox/listbox-item.svelte @@ -0,0 +1,14 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/listbox/listbox.css b/src/uix/eidos/components/listbox/listbox.css new file mode 100644 index 000000000..701c7f134 --- /dev/null +++ b/src/uix/eidos/components/listbox/listbox.css @@ -0,0 +1,211 @@ +/* + * Listbox recipe. + * + * [data-listbox] → root container + * [data-listbox-item] → option row + * [data-listbox-item-indicator] → ✓ glyph on selected items + * [data-listbox-group] → group container + * [data-listbox-group-label] → muted group label + * + * Sizes — xs..xl on the root; cascades to item height + font-size. + * Variant `'surface' | 'outline' | 'ghost'` (ControlVariant). + * Color cascades the accent for selected-item highlight + focus ring. + * + * Cross-component reuse: + * - The ItemIndicator visual is the canonical check glyph — same as + * [data-checkbox-indicator] / menu-item indicators. Painted here as + * a filled accent square with white ✓ on selected items. + */ + +[data-listbox] { + --_listbox-item-height: var(--control-height-md); + --_listbox-item-px: var(--space-3); + --_listbox-item-font-size: var(--font-size-sm); + --_listbox-accent: var(--color-primary-solid); + --_listbox-accent-soft: var(--color-primary-element); + --_listbox-accent-text: var(--color-content-on-solid); + + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-1); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + color: var(--color-content-primary); + font-family: var(--font-ui); + font-size: var(--_listbox-item-font-size); + line-height: var(--leading-ui); + outline: none; +} + +[data-listbox][data-block] { + display: flex; + inline-size: 100%; +} + +[data-listbox][data-orientation='horizontal'] { + flex-direction: row; + flex-wrap: wrap; + align-items: center; +} + +/* ── Variant — surface (default) ─ */ +[data-listbox][data-variant='surface'] { + background: var(--color-surface-default); + border-color: var(--color-border-default); +} + +[data-listbox][data-variant='outline'] { + background: transparent; + border-color: var(--color-border-default); +} + +[data-listbox][data-variant='ghost'] { + background: transparent; + border-color: transparent; +} + +/* ── Sizes ─ */ +[data-listbox][data-size='xs'] { + --_listbox-item-height: var(--control-height-xs); + --_listbox-item-px: var(--space-2); + --_listbox-item-font-size: var(--font-size-xs); +} + +[data-listbox][data-size='sm'] { + --_listbox-item-height: var(--control-height-sm); + --_listbox-item-px: var(--space-2); + --_listbox-item-font-size: var(--font-size-sm); +} + +[data-listbox][data-size='lg'] { + --_listbox-item-height: var(--control-height-lg); + --_listbox-item-px: var(--space-4); + --_listbox-item-font-size: var(--font-size-md); +} + +[data-listbox][data-size='xl'] { + --_listbox-item-height: var(--control-height-xl); + --_listbox-item-px: var(--space-5); + --_listbox-item-font-size: var(--font-size-lg); +} + +/* ── Color cascades ─ */ +[data-listbox][data-color='secondary'] { + --_listbox-accent: var(--color-secondary-solid); + --_listbox-accent-soft: var(--color-secondary-element); +} +[data-listbox][data-color='affirm'] { + --_listbox-accent: var(--color-affirm-solid); + --_listbox-accent-soft: var(--color-affirm-element); +} +[data-listbox][data-color='risk'] { + --_listbox-accent: var(--color-risk-solid); + --_listbox-accent-soft: var(--color-risk-element); +} +[data-listbox][data-color='threat'] { + --_listbox-accent: var(--color-threat-solid); + --_listbox-accent-soft: var(--color-threat-element); +} +[data-listbox][data-color='fulfill'] { + --_listbox-accent: var(--color-fulfill-solid); + --_listbox-accent-soft: var(--color-fulfill-element); +} +[data-listbox][data-color='loss'] { + --_listbox-accent: var(--color-loss-solid); + --_listbox-accent-soft: var(--color-loss-element); +} +[data-listbox][data-color='neutral'] { + --_listbox-accent: var(--color-neutral-solid); + --_listbox-accent-soft: var(--color-neutral-element); +} + +[data-listbox][data-disabled] { + opacity: 0.55; + cursor: not-allowed; +} + +[data-listbox][data-invalid] { + border-color: var(--color-threat-element); +} + +[data-listbox]:focus-visible, +[data-listbox][data-focused] { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +/* ── Item ─ */ +[data-listbox-item] { + display: flex; + align-items: center; + gap: var(--space-2); + min-block-size: var(--_listbox-item-height); + padding-block: var(--space-1); + padding-inline: var(--_listbox-item-px); + border-radius: var(--radius-sm); + color: var(--color-content-primary); + cursor: pointer; + user-select: none; + transition: + background var(--duration-fast) var(--ease-default), + color var(--duration-fast) var(--ease-default); +} + +[data-listbox-item][data-highlighted]:not([data-disabled]) { + background: var(--color-surface-overlay); +} + +[data-listbox-item][data-selected]:not([data-disabled]) { + background: var(--_listbox-accent-soft); + color: var(--color-content-primary); +} + +[data-listbox-item][data-selected][data-highlighted]:not([data-disabled]) { + background: var(--_listbox-accent-soft); +} + +[data-listbox-item][data-disabled] { + color: var(--color-content-muted); + cursor: default; + opacity: 0.6; +} + +/* ── ItemIndicator (✓ glyph on selected items) ─ + * + * Painted as a 0.875rem filled-accent rounded square with white check — + * same shape as the menu-indicator partial used by Dropdown/Context + * Menu CheckboxItems. The ItemIndicator is rendered by the consumer + * (soma emits the data-attr; visibility is gated to selected). + */ +[data-listbox-item-indicator] { + display: none; + inline-size: 0.875rem; + block-size: 0.875rem; + flex: 0 0 auto; + color: var(--_listbox-accent); +} + +[data-listbox-item][data-selected] [data-listbox-item-indicator] { + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* ── Group / GroupLabel ─ */ +[data-listbox-group] { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +[data-listbox-group-label] { + padding-block: var(--space-1); + padding-inline: var(--_listbox-item-px); + color: var(--color-content-muted); + font-size: var(--font-size-xs); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} diff --git a/src/uix/eidos/components/listbox/listbox.svelte b/src/uix/eidos/components/listbox/listbox.svelte new file mode 100644 index 000000000..ef6be266c --- /dev/null +++ b/src/uix/eidos/components/listbox/listbox.svelte @@ -0,0 +1,53 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/listbox/types.ts b/src/uix/eidos/components/listbox/types.ts new file mode 100644 index 000000000..fb02d3ff9 --- /dev/null +++ b/src/uix/eidos/components/listbox/types.ts @@ -0,0 +1,42 @@ +import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'; +import type { + ProviderProps, + ItemProps, + ItemIndicatorProps, + GroupProps, + GroupLabelProps, + ProviderSnippetProps, + ItemSnippetProps, + ListboxSelectionMode +} from '$soma/components/listbox'; + +/** + * Listbox visual size — five-step subset matching the rest of the + * form-control family (Select, Combobox, Field, etc.). + */ +export type ListboxSize = Extract; + +export type ListboxVariant = ControlVariant; +export type ListboxColor = ColorRole; + +export type ListboxProps = ProviderProps & { + /** Visual size — drives item height + font-size. @default 'md' */ + size?: ResponsiveProp; + /** Container chrome variant. @default 'surface' */ + variant?: ListboxVariant; + /** Accent palette for selected items + focus ring. @default 'primary' */ + color?: ListboxColor; + /** Stretch to fill the container's inline-size. @default false */ + block?: boolean; +}; + +export type ListboxItemProps = ItemProps; +export type ListboxItemIndicatorProps = ItemIndicatorProps; +export type ListboxGroupProps = GroupProps; +export type ListboxGroupLabelProps = GroupLabelProps; + +export type { + ProviderSnippetProps as ListboxProviderSnippetProps, + ItemSnippetProps as ListboxItemSnippetProps, + ListboxSelectionMode +}; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 2299d9ea1..fc5052c1e 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -96,6 +96,7 @@ @import './components/context-menu/context-menu.css'; @import './components/menubar/menubar.css'; @import './components/command/command.css'; +@import './components/listbox/listbox.css'; /* Shared menu-item indicator partial — must load AFTER the menu recipes so its `padding-inline-start` calc wins over the row rule's `padding-inline` shorthand at equal specificity. */ diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index e52cf9729..8bd88db8a 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -148,6 +148,7 @@ { slug: '/uix/components/search-field', label: 'Search field' }, { slug: '/uix/components/select', label: 'Select' }, { slug: '/uix/components/combobox', label: 'Combobox' }, + { slug: '/uix/components/listbox', label: 'Listbox' }, { slug: '/uix/components/calendar', label: 'Calendar' }, { slug: '/uix/components/month-grid', label: 'Month grid' }, { slug: '/uix/components/year-grid', label: 'Year grid' }, diff --git a/web/routes/uix/components/listbox/+page.svelte b/web/routes/uix/components/listbox/+page.svelte new file mode 100644 index 000000000..d3f61e38f --- /dev/null +++ b/web/routes/uix/components/listbox/+page.svelte @@ -0,0 +1,601 @@ + + +
+
+
Forms · Listbox
+

Listbox

+

+ Embedded selection list (role="listbox") with single + or multiple selection, roving-tabindex keyboard nav, alphanumeric + typeahead, ARIA-grouped sections, and form participation. Soma + OR-merges disabled / readonly / + required / invalid with + Field.Provider when nested. For atomic options + only — use GridList when rows contain interactive + children, or Select / Combobox when + the surface is a closed combobox-style picker. +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + sizes5 + + + apg + listbox ↗ + + + selected {value.join(', ') || '—'} + +
+
+ +
+
+
+ + + + Apple + + + + Banana + + + + Cherry (disabled) + + + Citrus + + + Orange + + + + Lemon + + + + Grapefruit + + + + Berries + + + Strawberry + + + + Blueberry + + + +
+
+
+ trace + {#if trace.length === 0} + focus the listbox + arrow / type to highlight + {:else} + {#each trace.slice(0, 3) as entry (entry.at)} + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + value [{value.join(', ')}] + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Soma owns selection + nav + typeahead. Eidos adds + variant / size / color chrome. +

+ +
+ soma Provider props +
+
+ + + + + + +
+ +
+ eidos visual props +
+
+ + + + +
+ +
+
+ soma + headless · selection + keyboard + Field merge + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · variant + size + color + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

5 parts: Root, Item, ItemIndicator, Group, GroupLabel.

+ +
Root (Listbox)
+
+ + + + + + + + + + + + + + + +
PropTypeDefaultDescription
value bindablestring[][]Current selection. Always string[]; single mode keeps at most one entry.
selectionMode'single' | 'multiple''single'Selection behavior.
loopbooleantrueWrap arrow nav at edges.
typeaheadbooleantrueAlphanumeric jump-to-item.
orientation'vertical' | 'horizontal''vertical'Arrow nav axis.
disabled · readonly · required · invalidbooleanfalseOR-merged with Field.Provider when nested.
namestring—Form name → hidden inputs per selected value.
variant eidosControlVariant: 'surface' | 'outline' | 'ghost''surface'Container chrome.
size eidos'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Item height + font-size.
color eidosColorRole'primary'Selected-item highlight + focus ring accent.
block eidosbooleanfalseFill container's inline-size.
+
+ +
Item
+
+ + + + + + +
PropTypeDefaultDescription
valuestringrequiredUnique identifier in the listbox.
disabledbooleanfalseSkip the item from keyboard nav + click selection.
+
+ +
ItemIndicator · Group · GroupLabel
+

+ ItemIndicator renders a ✓ glyph inside selected items + (same recipe as menu-indicator partial). Group wraps a + logical section; GroupLabel is the muted heading + (ARIA-linked via aria-labelledby). +

+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo + · declarative contract +

+

Source: src/uix/morfo/components/listbox.ts.

+ +
+ + + + + + + + + + +
FieldValue
name"{listboxMorfo.name}"
kebab"{listboxMorfo.kebab}"
scope[{listboxMorfo.scope.map((s) => `"${s}"`).join(', ')}]
apg{listboxMorfo.apg}
parts.length{listboxMorfo.parts.length}
events.length{events.length}
+
+ +
Parts
+
+ + + + {#each partsList as part (part.kebab)} + + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStatesOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}{part.optional ? 'yes' : 'no'}
+
+ +
Events declaration
+
+ + + + {#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 ?? '?'}` : '—'} + + + + + + + + + {/each} + +
namefamilyverbsequenceintenttarget
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{intentStr}{action.target}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema + · events +

+

+ Two events: commit-select (intent affirm) + when an item enters the selection; commit-unselect + (intent neutral) when it leaves. +

+ +
+ + + + {#each events as action (action.name)} + {@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined} + {@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined} + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '—'}{action.semantic.sequence ?? 'pre'}{effectiveIntent ?? '—'} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

Selectors at src/uix/eidos/components/listbox/listbox.css.

+
+ + + + + + + + + + + + + +
SelectorSourceWhat it paints
[data-listbox]eidosContainer — flex column, gap, border, padding.
[data-listbox][data-size]eidosItem height + font-size scale.
[data-listbox][data-variant='outline' | 'ghost']eidosContainer chrome (border + bg).
[data-listbox][data-color]eidosAccent palette for selected highlight + focus ring.
[data-listbox][data-invalid]morfoThreat-coloured border override.
[data-listbox-item]morfoOption row — flex, padding, hover/highlight states.
[data-listbox-item][data-selected]morfoSelected-state band (accent-soft fill).
[data-listbox-item-indicator]morfoHidden by default; revealed inside selected items.
[data-listbox-group-label]morfoMuted uppercase section heading.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ Follows the APG listbox pattern. + Container is role="listbox"; each Item is + role="option" with aria-selected; + Group is role="group" with + aria-labelledby pointing to GroupLabel. + aria-multiselectable is stamped automatically + in multiple mode. +

+ +
Keyboard
+
+ + + + + + + + + + +
KeyAction
ArrowDown / ArrowUpMove highlight ±1 (vertical orientation).
ArrowRight / ArrowLeftSame but for horizontal orientation; RTL inverts.
Home / EndFirst / last item.
Enter / SpaceToggle selection of highlighted item.
Ctrl/Cmd+ASelect all (multiple mode only).
Letter / digitTypeahead — focus next item starting with that character.
+
+ +
ARIA contract
+
+ + + + + + + + + + + + + +
PartAttributeValue
listboxrole"listbox"
listboxaria-multiselectable"true" (multiple mode)
listboxaria-orientation"horizontal" | "vertical"
listboxaria-disabled / aria-readonly / aria-required / aria-invalidflags
itemrole"option"
itemaria-selected"true" | "false"
itemaria-disabled"true" (disabled)
grouprole"group"
grouparia-labelledby{``}
+
+
+ {/if} +