import { context, type WithRefOpts } from '../../provider'; import { boolToEmptyStrOrUndef, boolToStr } from '$adom'; import { readableActive, state, type Active, type ActiveProps, type StateProps } from '$libs/reactive'; import type { SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import { untrack } from 'svelte'; import { KEYS, getDirectionalKeys } from '../../keyboard'; function dbg(_label: string, _payload?: unknown): void {} import { Soma } from '../../core/soma.svelte'; import { COMMAND_LANGS } from './langs'; import type { CommandFilterFn, CommandSnippetProps } from './types'; import { commandMorfo } from '../../../morfo/components/command'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; // ── Registry shapes ────────────────────────────────────────────────────────── interface ItemEntry { value: string; keywords: readonly string[]; forceMount: boolean; disabled: boolean; groupValue: string | undefined; element: HTMLElement | null; score: number; } /** Shallow-equal for keyword arrays (identical length + same entries in order). */ function stringArrayEqual( a: readonly string[] | undefined, b: readonly string[] | undefined ): boolean { if (a === b) return true; if (!a || !b) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } /** * True when a patch would leave the entry unchanged. Compares primitives by * identity and `keywords` by content (since consumers often re-spread the * array on every render). */ function isEntryPatchNoop(entry: ItemEntry, patch: Partial): boolean { for (const key of Object.keys(patch) as (keyof ItemEntry)[]) { const next = patch[key]; const prev = entry[key]; if (key === 'keywords') { if (!stringArrayEqual(prev as readonly string[], next as readonly string[])) return false; } else if (next !== prev) { return false; } } return true; } interface GroupEntry { value: string; forceMount: boolean; itemValues: Set; } // ── Root ───────────────────────────────────────────────────────────────────── interface CommandOpts extends WithRefOpts, StateProps<{ value: string; search: string }>, ActiveProps<{ label: string | undefined; shouldFilter: boolean; filter: CommandFilterFn; loop: boolean; vimBindings: boolean; columns: number; disablePointerSelection: boolean; dir: 'ltr' | 'rtl'; }> {} export class CommandProvider { readonly opts: CommandOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; readonly runtime: SomaRuntime; static readonly ctx = context('Command'); static get(): CommandProvider | undefined { return this.ctx.getOr(undefined) as CommandProvider | undefined; } static require(): CommandProvider { return this.ctx.get(); } static create(opts: CommandOpts) { return new CommandProvider(opts); } /** * Plain storage + a version counter that increments synchronously on each * mutation. Synchronous = consumer event handlers (clicks) see up-to-date * state immediately. `isEntryPatchNoop` still short-circuits the patch * effect to avoid loops when consumers pass new array literals for * `keywords` on every render. */ private readonly _items: ItemEntry[] = []; private readonly itemIndex = new Map(); private readonly _groups: GroupEntry[] = []; private readonly groupIndex = new Map(); itemsVersion = $state(0); groupsVersion = $state(0); inputId = state(''); listId = state(''); private constructor(opts: CommandOpts) { this.opts = opts; this.soma = Soma.require(); this.runtime = this.soma.runtime(commandMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, context: CommandProvider.ctx }); dbg('Provider.ctor'); // Build the stable snippet props object. Getters read reactive state // lazily so the snapshot identity never changes across renders. const self = this; this.snippetProps = { get value() { return self.opts.value.current; }, get search() { return self.opts.search.current; }, get visible() { return self.visibleOrderedValues; }, get isEmpty() { return self.isEmpty; }, next: (step?: number) => self.next(step), prev: (step?: number) => self.prev(step), first: () => self.first(), last: () => self.last(), select: () => self.selectCurrent(), setValue: (value: string) => self.setValue(value), setSearch: (value: string) => { self.opts.search.current = value; }, clearSearch: () => { self.opts.search.current = ''; }, isVisible: (value: string) => self.isVisible(value), isSelected: (value: string) => self.isSelected(value) }; // When the filter changes (items added/removed or search changes), if // the current highlight falls out of the visible set, move it to the // first visible item. `$effect` + `untrack` avoids re-triggering on the // write we perform inside. $effect(() => { const values = this.visibleOrderedValues; dbg('Provider.autoSelect', { n: values.length }); if (values.length === 0) return; untrack(() => { if (!values.includes(this.opts.value.current)) { this.opts.value.current = values[0]; } }); }); } // ── Registration ────────────────────────────────────────────────────────── registerItem(entry: ItemEntry): void { dbg('registerItem', entry.value); this.itemIndex.set(entry.value, this._items.length); this._items.push(entry); this.itemsVersion++; } unregisterItem(value: string): void { dbg('unregisterItem', value); const idx = this.itemIndex.get(value); if (idx === undefined) return; this._items.splice(idx, 1); this.itemIndex.delete(value); for (let i = idx; i < this._items.length; i++) this.itemIndex.set(this._items[i].value, i); if (this.opts.value.current === value) { const visible = this.visibleOrderedValues; this.opts.value.current = visible[0] ?? ''; } this.itemsVersion++; } registerGroup(entry: GroupEntry): void { this.groupIndex.set(entry.value, this._groups.length); this._groups.push(entry); this.groupsVersion++; } unregisterGroup(value: string): void { const idx = this.groupIndex.get(value); if (idx === undefined) return; this._groups.splice(idx, 1); this.groupIndex.delete(value); for (let i = idx; i < this._groups.length; i++) this.groupIndex.set(this._groups[i].value, i); this.groupsVersion++; } updateItem(value: string, patch: Partial): void { dbg('updateItem:call', value); const idx = this.itemIndex.get(value); if (idx === undefined) return; const entry = this._items[idx]; if (isEntryPatchNoop(entry, patch)) { dbg('updateItem:noop', value); return; } dbg('updateItem:mutate', value); this._items[idx] = { ...entry, ...patch }; this.itemsVersion++; } private findItem(value: string): ItemEntry | undefined { const idx = this.itemIndex.get(value); return idx !== undefined ? this._items[idx] : undefined; } private findGroup(value: string): GroupEntry | undefined { const idx = this.groupIndex.get(value); return idx !== undefined ? this._groups[idx] : undefined; } // ── Derived state ───────────────────────────────────────────────────────── /** Map of value → score after running the current filter. */ readonly scores = $derived.by(() => { void this.itemsVersion; dbg('scores.derive', { items: this._items.length }); const search = this.opts.search.current; const shouldFilter = this.opts.shouldFilter.current; const filterFn = this.opts.filter.current; const result = new Map(); for (const entry of this._items) { if (!shouldFilter || search === '') { result.set(entry.value, 1); } else { result.set(entry.value, filterFn(entry.value, search, entry.keywords)); } } return result; }); /** Ordered list of visible item values (score > 0 or forceMount). */ readonly visibleOrderedValues = $derived.by(() => { void this.itemsVersion; dbg('visible.derive', { items: this._items.length }); const result: string[] = []; for (const entry of this._items) { const score = this.scores.get(entry.value) ?? 0; if (entry.forceMount || score > 0) result.push(entry.value); } return result; }); /** * Precomputed visibility set — the hot path for per-item `isVisible`. * * Items consult this with `visibleSet.has(value)` (O(1)) instead of calling * `provider.isVisible(value)` per item. Calling the method meant every * Item's `$derived` subscribed to `itemsVersion` directly, so each bump * re-ran *all* items' derivations (O(N²) cascade). With the Set, items * subscribe to a single signal and each derivation is an O(1) lookup; * Svelte only invalidates an Item's dependents when its boolean result * actually flips. */ readonly visibleSet = $derived.by(() => { void this.itemsVersion; const scores = this.scores; const result = new Set(); for (const entry of this._items) { if (entry.forceMount || (scores.get(entry.value) ?? 0) > 0) result.add(entry.value); } return result; }); /** True when no item survives the filter. */ readonly isEmpty = $derived.by(() => { dbg('isEmpty.derive'); return this.visibleOrderedValues.length === 0; }); /** * Imperative visibility query (used by the snippet API and navigation * helpers). Reads from the precomputed `visibleSet` so callers subscribe * to a single signal and the per-call cost is O(1). */ isVisible(value: string): boolean { return this.visibleSet.has(value); } isSelected(value: string): boolean { return this.opts.value.current === value; } /** * True when the group contains at least one visible item. * * Note: we intentionally walk `_items` (not `group.itemValues`) because the * registration order is not guaranteed. Items can register BEFORE the * group exists — e.g. when Svelte runs child effects before parent ones. * In that case `group.itemValues` would stay empty and the group would * never show its children. Iterating items keeps this robust regardless * of mount order. */ groupHasVisibleItems(groupValue: string): boolean { void this.groupsVersion; void this.itemsVersion; const gIdx = this.groupIndex.get(groupValue); if (gIdx === undefined) return false; const group = this._groups[gIdx]; if (!group) return false; if (group.forceMount) return true; const visible = this.visibleSet; for (const item of this._items) { if (item.groupValue === groupValue && visible.has(item.value)) return true; } return false; } // ── Navigation ──────────────────────────────────────────────────────────── private indexOfSelected(): number { return this.visibleOrderedValues.indexOf(this.opts.value.current); } private setSelectedByIndex(i: number): void { const values = this.visibleOrderedValues; if (values.length === 0) return; const clamped = this.opts.loop.current ? ((i % values.length) + values.length) % values.length : Math.max(0, Math.min(values.length - 1, i)); const next = values[clamped]; if (next && next !== this.opts.value.current) this.opts.value.current = next; } next(step = 1): void { const idx = this.indexOfSelected(); this.setSelectedByIndex(idx === -1 ? 0 : idx + step); } prev(step = 1): void { const idx = this.indexOfSelected(); this.setSelectedByIndex(idx === -1 ? 0 : idx - step); } first(): void { this.setSelectedByIndex(0); } last(): void { this.setSelectedByIndex(this.visibleOrderedValues.length - 1); } setValue(value: string): void { if (this.isVisible(value)) this.opts.value.current = value; } selectCurrent(): void { const value = this.opts.value.current; const entry = this.findItem(value); if (!entry || entry.disabled || !this.isVisible(value)) return; this.onSelectHandlers.get(value)?.(value); // Morfo: commit-submit-invoke on `item`. Look up the item element // by data-value so the cascade matches this specific item (Enter // key path — onclick handler emits independently for mouse path). const itemEl = this.opts.ref?.current?.querySelector( `[${attrs.item}][data-value="${CSS.escape(value)}"]` ); if (itemEl) { void this.runtime.trigger('commit-submit-invoke', { fallbackTarget: itemEl }); } } private onSelectHandlers = new Map void>(); registerOnSelect(value: string, fn: (v: string) => void): void { this.onSelectHandlers.set(value, fn); } unregisterOnSelect(value: string): void { this.onSelectHandlers.delete(value); } /** * Snippet props surface. Reactive fields are exposed via getters so the * snapshot object itself is a stable reference — only the individual * getters notify consumers. This avoids invalidating `snippetProps` * whenever `value` / `search` / `visible` changes, which would otherwise * cause the `children` snippet to re-run on every tick. */ readonly snippetProps: CommandSnippetProps; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, dir: this.opts.dir.current } as const) ); } // ── Input (role="combobox") ────────────────────────────────────────────────── interface CommandInputOpts extends WithRefOpts {} export class CommandInputProvider { readonly opts: CommandInputOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandInputOpts) { return new CommandInputProvider(opts); } readonly provider: CommandProvider; private constructor(opts: CommandInputOpts) { this.opts = opts; this.provider = CommandProvider.require(); this.runtimePart = this.provider.runtime.part('input', { id: opts.id, ref: opts.ref, owner: this }); this.provider.inputId.current = opts.id.current; } readonly oninput = (e: Event) => { const target = e.currentTarget as HTMLInputElement; this.provider.opts.search.current = target.value; }; readonly onkeydown = (e: SomaKeyboardEvent) => { const columns = Math.max(1, this.provider.opts.columns.current); const vim = this.provider.opts.vimBindings.current; // In RTL, ArrowLeft moves forward and ArrowRight moves backward. const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys( this.provider.opts.dir.current, 'horizontal' ); if (e.key === KEYS.ARROW_DOWN) { e.preventDefault(); this.provider.next(columns); return; } if (e.key === KEYS.ARROW_UP) { e.preventDefault(); this.provider.prev(columns); return; } if (e.key === horizNext && columns > 1) { e.preventDefault(); this.provider.next(1); return; } if (e.key === horizPrev && columns > 1) { e.preventDefault(); this.provider.prev(1); return; } if (e.key === KEYS.HOME && !e.shiftKey) { e.preventDefault(); this.provider.first(); return; } if (e.key === KEYS.END && !e.shiftKey) { e.preventDefault(); this.provider.last(); return; } if (e.key === KEYS.ENTER) { e.preventDefault(); this.provider.selectCurrent(); return; } if (vim && e.ctrlKey) { if (e.key === 'n' || e.key === 'j') { e.preventDefault(); this.provider.next(1); return; } if (e.key === 'p' || e.key === 'k') { e.preventDefault(); this.provider.prev(1); return; } } }; readonly props = $derived.by(() => { const selected = this.provider.opts.value.current; const listId = this.provider.listId.current; return this.runtimePart.assert({ ...this.runtimePart.props, type: 'text' as const, role: 'combobox' as const, autocomplete: 'off' as const, autocorrect: 'off' as const, spellcheck: false, 'aria-autocomplete': 'list' as const, 'aria-expanded': true as const, 'aria-controls': listId || undefined, 'aria-activedescendant': selected ? `${listId}-item-${cssSafe(selected)}` : undefined, value: this.provider.opts.search.current, oninput: this.oninput, onkeydown: this.onkeydown } as const); }); } // Escape a value to produce a deterministic id suffix. function cssSafe(v: string): string { return v.replace(/[^a-zA-Z0-9_-]/g, '_'); } // ── List (role="listbox") ─────────────────────────────────────────────────── interface CommandListOpts extends WithRefOpts {} export class CommandListProvider { readonly opts: CommandListOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandListOpts) { return new CommandListProvider(opts); } readonly provider: CommandProvider; private constructor(opts: CommandListOpts) { this.opts = opts; this.provider = CommandProvider.require(); this.runtimePart = this.provider.runtime.part('list', { id: opts.id, ref: opts.ref, owner: this }); this.provider.listId.current = opts.id.current; } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'listbox' as const, 'aria-label': this.provider.opts.label.current ?? this.provider.soma.langs.ts(COMMAND_LANGS.LIST) } as const) ); } // ── Viewport — exposes --command-list-height ─────────────────────────── interface CommandViewportOpts extends WithRefOpts {} export class CommandViewportProvider { readonly opts: CommandViewportOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandViewportOpts) { return new CommandViewportProvider(opts); } height = $state(undefined); private constructor(opts: CommandViewportOpts) { this.opts = opts; const provider = CommandProvider.require(); this.runtimePart = provider.runtime.part('viewport', { id: opts.id, ref: opts.ref, owner: this }); $effect(() => { const el = opts.ref.current; if (!el) return; const child = el.firstElementChild as HTMLElement | null; if (!child) return; return provider.soma.dom.observeResize(child, ([entry]) => { this.height = entry?.borderBoxSize?.[0]?.blockSize ?? entry?.contentRect.height; }); }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, style: this.height !== undefined ? `--command-list-height: ${this.height}px;` : undefined } as const) ); } // ── Item (role="option") ──────────────────────────────────────────────────── interface CommandItemOpts extends WithRefOpts, ActiveProps<{ value: string; keywords: readonly string[] | string[]; disabled: boolean; forceMount: boolean; onSelect: ((v: string) => void) | undefined; }> {} export class CommandItemProvider { readonly opts: CommandItemOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandItemOpts) { return new CommandItemProvider(opts); } readonly provider: CommandProvider; readonly group: CommandGroupProvider | undefined; private constructor(opts: CommandItemOpts) { this.opts = opts; this.provider = CommandProvider.require(); this.group = CommandGroupProvider.get(); this.runtimePart = this.provider.runtime.part('item', { id: opts.id, ref: opts.ref, owner: this }); dbg('Item.ctor', opts.value.current); const groupValue = this.group?.opts.value.current; // Register ONCE per item identity. EVERYTHING except `opts.value.current` // must go through `untrack` — including the `registerItem` call itself, // because its internal reads (items.length, items.push) are reactive // reads on the $state array. Without untrack, the effect subscribes to // items.length; pushing this very item then re-runs the effect → // cleanup → re-register → loop. $effect(() => { const value = opts.value.current; dbg('Item.fx1.register', value); untrack(() => { const initial = { keywords: opts.keywords.current, forceMount: opts.forceMount.current, disabled: opts.disabled.current, element: opts.ref.current }; this.provider.registerItem({ value, groupValue, score: 0, ...initial }); const handler = opts.onSelect.current; if (handler) this.provider.registerOnSelect(value, handler); }); return () => { dbg('Item.fx1.cleanup', value); untrack(() => { this.provider.unregisterItem(value); this.provider.unregisterOnSelect(value); }); }; }); // Patch mutable props. Item identity (`opts.value`) is read via // `untrack` so a value change doesn't call `updateItem` with the new // id (there's no entry for it yet — the registration effect handles // it). $effect(() => { const keywords = opts.keywords.current; const disabled = opts.disabled.current; const forceMount = opts.forceMount.current; dbg('Item.fx2.patch'); untrack(() => { this.provider.updateItem(opts.value.current, { keywords, disabled, forceMount }); }); }); // Keep the onSelect handler fresh without re-registering the item. $effect(() => { const handler = opts.onSelect.current; const value = untrack(() => opts.value.current); dbg('Item.fx3.handler', value); if (handler) this.provider.registerOnSelect(value, handler); else this.provider.unregisterOnSelect(value); }); } readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current)); readonly isVisible = $derived.by(() => this.provider.visibleSet.has(this.opts.value.current)); readonly onclick = (e: SomaMouseEvent) => { if (this.opts.disabled.current) return; this.provider.setValue(this.opts.value.current); this.opts.onSelect.current?.(this.opts.value.current); // Morfo: commit-submit-invoke on `item` (affirm). Fire with the // clicked element so the cascade matches this specific item. void this.provider.runtime.trigger('commit-submit-invoke', { fallbackTarget: e.currentTarget }); }; readonly onpointermove = () => { if (this.provider.opts.disablePointerSelection.current) return; if (this.opts.disabled.current) return; this.provider.setValue(this.opts.value.current); }; readonly idForAria = $derived.by( () => `${this.provider.listId.current}-item-${cssSafe(this.opts.value.current)}` ); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, id: this.idForAria, role: 'option' as const, 'aria-selected': this.isSelected, 'aria-disabled': boolToStr(this.opts.disabled.current), 'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-value': this.opts.value.current, onclick: this.onclick, onpointermove: this.onpointermove } as const) ); } // ── LinkItem — same semantics but renders ─────────────────────────────── interface CommandLinkItemOpts extends WithRefOpts, ActiveProps<{ value: string; keywords: readonly string[] | string[]; disabled: boolean; forceMount: boolean; href: string; onSelect: ((v: string) => void) | undefined; }> {} export class CommandLinkItemProvider { readonly opts: CommandLinkItemOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandLinkItemOpts) { return new CommandLinkItemProvider(opts); } readonly provider: CommandProvider; readonly group: CommandGroupProvider | undefined; private constructor(opts: CommandLinkItemOpts) { this.opts = opts; this.provider = CommandProvider.require(); this.group = CommandGroupProvider.get(); this.runtimePart = this.provider.runtime.part('link-item', { id: opts.id, ref: opts.ref, owner: this }); const groupValue = this.group?.opts.value.current; // See the matching block in CommandItemProvider: register once per // identity, with the whole register/unregister call wrapped in untrack // so the effect doesn't subscribe to items.length via the internal // $state array reads (that would loop). $effect(() => { const value = opts.value.current; untrack(() => { const initial = { keywords: opts.keywords.current, forceMount: opts.forceMount.current, disabled: opts.disabled.current, element: opts.ref.current }; this.provider.registerItem({ value, groupValue, score: 0, ...initial }); const handler = opts.onSelect.current; if (handler) this.provider.registerOnSelect(value, handler); }); return () => { untrack(() => { this.provider.unregisterItem(value); this.provider.unregisterOnSelect(value); }); }; }); $effect(() => { const keywords = opts.keywords.current; const disabled = opts.disabled.current; const forceMount = opts.forceMount.current; untrack(() => { this.provider.updateItem(opts.value.current, { keywords, disabled, forceMount }); }); }); $effect(() => { const handler = opts.onSelect.current; const value = untrack(() => opts.value.current); if (handler) this.provider.registerOnSelect(value, handler); else this.provider.unregisterOnSelect(value); }); } readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current)); readonly isVisible = $derived.by(() => this.provider.visibleSet.has(this.opts.value.current)); readonly onclick = (e: SomaMouseEvent) => { if (this.opts.disabled.current) { e.preventDefault(); return; } this.opts.onSelect.current?.(this.opts.value.current); }; readonly onpointermove = () => { if (this.provider.opts.disablePointerSelection.current) return; if (this.opts.disabled.current) return; this.provider.setValue(this.opts.value.current); }; readonly idForAria = $derived.by( () => `${this.provider.listId.current}-item-${cssSafe(this.opts.value.current)}` ); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, id: this.idForAria, role: 'option' as const, href: this.opts.href.current, 'aria-selected': this.isSelected, 'aria-disabled': boolToStr(this.opts.disabled.current), 'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-value': this.opts.value.current, onclick: this.onclick, onpointermove: this.onpointermove } as const) ); } // ── Group ──────────────────────────────────────────────────────────────────── interface CommandGroupOpts extends WithRefOpts, ActiveProps<{ value: string; forceMount: boolean }> {} export class CommandGroupProvider { readonly opts: CommandGroupOpts; readonly runtimePart: SomaRuntimePart; static readonly ctx = context('CommandGroup'); static get(): CommandGroupProvider | undefined { return this.ctx.getOr(undefined) as CommandGroupProvider | undefined; } static require(): CommandGroupProvider { return this.ctx.get(); } static create(opts: CommandGroupOpts) { return new CommandGroupProvider(opts); } readonly provider: CommandProvider; headingId = state(''); private constructor(opts: CommandGroupOpts) { this.opts = opts; this.provider = CommandProvider.require(); this.runtimePart = this.provider.runtime.part('group', { id: opts.id, ref: opts.ref, owner: this, context: CommandGroupProvider.ctx }); // Register ONCE per group identity. The register/unregister calls read // from the reactive `groups` array internally, so wrap in untrack to // avoid subscribing this effect to items.length (would loop on mount). $effect(() => { const value = opts.value.current; untrack(() => { const forceMount = opts.forceMount.current; this.provider.registerGroup({ value, forceMount, itemValues: new Set() }); }); return () => untrack(() => this.provider.unregisterGroup(value)); }); } readonly hasVisibleItems = $derived.by(() => this.provider.groupHasVisibleItems(this.opts.value.current) ); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'group' as const, 'aria-labelledby': this.headingId.current || undefined, 'data-empty': boolToEmptyStrOrUndef(!this.hasVisibleItems) } as const) ); } // ── GroupHeading ───────────────────────────────────────────────────────────── interface CommandGroupHeadingOpts extends WithRefOpts {} export class CommandGroupHeadingProvider { readonly opts: CommandGroupHeadingOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandGroupHeadingOpts) { return new CommandGroupHeadingProvider(opts); } readonly group: CommandGroupProvider; private constructor(opts: CommandGroupHeadingOpts) { this.opts = opts; this.group = CommandGroupProvider.require(); this.runtimePart = this.group.provider.runtime.part('group-heading', { id: opts.id, ref: opts.ref, owner: this }); this.group.headingId.current = opts.id.current; } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, 'aria-hidden': true as const } as const) ); } // ── GroupItems ─────────────────────────────────────────────────────────────── interface CommandGroupItemsOpts extends WithRefOpts {} export class CommandGroupItemsProvider { readonly opts: CommandGroupItemsOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandGroupItemsOpts) { return new CommandGroupItemsProvider(opts); } private constructor(opts: CommandGroupItemsOpts) { this.opts = opts; const group = CommandGroupProvider.require(); this.runtimePart = group.provider.runtime.part('group-items', { id: opts.id, ref: opts.ref, owner: this }); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'presentation' as const } as const) ); } // ── Empty ──────────────────────────────────────────────────────────────────── interface CommandEmptyOpts extends WithRefOpts {} export class CommandEmptyProvider { readonly opts: CommandEmptyOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandEmptyOpts) { return new CommandEmptyProvider(opts); } readonly provider: CommandProvider; private constructor(opts: CommandEmptyOpts) { this.opts = opts; this.provider = CommandProvider.require(); this.runtimePart = this.provider.runtime.part('empty', { id: opts.id, ref: opts.ref, owner: this }); } readonly shouldRender = $derived.by(() => this.provider.isEmpty); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'status' as const, 'aria-live': 'polite' as const } as const) ); } // ── Loading ────────────────────────────────────────────────────────────────── interface CommandLoadingOpts extends WithRefOpts, ActiveProps<{ progress: number | undefined }> {} export class CommandLoadingProvider { readonly opts: CommandLoadingOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandLoadingOpts) { return new CommandLoadingProvider(opts); } private constructor(opts: CommandLoadingOpts) { this.opts = opts; const provider = CommandProvider.require(); this.runtimePart = provider.runtime.part('loading', { id: opts.id, ref: opts.ref, owner: this }); } readonly percent: Active = readableActive(() => { const v = this.opts.progress.current; if (v === undefined) return undefined; return Math.min(100, Math.max(0, v)); }); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'progressbar' as const, 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': this.percent.current, 'data-value': this.percent.current } as const) ); } // ── Separator ──────────────────────────────────────────────────────────────── interface CommandSeparatorOpts extends WithRefOpts, ActiveProps<{ forceMount: boolean }> {} export class CommandSeparatorProvider { readonly opts: CommandSeparatorOpts; readonly runtimePart: SomaRuntimePart; static create(opts: CommandSeparatorOpts) { return new CommandSeparatorProvider(opts); } readonly provider: CommandProvider; private constructor(opts: CommandSeparatorOpts) { this.opts = opts; this.provider = CommandProvider.require(); this.runtimePart = this.provider.runtime.part('separator', { id: opts.id, ref: opts.ref, owner: this }); } /** Hide the separator when the palette has no visible items, unless forced. */ readonly shouldRender = $derived.by(() => this.opts.forceMount.current || !this.provider.isEmpty); readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, role: 'separator' as const, 'aria-orientation': 'horizontal' as const } as const) ); }