mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 22:24:11 +00:00
2fc072a9d3
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
202 lines
7.2 KiB
TypeScript
202 lines
7.2 KiB
TypeScript
import { EntryId, MaybeString, SupportedEntry } from 'ontime-types';
|
|
import { KeyboardEvent, PointerEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
|
|
|
|
import Input from '../../../common/components/input/input/Input';
|
|
import Kbd from '../../../common/components/kbd/Kbd';
|
|
import Modal from '../../../common/components/modal/Modal';
|
|
import useFinder, { FilterableEntry } from './useFinder';
|
|
|
|
import style from './Finder.module.scss';
|
|
|
|
interface FinderProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function Finder({ isOpen, onClose }: FinderProps) {
|
|
const [search, setSearch] = useState('');
|
|
const [filter, setFilter] = useState<MaybeString>(null);
|
|
const [selectedId, setSelectedId] = useState<MaybeString>(null);
|
|
|
|
/**
|
|
* Keeps typing responsive while the list re-renders.
|
|
* The search itself is cheap, rendering the results is what costs.
|
|
*/
|
|
const deferredSearch = useDeferredValue(search);
|
|
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const activeRef = useRef<HTMLLIElement>(null);
|
|
const lastPointer = useRef({ x: -1, y: -1 });
|
|
|
|
/**
|
|
* We track the selection by ID so that it survives the result list changing under us:
|
|
* an entry that no longer exists falls back to the first result instead of dangling past the end
|
|
*/
|
|
const activeIndex = Math.max(
|
|
0,
|
|
results.findIndex((entry) => entry.id === selectedId),
|
|
);
|
|
const activeEntry = results.at(activeIndex);
|
|
|
|
/** keep the highlighted entry in view while navigating with the keyboard */
|
|
useEffect(() => {
|
|
activeRef.current?.scrollIntoView({ block: 'nearest' });
|
|
}, [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;
|
|
}
|
|
if (event.key === 'ArrowDown') {
|
|
setSelectedId(results[(activeIndex + 1) % results.length].id);
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
|
|
}
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
submit(activeEntry);
|
|
}
|
|
};
|
|
|
|
const submit = (entry: FilterableEntry | undefined) => {
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
select(entry);
|
|
onClose();
|
|
};
|
|
|
|
const handlePointerMove = (event: PointerEvent<HTMLLIElement>, id: EntryId) => {
|
|
// scrolling the list under a stationary cursor also fires a move event, which would
|
|
// pull the selection away from wherever the keyboard navigation left it
|
|
if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y) {
|
|
return;
|
|
}
|
|
lastPointer.current = { x: event.clientX, y: event.clientY };
|
|
setSelectedId(id);
|
|
};
|
|
|
|
/** Scopes the search to a single field, or back to all fields when tapped again */
|
|
const handleFilter = (filterKey: string) => {
|
|
setFilter((previous) => (previous === filterKey ? null : filterKey));
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const hiddenResults = total - results.length;
|
|
|
|
return (
|
|
<Modal
|
|
title=''
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
showBackdrop
|
|
bodyElements={
|
|
<div onKeyDown={navigate}>
|
|
<Input
|
|
ref={inputRef}
|
|
height='large'
|
|
fluid
|
|
autoFocus
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder='Search...'
|
|
/>
|
|
<div className={style.filters} data-testid='finder-filters'>
|
|
<span className={style.filterLabel}>Filter by</span>
|
|
{filters.map((option) => (
|
|
<button
|
|
key={option.key}
|
|
type='button'
|
|
className={style.filterBadge}
|
|
data-active={appliedFilter === option.key}
|
|
aria-pressed={appliedFilter === option.key}
|
|
onClick={() => handleFilter(option.key)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<ul className={style.scrollContainer}>
|
|
{error && <li className={style.error}>{error}</li>}
|
|
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
|
|
{results.map((entry) => {
|
|
const isSelected = activeEntry?.id === entry.id;
|
|
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
|
|
const displayCue = 'cue' in entry ? entry.cue : '';
|
|
// the title and cue are already on the row, anything else needs showing
|
|
const showMatch = entry.match !== null && entry.match.label !== 'Title' && entry.match.label !== 'Cue';
|
|
|
|
return (
|
|
<li
|
|
key={entry.id}
|
|
ref={isSelected ? activeRef : undefined}
|
|
className={style.entry}
|
|
data-testid='finder-result'
|
|
data-selected={isSelected}
|
|
onClick={() => submit(entry)}
|
|
onPointerMove={(event) => handlePointerMove(event, entry.id)}
|
|
>
|
|
<div className={style.data}>
|
|
<div className={style.index} style={{ '--color': entry.colour }}>
|
|
{displayIndex}
|
|
</div>
|
|
<div className={style.cue}>{displayCue}</div>
|
|
<div className={style.title}>{entry.title}</div>
|
|
{showMatch && (
|
|
<div className={style.match} data-testid='finder-result-match'>
|
|
<span className={style.matchLabel}>{entry.match?.label}</span>
|
|
{entry.match?.excerpt}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isSelected && <span className={style.go}>Go ⏎</span>}
|
|
</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>
|
|
}
|
|
footerElements={
|
|
<div className={style.footer}>
|
|
<div className={style.hints}>
|
|
<span className={style.hintItem}>
|
|
<Kbd>↑</Kbd>
|
|
<Kbd>↓</Kbd>
|
|
Navigate
|
|
</span>
|
|
<span className={style.hintItem}>
|
|
<Kbd>Enter</Kbd>
|
|
Go
|
|
</span>
|
|
<span className={style.hintItem}>
|
|
<Kbd>Esc</Kbd>
|
|
Close
|
|
</span>
|
|
</div>
|
|
{total > 0 && (
|
|
<div className={style.count} data-testid='finder-count'>
|
|
{hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
);
|
|
}
|