From ff67199e821f6d4cbc48a943e2d3ade332dae81a Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 10 Apr 2026 16:34:58 -0700 Subject: [PATCH] fuzzy target search --- src/components/ComboboxField.tsx | 26 ++++++++++++++++++++------ src/pages/RepoPage.tsx | 1 + 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/ComboboxField.tsx b/src/components/ComboboxField.tsx index d5fe984..9fb0ee1 100644 --- a/src/components/ComboboxField.tsx +++ b/src/components/ComboboxField.tsx @@ -16,16 +16,29 @@ type ComboboxFieldProps = { layout?: 'stacked' | 'inline' /** When set and `value` is non-empty, first row clears selection (`onChange('')`). */ clearSelectionLabel?: string + /** Match by letters/digits only (case-insensitive); ignores spaces and punctuation in query and options. */ + filterNormalize?: boolean +} + +function normalizeForFilter(s: string): string { + return s.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '') } function buildRows( options: readonly string[], filter: string, clearSelectionLabel: string | undefined, - value: string + value: string, + filterNormalize: boolean ): Row[] { - const q = filter.trim().toLowerCase() - const filtered = !q ? [...options] : options.filter(o => o.toLowerCase().includes(q)) + let filtered: readonly string[] + if (filterNormalize) { + const nq = normalizeForFilter(filter) + filtered = !nq ? [...options] : options.filter(o => normalizeForFilter(o).includes(nq)) + } else { + const q = filter.trim().toLowerCase() + filtered = !q ? [...options] : options.filter(o => o.toLowerCase().includes(q)) + } const r: Row[] = [] if (clearSelectionLabel && value) r.push({ kind: 'clear' }) for (const o of filtered) r.push({ kind: 'opt', value: o }) @@ -46,6 +59,7 @@ export function ComboboxField({ placeholder = 'Choose…', layout = 'stacked', clearSelectionLabel, + filterNormalize = false, }: ComboboxFieldProps) { const rid = useId().replace(/:/g, '') const triggerId = id ?? `cb-${rid}` @@ -57,14 +71,14 @@ export function ComboboxField({ const listRef = useRef(null) const rows = useMemo( - () => buildRows(options, filter, clearSelectionLabel, value), - [options, filter, clearSelectionLabel, value] + () => buildRows(options, filter, clearSelectionLabel, value, filterNormalize), + [options, filter, clearSelectionLabel, value, filterNormalize] ) const handleOpenChange = (next: boolean) => { if (next) { setFilter('') - const initial = buildRows(options, '', clearSelectionLabel, value) + const initial = buildRows(options, '', clearSelectionLabel, value, filterNormalize) const idx = initial.findIndex(row => row.kind === 'opt' && row.value === value) setHighlighted(idx >= 0 ? idx : 0) } else { diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 9bf8f7f..8445169 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -581,6 +581,7 @@ export default function RepoPage() { id="mesh-forge-target" options={envNames} value={envDraft} + filterNormalize placeholder="--target--" clearSelectionLabel="Clear target" onChange={v => {