feat(ui): command palette with pure AND-term matcher

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 6dbddd9822
commit 3045b889ff

@ -6,3 +6,5 @@ export * from './states'
export * from './chips' export * from './chips'
export * from './tabs' export * from './tabs'
export * from './record' export * from './record'
export * from './palette-match'
export * from './palette'

@ -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
}

@ -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<HTMLInputElement>(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 (
<div className="wf-palette-back" onClick={props.onClose}>
<div className="wf-palette" onClick={(e) => e.stopPropagation()} onKeyDown={onKey}>
<input
ref={inputRef}
value={q}
placeholder={props.placeholder ?? 'Search…'}
onChange={(e) => { setQ(e.target.value); setSel(0); props.onQuery?.(e.target.value) }}
/>
<div className="wf-palette-list">
{hits.length === 0 && <div className="wf-palette-group">No matches</div>}
{groups.map((g) => (
<div key={g.name}>
<div className="wf-palette-group">{g.name}</div>
{g.items.map(({ item, index }) => (
<button
key={item.id}
type="button"
className={`wf-palette-item${index === sel ? ' sel' : ''}`}
onMouseEnter={() => setSel(index)}
onClick={() => pick(item)}
>
{item.label}
{item.hint !== undefined && <span className="hint">{item.hint}</span>}
</button>
))}
</div>
))}
</div>
<div className="wf-palette-foot">
<span><kbd></kbd> navigate</span>
<span><kbd></kbd> open</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
)
}

@ -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)
})
})
Loading…
Cancel
Save