fix: make keyboard note navigation predictable

This commit is contained in:
lucaronin
2026-04-16 09:04:13 +02:00
parent 1b3a9e3ecf
commit 57a87e43b3
10 changed files with 511 additions and 199 deletions

View File

@@ -253,11 +253,11 @@ fn build_go_menu(app: &App) -> MenuResult {
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
let go_back = MenuItemBuilder::new("Go Back")
.id(VIEW_GO_BACK)
.accelerator("CmdOrCtrl+[")
.accelerator("CmdOrCtrl+Left")
.build(app)?;
let go_forward = MenuItemBuilder::new("Go Forward")
.id(VIEW_GO_FORWARD)
.accelerator("CmdOrCtrl+]")
.accelerator("CmdOrCtrl+Right")
.build(app)?;
Ok(SubmenuBuilder::new(app, "Go")

View File

@@ -0,0 +1,57 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import {
allSelection,
mockEntries,
} from '../test-utils/noteListTestUtils'
import type { VaultEntry } from '../types'
function NoteListKeyboardHarness({
onOpen,
}: {
onOpen: (entry: VaultEntry) => void
}) {
const [selectedNote, setSelectedNote] = useState<VaultEntry | null>(null)
const handleOpen = (entry: VaultEntry) => {
setSelectedNote(entry)
onOpen(entry)
}
return (
<NoteList
entries={mockEntries}
selection={allSelection}
selectedNote={selectedNote}
noteListFilter="open"
onNoteListFilterChange={() => {}}
onSelectNote={handleOpen}
onReplaceActiveTab={handleOpen}
onCreateNote={() => {}}
/>
)
}
describe('NoteList keyboard activation', () => {
it('focuses the list on click and continues arrow navigation from the clicked note', async () => {
const onOpen = vi.fn()
render(<NoteListKeyboardHarness onOpen={onOpen} />)
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
const container = screen.getByTestId('note-list-container')
await waitFor(() => {
expect(document.activeElement).toBe(container)
expect(
container.querySelector('[data-highlighted="true"]')?.getAttribute('data-note-path'),
).toBe(mockEntries[1].path)
})
fireEvent.keyDown(container, { key: 'ArrowDown' })
expect(onOpen).toHaveBeenNthCalledWith(1, mockEntries[1])
expect(onOpen).toHaveBeenNthCalledWith(2, mockEntries[2])
})
})

View File

@@ -31,6 +31,169 @@ function MultiSelectBar({
)
}
function NoteListContent({
entitySelection,
searchedGroups,
query,
collapsedGroups,
sortPrefs,
toggleGroup,
handleSortChange,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
modifiedFilesError,
searched,
noteListVirtuosoRef,
}: Pick<
NoteListLayoutProps,
| 'entitySelection'
| 'searchedGroups'
| 'query'
| 'collapsedGroups'
| 'sortPrefs'
| 'toggleGroup'
| 'handleSortChange'
| 'renderItem'
| 'typeEntryMap'
| 'handleClickNote'
| 'isArchivedView'
| 'isChangesView'
| 'isInboxView'
| 'modifiedFilesError'
| 'searched'
| 'noteListVirtuosoRef'
>) {
return (
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
<EntityView
entity={entitySelection.entry}
groups={searchedGroups}
query={query}
collapsedGroups={collapsedGroups}
sortPrefs={sortPrefs}
onToggleGroup={toggleGroup}
onSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
onClickNote={handleClickNote}
/>
) : (
<ListView
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}
changesError={modifiedFilesError}
searched={searched}
query={query}
renderItem={renderItem}
virtuosoRef={noteListVirtuosoRef}
/>
)}
</div>
)
}
function NoteListBody({
handleListKeyDown,
noteListContainerRef,
handleNoteListBlur,
handleNoteListFocus,
focusNoteList,
noteListVirtuosoRef,
entitySelection,
searchedGroups,
query,
collapsedGroups,
sortPrefs,
toggleGroup,
handleSortChange,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
modifiedFilesError,
searched,
showFilterPills,
noteListFilter,
filterCounts,
onNoteListFilterChange,
}: Pick<
NoteListLayoutProps,
| 'handleListKeyDown'
| 'noteListContainerRef'
| 'handleNoteListBlur'
| 'handleNoteListFocus'
| 'focusNoteList'
| 'noteListVirtuosoRef'
| 'entitySelection'
| 'searchedGroups'
| 'query'
| 'collapsedGroups'
| 'sortPrefs'
| 'toggleGroup'
| 'handleSortChange'
| 'renderItem'
| 'typeEntryMap'
| 'handleClickNote'
| 'isArchivedView'
| 'isChangesView'
| 'isInboxView'
| 'modifiedFilesError'
| 'searched'
| 'showFilterPills'
| 'noteListFilter'
| 'filterCounts'
| 'onNoteListFilterChange'
>) {
return (
<div
ref={noteListContainerRef}
className="relative flex flex-1 flex-col overflow-hidden outline-none"
style={{ minHeight: 0 }}
tabIndex={0}
onBlur={handleNoteListBlur}
onKeyDown={handleListKeyDown}
onFocus={handleNoteListFocus}
onClickCapture={focusNoteList}
data-testid="note-list-container"
>
<NoteListContent
entitySelection={entitySelection}
searchedGroups={searchedGroups}
query={query}
collapsedGroups={collapsedGroups}
sortPrefs={sortPrefs}
toggleGroup={toggleGroup}
handleSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
handleClickNote={handleClickNote}
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}
modifiedFilesError={modifiedFilesError}
searched={searched}
noteListVirtuosoRef={noteListVirtuosoRef}
/>
{showFilterPills && (
<FilterPills
active={noteListFilter}
counts={filterCounts}
onChange={onNoteListFilterChange}
position="bottom"
/>
)}
</div>
)
}
export function NoteListLayout({
title,
typeDocument,
@@ -48,7 +211,11 @@ export function NoteListLayout({
toggleSearch,
setSearch,
handleListKeyDown,
noteListKeyboard,
noteListContainerRef,
handleNoteListBlur,
handleNoteListFocus,
focusNoteList,
noteListVirtuosoRef,
entitySelection,
searchedGroups,
collapsedGroups,
@@ -97,50 +264,33 @@ export function NoteListLayout({
onToggleSearch={toggleSearch}
onSearchChange={setSearch}
/>
<div
className="relative flex flex-1 flex-col overflow-hidden outline-none"
style={{ minHeight: 0 }}
tabIndex={0}
onKeyDown={handleListKeyDown}
onFocus={noteListKeyboard.handleFocus}
data-testid="note-list-container"
>
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
<EntityView
entity={entitySelection.entry}
groups={searchedGroups}
query={query}
collapsedGroups={collapsedGroups}
sortPrefs={sortPrefs}
onToggleGroup={toggleGroup}
onSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
onClickNote={handleClickNote}
/>
) : (
<ListView
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}
changesError={modifiedFilesError}
searched={searched}
query={query}
renderItem={renderItem}
virtuosoRef={noteListKeyboard.virtuosoRef}
/>
)}
</div>
{showFilterPills && (
<FilterPills
active={noteListFilter}
counts={filterCounts}
onChange={onNoteListFilterChange}
position="bottom"
/>
)}
</div>
<NoteListBody
handleListKeyDown={handleListKeyDown}
noteListContainerRef={noteListContainerRef}
handleNoteListBlur={handleNoteListBlur}
handleNoteListFocus={handleNoteListFocus}
focusNoteList={focusNoteList}
noteListVirtuosoRef={noteListVirtuosoRef}
entitySelection={entitySelection}
searchedGroups={searchedGroups}
query={query}
collapsedGroups={collapsedGroups}
sortPrefs={sortPrefs}
toggleGroup={toggleGroup}
handleSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
handleClickNote={handleClickNote}
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}
modifiedFilesError={modifiedFilesError}
searched={searched}
showFilterPills={showFilterPills}
noteListFilter={noteListFilter}
filterCounts={filterCounts}
onNoteListFilterChange={onNoteListFilterChange}
/>
<MultiSelectBar
multiSelect={multiSelect}
isArchivedView={isArchivedView}

View File

@@ -358,6 +358,70 @@ export interface NoteListProps {
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
function buildNoteListLayoutModel(params: {
selection: SidebarSelection
views?: ViewFile[]
sidebarCollapsed?: boolean
modifiedFilesError?: string | null
noteListFilter: NoteListFilter
filterCounts: ReturnType<typeof useFilterCounts>
onNoteListFilterChange: (filter: NoteListFilter) => void
onOpenType: (entry: VaultEntry) => void
content: ReturnType<typeof useNoteListContent>
interaction: ReturnType<typeof useNoteListInteractionState> & {
renderItem: (entry: VaultEntry) => React.ReactNode
entitySelection: SidebarSelection | null
}
}) {
return {
title: resolveHeaderTitle(params.selection, params.content.typeDocument, params.views),
typeDocument: params.content.typeDocument,
isEntityView: params.content.isEntityView,
listSort: params.content.listSort,
listDirection: params.content.listDirection,
customProperties: params.content.customProperties,
sidebarCollapsed: params.sidebarCollapsed,
searchVisible: params.content.searchVisible,
search: params.content.search,
propertyPicker: params.content.propertyPicker,
handleSortChange: params.content.handleSortChange,
handleCreateNote: params.interaction.handleCreateNote,
onOpenType: params.onOpenType,
toggleSearch: params.content.toggleSearch,
setSearch: params.content.setSearch,
handleListKeyDown: params.interaction.handleListKeyDown,
noteListContainerRef: params.interaction.noteListKeyboard.containerRef,
handleNoteListBlur: params.interaction.noteListKeyboard.handleBlur,
handleNoteListFocus: params.interaction.noteListKeyboard.handleFocus,
focusNoteList: params.interaction.noteListKeyboard.focusList,
noteListVirtuosoRef: params.interaction.noteListKeyboard.virtuosoRef,
entitySelection: params.interaction.entitySelection,
searchedGroups: params.content.searchedGroups,
collapsedGroups: params.interaction.collapsedGroups,
sortPrefs: params.content.sortPrefs,
toggleGroup: params.interaction.toggleGroup,
renderItem: params.interaction.renderItem,
typeEntryMap: params.content.typeEntryMap,
handleClickNote: params.interaction.handleClickNote,
isArchivedView: params.content.isArchivedView,
isChangesView: params.selection.kind === 'filter' && params.selection.filter === 'changes',
isInboxView: params.selection.kind === 'filter' && params.selection.filter === 'inbox',
modifiedFilesError: params.modifiedFilesError,
searched: params.content.searched,
query: params.content.query,
showFilterPills: params.selection.kind === 'sectionGroup' || params.selection.kind === 'folder',
noteListFilter: params.noteListFilter,
filterCounts: params.filterCounts,
onNoteListFilterChange: params.onNoteListFilterChange,
multiSelect: params.interaction.multiSelect,
handleBulkArchive: params.interaction.handleBulkArchive,
handleBulkDeletePermanently: params.interaction.handleBulkDeletePermanently,
handleBulkUnarchive: params.interaction.handleBulkUnarchive,
contextMenuNode: params.interaction.changesContextMenu.contextMenuNode,
dialogNode: params.interaction.changesContextMenu.dialogNode,
}
}
export function useNoteListModel({
entries,
selection,
@@ -387,29 +451,11 @@ export function useNoteListModel({
views,
visibleNotesRef,
}: NoteListProps) {
const selectedNotePath = selectedNote?.path ?? null
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { isInboxView, isChangesView, showFilterPills } = useViewFlags(selection)
const { isInboxView } = useViewFlags(selection)
const filterCounts = useFilterCounts(entries, selection)
const {
customProperties,
displayPropsOverride,
handleSortChange,
isArchivedView,
isEntityView,
listDirection,
listSort,
propertyPicker,
query,
search,
searchVisible,
searched,
searchedGroups,
setSearch,
sortPrefs,
toggleSearch,
typeDocument,
typeEntryMap,
} = useNoteListContent({
const content = useNoteListContent({
entries,
selection,
noteListFilter,
@@ -427,27 +473,14 @@ export function useNoteListModel({
views,
visibleNotesRef,
})
const {
changesContextMenu,
collapsedGroups,
getChangeStatus,
handleBulkArchive,
handleBulkDeletePermanently,
handleBulkUnarchive,
handleClickNote,
handleCreateNote,
handleListKeyDown,
multiSelect,
noteListKeyboard,
toggleGroup,
} = useNoteListInteractionState({
searched,
selectedNotePath: selectedNote?.path ?? null,
const interaction = useNoteListInteractionState({
searched: content.searched,
selectedNotePath,
selection,
noteListFilter,
isArchivedView,
isEntityView,
isChangesView,
isArchivedView: content.isArchivedView,
isEntityView: content.isEntityView,
isChangesView: selection.kind === 'filter' && selection.filter === 'changes',
modifiedFiles,
onReplaceActiveTab,
onSelectNote,
@@ -461,60 +494,33 @@ export function useNoteListModel({
})
const renderItem = useRenderItem({
entries,
selectedNotePath: selectedNote?.path ?? null,
typeEntryMap,
displayPropsOverride,
isChangesView,
selectedNotePath,
typeEntryMap: content.typeEntryMap,
displayPropsOverride: content.displayPropsOverride,
isChangesView: selection.kind === 'filter' && selection.filter === 'changes',
onDiscardFile,
resolvedGetNoteStatus,
getChangeStatus,
handleClickNote,
noteContextMenu: changesContextMenu.handleNoteContextMenu,
multiSelect,
noteListKeyboard,
getChangeStatus: interaction.getChangeStatus,
handleClickNote: interaction.handleClickNote,
noteContextMenu: interaction.changesContextMenu.handleNoteContextMenu,
multiSelect: interaction.multiSelect,
noteListKeyboard: interaction.noteListKeyboard,
})
return {
title: resolveHeaderTitle(selection, typeDocument, views),
typeDocument,
isEntityView,
listSort,
listDirection,
customProperties,
return buildNoteListLayoutModel({
selection,
views,
sidebarCollapsed,
searchVisible,
search,
propertyPicker,
handleSortChange,
handleCreateNote,
onOpenType: onReplaceActiveTab,
toggleSearch,
setSearch,
handleListKeyDown,
noteListKeyboard,
entitySelection: isEntityView && selection.kind === 'entity' ? selection : null,
searchedGroups,
collapsedGroups,
sortPrefs,
toggleGroup,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
modifiedFilesError,
searched,
query,
showFilterPills,
noteListFilter,
filterCounts,
onNoteListFilterChange,
multiSelect,
handleBulkArchive,
handleBulkDeletePermanently,
handleBulkUnarchive,
contextMenuNode: changesContextMenu.contextMenuNode,
dialogNode: changesContextMenu.dialogNode,
}
content,
interaction: {
...interaction,
renderItem,
entitySelection: content.isEntityView && selection.kind === 'entity' ? selection : null,
},
})
}

View File

@@ -225,12 +225,12 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
[APP_COMMAND_IDS.viewGoBack]: {
route: { kind: 'handler', handler: 'onGoBack' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: '[', display: '⌘[' },
shortcut: { combo: 'command-or-ctrl', key: 'ArrowLeft', code: 'ArrowLeft', display: '⌘' },
},
[APP_COMMAND_IDS.viewGoForward]: {
route: { kind: 'handler', handler: 'onGoForward' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: ']', display: '⌘]' },
shortcut: { combo: 'command-or-ctrl', key: 'ArrowRight', code: 'ArrowRight', display: '⌘' },
},
[APP_COMMAND_IDS.goAllNotes]: {
route: { kind: 'filter', value: 'all' },

View File

@@ -117,6 +117,13 @@ describe('appCommandDispatcher', () => {
ctrlKey: true,
shiftKey: true,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.viewGoBack)).toMatchObject({
key: 'ArrowLeft',
code: 'ArrowLeft',
metaKey: true,
ctrlKey: false,
shiftKey: false,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.appCheckForUpdates)).toBeNull()
})
@@ -141,6 +148,26 @@ describe('appCommandDispatcher', () => {
shiftKey: true,
}),
).toBe(APP_COMMAND_IDS.viewToggleProperties)
expect(
findShortcutCommandIdForEvent({
key: 'ArrowLeft',
code: 'ArrowLeft',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: false,
}),
).toBe(APP_COMMAND_IDS.viewGoBack)
expect(
findShortcutCommandIdForEvent({
key: 'ArrowRight',
code: 'ArrowRight',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: false,
}),
).toBe(APP_COMMAND_IDS.viewGoForward)
expect(
findShortcutCommandIdForEvent({
key: 'l',

View File

@@ -19,8 +19,8 @@ export function buildNavigationCommands(config: NavigationCommandsConfig): Comma
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
]
if (showInbox) {
commands.splice(5, 0, {

View File

@@ -1,8 +1,9 @@
import { useEffect } from 'react'
/**
* Registers mouse button 3/4 (back/forward) and macOS trackpad two-finger
* horizontal swipe gestures for navigation.
* Registers mouse button 3/4 (back/forward) navigation.
* The horizontal trackpad swipe path was removed because it was firing
* unreliably in WKWebView and conflicted with the explicit keyboard path.
*/
export function useNavigationGestures({
onGoBack,
@@ -12,41 +13,22 @@ export function useNavigationGestures({
onGoForward: () => void
}) {
useEffect(() => {
const handleMouseBack = (e: MouseEvent) => {
if (e.button === 3) { e.preventDefault(); onGoBack() }
if (e.button === 4) { e.preventDefault(); onGoForward() }
}
window.addEventListener('mouseup', handleMouseBack)
// Trackpad swipe: accumulate horizontal wheel delta and trigger on threshold
let accumulatedDeltaX = 0
let resetTimer: ReturnType<typeof setTimeout> | null = null
const SWIPE_THRESHOLD = 120
const handleWheel = (e: WheelEvent) => {
// Only handle horizontal-dominant gestures (trackpad swipe)
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
if (e.ctrlKey || e.metaKey) return // ignore pinch-zoom
accumulatedDeltaX += e.deltaX
if (resetTimer) clearTimeout(resetTimer)
resetTimer = setTimeout(() => { accumulatedDeltaX = 0 }, 300)
if (accumulatedDeltaX > SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
onGoForward()
} else if (accumulatedDeltaX < -SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
const handleMouseNavigation = (event: MouseEvent) => {
if (event.button === 3) {
event.preventDefault()
onGoBack()
return
}
if (event.button === 4) {
event.preventDefault()
onGoForward()
}
}
window.addEventListener('wheel', handleWheel, { passive: true })
window.addEventListener('mouseup', handleMouseNavigation)
return () => {
window.removeEventListener('mouseup', handleMouseBack)
window.removeEventListener('wheel', handleWheel)
if (resetTimer) clearTimeout(resetTimer)
window.removeEventListener('mouseup', handleMouseNavigation)
}
}, [onGoBack, onGoForward])
}

View File

@@ -42,11 +42,13 @@ describe('useNoteListKeyboard', () => {
})
it('ArrowDown highlights first item from no selection', () => {
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')))
expect(result.current.highlightedPath).toBe('/a.md')
expect(open).toHaveBeenCalledWith(items[0])
})
it('ArrowDown advances highlight', () => {
@@ -70,11 +72,26 @@ describe('useNoteListKeyboard', () => {
})
it('ArrowUp highlights last item from no selection', () => {
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('ArrowUp')))
expect(result.current.highlightedPath).toBe('/c.md')
expect(open).toHaveBeenCalledWith(items[2])
})
it('scrolls the highlighted item into view with nearest-style behavior', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
const scrollIntoView = vi.fn()
result.current.virtuosoRef.current = { scrollIntoView } as never
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(scrollIntoView).toHaveBeenCalledWith({ index: 0, behavior: 'auto' })
})
it('ArrowUp clamps at start of list', () => {

View File

@@ -10,16 +10,80 @@ interface NoteListKeyboardOptions {
enabled: boolean
}
function resolveHighlightedPath(items: VaultEntry[], selectedNotePath: string | null): string | null {
if (items.length === 0) return null
if (!selectedNotePath) return items[0].path
return items.some((entry) => entry.path === selectedNotePath)
? 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)
}
function resolveCurrentIndex(
items: VaultEntry[],
highlightedPath: string | null,
selectedNotePath: string | null,
): number {
const activePath = highlightedPath ?? selectedNotePath
return activePath ? items.findIndex((entry) => entry.path === activePath) : -1
}
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
}
export function useNoteListKeyboard({
items, selectedNotePath, onOpen, onPrefetch, enabled,
}: NoteListKeyboardOptions) {
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [highlightedPathState, setHighlightedPath] = useState<string | null>(null)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const containerRef = useRef<HTMLDivElement>(null)
const highlightedPathRef = useRef<string | null>(null)
const itemsRef = useRef(items)
const selectedNotePathRef = useRef(selectedNotePath)
// Reset highlight when items change (filter/sort/selection changed)
useEffect(() => {
setHighlightedIndex(-1) // eslint-disable-line react-hooks/set-state-in-effect -- reset on data change
}, [items])
itemsRef.current = items
selectedNotePathRef.current = selectedNotePath
}, [items, selectedNotePath])
const syncHighlightedPath = useCallback((nextPath: string | null) => {
highlightedPathRef.current = nextPath
setHighlightedPath(nextPath)
}, [])
const syncToCurrentSelection = useCallback(() => {
syncHighlightedPath(resolveHighlightedPath(itemsRef.current, selectedNotePathRef.current))
}, [syncHighlightedPath])
const moveHighlight = useCallback((direction: 1 | -1) => {
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' })
onOpen(nextItem)
onPrefetch?.(nextItem)
}, [items, onOpen, onPrefetch, selectedNotePath, syncHighlightedPath])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!enabled || items.length === 0) return
@@ -27,37 +91,46 @@ export function useNoteListKeyboard({
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlightedIndex(prev => {
const next = Math.min((prev < 0 ? -1 : prev) + 1, items.length - 1)
virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' })
if (next >= 0 && next < items.length) onPrefetch?.(items[next])
return next
})
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlightedIndex(prev => {
const next = Math.max((prev < 0 ? items.length : prev) - 1, 0)
virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' })
if (next >= 0 && next < items.length) onPrefetch?.(items[next])
return next
})
} else if (e.key === 'Enter' && highlightedIndex >= 0 && highlightedIndex < items.length) {
moveHighlight(-1)
} else if (e.key === 'Enter' && highlightedPathRef.current) {
e.preventDefault()
onOpen(items[highlightedIndex])
const highlightedItem = items.find((entry) => entry.path === highlightedPathRef.current)
if (highlightedItem) onOpen(highlightedItem)
}
}, [enabled, items, highlightedIndex, onOpen, onPrefetch])
}, [enabled, items, moveHighlight, onOpen])
const handleFocus = useCallback(() => {
if (highlightedIndex >= 0 || items.length === 0) return
const activeIdx = selectedNotePath
? items.findIndex(n => n.path === selectedNotePath)
: -1
setHighlightedIndex(activeIdx >= 0 ? activeIdx : 0)
}, [highlightedIndex, items, selectedNotePath])
syncToCurrentSelection()
}, [syncToCurrentSelection])
const highlightedPath = (highlightedIndex >= 0 && highlightedIndex < items.length)
? items[highlightedIndex].path
const handleBlur = useCallback(() => {
syncHighlightedPath(null)
}, [syncHighlightedPath])
const focusList = useCallback(() => {
const container = containerRef.current
if (!container) return
container.focus()
requestAnimationFrame(() => {
if (isListActive(containerRef.current)) syncToCurrentSelection()
})
}, [syncToCurrentSelection])
const highlightedPath = items.some((entry) => entry.path === highlightedPathState)
? highlightedPathState
: null
return { highlightedPath, handleKeyDown, handleFocus, virtuosoRef }
return {
containerRef,
focusList,
highlightedPath,
handleBlur,
handleKeyDown,
handleFocus,
virtuosoRef,
}
}