fix: keep keyboard note switching snappy

This commit is contained in:
lucaronin
2026-04-19 11:49:48 +02:00
parent b22da65d44
commit 2d5361b2e9
4 changed files with 263 additions and 36 deletions

View File

@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'
import { useNoteListKeyboard } from './useNoteListKeyboard'
import type { VaultEntry } from '../types'
@@ -30,9 +30,41 @@ function keyEvent(key: string, opts: Partial<React.KeyboardEvent> = {}): React.K
return { key, preventDefault: vi.fn(), metaKey: false, ctrlKey: false, altKey: false, ...opts } as unknown as React.KeyboardEvent
}
function installAnimationFrameStub() {
let nextId = 1
const callbacks = new Map<number, FrameRequestCallback>()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextId++
callbacks.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
callbacks.delete(id)
})
return {
flushAnimationFrame: () => {
const pending = [...callbacks.entries()]
callbacks.clear()
for (const [, callback] of pending) callback(0)
},
}
}
describe('useNoteListKeyboard', () => {
const items = [makeEntry('/a.md', 'A'), makeEntry('/b.md', 'B'), makeEntry('/c.md', 'C')]
const onOpen = vi.fn()
let flushAnimationFrame: () => void
beforeEach(() => {
vi.clearAllMocks()
;({ flushAnimationFrame } = installAnimationFrameStub())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('initializes with no highlight', () => {
const { result } = renderHook(() =>
@@ -48,27 +80,38 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/a.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[0])
})
it('ArrowDown advances highlight', () => {
it('ArrowDown advances highlight and opens the latest highlighted note on the next frame', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/b.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[1])
})
it('ArrowDown clamps at end of list', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/c.md')
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[2])
})
it('ArrowUp highlights last item from no selection', () => {
@@ -78,6 +121,8 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowUp')))
expect(result.current.highlightedPath).toBe('/c.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[2])
})
@@ -110,7 +155,10 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('Enter')))
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[0])
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
})
it('Enter does nothing when no item highlighted', () => {
@@ -185,6 +233,7 @@ describe('useNoteListKeyboard', () => {
})
expect(result.current.highlightedPath).toBe('/a.md')
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[0])
})
@@ -208,4 +257,23 @@ describe('useNoteListKeyboard', () => {
editor.remove()
})
it('coalesces rapid arrow navigation into a single open for the latest highlighted note', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/c.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[2])
})
})