mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user