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
parent
6dbddd9822
commit
3045b889ff
@ -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…
Reference in New Issue