fix: make keyboard note navigation predictable
This commit is contained in:
@@ -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")
|
||||
|
||||
57
src/components/NoteList.keyboard.test.tsx
Normal file
57
src/components/NoteList.keyboard.test.tsx
Normal 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])
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user