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