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,
* which would make the shortcut dead while editing an entry, and while typing in the
* finder itself. Escape is handled by the dialog.
* which would make the shortcut dead while editing an entry.
*
* 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) {
return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -21,6 +21,15 @@
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 {
color: $error-red;
}
@@ -57,6 +66,12 @@
outline: 2px solid $blue-700;
outline-offset: 1px;
}
&[data-active='true'] {
background-color: $blue-700;
border-color: $blue-700;
color: $ui-white;
}
}
.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 Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal';
import useFinder, { applyFilter, FilterableEntry } from './useFinder';
import useFinder, { FilterableEntry } from './useFinder';
import style from './Finder.module.scss';
@@ -15,6 +15,7 @@ interface FinderProps {
export default function Finder({ isOpen, onClose }: FinderProps) {
const [search, setSearch] = useState('');
const [filter, setFilter] = 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.
*/
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 activeRef = useRef<HTMLLIElement>(null);
@@ -44,6 +45,13 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
}, [activeEntry?.id]);
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
if (results.length === 0) {
return;
@@ -79,13 +87,13 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
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) => {
setSearch(applyFilter(search, filters, filterKey));
setFilter((previous) => (previous === filterKey ? null : filterKey));
inputRef.current?.focus();
};
const hasOverflow = total > results.length;
const hiddenResults = total - results.length;
return (
<Modal
@@ -106,14 +114,16 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
/>
<div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span>
{filters.map((filter) => (
{filters.map((option) => (
<button
key={filter.key}
key={option.key}
type='button'
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>
))}
</div>
@@ -154,6 +164,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
</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>
</div>
}
@@ -176,7 +191,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
</div>
{total > 0 && (
<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>
@@ -117,7 +117,11 @@ function parseQuery(searchValue: string, filters: FinderFilter[]) {
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: customFields } = useCustomFields();
@@ -131,8 +135,8 @@ export default function useFinder(searchValue: string) {
return [...staticFilters, ...customFilters];
}, [customFields]);
const { results, error, total } = useMemo(() => {
const empty = { results: [] as FilterableEntry[], error: null, total: 0 };
const { results, error, total, appliedFilter } = useMemo(() => {
const empty = { results: [] as FilterableEntry[], error: null, total: 0, appliedFilter: activeFilter };
if (!data || data.length === 0) {
return { ...empty, error: 'No data' };
@@ -143,10 +147,16 @@ export default function useFinder(searchValue: string) {
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) {
return searchByIndex(searchString);
return { ...searchByIndex(searchString), appliedFilter: filterKey };
}
if (searchString === '') {
@@ -154,7 +164,7 @@ export default function useFinder(searchValue: string) {
return empty;
}
return searchByField(filterKey, searchString);
return { ...searchByField(filterKey, searchString), appliedFilter: filterKey };
/** Returns the single event at a given 1-based index */
function searchByIndex(indexString: string) {
@@ -276,7 +286,7 @@ export default function useFinder(searchValue: string) {
return { results, error: null, total };
}
}, [data, customFields, filters, searchValue]);
}, [data, customFields, filters, searchValue, activeFilter]);
const select = useCallback(
(selectedEvent: FilterableEntry) => {
@@ -289,11 +299,5 @@ export default function useFinder(searchValue: string) {
[selectAndRevealEntry],
);
return { select, results, error, total, filters };
}
/** 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} `;
return { select, results, error, total, filters, appliedFilter };
}