fix: keep palette keyboard selection stable
This commit is contained in:
@@ -278,6 +278,28 @@ describe('CommandPalette', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps keyboard selection stable when the mouse is already over a row', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Commit & Push'))
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(commands[1].execute).toHaveBeenCalledOnce()
|
||||
expect(commands[2].execute).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets real mouse movement take over command selection', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.mouseMove(screen.getByText('Commit & Push'))
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(commands[2].execute).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => {
|
||||
const changeNoteType = makeCommand({
|
||||
id: 'change-note-type',
|
||||
|
||||
@@ -484,7 +484,7 @@ function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) {
|
||||
selected ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
onMouseMove={onHover}
|
||||
>
|
||||
<span className="text-sm text-foreground">{command.label}</span>
|
||||
{command.shortcut && (
|
||||
|
||||
@@ -33,6 +33,34 @@ describe('NoteSearchList', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const onItemHover = vi.fn()
|
||||
|
||||
const renderList = ({
|
||||
listItems = items,
|
||||
selectedIndex = 0,
|
||||
getItemKey = (item: TestItem) => item.id,
|
||||
itemClick = onItemClick,
|
||||
itemHover,
|
||||
activateOnMouseDown,
|
||||
emptyMessage,
|
||||
}: {
|
||||
listItems?: TestItem[]
|
||||
selectedIndex?: number
|
||||
getItemKey?: (item: TestItem, index: number) => string
|
||||
itemClick?: (item: TestItem, index: number) => void
|
||||
itemHover?: (index: number) => void
|
||||
activateOnMouseDown?: boolean
|
||||
emptyMessage?: string
|
||||
} = {}) => render(
|
||||
<NoteSearchList
|
||||
items={listItems}
|
||||
selectedIndex={selectedIndex}
|
||||
getItemKey={getItemKey}
|
||||
onItemClick={itemClick}
|
||||
onItemHover={itemHover}
|
||||
activateOnMouseDown={activateOnMouseDown}
|
||||
emptyMessage={emptyMessage}
|
||||
/>,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -112,39 +140,17 @@ describe('NoteSearchList', () => {
|
||||
})
|
||||
|
||||
it('shows empty message when no items', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[]}
|
||||
selectedIndex={0}
|
||||
getItemKey={() => ''}
|
||||
onItemClick={onItemClick}
|
||||
emptyMessage="No matching notes"
|
||||
/>,
|
||||
)
|
||||
renderList({ listItems: [], getItemKey: () => '', emptyMessage: 'No matching notes' })
|
||||
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows default empty message', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[]}
|
||||
selectedIndex={0}
|
||||
getItemKey={() => ''}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
renderList({ listItems: [], getItemKey: () => '' })
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onItemClick when an item is clicked', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
renderList()
|
||||
fireEvent.click(screen.getByText('Beta Notes'))
|
||||
expect(onItemClick).toHaveBeenCalledWith(items[1], 1)
|
||||
})
|
||||
@@ -184,17 +190,15 @@ describe('NoteSearchList', () => {
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onItemHover when mouse enters an item', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
onItemHover={onItemHover}
|
||||
/>,
|
||||
)
|
||||
it('does not call onItemHover for mouse enter alone', () => {
|
||||
renderList({ itemHover: onItemHover })
|
||||
fireEvent.mouseEnter(screen.getByText('Gamma Experiment'))
|
||||
expect(onItemHover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onItemHover when the mouse actually moves over an item', () => {
|
||||
renderList({ itemHover: onItemHover })
|
||||
fireEvent.mouseMove(screen.getByText('Gamma Experiment'))
|
||||
expect(onItemHover).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,6 +36,47 @@ interface NoteSearchListItemProps<T extends NoteSearchResultItem> {
|
||||
activateOnMouseDown?: boolean
|
||||
}
|
||||
|
||||
type SearchItemPressEvent = MouseEvent<HTMLButtonElement> | PointerEvent<HTMLButtonElement>
|
||||
|
||||
function useSearchItemActivation<T extends NoteSearchResultItem>({
|
||||
item,
|
||||
index,
|
||||
onItemClick,
|
||||
activateOnMouseDown,
|
||||
}: Pick<NoteSearchListItemProps<T>, 'item' | 'index' | 'onItemClick' | 'activateOnMouseDown'>) {
|
||||
const pressActivatedRef = useRef(false)
|
||||
|
||||
const activateItem = () => onItemClick(item, index)
|
||||
|
||||
const clearPressActivation = () => {
|
||||
pressActivatedRef.current = false
|
||||
}
|
||||
|
||||
const activateFromPress = (event: SearchItemPressEvent) => {
|
||||
event.preventDefault()
|
||||
if (!activateOnMouseDown) return
|
||||
|
||||
event.stopPropagation()
|
||||
if (pressActivatedRef.current) return
|
||||
|
||||
pressActivatedRef.current = true
|
||||
window.setTimeout(clearPressActivation, 0)
|
||||
activateItem()
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!activateOnMouseDown) {
|
||||
activateItem()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
return { activateFromPress, handleClick }
|
||||
}
|
||||
|
||||
function NoteSearchListItem<T extends NoteSearchResultItem>({
|
||||
item,
|
||||
index,
|
||||
@@ -44,31 +85,12 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
|
||||
onItemHover,
|
||||
activateOnMouseDown,
|
||||
}: NoteSearchListItemProps<T>) {
|
||||
const pressActivatedRef = useRef(false)
|
||||
|
||||
const activateFromPress = (event: MouseEvent<HTMLButtonElement> | PointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
if (!activateOnMouseDown) return
|
||||
|
||||
event.stopPropagation()
|
||||
if (pressActivatedRef.current) return
|
||||
|
||||
pressActivatedRef.current = true
|
||||
window.setTimeout(() => {
|
||||
pressActivatedRef.current = false
|
||||
}, 0)
|
||||
onItemClick(item, index)
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (activateOnMouseDown) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
onItemClick(item, index)
|
||||
}
|
||||
const { activateFromPress, handleClick } = useSearchItemActivation({
|
||||
item,
|
||||
index,
|
||||
onItemClick,
|
||||
activateOnMouseDown,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -83,7 +105,7 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
|
||||
onPointerDownCapture={activateFromPress}
|
||||
onMouseDownCapture={activateFromPress}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => onItemHover?.(index)}
|
||||
onMouseMove={() => onItemHover?.(index)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground">
|
||||
{item.TypeIcon && (
|
||||
|
||||
@@ -161,6 +161,17 @@ describe('QuickOpenPalette', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps keyboard selection stable when the mouse is already over a result', () => {
|
||||
render(<QuickOpenPalette open={true} entries={entries} onSelect={onSelect} onClose={onClose} />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Gamma Experiment'))
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(entries[1])
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not go below the last item with ArrowDown', () => {
|
||||
render(<QuickOpenPalette open={true} entries={entries} onSelect={onSelect} onClose={onClose} />)
|
||||
|
||||
|
||||
@@ -75,6 +75,28 @@ async function dispatchAppCommand(page: Page, id: string): Promise<void> {
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function openQuickOpenFromKeyboard(page: Page): Promise<void> {
|
||||
await dispatchShortcutEvent(page, {
|
||||
key: 'p',
|
||||
code: 'KeyP',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
function selectedQuickOpenTitle(page: Page) {
|
||||
return page.getByTestId('quick-open-palette').locator('div.bg-accent > button:has([data-testid="note-search-item-icon"])').first()
|
||||
}
|
||||
|
||||
function quickOpenTitleAt(page: Page, index: number) {
|
||||
return page.getByTestId('quick-open-palette').locator('button:has([data-testid="note-search-item-icon"])').nth(index)
|
||||
}
|
||||
|
||||
async function installGlobalSearchResultsHarness(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => Boolean(window.__mockHandlers?.search_vault))
|
||||
await page.evaluate((results) => {
|
||||
@@ -134,17 +156,7 @@ test.describe('keyboard command routing', () => {
|
||||
test('desktop shortcut bridge opens quick open through both Cmd+P and Cmd+O @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
|
||||
await dispatchShortcutEvent(page, {
|
||||
key: 'p',
|
||||
code: 'KeyP',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
await openQuickOpenFromKeyboard(page)
|
||||
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
@@ -164,6 +176,31 @@ test.describe('keyboard command routing', () => {
|
||||
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
|
||||
})
|
||||
|
||||
test('quick open ignores a stationary pointer until it moves @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await openQuickOpenFromKeyboard(page)
|
||||
|
||||
const initialTitle = await selectedQuickOpenTitle(page).textContent()
|
||||
const secondTitle = quickOpenTitleAt(page, 1)
|
||||
const targetTitle = await secondTitle.textContent()
|
||||
const secondTitleBox = await secondTitle.boundingBox()
|
||||
if (!initialTitle) throw new Error('Quick open did not select an initial note')
|
||||
if (!targetTitle) throw new Error('Quick open did not render a second note title')
|
||||
if (!secondTitleBox) throw new Error('Quick open did not render a second note box')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByTestId('quick-open-palette')).not.toBeVisible({ timeout: 5_000 })
|
||||
await page.mouse.move(
|
||||
secondTitleBox.x + secondTitleBox.width / 2,
|
||||
secondTitleBox.y + secondTitleBox.height / 2,
|
||||
)
|
||||
|
||||
await openQuickOpenFromKeyboard(page)
|
||||
await expect(selectedQuickOpenTitle(page)).toHaveText(initialTitle)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(selectedQuickOpenTitle(page)).toHaveText(targetTitle)
|
||||
})
|
||||
|
||||
test('global search arrow keys move one result at a time @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await installGlobalSearchResultsHarness(page)
|
||||
|
||||
Reference in New Issue
Block a user