import { createEffect, createMemo, createSignal, createUniqueId, on } from 'solid-js'; import { CommandList, type CommandGroup } from '../ui/command'; import { defineWebComponent } from './define'; /** * A single command/mention item for ``. * * Set `items` as a JS property (array ref) — not an HTML attribute. */ export interface KaiCommandItem { /** Unique identifier emitted in `kai-select`. */ id: string; /** Display name shown in the list row. */ label: string; /** Named icon (e.g. "search"), image URL / data-URI, or plain text. */ icon?: string; /** Muted supplementary text (e.g. a file path or a short description). */ description?: string; /** Optional keyboard shortcut shown as right-aligned key caps; uses the * kai-kbd `keys` syntax (e.g. "Mod+K", "Alt+1"). */ shortcut?: string; /** Group name that buckets this item under a section header. */ group?: string; } interface Props extends Record { /** Flat list of items. Set as a JS property — not an HTML attribute. */ items?: KaiCommandItem[]; /** Placeholder text for the search input. */ placeholder?: string; /** Label shown when no items match the current query. */ emptyLabel?: string; } interface Events { /** Fired when the user selects an item (click or Enter). */ 'kai-select': { id: string }; /** Fired on every keystroke in the search input. */ 'kai-query-change': { value: string }; /** Fired when the highlighted/active item changes — via Arrow keys or when * filtering re-clamps the active row. `id` is the newly active item's id, or * `undefined` when no item is active (e.g. the filtered list is empty). Lets a * host preview the active item without committing a selection. */ 'kai-active-change': { id: string | undefined }; } /** * `` — a grouped, filterable command/mention palette. * * Set the `items` property in JavaScript (array, not attribute), listen for * `kai-select` to know which item was picked, and `kai-query-change` to mirror * the search query to your own logic (e.g. async server-side filtering). * * ```html * * * ``` */ defineWebComponent('kai-command', { items: undefined, placeholder: undefined, emptyLabel: undefined, }, (props, { dispatch, element, expose }) => { const listboxId = createUniqueId(); const [query, setQuery] = createSignal(''); const [activeId, setActiveId] = createSignal(undefined); /** Items cast to the correct type; defaults to empty array. */ const allItems = () => (props.items as KaiCommandItem[] | undefined) ?? []; /** Items filtered by the current query (label or description, case-insensitive). */ const filteredItems = createMemo(() => { const q = query().toLowerCase(); if (!q) return allItems(); return allItems().filter( (item) => item.label.toLowerCase().includes(q) || (item.description?.toLowerCase().includes(q) ?? false), ); }); /** Filtered items grouped by `group`, preserving first-appearance order. */ const groupedItems = createMemo(() => { const groups: CommandGroup[] = []; for (const item of filteredItems()) { let g = groups.find((x) => x.group === item.group); if (!g) { g = { group: item.group, items: [] }; groups.push(g); } g.items.push({ id: item.id, label: item.label, icon: item.icon, description: item.description, shortcut: item.shortcut }); } return groups; }); /** Flat ordered array of filtered item ids — used for keyboard navigation. */ const flatIds = createMemo(() => filteredItems().map((item) => item.id)); /** Clamp/reset activeId when the filtered list changes. */ createEffect(() => { const ids = flatIds(); const current = activeId(); if (!current || !ids.includes(current)) { setActiveId(ids[0]); } }); /** Mirror the active/highlighted item out as `kai-active-change` whenever it * changes (Arrow keys or a filter-driven re-clamp). Skips the initial settle * by only firing when the value actually differs from the previous one. */ createEffect(on(activeId, (id, prev) => { if (id !== prev) dispatch('kai-active-change', { id }); })); function select(id: string) { dispatch('kai-select', { id }); } function handleInput(e: Event) { const value = (e.currentTarget as HTMLInputElement).value; setQuery(value); dispatch('kai-query-change', { value }); } function handleKeyDown(e: KeyboardEvent) { const ids = flatIds(); if (!ids.length) return; const current = activeId(); const idx = current !== undefined ? ids.indexOf(current) : -1; if (e.key === 'ArrowDown') { e.preventDefault(); setActiveId(ids[(idx + 1) % ids.length]); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveId(ids[(idx - 1 + ids.length) % ids.length]); } else if (e.key === 'Enter') { e.preventDefault(); const id = activeId(); if (id !== undefined) select(id); } else if (e.key === 'Escape') { e.preventDefault(); setQuery(''); dispatch('kai-query-change', { value: '' }); } } // ── Imperative API (instance methods on the host) ────────────────────────── // Counterpart to the kai-* events: lets a host make the palette type-ready and // reset its query without reaching into the shadow root. expose({ /** Focus the search combobox input inside the shadow root so the palette is * type-ready on demand (Shadow-DOM autofocus is unreliable, so hosts call * this after opening the palette). */ focus: (options?: FocusOptions) => element.shadowRoot ?.querySelector('input[role="combobox"], input') ?.focus(options), /** Blur the focused search input. */ blur: () => element.shadowRoot ?.querySelector('input[role="combobox"], input') ?.blur(), /** Reset the search query to empty — re-showing all items — and fire * `kai-query-change` with `''`. Mirrors the Escape-key behavior. */ clear: () => { setQuery(''); dispatch('kai-query-change', { value: '' }); }, }); return (
); });