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>
2026-03-01 19:32:15 +01:00
|
|
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
|
|
|
import type { VirtuosoHandle } from 'react-virtuoso'
|
|
|
|
|
import type { VaultEntry } from '../types'
|
2026-04-19 19:30:58 +02:00
|
|
|
import { logKeyboardNavigationTrace } from '../utils/noteOpenPerformance'
|
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>
2026-03-01 19:32:15 +01:00
|
|
|
|
|
|
|
|
interface NoteListKeyboardOptions {
|
|
|
|
|
items: VaultEntry[]
|
|
|
|
|
selectedNotePath: string | null
|
|
|
|
|
onOpen: (entry: VaultEntry) => void
|
2026-04-19 03:37:33 +02:00
|
|
|
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
2026-04-07 20:09:04 +02:00
|
|
|
onPrefetch?: (entry: VaultEntry) => void
|
2026-04-21 00:47:45 +02:00
|
|
|
searchVisible?: boolean
|
|
|
|
|
toggleSearch?: () => void
|
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>
2026-03-01 19:32:15 +01:00
|
|
|
enabled: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:30:58 +02:00
|
|
|
interface ItemIndex {
|
|
|
|
|
entryByPath: Map<string, VaultEntry>
|
|
|
|
|
indexByPath: Map<string, number>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const itemIndexCache = new WeakMap<VaultEntry[], ItemIndex>()
|
|
|
|
|
|
|
|
|
|
function buildItemIndex(items: VaultEntry[]): ItemIndex {
|
|
|
|
|
const entryByPath = new Map<string, VaultEntry>()
|
|
|
|
|
const indexByPath = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
for (const [index, entry] of items.entries()) {
|
|
|
|
|
entryByPath.set(entry.path, entry)
|
|
|
|
|
indexByPath.set(entry.path, index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { entryByPath, indexByPath }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getItemIndex(items: VaultEntry[]): ItemIndex {
|
|
|
|
|
const cached = itemIndexCache.get(items)
|
|
|
|
|
if (cached) return cached
|
|
|
|
|
|
|
|
|
|
const nextIndex = buildItemIndex(items)
|
|
|
|
|
itemIndexCache.set(items, nextIndex)
|
|
|
|
|
return nextIndex
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 09:04:13 +02:00
|
|
|
function resolveHighlightedPath(items: VaultEntry[], selectedNotePath: string | null): string | null {
|
|
|
|
|
if (items.length === 0) return null
|
|
|
|
|
if (!selectedNotePath) return items[0].path
|
|
|
|
|
|
2026-04-19 19:30:58 +02:00
|
|
|
return getItemIndex(items).entryByPath.has(selectedNotePath)
|
2026-04-16 09:04:13 +02:00
|
|
|
? selectedNotePath
|
|
|
|
|
: items[0].path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isListActive(container: HTMLDivElement | null): boolean {
|
|
|
|
|
if (!container) return false
|
|
|
|
|
const activeElement = document.activeElement
|
|
|
|
|
return activeElement instanceof Node && container.contains(activeElement)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
function isPanelActive(panel: HTMLDivElement | null): boolean {
|
|
|
|
|
if (!panel) return false
|
|
|
|
|
const activeElement = document.activeElement
|
|
|
|
|
return activeElement instanceof Node && panel.contains(activeElement)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 17:03:50 +02:00
|
|
|
function isEditableElement(element: Element | null): boolean {
|
|
|
|
|
if (!element) return false
|
|
|
|
|
if (
|
|
|
|
|
element instanceof HTMLInputElement
|
|
|
|
|
|| element instanceof HTMLTextAreaElement
|
|
|
|
|
|| element instanceof HTMLSelectElement
|
|
|
|
|
) return true
|
|
|
|
|
if (!(element instanceof HTMLElement)) return false
|
|
|
|
|
return element.isContentEditable || !!element.closest('[contenteditable="true"]')
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
function isInteractiveElement(element: Element | null): boolean {
|
|
|
|
|
if (!element) return false
|
|
|
|
|
if (isEditableElement(element)) return true
|
|
|
|
|
if (!(element instanceof HTMLElement)) return false
|
|
|
|
|
return element instanceof HTMLButtonElement
|
|
|
|
|
|| element instanceof HTMLAnchorElement
|
|
|
|
|
|| element.getAttribute('role') === 'button'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isNestedInteractiveTarget(
|
|
|
|
|
target: EventTarget | null,
|
|
|
|
|
currentTarget: EventTarget | null,
|
|
|
|
|
): boolean {
|
|
|
|
|
return target instanceof Element
|
|
|
|
|
&& currentTarget instanceof Element
|
|
|
|
|
&& target !== currentTarget
|
|
|
|
|
&& currentTarget.contains(target)
|
|
|
|
|
&& isInteractiveElement(target)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 09:04:13 +02:00
|
|
|
function resolveCurrentIndex(
|
|
|
|
|
items: VaultEntry[],
|
|
|
|
|
highlightedPath: string | null,
|
|
|
|
|
selectedNotePath: string | null,
|
|
|
|
|
): number {
|
|
|
|
|
const activePath = highlightedPath ?? selectedNotePath
|
2026-04-19 19:30:58 +02:00
|
|
|
if (!activePath) return -1
|
|
|
|
|
return getItemIndex(items).indexByPath.get(activePath) ?? -1
|
2026-04-16 09:04:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function moveHighlightIndex(
|
|
|
|
|
previousIndex: number,
|
|
|
|
|
direction: 1 | -1,
|
|
|
|
|
itemCount: number,
|
|
|
|
|
): number {
|
|
|
|
|
if (itemCount === 0) return -1
|
|
|
|
|
if (previousIndex < 0) return direction === 1 ? 0 : itemCount - 1
|
|
|
|
|
|
|
|
|
|
const currentIndex = Math.min(previousIndex, itemCount - 1)
|
|
|
|
|
const nextIndex = currentIndex + direction
|
|
|
|
|
if (nextIndex < 0 || nextIndex >= itemCount) return previousIndex
|
|
|
|
|
return nextIndex
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
function resolveHighlightedEntry(items: VaultEntry[], highlightedPath: string | null): VaultEntry | undefined {
|
|
|
|
|
if (!highlightedPath) return undefined
|
2026-04-19 19:30:58 +02:00
|
|
|
return getItemIndex(items).entryByPath.get(highlightedPath)
|
2026-04-19 03:37:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function usesCommandModifier(event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey'>): boolean {
|
|
|
|
|
return event.metaKey || event.ctrlKey
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
function isToggleSearchShortcut(
|
|
|
|
|
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!usesCommandModifier(event) || event.altKey || event.shiftKey) return false
|
|
|
|
|
return event.code === 'KeyF' || event.key.toLowerCase() === 'f'
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
function isNeighborhoodKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
|
|
|
|
|
return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useKeyboardItemRefs(items: VaultEntry[], selectedNotePath: string | null) {
|
2026-04-16 09:04:13 +02:00
|
|
|
const itemsRef = useRef(items)
|
|
|
|
|
const selectedNotePathRef = useRef(selectedNotePath)
|
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>
2026-03-01 19:32:15 +01:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-16 09:04:13 +02:00
|
|
|
itemsRef.current = items
|
|
|
|
|
selectedNotePathRef.current = selectedNotePath
|
|
|
|
|
}, [items, selectedNotePath])
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
return { itemsRef, selectedNotePathRef }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useHighlightedPath() {
|
|
|
|
|
const [highlightedPathState, setHighlightedPath] = useState<string | null>(null)
|
|
|
|
|
const highlightedPathRef = useRef<string | null>(null)
|
|
|
|
|
|
2026-04-16 09:04:13 +02:00
|
|
|
const syncHighlightedPath = useCallback((nextPath: string | null) => {
|
|
|
|
|
highlightedPathRef.current = nextPath
|
|
|
|
|
setHighlightedPath(nextPath)
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
return { highlightedPathRef, highlightedPathState, syncHighlightedPath }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useSelectionSync(
|
|
|
|
|
itemsRef: React.RefObject<VaultEntry[]>,
|
|
|
|
|
selectedNotePathRef: React.RefObject<string | null>,
|
|
|
|
|
syncHighlightedPath: (nextPath: string | null) => void,
|
|
|
|
|
) {
|
|
|
|
|
return useCallback(() => {
|
2026-04-16 09:04:13 +02:00
|
|
|
syncHighlightedPath(resolveHighlightedPath(itemsRef.current, selectedNotePathRef.current))
|
2026-04-19 03:37:33 +02:00
|
|
|
}, [itemsRef, selectedNotePathRef, syncHighlightedPath])
|
|
|
|
|
}
|
2026-04-16 09:04:13 +02:00
|
|
|
|
2026-04-19 11:49:48 +02:00
|
|
|
interface ScheduledOpenState {
|
|
|
|
|
entry: VaultEntry | null
|
|
|
|
|
frameId: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelScheduledOpen(stateRef: React.RefObject<ScheduledOpenState>): void {
|
|
|
|
|
const frameId = stateRef.current.frameId
|
|
|
|
|
if (frameId !== null) cancelAnimationFrame(frameId)
|
|
|
|
|
stateRef.current.entry = null
|
|
|
|
|
stateRef.current.frameId = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flushScheduledOpen(
|
|
|
|
|
stateRef: React.RefObject<ScheduledOpenState>,
|
|
|
|
|
onOpen: (entry: VaultEntry) => void,
|
|
|
|
|
entry?: VaultEntry,
|
|
|
|
|
): void {
|
|
|
|
|
if (entry) stateRef.current.entry = entry
|
|
|
|
|
const nextEntry = stateRef.current.entry
|
|
|
|
|
if (!nextEntry) return
|
|
|
|
|
|
|
|
|
|
if (stateRef.current.frameId !== null) cancelAnimationFrame(stateRef.current.frameId)
|
|
|
|
|
stateRef.current.entry = null
|
|
|
|
|
stateRef.current.frameId = null
|
|
|
|
|
onOpen(nextEntry)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scheduleOpenForNextFrame(
|
|
|
|
|
stateRef: React.RefObject<ScheduledOpenState>,
|
|
|
|
|
onOpen: (entry: VaultEntry) => void,
|
|
|
|
|
entry: VaultEntry,
|
|
|
|
|
): void {
|
|
|
|
|
stateRef.current.entry = entry
|
|
|
|
|
if (stateRef.current.frameId !== null) return
|
|
|
|
|
|
|
|
|
|
stateRef.current.frameId = requestAnimationFrame(() => {
|
|
|
|
|
flushScheduledOpen(stateRef, onOpen)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useScheduledOpen(onOpen: (entry: VaultEntry) => void, enabled: boolean) {
|
|
|
|
|
const stateRef = useRef<ScheduledOpenState>({ entry: null, frameId: null })
|
|
|
|
|
|
|
|
|
|
const scheduleOpen = useCallback((entry: VaultEntry) => {
|
|
|
|
|
scheduleOpenForNextFrame(stateRef, onOpen, entry)
|
|
|
|
|
}, [onOpen])
|
|
|
|
|
|
|
|
|
|
const flushOpen = useCallback((entry?: VaultEntry) => {
|
|
|
|
|
flushScheduledOpen(stateRef, onOpen, entry)
|
|
|
|
|
}, [onOpen])
|
|
|
|
|
|
|
|
|
|
const cancelOpen = useCallback(() => {
|
|
|
|
|
cancelScheduledOpen(stateRef)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (enabled) return
|
|
|
|
|
cancelOpen()
|
|
|
|
|
}, [cancelOpen, enabled])
|
|
|
|
|
|
|
|
|
|
useEffect(() => cancelOpen, [cancelOpen])
|
|
|
|
|
|
|
|
|
|
return { cancelOpen, flushOpen, scheduleOpen }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
function useMoveHighlight({
|
|
|
|
|
items,
|
|
|
|
|
selectedNotePath,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
syncHighlightedPath,
|
|
|
|
|
virtuosoRef,
|
|
|
|
|
onPrefetch,
|
2026-04-19 11:49:48 +02:00
|
|
|
scheduleOpen,
|
2026-04-19 03:37:33 +02:00
|
|
|
}: {
|
|
|
|
|
items: VaultEntry[]
|
|
|
|
|
selectedNotePath: string | null
|
|
|
|
|
highlightedPathRef: React.RefObject<string | null>
|
|
|
|
|
syncHighlightedPath: (nextPath: string | null) => void
|
|
|
|
|
virtuosoRef: React.RefObject<VirtuosoHandle | null>
|
|
|
|
|
onPrefetch?: (entry: VaultEntry) => void
|
2026-04-19 11:49:48 +02:00
|
|
|
scheduleOpen: (entry: VaultEntry) => void
|
2026-04-19 03:37:33 +02:00
|
|
|
}) {
|
|
|
|
|
return useCallback((direction: 1 | -1) => {
|
2026-04-19 19:30:58 +02:00
|
|
|
const startedAt = performance.now()
|
2026-04-16 09:04:13 +02:00
|
|
|
const currentIndex = resolveCurrentIndex(items, highlightedPathRef.current, selectedNotePath)
|
|
|
|
|
const nextIndex = moveHighlightIndex(currentIndex, direction, items.length)
|
|
|
|
|
const currentPath = highlightedPathRef.current ?? selectedNotePath
|
|
|
|
|
const nextItem = items[nextIndex]
|
|
|
|
|
if (!nextItem || nextItem.path === currentPath) return
|
|
|
|
|
|
|
|
|
|
syncHighlightedPath(nextItem.path)
|
|
|
|
|
virtuosoRef.current?.scrollIntoView({ index: nextIndex, behavior: 'auto' })
|
2026-04-19 11:49:48 +02:00
|
|
|
scheduleOpen(nextItem)
|
2026-04-16 09:04:13 +02:00
|
|
|
onPrefetch?.(nextItem)
|
2026-04-19 19:30:58 +02:00
|
|
|
logKeyboardNavigationTrace(direction === 1 ? 'down' : 'up', items.length, performance.now() - startedAt)
|
2026-04-19 11:49:48 +02:00
|
|
|
}, [highlightedPathRef, items, onPrefetch, scheduleOpen, selectedNotePath, syncHighlightedPath, virtuosoRef])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveEntryForActivation(
|
|
|
|
|
items: VaultEntry[],
|
|
|
|
|
highlightedPathRef: React.RefObject<string | null>,
|
|
|
|
|
): VaultEntry | undefined {
|
|
|
|
|
return resolveHighlightedEntry(items, highlightedPathRef.current)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleNeighborhoodActivation(options: {
|
|
|
|
|
event: Pick<KeyboardEvent, 'preventDefault'>
|
|
|
|
|
items: VaultEntry[]
|
|
|
|
|
highlightedPathRef: React.RefObject<string | null>
|
|
|
|
|
cancelOpen: () => void
|
|
|
|
|
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
|
|
|
|
}): boolean {
|
|
|
|
|
const {
|
|
|
|
|
event,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
cancelOpen,
|
|
|
|
|
onEnterNeighborhood,
|
|
|
|
|
} = options
|
|
|
|
|
|
|
|
|
|
const highlightedItem = resolveEntryForActivation(items, highlightedPathRef)
|
|
|
|
|
if (!highlightedItem) return false
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
cancelOpen()
|
|
|
|
|
void onEnterNeighborhood?.(highlightedItem)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleArrowNavigation(
|
|
|
|
|
event: Pick<KeyboardEvent, 'key' | 'preventDefault'>,
|
|
|
|
|
moveHighlight: (direction: 1 | -1) => void,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
moveHighlight(1)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
moveHighlight(-1)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleHighlightedOpen(options: {
|
|
|
|
|
event: Pick<KeyboardEvent, 'preventDefault'>
|
|
|
|
|
items: VaultEntry[]
|
|
|
|
|
highlightedPathRef: React.RefObject<string | null>
|
|
|
|
|
flushOpen: (entry?: VaultEntry) => void
|
|
|
|
|
}): boolean {
|
|
|
|
|
const {
|
|
|
|
|
event,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
flushOpen,
|
|
|
|
|
} = options
|
|
|
|
|
|
|
|
|
|
const highlightedItem = resolveEntryForActivation(items, highlightedPathRef)
|
|
|
|
|
if (!highlightedItem) return false
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
flushOpen(highlightedItem)
|
|
|
|
|
return true
|
2026-04-19 03:37:33 +02:00
|
|
|
}
|
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>
2026-03-01 19:32:15 +01:00
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
function useProcessKeyDown({
|
|
|
|
|
enabled,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
moveHighlight,
|
2026-04-19 11:49:48 +02:00
|
|
|
flushOpen,
|
|
|
|
|
cancelOpen,
|
2026-04-19 03:37:33 +02:00
|
|
|
onEnterNeighborhood,
|
2026-04-21 00:47:45 +02:00
|
|
|
onToggleSearchShortcut,
|
2026-04-19 03:37:33 +02:00
|
|
|
}: {
|
|
|
|
|
enabled: boolean
|
|
|
|
|
items: VaultEntry[]
|
|
|
|
|
highlightedPathRef: React.RefObject<string | null>
|
|
|
|
|
moveHighlight: (direction: 1 | -1) => void
|
2026-04-19 11:49:48 +02:00
|
|
|
flushOpen: (entry?: VaultEntry) => void
|
|
|
|
|
cancelOpen: () => void
|
2026-04-19 03:37:33 +02:00
|
|
|
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
2026-04-21 00:47:45 +02:00
|
|
|
onToggleSearchShortcut?: () => void
|
2026-04-19 03:37:33 +02:00
|
|
|
}) {
|
2026-04-21 00:47:45 +02:00
|
|
|
return useCallback((event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>) => {
|
|
|
|
|
if (!enabled) return
|
2026-04-19 03:37:33 +02:00
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
if (handleSearchShortcutEvent(event, onToggleSearchShortcut)) return
|
|
|
|
|
if (items.length === 0) return
|
|
|
|
|
if (handleNeighborhoodShortcutEvent({
|
2026-04-19 11:49:48 +02:00
|
|
|
event,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
2026-04-21 00:47:45 +02:00
|
|
|
cancelOpen,
|
|
|
|
|
onEnterNeighborhood,
|
|
|
|
|
})) return
|
|
|
|
|
if (shouldIgnoreListKeyboardEvent(event)) return
|
|
|
|
|
if (handleArrowNavigation(event, moveHighlight)) return
|
|
|
|
|
|
|
|
|
|
handleEnterShortcutEvent(event, items, highlightedPathRef, flushOpen)
|
|
|
|
|
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood, onToggleSearchShortcut])
|
2026-04-19 03:37:33 +02:00
|
|
|
}
|
2026-04-16 17:03:50 +02:00
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
function useFocusHandlers({
|
|
|
|
|
containerRef,
|
|
|
|
|
syncToCurrentSelection,
|
|
|
|
|
syncHighlightedPath,
|
|
|
|
|
}: {
|
|
|
|
|
containerRef: React.RefObject<HTMLDivElement | null>
|
|
|
|
|
syncToCurrentSelection: () => void
|
|
|
|
|
syncHighlightedPath: (nextPath: string | null) => void
|
|
|
|
|
}) {
|
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>
2026-03-01 19:32:15 +01:00
|
|
|
const handleFocus = useCallback(() => {
|
2026-04-16 09:04:13 +02:00
|
|
|
syncToCurrentSelection()
|
|
|
|
|
}, [syncToCurrentSelection])
|
|
|
|
|
|
|
|
|
|
const handleBlur = useCallback(() => {
|
|
|
|
|
syncHighlightedPath(null)
|
|
|
|
|
}, [syncHighlightedPath])
|
|
|
|
|
|
|
|
|
|
const focusList = useCallback(() => {
|
|
|
|
|
const container = containerRef.current
|
|
|
|
|
if (!container) return
|
|
|
|
|
|
|
|
|
|
container.focus()
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (isListActive(containerRef.current)) syncToCurrentSelection()
|
|
|
|
|
})
|
2026-04-19 03:37:33 +02:00
|
|
|
}, [containerRef, syncToCurrentSelection])
|
|
|
|
|
|
|
|
|
|
return { focusList, handleBlur, handleFocus }
|
|
|
|
|
}
|
2026-04-16 09:04:13 +02:00
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
function usePanelFocusState(panelRef: React.RefObject<HTMLDivElement | null>) {
|
|
|
|
|
const [isPanelActiveState, setIsPanelActiveState] = useState(false)
|
|
|
|
|
|
|
|
|
|
const syncPanelState = useCallback(() => {
|
|
|
|
|
setIsPanelActiveState(isPanelActive(panelRef.current))
|
|
|
|
|
}, [panelRef])
|
|
|
|
|
|
|
|
|
|
const handlePanelFocusCapture = useCallback(() => {
|
|
|
|
|
setIsPanelActiveState(true)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const handlePanelBlurCapture = useCallback(() => {
|
|
|
|
|
requestAnimationFrame(syncPanelState)
|
|
|
|
|
}, [syncPanelState])
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
handlePanelBlurCapture,
|
|
|
|
|
handlePanelFocusCapture,
|
|
|
|
|
isPanelActive: isPanelActiveState,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 03:37:33 +02:00
|
|
|
function useGlobalKeyboardHandling({
|
|
|
|
|
enabled,
|
2026-04-21 00:47:45 +02:00
|
|
|
panelRef,
|
2026-04-19 03:37:33 +02:00
|
|
|
containerRef,
|
|
|
|
|
processKeyDown,
|
|
|
|
|
}: {
|
|
|
|
|
enabled: boolean
|
2026-04-21 00:47:45 +02:00
|
|
|
panelRef: React.RefObject<HTMLDivElement | null>
|
2026-04-19 03:37:33 +02:00
|
|
|
containerRef: React.RefObject<HTMLDivElement | null>
|
|
|
|
|
processKeyDown: (event: KeyboardEvent) => void
|
|
|
|
|
}) {
|
2026-04-21 00:47:45 +02:00
|
|
|
const shouldSkipGlobalKeyDown = useCallback((activeElement: Element | null) => {
|
|
|
|
|
if (isEditableElement(activeElement)) return true
|
2026-04-21 00:50:44 +02:00
|
|
|
return Boolean(
|
2026-04-21 00:47:45 +02:00
|
|
|
activeElement !== containerRef.current
|
|
|
|
|
&& containerRef.current?.contains(activeElement)
|
|
|
|
|
&& isInteractiveElement(activeElement)
|
|
|
|
|
)
|
|
|
|
|
}, [containerRef])
|
|
|
|
|
|
2026-04-16 17:03:50 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!enabled) return
|
2026-04-21 00:47:45 +02:00
|
|
|
const handleWindowKeyDown = createGlobalKeyDownHandler(panelRef, shouldSkipGlobalKeyDown, processKeyDown)
|
2026-04-16 17:03:50 +02:00
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleWindowKeyDown)
|
|
|
|
|
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
2026-04-21 00:47:45 +02:00
|
|
|
}, [enabled, panelRef, processKeyDown, shouldSkipGlobalKeyDown])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useSearchToggleShortcut({
|
|
|
|
|
toggleSearch,
|
|
|
|
|
searchVisible,
|
|
|
|
|
focusList,
|
|
|
|
|
}: {
|
|
|
|
|
toggleSearch?: () => void
|
|
|
|
|
searchVisible: boolean
|
|
|
|
|
focusList: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return useCallback(() => {
|
|
|
|
|
if (!toggleSearch) return
|
|
|
|
|
|
|
|
|
|
toggleSearch()
|
|
|
|
|
if (!searchVisible) return
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
focusList()
|
|
|
|
|
})
|
|
|
|
|
}, [focusList, searchVisible, toggleSearch])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useDirectKeyDownHandler(
|
|
|
|
|
processKeyDown: (event: React.KeyboardEvent) => void,
|
|
|
|
|
) {
|
|
|
|
|
return useCallback((event: React.KeyboardEvent) => {
|
|
|
|
|
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
|
|
|
|
|
processKeyDown(event)
|
|
|
|
|
}, [processKeyDown])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveStableHighlightedPath(items: VaultEntry[], highlightedPathState: string | null): string | null {
|
|
|
|
|
return getItemIndex(items).entryByPath.has(highlightedPathState ?? '')
|
|
|
|
|
? highlightedPathState
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSearchShortcutEvent(
|
|
|
|
|
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>,
|
|
|
|
|
onToggleSearchShortcut?: () => void,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!isToggleSearchShortcut(event) || !onToggleSearchShortcut) return false
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
onToggleSearchShortcut()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleNeighborhoodShortcutEvent(options: {
|
|
|
|
|
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>
|
|
|
|
|
items: VaultEntry[]
|
|
|
|
|
highlightedPathRef: React.RefObject<string | null>
|
|
|
|
|
cancelOpen: () => void
|
|
|
|
|
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
|
|
|
|
}): boolean {
|
|
|
|
|
const {
|
|
|
|
|
event,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
cancelOpen,
|
|
|
|
|
onEnterNeighborhood,
|
|
|
|
|
} = options
|
|
|
|
|
|
|
|
|
|
if (!isNeighborhoodKey(event)) return false
|
|
|
|
|
handleNeighborhoodActivation({
|
|
|
|
|
event,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
cancelOpen,
|
|
|
|
|
onEnterNeighborhood,
|
|
|
|
|
})
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldIgnoreListKeyboardEvent(
|
|
|
|
|
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey'>,
|
|
|
|
|
): boolean {
|
|
|
|
|
return usesCommandModifier(event) || event.altKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleEnterShortcutEvent(
|
|
|
|
|
event: Pick<KeyboardEvent, 'key' | 'preventDefault'>,
|
|
|
|
|
items: VaultEntry[],
|
|
|
|
|
highlightedPathRef: React.RefObject<string | null>,
|
|
|
|
|
flushOpen: (entry?: VaultEntry) => void,
|
|
|
|
|
) {
|
|
|
|
|
if (event.key !== 'Enter') return
|
|
|
|
|
handleHighlightedOpen({
|
|
|
|
|
event,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
flushOpen,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createGlobalKeyDownHandler(
|
|
|
|
|
panelRef: React.RefObject<HTMLDivElement | null>,
|
|
|
|
|
shouldSkipGlobalKeyDown: (activeElement: Element | null) => boolean,
|
|
|
|
|
processKeyDown: (event: KeyboardEvent) => void,
|
|
|
|
|
) {
|
|
|
|
|
return (event: KeyboardEvent) => {
|
|
|
|
|
if (event.defaultPrevented) return
|
|
|
|
|
if (isToggleSearchShortcut(event) && isPanelActive(panelRef.current)) {
|
|
|
|
|
processKeyDown(event)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (shouldSkipGlobalKeyDown(document.activeElement)) return
|
|
|
|
|
processKeyDown(event)
|
|
|
|
|
}
|
2026-04-19 03:37:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useNoteListKeyboard({
|
2026-04-21 00:47:45 +02:00
|
|
|
items,
|
|
|
|
|
selectedNotePath,
|
|
|
|
|
onOpen,
|
|
|
|
|
onEnterNeighborhood,
|
|
|
|
|
onPrefetch,
|
|
|
|
|
searchVisible = false,
|
|
|
|
|
toggleSearch,
|
|
|
|
|
enabled,
|
2026-04-19 03:37:33 +02:00
|
|
|
}: NoteListKeyboardOptions) {
|
|
|
|
|
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
2026-04-21 00:47:45 +02:00
|
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
2026-04-19 03:37:33 +02:00
|
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath)
|
|
|
|
|
const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath()
|
|
|
|
|
const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath)
|
2026-04-19 11:49:48 +02:00
|
|
|
const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled)
|
2026-04-21 00:47:45 +02:00
|
|
|
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
|
|
|
|
containerRef,
|
|
|
|
|
syncToCurrentSelection,
|
|
|
|
|
syncHighlightedPath,
|
|
|
|
|
})
|
|
|
|
|
const { handlePanelBlurCapture, handlePanelFocusCapture, isPanelActive: isPanelActiveState } = usePanelFocusState(panelRef)
|
|
|
|
|
const handleToggleSearchShortcut = useSearchToggleShortcut({
|
|
|
|
|
focusList,
|
|
|
|
|
searchVisible,
|
|
|
|
|
toggleSearch,
|
|
|
|
|
})
|
2026-04-19 03:37:33 +02:00
|
|
|
const moveHighlight = useMoveHighlight({
|
|
|
|
|
items,
|
|
|
|
|
selectedNotePath,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
syncHighlightedPath,
|
|
|
|
|
virtuosoRef,
|
|
|
|
|
onPrefetch,
|
2026-04-19 11:49:48 +02:00
|
|
|
scheduleOpen,
|
2026-04-19 03:37:33 +02:00
|
|
|
})
|
|
|
|
|
const processKeyDown = useProcessKeyDown({
|
|
|
|
|
enabled,
|
|
|
|
|
items,
|
|
|
|
|
highlightedPathRef,
|
|
|
|
|
moveHighlight,
|
2026-04-19 11:49:48 +02:00
|
|
|
flushOpen,
|
|
|
|
|
cancelOpen,
|
2026-04-19 03:37:33 +02:00
|
|
|
onEnterNeighborhood,
|
2026-04-21 00:47:45 +02:00
|
|
|
onToggleSearchShortcut: handleToggleSearchShortcut,
|
2026-04-19 03:37:33 +02:00
|
|
|
})
|
2026-04-21 00:47:45 +02:00
|
|
|
const handleKeyDown = useDirectKeyDownHandler(processKeyDown)
|
|
|
|
|
useGlobalKeyboardHandling({ enabled, panelRef, containerRef, processKeyDown })
|
2026-04-19 11:49:48 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
cancelOpen()
|
|
|
|
|
}, [cancelOpen, selectedNotePath])
|
2026-04-16 17:03:50 +02:00
|
|
|
|
2026-04-21 00:47:45 +02:00
|
|
|
const highlightedPath = resolveStableHighlightedPath(items, highlightedPathState)
|
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>
2026-03-01 19:32:15 +01:00
|
|
|
|
2026-04-16 09:04:13 +02:00
|
|
|
return {
|
|
|
|
|
containerRef,
|
|
|
|
|
focusList,
|
2026-04-21 00:47:45 +02:00
|
|
|
handlePanelBlurCapture,
|
|
|
|
|
handlePanelFocusCapture,
|
2026-04-16 09:04:13 +02:00
|
|
|
highlightedPath,
|
|
|
|
|
handleBlur,
|
|
|
|
|
handleKeyDown,
|
|
|
|
|
handleFocus,
|
2026-04-21 00:47:45 +02:00
|
|
|
isPanelActive: isPanelActiveState,
|
|
|
|
|
panelRef,
|
|
|
|
|
toggleSearchShortcut: handleToggleSearchShortcut,
|
2026-04-16 09:04:13 +02:00
|
|
|
virtuosoRef,
|
|
|
|
|
}
|
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>
2026-03-01 19:32:15 +01:00
|
|
|
}
|