Skip to content

Commit ae6bec8

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): typeahead in filter dropdowns to search a filter's values (#520) (#521)
* feat(spa): typeahead in filter dropdowns to search a filter's values (#520) A list_filter dropdown with many options (long choices, or an FK/related filter with lots of lookups) was slow to scan with no way to type to a value. Add a typeahead box to the top of the options popover, shown when a filter has enough options that scanning is slow (>= 8) — a 2-3 option boolean doesn't get one, which would just be redundant chrome. Typing narrows to options whose label contains the query (only valid matches stay selectable); the first match is pre-highlighted so Enter accepts it; ArrowUp/Down move the highlight, Tab/Shift+Tab cycle through the matches, Enter selects; a "No matches" hint shows when nothing matches. Date filters keep their date input unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(search): drop removed onClearAll prop from typeahead test setup FilterBar's onClearAll prop was removed upstream while this branch was in flight; the rebase carried it into the new test helper. Remove it so the package typechecks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40f621a commit ae6bec8

2 files changed

Lines changed: 177 additions & 37 deletions

File tree

frontend/packages/search/src/FilterBar.test.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,79 @@ describe('FilterBar', () => {
7575
expect(onSearchChange).toHaveBeenCalledWith('abc');
7676
});
7777

78+
it('does NOT show a typeahead box for a small filter', () => {
79+
setup();
80+
fireEvent.click(screen.getByRole('button', { name: /Status/ }));
81+
expect(screen.queryByPlaceholderText('Type to filter…')).not.toBeInTheDocument();
82+
});
83+
});
84+
85+
// A filter with enough options to earn the typeahead box (>= 8).
86+
const bigFilters: FilterDescriptor[] = [
87+
{
88+
name: 'kind',
89+
label: 'Kind',
90+
type: 'choice',
91+
choices: [
92+
{ value: 'alpha', label: 'Alpha' },
93+
{ value: 'beta', label: 'Beta' },
94+
{ value: 'gamma', label: 'Gamma' },
95+
{ value: 'delta', label: 'Delta' },
96+
{ value: 'epsilon', label: 'Epsilon' },
97+
{ value: 'zeta', label: 'Zeta' },
98+
{ value: 'elig', label: 'Eligible' },
99+
{ value: 'inelig', label: 'Ineligible' },
100+
],
101+
},
102+
];
103+
104+
function setupBig() {
105+
const onFilterChange = vi.fn();
106+
render(
107+
<FilterBar
108+
searchValue=""
109+
onSearchChange={() => {}}
110+
filters={bigFilters}
111+
active={{}}
112+
onFilterChange={onFilterChange}
113+
/>,
114+
);
115+
fireEvent.click(screen.getByRole('button', { name: /Kind/ }));
116+
return { onFilterChange, input: screen.getByPlaceholderText('Type to filter…') };
117+
}
118+
119+
describe('FilterBar typeahead (large filter)', () => {
120+
it('shows the typeahead box and narrows to matching options only', () => {
121+
const { input } = setupBig();
122+
expect(input).toBeInTheDocument();
123+
fireEvent.change(input, { target: { value: 'elig' } });
124+
// Only the two matches survive; non-matches are gone.
125+
expect(screen.getByText('Eligible')).toBeInTheDocument();
126+
expect(screen.getByText('Ineligible')).toBeInTheDocument();
127+
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
128+
});
129+
130+
it('Enter selects the first match after typing', () => {
131+
const { onFilterChange, input } = setupBig();
132+
fireEvent.change(input, { target: { value: 'elig' } });
133+
fireEvent.keyDown(input, { key: 'Enter' });
134+
expect(onFilterChange).toHaveBeenCalledWith('kind', 'elig'); // Eligible
135+
});
136+
137+
it('Tab advances to the next valid match, then Enter selects it', () => {
138+
const { onFilterChange, input } = setupBig();
139+
fireEvent.change(input, { target: { value: 'elig' } });
140+
fireEvent.keyDown(input, { key: 'Tab' }); // first match → next match
141+
fireEvent.keyDown(input, { key: 'Enter' });
142+
expect(onFilterChange).toHaveBeenCalledWith('kind', 'inelig'); // Ineligible
143+
});
144+
145+
it('shows a "No matches" hint when nothing matches', () => {
146+
const { input } = setupBig();
147+
fireEvent.change(input, { target: { value: 'zzzzz' } });
148+
expect(screen.getByText('No matches.')).toBeInTheDocument();
149+
});
150+
78151
it('renders `leading` content before the search input (to its left)', () => {
79152
render(
80153
<FilterBar

frontend/packages/search/src/FilterBar.tsx

Lines changed: 104 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@
99
// trailing slot is pinned right (the page composes it so the row ends in
1010
// "Clear all" then "Customize").
1111

12-
import { useState, type ReactNode } from 'react';
12+
import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react';
1313
import { Check, ChevronDown } from 'lucide-react';
1414

1515
import type { FilterDescriptor, FilterOption } from '@dar/data';
1616
import { Input, Popover } from '@dar/ui';
1717

18+
// Above this many options a dropdown gets a typeahead box — below it,
19+
// scanning is faster than typing and a search field would just be
20+
// redundant chrome (CLAUDE.md §7).
21+
const FILTER_SEARCH_THRESHOLD = 8;
22+
1823
export interface FilterBarProps {
1924
/** Show the search input (e.g. the model has `search_fields`). */
2025
showSearch?: boolean;
@@ -160,43 +165,105 @@ function FilterDropdown({ filter, value, onChange }: FilterDropdownProps) {
160165
) : null}
161166
</div>
162167
) : (
163-
<ul className="max-h-72 overflow-auto py-1">
164-
<li>
165-
<button
166-
type="button"
167-
onClick={() => select('')}
168-
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-100 ${
169-
value ? 'text-gray-700' : 'font-medium text-primary'
170-
}`}
171-
>
172-
{value ? <span className="w-3.5 shrink-0" /> : <Check className="h-3.5 w-3.5 shrink-0" aria-hidden />}
173-
All
174-
</button>
175-
</li>
176-
{opts.map((o) => {
177-
const v = String(o.value);
178-
const selected = v === value;
179-
return (
180-
<li key={v}>
181-
<button
182-
type="button"
183-
onClick={() => select(v)}
184-
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-100 ${
185-
selected ? 'font-medium text-primary' : 'text-gray-700'
186-
}`}
187-
>
188-
{selected ? (
189-
<Check className="h-3.5 w-3.5 shrink-0" aria-hidden />
190-
) : (
191-
<span className="w-3.5 shrink-0" />
192-
)}
193-
<span className="truncate">{o.label}</span>
194-
</button>
195-
</li>
196-
);
197-
})}
198-
</ul>
168+
<FilterOptions filterLabel={filter.label} opts={opts} value={value} onSelect={select} />
199169
)}
200170
</Popover>
201171
);
202172
}
173+
174+
interface FilterOptionsProps {
175+
filterLabel: string;
176+
opts: FilterOption[];
177+
value: string;
178+
onSelect: (value: string) => void;
179+
}
180+
181+
// The option list inside a filter dropdown. With enough options it gets a
182+
// typeahead box: typing narrows to the matching options (only valid
183+
// matches stay selectable), and ↑/↓ + Enter or Tab move through them so
184+
// the operator can search the filter's values quickly without the mouse.
185+
function FilterOptions({ filterLabel, opts, value, onSelect }: FilterOptionsProps) {
186+
const showSearch = opts.length >= FILTER_SEARCH_THRESHOLD;
187+
const [query, setQuery] = useState('');
188+
const norm = query.trim().toLowerCase();
189+
const matches = norm ? opts.filter((o) => o.label.toLowerCase().includes(norm)) : opts;
190+
// Navigable list: "All" (clear) first, then the matching options.
191+
const items: FilterOption[] = [{ value: '', label: 'All' }, ...matches];
192+
const [highlight, setHighlight] = useState(0);
193+
194+
// When a query is active, pre-highlight the first real match (so Enter
195+
// accepts it); with no query, rest on "All". Re-runs as the list narrows.
196+
useEffect(() => {
197+
setHighlight(norm && matches.length > 0 ? 1 : 0);
198+
}, [norm, matches.length]);
199+
200+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
201+
if (items.length === 0) return;
202+
if (e.key === 'ArrowDown') {
203+
e.preventDefault();
204+
setHighlight((h) => Math.min(h + 1, items.length - 1));
205+
} else if (e.key === 'ArrowUp') {
206+
e.preventDefault();
207+
setHighlight((h) => Math.max(h - 1, 0));
208+
} else if (e.key === 'Enter') {
209+
e.preventDefault();
210+
const it = items[highlight];
211+
if (it) onSelect(String(it.value));
212+
} else if (e.key === 'Tab') {
213+
// Tab → next valid match (Shift+Tab → previous), wrapping. Trapped
214+
// here so the operator can scan matches without leaving the field.
215+
e.preventDefault();
216+
setHighlight((h) => (h + (e.shiftKey ? items.length - 1 : 1)) % items.length);
217+
}
218+
};
219+
220+
return (
221+
<div>
222+
{showSearch ? (
223+
<div className="border-b border-gray-200 p-2">
224+
<input
225+
type="text"
226+
// The popover just opened expressly to filter, so focusing the
227+
// box is the point — the operator can type immediately.
228+
autoFocus
229+
value={query}
230+
onChange={(e) => setQuery(e.target.value)}
231+
onKeyDown={onKeyDown}
232+
placeholder="Type to filter…"
233+
aria-label={`Filter ${filterLabel} options`}
234+
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
235+
/>
236+
</div>
237+
) : null}
238+
<ul className="max-h-72 overflow-auto py-1">
239+
{items.map((it, i) => {
240+
const v = String(it.value);
241+
const isAll = v === '';
242+
const selected = isAll ? value === '' : v === value;
243+
const active = showSearch && i === highlight;
244+
return (
245+
<li key={isAll ? '__all__' : v}>
246+
<button
247+
type="button"
248+
onClick={() => onSelect(v)}
249+
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-100 ${
250+
active ? 'bg-gray-100' : ''
251+
} ${selected ? 'font-medium text-primary' : 'text-gray-700'}`}
252+
>
253+
{selected ? (
254+
<Check className="h-3.5 w-3.5 shrink-0" aria-hidden />
255+
) : (
256+
<span className="w-3.5 shrink-0" />
257+
)}
258+
<span className="truncate">{it.label}</span>
259+
</button>
260+
</li>
261+
);
262+
})}
263+
{showSearch && matches.length === 0 ? (
264+
<li className="px-3 py-2 text-sm text-gray-400">No matches.</li>
265+
) : null}
266+
</ul>
267+
</div>
268+
);
269+
}

0 commit comments

Comments
 (0)