feat(finder): make filters a toggle and flag hidden results

The filter badges wrote a keyword prefix into the search box, which put
syntax in front of the user for something the badge already expresses.
They are now toggles: the input holds only what is being searched for,
the active badge is highlighted, and pressing it again returns to
searching every field. Typing a keyword still works and lights up the
badge it refers to, so the documented syntax is not lost.

The result list said nothing when it had more matches than it rendered.
It now ends with a count of what is hidden, since the footer total is
easy to miss while scanning results.

Also stops the search shortcut toggling the dialog. Toggling on a key
that mounts and unmounts the dialog is unreliable, and browsers treat a
repeated find shortcut as "focus the search again". It now opens, and
selects the existing query when the finder is already up, leaving
Escape to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
This commit is contained in:
Claude
2026-08-18 16:54:10 +00:00
parent cd09476858
commit 2fc072a9d3
5 changed files with 136 additions and 38 deletions
@@ -10,10 +10,14 @@ function FinderPlacement() {
/** /**
* The empty tagsToIgnore is significant: by default the hook skips input elements, * The empty tagsToIgnore is significant: by default the hook skips input elements,
* which would make the shortcut dead while editing an entry, and while typing in the * which would make the shortcut dead while editing an entry.
* finder itself. Escape is handled by the dialog. *
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/ */
useHotkeys([['mod + f', handler.toggle, { preventDefault: true }]], []); useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
if (isOpen) { if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />; return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -21,6 +21,15 @@
color: $label-gray; color: $label-gray;
} }
.more {
padding-inline: 0.5rem;
padding-block: 0.75rem;
font-size: calc(1rem - 2px);
color: $label-gray;
border-top: 1px solid $gray-1000;
text-align: center;
}
.error { .error {
color: $error-red; color: $error-red;
} }
@@ -57,6 +66,12 @@
outline: 2px solid $blue-700; outline: 2px solid $blue-700;
outline-offset: 1px; outline-offset: 1px;
} }
&[data-active='true'] {
background-color: $blue-700;
border-color: $blue-700;
color: $ui-white;
}
} }
.data { .data {
+25 -10
View File
@@ -4,7 +4,7 @@ import { KeyboardEvent, PointerEvent, useDeferredValue, useEffect, useRef, useSt
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd'; import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import useFinder, { applyFilter, FilterableEntry } from './useFinder'; import useFinder, { FilterableEntry } from './useFinder';
import style from './Finder.module.scss'; import style from './Finder.module.scss';
@@ -15,6 +15,7 @@ interface FinderProps {
export default function Finder({ isOpen, onClose }: FinderProps) { export default function Finder({ isOpen, onClose }: FinderProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filter, setFilter] = useState<MaybeString>(null);
const [selectedId, setSelectedId] = useState<MaybeString>(null); const [selectedId, setSelectedId] = useState<MaybeString>(null);
/** /**
@@ -22,7 +23,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
* The search itself is cheap, rendering the results is what costs. * The search itself is cheap, rendering the results is what costs.
*/ */
const deferredSearch = useDeferredValue(search); const deferredSearch = useDeferredValue(search);
const { select, results, error, total, filters } = useFinder(deferredSearch); const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef<HTMLLIElement>(null); const activeRef = useRef<HTMLLIElement>(null);
@@ -44,6 +45,13 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
}, [activeEntry?.id]); }, [activeEntry?.id]);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => { const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// pressing the search shortcut again selects the query, ready to be replaced
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
inputRef.current?.select();
return;
}
// all operations need results // all operations need results
if (results.length === 0) { if (results.length === 0) {
return; return;
@@ -79,13 +87,13 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
setSelectedId(id); setSelectedId(id);
}; };
/** Scopes the search to a single field, keeping whatever the user already typed */ /** Scopes the search to a single field, or back to all fields when tapped again */
const handleFilter = (filterKey: string) => { const handleFilter = (filterKey: string) => {
setSearch(applyFilter(search, filters, filterKey)); setFilter((previous) => (previous === filterKey ? null : filterKey));
inputRef.current?.focus(); inputRef.current?.focus();
}; };
const hasOverflow = total > results.length; const hiddenResults = total - results.length;
return ( return (
<Modal <Modal
@@ -106,14 +114,16 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
/> />
<div className={style.filters} data-testid='finder-filters'> <div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span> <span className={style.filterLabel}>Filter by</span>
{filters.map((filter) => ( {filters.map((option) => (
<button <button
key={filter.key} key={option.key}
type='button' type='button'
className={style.filterBadge} className={style.filterBadge}
onClick={() => handleFilter(filter.key)} data-active={appliedFilter === option.key}
aria-pressed={appliedFilter === option.key}
onClick={() => handleFilter(option.key)}
> >
{filter.label} {option.label}
</button> </button>
))} ))}
</div> </div>
@@ -154,6 +164,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
</li> </li>
); );
})} })}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
</ul> </ul>
</div> </div>
} }
@@ -176,7 +191,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
</div> </div>
{total > 0 && ( {total > 0 && (
<div className={style.count} data-testid='finder-count'> <div className={style.count} data-testid='finder-count'>
{hasOverflow ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`} {hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
</div> </div>
)} )}
</div> </div>
@@ -117,7 +117,11 @@ function parseQuery(searchValue: string, filters: FinderFilter[]) {
return { filterKey: null, searchString: searchValue }; return { filterKey: null, searchString: searchValue };
} }
export default function useFinder(searchValue: string) { /**
* @param searchValue - the text the user is looking for
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data, rundownId } = useFlatRundown(); const { data, rundownId } = useFlatRundown();
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
@@ -131,8 +135,8 @@ export default function useFinder(searchValue: string) {
return [...staticFilters, ...customFilters]; return [...staticFilters, ...customFilters];
}, [customFields]); }, [customFields]);
const { results, error, total } = useMemo(() => { const { results, error, total, appliedFilter } = useMemo(() => {
const empty = { results: [] as FilterableEntry[], error: null, total: 0 }; const empty = { results: [] as FilterableEntry[], error: null, total: 0, appliedFilter: activeFilter };
if (!data || data.length === 0) { if (!data || data.length === 0) {
return { ...empty, error: 'No data' }; return { ...empty, error: 'No data' };
@@ -143,10 +147,16 @@ export default function useFinder(searchValue: string) {
return empty; return empty;
} }
const { filterKey, searchString } = parseQuery(normalised, filters); /**
* A selected badge wins, but typing a prefix still works for anyone who knows the
* keywords, and lights up the matching badge rather than being silently ignored.
*/
const { filterKey, searchString } = activeFilter
? { filterKey: activeFilter, searchString: normalised }
: parseQuery(normalised, filters);
if (filterKey === indexFilter) { if (filterKey === indexFilter) {
return searchByIndex(searchString); return { ...searchByIndex(searchString), appliedFilter: filterKey };
} }
if (searchString === '') { if (searchString === '') {
@@ -154,7 +164,7 @@ export default function useFinder(searchValue: string) {
return empty; return empty;
} }
return searchByField(filterKey, searchString); return { ...searchByField(filterKey, searchString), appliedFilter: filterKey };
/** Returns the single event at a given 1-based index */ /** Returns the single event at a given 1-based index */
function searchByIndex(indexString: string) { function searchByIndex(indexString: string) {
@@ -276,7 +286,7 @@ export default function useFinder(searchValue: string) {
return { results, error: null, total }; return { results, error: null, total };
} }
}, [data, customFields, filters, searchValue]); }, [data, customFields, filters, searchValue, activeFilter]);
const select = useCallback( const select = useCallback(
(selectedEvent: FilterableEntry) => { (selectedEvent: FilterableEntry) => {
@@ -289,11 +299,5 @@ export default function useFinder(searchValue: string) {
[selectAndRevealEntry], [selectAndRevealEntry],
); );
return { select, results, error, total, filters }; return { select, results, error, total, filters, appliedFilter };
}
/** Replaces any filter prefix in the current value, keeping whatever the user already typed */
export function applyFilter(currentValue: string, filters: FinderFilter[], filterKey: string): string {
const { searchString } = parseQuery(currentValue.trim().toLowerCase(), filters);
return searchString ? `${filterKey} ${searchString}` : `${filterKey} `;
} }
@@ -233,17 +233,34 @@ test('Find in rundown', async ({ page }) => {
await expect(page.getByPlaceholder('Search...')).toBeHidden(); await expect(page.getByPlaceholder('Search...')).toBeHidden();
}); });
test('Close finder with the search shortcut', async ({ page }) => { test('Search shortcut reaches the finder from a focused field', async ({ page }) => {
await page.goto('/rundown'); await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByTestId('panel-rundown')).toBeVisible(); await expect(page.getByTestId('panel-rundown')).toBeVisible();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
// the caret starts inside an entry title, where the shortcut used to be swallowed
await page.getByTestId('entry-1').getByTestId('entry__title').click();
await page.keyboard.press('ControlOrMeta+f'); await page.keyboard.press('ControlOrMeta+f');
// waiting for focus rather than visibility, so the dialog has finished opening
// before the next keystroke is sent
await expect(page.getByPlaceholder('Search...')).toBeFocused(); await expect(page.getByPlaceholder('Search...')).toBeFocused();
// the shortcut has to close the finder while the caret is in the search field // pressing it again selects the query rather than closing, so it can be replaced
await page.getByPlaceholder('Search...').fill('something');
await page.getByPlaceholder('Search...').press('ControlOrMeta+f'); await page.getByPlaceholder('Search...').press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeFocused();
await page.getByPlaceholder('Search...').pressSequentially('new');
await expect(page.getByPlaceholder('Search...')).toHaveValue('new');
// escape is what closes it
await page.getByPlaceholder('Search...').press('Escape');
await expect(page.getByPlaceholder('Search...')).toBeHidden(); await expect(page.getByPlaceholder('Search...')).toBeHidden();
}); });
@@ -414,17 +431,30 @@ test('Finder filter badges scope the search', async ({ page }) => {
await page.getByPlaceholder('Search...').fill('sound'); await page.getByPlaceholder('Search...').fill('sound');
await expect(page.getByTestId('finder-result')).toHaveCount(1); await expect(page.getByTestId('finder-result')).toHaveCount(1);
// the badge scopes the search without cluttering the input with a prefix
const filters = page.getByTestId('finder-filters'); const filters = page.getByTestId('finder-filters');
await filters.getByRole('button', { name: 'Note', exact: true }).click(); const noteBadge = filters.getByRole('button', { name: 'Note', exact: true });
await expect(page.getByPlaceholder('Search...')).toHaveValue('note sound'); await noteBadge.click();
await expect(page.getByPlaceholder('Search...')).toHaveValue('sound');
await expect(noteBadge).toHaveAttribute('data-active', 'true');
await expect(page.getByTestId('finder-result')).toHaveCount(0); await expect(page.getByTestId('finder-result')).toHaveCount(0);
// switching to another badge replaces the filter rather than stacking // switching to another badge replaces the filter rather than stacking
await filters.getByRole('button', { name: 'Title', exact: true }).click(); const titleBadge = filters.getByRole('button', { name: 'Title', exact: true });
await expect(page.getByPlaceholder('Search...')).toHaveValue('title sound'); await titleBadge.click();
await expect(titleBadge).toHaveAttribute('data-active', 'true');
await expect(noteBadge).toHaveAttribute('data-active', 'false');
await expect(page.getByTestId('finder-result')).toHaveCount(1); await expect(page.getByTestId('finder-result')).toHaveCount(1);
await expect(page.getByTestId('finder-count')).toContainText('1 result'); await expect(page.getByTestId('finder-count')).toContainText('1 result');
// tapping the active badge again clears the filter
await titleBadge.click();
await expect(titleBadge).toHaveAttribute('data-active', 'false');
// typing a keyword still works, and lights up the badge it refers to
await page.getByPlaceholder('Search...').fill('note sound');
await expect(noteBadge).toHaveAttribute('data-active', 'true');
// custom fields defined by the project are offered as filters too // custom fields defined by the project are offered as filters too
await expect(filters.getByRole('button', { name: 'Song', exact: true })).toBeVisible(); await expect(filters.getByRole('button', { name: 'Song', exact: true })).toBeVisible();
await expect(filters.getByRole('button', { name: 'Artist', exact: true })).toBeVisible(); await expect(filters.getByRole('button', { name: 'Artist', exact: true })).toBeVisible();
@@ -458,10 +488,40 @@ test('Finder searches custom fields', async ({ page }) => {
await expect(page.getByTestId('finder-result')).toHaveCount(1); await expect(page.getByTestId('finder-result')).toHaveCount(1);
await expect(page.getByTestId('finder-result-match')).toContainText('Artist'); await expect(page.getByTestId('finder-result-match')).toContainText('Artist');
// and the field can be scoped explicitly // and the field can be scoped explicitly from its badge
await page.getByTestId('finder-filters').getByRole('button', { name: 'Artist', exact: true }).click(); const artistBadge = page.getByTestId('finder-filters').getByRole('button', { name: 'Artist', exact: true });
await expect(page.getByPlaceholder('Search...')).toHaveValue('Artist zebrafish'); await artistBadge.click();
await expect(artistBadge).toHaveAttribute('data-active', 'true');
await expect(page.getByPlaceholder('Search...')).toHaveValue('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(1); await expect(page.getByTestId('finder-result')).toHaveCount(1);
// scoping to a different custom field excludes it
await page.getByTestId('finder-filters').getByRole('button', { name: 'Song', exact: true }).click();
await expect(page.getByTestId('finder-result')).toHaveCount(0);
});
test('Finder reports results it could not show', async ({ page }) => {
await page.goto('/rundown');
await expect(page.getByTestId('panel-rundown')).toBeVisible();
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeFocused();
// the demo rundown holds far more entries than the finder renders at once
await page.getByPlaceholder('Search...').fill('e');
const shown = await page.getByTestId('finder-result').count();
const countText = await page.getByTestId('finder-count').textContent();
if (countText?.startsWith('Showing')) {
// the list itself has to say so, not only the footer
await expect(page.getByTestId('finder-more')).toBeVisible();
await expect(page.getByTestId('finder-more')).toContainText('more result');
expect(countText).toContain(`Showing ${shown} of`);
} else {
// everything fit, so there is nothing to announce
await expect(page.getByTestId('finder-more')).toHaveCount(0);
}
}); });
test('Open settings', async ({ page }) => { test('Open settings', async ({ page }) => {