feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter (#158)
* feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter - Add missing menu bar items: Archive Note (⌘E), Trash Note (⌘⌫), Find in Vault (⌘⇧F), Go Back (⌘[), Go Forward (⌘]) - Wire new menu events through useMenuEvents → useAppCommands - Note list: ↑↓ moves highlight, Enter opens selected note (useNoteListKeyboard hook) - Inspector properties: Tab between rows, Enter to start editing - Settings panel: auto-focus first input on open - Extract StatusDot, StateBadge, noteItemStyle from NoteItem (reduces complexity) - Extract dispatchActiveTabEvent/dispatchOptionalEvent from useMenuEvents - 21 new tests (useNoteListKeyboard + useMenuEvents for new events) - All 1275 frontend tests pass, 319 Rust tests pass - CodeScene quality gates: passed (NoteItem improved from 22→18 complexity) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: keyboard-first-nav wireframes (note list highlight, menu shortcuts, inspector tab nav) * test: add search.rs coverage for fallback branches (qmd_uri_to_vault_path, extract_clean_snippet) * chore: exclude search.rs and lib.rs from coverage (qmd integration + Tauri setup code) --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
160
src/hooks/useNoteListKeyboard.test.ts
Normal file
160
src/hooks/useNoteListKeyboard.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { useNoteListKeyboard } from './useNoteListKeyboard'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(path: string, title: string): VaultEntry {
|
||||
return {
|
||||
path,
|
||||
title,
|
||||
filename: `${title}.md`,
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
tags: [],
|
||||
snippet: '',
|
||||
status: null,
|
||||
favorite: false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
createdAt: null,
|
||||
modifiedAt: null,
|
||||
fileSize: 100,
|
||||
color: null,
|
||||
icon: null,
|
||||
outgoingLinks: [],
|
||||
relationships: {},
|
||||
}
|
||||
}
|
||||
|
||||
function keyEvent(key: string, opts: Partial<React.KeyboardEvent> = {}): React.KeyboardEvent {
|
||||
return { key, preventDefault: vi.fn(), metaKey: false, ctrlKey: false, altKey: false, ...opts } as unknown as React.KeyboardEvent
|
||||
}
|
||||
|
||||
describe('useNoteListKeyboard', () => {
|
||||
const items = [makeEntry('/a.md', 'A'), makeEntry('/b.md', 'B'), makeEntry('/c.md', 'C')]
|
||||
const onOpen = vi.fn()
|
||||
|
||||
it('initializes with no highlight', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
expect(result.current.highlightedPath).toBeNull()
|
||||
})
|
||||
|
||||
it('ArrowDown highlights first item from no selection', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
|
||||
expect(result.current.highlightedPath).toBe('/a.md')
|
||||
})
|
||||
|
||||
it('ArrowDown advances highlight', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
|
||||
expect(result.current.highlightedPath).toBe('/b.md')
|
||||
})
|
||||
|
||||
it('ArrowDown clamps at end of list', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, 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')
|
||||
})
|
||||
|
||||
it('ArrowUp highlights last item from no selection', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowUp')))
|
||||
expect(result.current.highlightedPath).toBe('/c.md')
|
||||
})
|
||||
|
||||
it('ArrowUp clamps at start of list', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowUp')))
|
||||
expect(result.current.highlightedPath).toBe('/a.md')
|
||||
})
|
||||
|
||||
it('Enter opens 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('Enter')))
|
||||
expect(open).toHaveBeenCalledWith(items[0])
|
||||
})
|
||||
|
||||
it('Enter does nothing when no item highlighted', () => {
|
||||
const open = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('Enter')))
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: false }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
|
||||
expect(result.current.highlightedPath).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing with modifier keys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown', { metaKey: true } as Partial<React.KeyboardEvent>)))
|
||||
expect(result.current.highlightedPath).toBeNull()
|
||||
})
|
||||
|
||||
it('resets highlight when items change', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ items: hookItems }) => useNoteListKeyboard({ items: hookItems, selectedNotePath: null, onOpen, enabled: true }),
|
||||
{ initialProps: { items } },
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
|
||||
expect(result.current.highlightedPath).toBe('/a.md')
|
||||
|
||||
rerender({ items: [makeEntry('/d.md', 'D')] })
|
||||
expect(result.current.highlightedPath).toBeNull()
|
||||
})
|
||||
|
||||
it('handleFocus sets highlight to selected note', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: '/b.md', onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleFocus())
|
||||
expect(result.current.highlightedPath).toBe('/b.md')
|
||||
})
|
||||
|
||||
it('handleFocus defaults to first item when no selected note', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleFocus())
|
||||
expect(result.current.highlightedPath).toBe('/a.md')
|
||||
})
|
||||
|
||||
it('does nothing on empty item list', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListKeyboard({ items: [], selectedNotePath: null, onOpen, enabled: true }),
|
||||
)
|
||||
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
|
||||
expect(result.current.highlightedPath).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user