mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
4fa24ae9bd
The log panel already presents filters as a "Filter by" label followed by a row of buttons which carry their state in the primary variant. The finder had grown its own markup and styles for the same idea, so it now uses the same component and the bespoke styles go. Unlike the log the accessible name stays the field label rather than changing with the state, since aria-pressed already carries that and a name which moves is harder to target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
204 lines
7.2 KiB
TypeScript
204 lines
7.2 KiB
TypeScript
import { EntryId, MaybeString } from 'ontime-types';
|
|
import { KeyboardEvent, PointerEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
|
|
|
|
import Button from '../../../common/components/buttons/Button';
|
|
import Input from '../../../common/components/input/input/Input';
|
|
import Kbd from '../../../common/components/kbd/Kbd';
|
|
import Modal from '../../../common/components/modal/Modal';
|
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
|
import useFinder, { FinderResult } 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: FinderResult | 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) => {
|
|
const isActive = appliedFilter === option.key;
|
|
return (
|
|
<Button
|
|
key={option.key}
|
|
variant={isActive ? 'primary' : 'subtle'}
|
|
size='small'
|
|
aria-pressed={isActive}
|
|
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;
|
|
// the title and cue are already on the row, a match anywhere else needs showing
|
|
const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== '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={getAccessibleColour(entry.colour)}>
|
|
{entry.eventIndex ?? '-'}
|
|
</div>
|
|
<div className={style.cue}>{entry.cue}</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>
|
|
}
|
|
/>
|
|
);
|
|
}
|