From 3045b889ff2ead524a48337c98945417306a0a8a Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 02:12:05 +0530 Subject: [PATCH] feat(ui): command palette with pure AND-term matcher Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/index.ts | 2 + packages/ui/src/palette-match.ts | 22 +++++++ packages/ui/src/palette.tsx | 85 ++++++++++++++++++++++++++ packages/ui/test/palette-match.test.ts | 26 ++++++++ 4 files changed, 135 insertions(+) create mode 100644 packages/ui/src/palette-match.ts create mode 100644 packages/ui/src/palette.tsx create mode 100644 packages/ui/test/palette-match.test.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2eaaf82..9845ff8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -6,3 +6,5 @@ export * from './states' export * from './chips' export * from './tabs' export * from './record' +export * from './palette-match' +export * from './palette' diff --git a/packages/ui/src/palette-match.ts b/packages/ui/src/palette-match.ts new file mode 100644 index 0000000..781b111 --- /dev/null +++ b/packages/ui/src/palette-match.ts @@ -0,0 +1,22 @@ +/** Pure matcher for the command palette — every query term must appear. */ + +export interface PaletteItem { + id: string + label: string + group: string + hint?: string +} + +export function matchItems(items: PaletteItem[], q: string, limit = 12): PaletteItem[] { + const terms = q.trim().toLowerCase().split(/\s+/).filter((t) => t !== '') + if (terms.length === 0) return items.slice(0, limit) + const out: PaletteItem[] = [] + for (const item of items) { + const hay = `${item.label} ${item.hint ?? ''}`.toLowerCase() + if (terms.every((t) => hay.includes(t))) { + out.push(item) + if (out.length >= limit) break + } + } + return out +} diff --git a/packages/ui/src/palette.tsx b/packages/ui/src/palette.tsx new file mode 100644 index 0000000..476605e --- /dev/null +++ b/packages/ui/src/palette.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from 'react' +import { matchItems, type PaletteItem } from './palette-match' + +/** + * Ctrl+K overlay. Generic: the parent supplies `items` (static + any async + * results it fetched after `onQuery`) and receives the picked item. + */ +export function CommandPalette(props: { + open: boolean + onClose: () => void + items: PaletteItem[] + onSelect: (item: PaletteItem) => void + onQuery?: (q: string) => void + placeholder?: string +}) { + const [q, setQ] = useState('') + const [sel, setSel] = useState(0) + const inputRef = useRef(null) + + useEffect(() => { + if (props.open) { setQ(''); setSel(0); props.onQuery?.(''); inputRef.current?.focus() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.open]) + + if (!props.open) return null + + const hits = matchItems(props.items, q) + const groups: { name: string; items: { item: PaletteItem; index: number }[] }[] = [] + hits.forEach((item, index) => { + const g = groups.find((x) => x.name === item.group) + if (g !== undefined) g.items.push({ item, index }) + else groups.push({ name: item.group, items: [{ item, index }] }) + }) + + const pick = (item: PaletteItem) => { props.onSelect(item); props.onClose() } + + const onKey = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); props.onClose() } + else if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, hits.length - 1)) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) } + else if (e.key === 'Enter') { + e.preventDefault() + const item = hits[sel] + if (item !== undefined) pick(item) + } + } + + return ( +
+
e.stopPropagation()} onKeyDown={onKey}> + { setQ(e.target.value); setSel(0); props.onQuery?.(e.target.value) }} + /> +
+ {hits.length === 0 &&
No matches
} + {groups.map((g) => ( +
+
{g.name}
+ {g.items.map(({ item, index }) => ( + + ))} +
+ ))} +
+
+ ↑↓ navigate + open + esc close +
+
+
+ ) +} diff --git a/packages/ui/test/palette-match.test.ts b/packages/ui/test/palette-match.test.ts new file mode 100644 index 0000000..f7422ac --- /dev/null +++ b/packages/ui/test/palette-match.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { matchItems, type PaletteItem } from '../src/palette-match' + +const items: PaletteItem[] = [ + { id: 'nav:dash', label: 'Dashboard', group: 'Go to' }, + { id: 'nav:clients', label: 'Clients', group: 'Go to' }, + { id: 'act:newdoc', label: 'New document', group: 'Actions', hint: 'quotation proforma invoice' }, + { id: 'client:1', label: 'CL-0007 · Hamiltel', group: 'Clients' }, +] + +describe('matchItems', () => { + it('returns everything (to limit) for an empty query', () => { + expect(matchItems(items, '').map((i) => i.id)).toEqual(['nav:dash', 'nav:clients', 'act:newdoc', 'client:1']) + expect(matchItems(items, '', 2)).toHaveLength(2) + }) + it('matches case-insensitively on label', () => { + expect(matchItems(items, 'hamil').map((i) => i.id)).toEqual(['client:1']) + }) + it('matches on hint text too', () => { + expect(matchItems(items, 'proforma').map((i) => i.id)).toEqual(['act:newdoc']) + }) + it('matches every whitespace-separated term (AND)', () => { + expect(matchItems(items, 'new doc').map((i) => i.id)).toEqual(['act:newdoc']) + expect(matchItems(items, 'new xyz')).toHaveLength(0) + }) +})