diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index ea89fa74..8f0b7872 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -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") diff --git a/src/components/NoteList.keyboard.test.tsx b/src/components/NoteList.keyboard.test.tsx new file mode 100644 index 00000000..4dbcc678 --- /dev/null +++ b/src/components/NoteList.keyboard.test.tsx @@ -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(null) + + const handleOpen = (entry: VaultEntry) => { + setSelectedNote(entry) + onOpen(entry) + } + + return ( + {}} + 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() + + 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]) + }) +}) diff --git a/src/components/note-list/NoteListLayout.tsx b/src/components/note-list/NoteListLayout.tsx index a4bad06a..a40132d3 100644 --- a/src/components/note-list/NoteListLayout.tsx +++ b/src/components/note-list/NoteListLayout.tsx @@ -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 ( +
+ {entitySelection ? ( + + ) : ( + + )} +
+ ) +} + +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 ( +
+ + {showFilterPills && ( + + )} +
+ ) +} + 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} /> -
-
- {entitySelection ? ( - - ) : ( - - )} -
- {showFilterPills && ( - - )} -
+ } +function buildNoteListLayoutModel(params: { + selection: SidebarSelection + views?: ViewFile[] + sidebarCollapsed?: boolean + modifiedFilesError?: string | null + noteListFilter: NoteListFilter + filterCounts: ReturnType + onNoteListFilterChange: (filter: NoteListFilter) => void + onOpenType: (entry: VaultEntry) => void + content: ReturnType + interaction: ReturnType & { + 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, + }, + }) } diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index a8ae266f..575b692c 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -225,12 +225,12 @@ export const APP_COMMAND_DEFINITIONS: Record [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' }, diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index 1ab9a4af..a2ca45f6 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -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', diff --git a/src/hooks/commands/navigationCommands.ts b/src/hooks/commands/navigationCommands.ts index c6a6313b..5fabbeb2 100644 --- a/src/hooks/commands/navigationCommands.ts +++ b/src/hooks/commands/navigationCommands.ts @@ -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, { diff --git a/src/hooks/useNavigationGestures.ts b/src/hooks/useNavigationGestures.ts index 27bd8cf4..014606de 100644 --- a/src/hooks/useNavigationGestures.ts +++ b/src/hooks/useNavigationGestures.ts @@ -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 | 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]) } diff --git a/src/hooks/useNoteListKeyboard.test.ts b/src/hooks/useNoteListKeyboard.test.ts index ae36a462..66dcf3b3 100644 --- a/src/hooks/useNoteListKeyboard.test.ts +++ b/src/hooks/useNoteListKeyboard.test.ts @@ -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', () => { diff --git a/src/hooks/useNoteListKeyboard.ts b/src/hooks/useNoteListKeyboard.ts index a36979d4..20af920e 100644 --- a/src/hooks/useNoteListKeyboard.ts +++ b/src/hooks/useNoteListKeyboard.ts @@ -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(null) const virtuosoRef = useRef(null) + const containerRef = useRef(null) + const highlightedPathRef = useRef(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, + } }