You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1057 lines
33 KiB
1057 lines
33 KiB
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<ItemEntry>): 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<string>;
|
|
}
|
|
|
|
// ── 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<CommandProvider>('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<string, number>();
|
|
private readonly _groups: GroupEntry[] = [];
|
|
private readonly groupIndex = new Map<string, number>();
|
|
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<ItemEntry>): 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<string, number>();
|
|
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<string>();
|
|
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);
|
|
}
|
|
|
|
private onSelectHandlers = new Map<string, (v: string) => 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<HTMLInputElement>) => {
|
|
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 --soma-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<number | undefined>(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 ? `--soma-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<HTMLElement>) => {
|
|
if (this.opts.disabled.current) return;
|
|
this.provider.setValue(this.opts.value.current);
|
|
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,
|
|
'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 <a> ───────────────────────────────
|
|
|
|
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<HTMLElement>) => {
|
|
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<CommandGroupProvider>('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<number | undefined> = 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)
|
|
);
|
|
}
|