fix: keep keyboard note switching snappy
This commit is contained in:
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user