diff --git a/apps/client/src/features/rundown/placements/FinderPlacement.tsx b/apps/client/src/features/rundown/placements/FinderPlacement.tsx index 65e72547b..e685d08b4 100644 --- a/apps/client/src/features/rundown/placements/FinderPlacement.tsx +++ b/apps/client/src/features/rundown/placements/FinderPlacement.tsx @@ -8,10 +8,8 @@ export default memo(FinderPlacement); function FinderPlacement() { const [isOpen, handler] = useDisclosure(); - useHotkeys([ - ['mod + f', handler.toggle, { preventDefault: true }], - ['Escape', handler.close, { preventDefault: true }], - ]); + // the finder handles its own dismissal: Escape is handled by the dialog, mod + f by the search input + useHotkeys([['mod + f', handler.toggle, { preventDefault: true }]]); if (isOpen) { return ; diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 2c0428e92..0afbe8bdd 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -312,6 +312,7 @@ export default function RundownEvent({ onClick={handleFocusClick} onContextMenu={onContextMenu} data-testid='rundown-event' + data-selected={isSelected} {...(isPlaying ? { 'data-running': true } : {})} > diff --git a/apps/client/src/views/editor/finder/Finder.tsx b/apps/client/src/views/editor/finder/Finder.tsx index a3717b097..744d90027 100644 --- a/apps/client/src/views/editor/finder/Finder.tsx +++ b/apps/client/src/views/editor/finder/Finder.tsx @@ -1,11 +1,11 @@ import { useDebouncedCallback } from '@mantine/hooks'; -import { SupportedEntry } from 'ontime-types'; -import { KeyboardEvent, useState } from 'react'; +import { EntryId, MaybeString, SupportedEntry } from 'ontime-types'; +import { KeyboardEvent, PointerEvent, 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 from './useFinder'; +import useFinder, { FilterableEntry } from './useFinder'; import style from './Finder.module.scss'; @@ -16,43 +16,72 @@ interface FinderProps { export default function Finder({ isOpen, onClose }: FinderProps) { const { find, select, results, error } = useFinder(); - const [selected, setSelected] = useState(0); + const [selectedId, setSelectedId] = useState(null); + + const activeRef = useRef(null); + const lastPointer = useRef({ x: -1, y: -1 }); const debouncedFind = useDebouncedCallback(find, 100); + /** + * 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) => { + /** + * Mantine ignores hotkeys while an input is focused, so the global toggle + * cannot close the finder once the user is typing + */ + if ((event.metaKey || event.ctrlKey) && event.key === 'f') { + event.preventDefault(); + onClose(); + return; + } + // all operations need results if (results.length === 0) { return; } if (event.key === 'ArrowDown') { - setSelected((prev) => (prev + 1) % results.length); + setSelectedId(results[(activeIndex + 1) % results.length].id); } if (event.key === 'ArrowUp') { - setSelected((prev) => (prev - 1 + results.length) % results.length); + setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id); } if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); - submit(); + submit(activeEntry); } }; - const submit = () => { - const selectedEvent = results[selected]; - select(selectedEvent); + const submit = (entry: FilterableEntry | undefined) => { + if (!entry) { + return; + } + select(entry); onClose(); }; - const handleMouseMoveEvent = (event: React.MouseEvent) => { - const target = event.target as HTMLElement; - const li = target.closest('li'); - if (li) { - const index = Number(li.dataset.index); - if (!isNaN(index)) { - setSelected(index); - } + const handlePointerMove = (event: PointerEvent, 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); }; return ( @@ -64,22 +93,24 @@ export default function Finder({ isOpen, onClose }: FinderProps) { bodyElements={
-
    +
      {error &&
    • {error}
    • } {results.length === 0 &&
    • No results
    • } {results.length > 0 && - results.map((entry, index) => { - const isSelected = selected === index; + results.map((entry) => { + const isSelected = activeEntry?.id === entry.id; const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-'; const displayCue = 'cue' in entry ? entry.cue : ''; return (
    • submit(entry)} + onPointerMove={(event) => handlePointerMove(event, entry.id)} >
      diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index fdfda8a2f..0ee047bff 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -35,7 +35,7 @@ type FilterableMilestone = { parent: MaybeString; }; -type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; +export type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; export default function useFinder() { const { data, rundownId } = useFlatRundown(); @@ -90,10 +90,6 @@ export default function useFinder() { return { results: [], error: 'Invalid index' }; } - if (searchIndex > data.length) { - return { results: [], error: null }; - } - // indexes exposed to the UI are 1-based let eventIndex = 1; const results: FilterableEvent[] = []; diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index 32c38d794..f701b2a3f 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -233,6 +233,92 @@ test('Find in rundown', async ({ page }) => { await expect(page.getByPlaceholder('Search...')).toBeHidden(); }); +test('Close finder with the search shortcut', async ({ page }) => { + await page.goto('/rundown'); + await expect(page.getByTestId('panel-rundown')).toBeVisible(); + + await page.keyboard.press('ControlOrMeta+f'); + await expect(page.getByPlaceholder('Search...')).toBeVisible(); + + // the shortcut has to close the finder while the caret is in the search field + await page.getByPlaceholder('Search...').press('ControlOrMeta+f'); + await expect(page.getByPlaceholder('Search...')).toBeHidden(); +}); + +test('Finder navigates to the clicked result', async ({ page }) => { + await page.goto('/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); + + // 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); + + // create three events which all match the same search + await page.getByRole('button', { name: 'Create Event' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); + await page.getByRole('button', { name: 'Event' }).nth(4).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(2); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(3); + + await page.getByTestId('entry-1').getByTestId('entry__title').fill('finder one'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + await page.getByTestId('entry-2').getByTestId('entry__title').fill('finder two'); + await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter'); + await page.getByTestId('entry-3').getByTestId('entry__title').fill('finder three'); + await page.getByTestId('entry-3').getByTestId('entry__title').press('Enter'); + + await page.keyboard.press('ControlOrMeta+f'); + await page.getByPlaceholder('Search...').fill('finder'); + await expect(page.getByTestId('finder-result')).toHaveCount(3); + + /** + * Dispatch the click without moving the pointer first, which is what a touch device does. + * The finder used to submit whichever row was highlighted rather than the one being clicked. + */ + await page.getByTestId('finder-result').nth(2).dispatchEvent('click'); + + await expect(page.getByPlaceholder('Search...')).toBeHidden(); + await expect(page.getByTestId('entry-3').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true'); + await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'false'); +}); + +test('Finder navigates to the result picked with the keyboard', async ({ page }) => { + await page.goto('/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); + + // 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); + + // create two events which both match the same search + await page.getByRole('button', { name: 'Create Event' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('finder one'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + + await page.getByRole('button', { name: 'Event' }).nth(4).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(2); + await page.getByTestId('entry-2').getByTestId('entry__title').fill('finder two'); + await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter'); + + await page.keyboard.press('ControlOrMeta+f'); + await page.getByPlaceholder('Search...').fill('finder'); + await expect(page.getByTestId('finder-result')).toHaveCount(2); + + // the first result is highlighted on open, so one step down lands on the second + await page.getByPlaceholder('Search...').press('ArrowDown'); + await expect(page.getByTestId('finder-result').nth(1)).toHaveAttribute('data-selected', 'true'); + + await page.getByPlaceholder('Search...').press('Enter'); + await expect(page.getByPlaceholder('Search...')).toBeHidden(); + await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true'); +}); + test('Open settings', async ({ page }) => { await page.goto('/editor'); await expect(page.getByTestId('editor-container')).toBeVisible();