fix: debounce note list search
This commit is contained in:
@@ -56,6 +56,7 @@ pub struct MenuStateUpdate {
|
||||
has_conflicts: Option<bool>,
|
||||
has_restorable_deleted_note: Option<bool>,
|
||||
has_no_remote: Option<bool>,
|
||||
note_list_search_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -77,6 +78,9 @@ pub fn update_menu_state(
|
||||
if let Some(v) = state.has_no_remote {
|
||||
menu::set_git_no_remote_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = state.note_list_search_enabled {
|
||||
menu::set_note_list_search_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
|
||||
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
|
||||
const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff";
|
||||
|
||||
@@ -62,6 +63,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
FILE_QUICK_OPEN_ALIAS,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_EDITOR_ONLY,
|
||||
@@ -110,6 +112,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
];
|
||||
|
||||
/// IDs of menu items that depend on the note list being the active surface.
|
||||
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
|
||||
|
||||
/// IDs of menu items that depend on a deleted-note preview being active.
|
||||
const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED];
|
||||
|
||||
@@ -185,6 +190,11 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.id(EDIT_FIND_IN_VAULT)
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
.build(app)?;
|
||||
let toggle_note_list_search = MenuItemBuilder::new("Toggle Note List Search")
|
||||
.id(EDIT_TOGGLE_NOTE_LIST_SEARCH)
|
||||
.accelerator("CmdOrCtrl+F")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode")
|
||||
.id(EDIT_TOGGLE_DIFF)
|
||||
.build(app)?;
|
||||
@@ -200,6 +210,7 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.select_all()
|
||||
.separator()
|
||||
.item(&find_in_vault)
|
||||
.item(&toggle_note_list_search)
|
||||
.item(&toggle_diff)
|
||||
.build()?)
|
||||
}
|
||||
@@ -452,6 +463,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on the note list being the active surface.
|
||||
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on having uncommitted changes.
|
||||
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled);
|
||||
@@ -486,6 +502,7 @@ mod tests {
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_EDITOR_ONLY,
|
||||
@@ -532,6 +549,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_list_search_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in NOTE_LIST_SEARCH_DEPENDENT_IDS {
|
||||
assert!(
|
||||
CUSTOM_IDS.contains(id),
|
||||
"note-list-search-dependent ID {id} not in CUSTOM_IDS"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in GIT_COMMIT_DEPENDENT_IDS {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters large datasets by search query', () => {
|
||||
it('filters large datasets by search query', async () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
|
||||
@@ -67,9 +67,11 @@ describe('NoteList virtualized datasets', () => {
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
|
||||
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts large datasets correctly', () => {
|
||||
|
||||
@@ -84,10 +84,16 @@ function renderManagedViewNoteList({
|
||||
}
|
||||
}
|
||||
|
||||
function searchNoteList(query: string) {
|
||||
async function searchNoteList(query: string) {
|
||||
const searchInput = screen.queryByPlaceholderText('Search notes...')
|
||||
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
function renderBookNoteList({
|
||||
@@ -117,11 +123,11 @@ function renderBookNoteList({
|
||||
})
|
||||
}
|
||||
|
||||
function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
||||
searchNoteList(matchingQuery)
|
||||
async function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
||||
await searchNoteList(matchingQuery)
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
|
||||
searchNoteList(hiddenQuery)
|
||||
await searchNoteList(hiddenQuery)
|
||||
expect(screen.queryByText(title)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
||||
}
|
||||
@@ -191,14 +197,14 @@ describe('NoteList rendering', () => {
|
||||
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by a case-insensitive search query', () => {
|
||||
it('filters by a case-insensitive search query', async () => {
|
||||
renderNoteList()
|
||||
searchNoteList('facebook')
|
||||
await searchNoteList('facebook')
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by snippet text when the title does not match', () => {
|
||||
it('filters by snippet text when the title does not match', async () => {
|
||||
renderNoteList({
|
||||
entries: [
|
||||
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
||||
@@ -206,13 +212,13 @@ describe('NoteList rendering', () => {
|
||||
],
|
||||
})
|
||||
|
||||
searchNoteList('nebula-only')
|
||||
await searchNoteList('nebula-only')
|
||||
|
||||
expect(screen.getByText('Beta Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by visible property values and ignores hidden properties', () => {
|
||||
it('filters by visible property values and ignores hidden properties', async () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Property Search Note',
|
||||
@@ -221,10 +227,10 @@ describe('NoteList rendering', () => {
|
||||
allNotesNoteListProperties: null,
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
||||
await expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
||||
})
|
||||
|
||||
it('uses the active all-notes columns when filtering by visible property values', () => {
|
||||
it('uses the active all-notes columns when filtering by visible property values', async () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Override Search Note',
|
||||
@@ -233,7 +239,7 @@ describe('NoteList rendering', () => {
|
||||
allNotesNoteListProperties: ['Owner'],
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
||||
await expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
||||
})
|
||||
|
||||
it('sorts entries by last modified descending by default', () => {
|
||||
@@ -322,7 +328,7 @@ describe('NoteList rendering', () => {
|
||||
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps existing neighborhood groups visible at zero after search filters them out', () => {
|
||||
it('keeps existing neighborhood groups visible at zero after search filters them out', async () => {
|
||||
const parent = makeEntry({
|
||||
path: '/vault/parent.md',
|
||||
filename: 'parent.md',
|
||||
@@ -344,7 +350,7 @@ describe('NoteList rendering', () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument()
|
||||
|
||||
searchNoteList('missing-neighborhood-match')
|
||||
await searchNoteList('missing-neighborhood-match')
|
||||
|
||||
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MagnifyingGlass, Plus } from '@phosphor-icons/react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,7 +10,7 @@ import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPr
|
||||
|
||||
const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4'
|
||||
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
@@ -19,12 +20,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
sidebarCollapsed?: boolean
|
||||
searchVisible: boolean
|
||||
search: string
|
||||
isSearching: boolean
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
propertyPicker?: ListPropertiesPopoverProps | null
|
||||
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
onCreateNote: () => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -51,7 +55,24 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
|
||||
</div>
|
||||
{searchVisible && (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search notes..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
className="h-8 text-[13px]"
|
||||
/>
|
||||
<div className="min-w-[92px] text-[12px] text-muted-foreground" aria-live="polite">
|
||||
{isSearching && (
|
||||
<span className="flex items-center gap-1" data-testid="note-list-search-loading">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Searching...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -192,12 +192,18 @@ export function NoteListLayout({
|
||||
sidebarCollapsed,
|
||||
searchVisible,
|
||||
search,
|
||||
isSearching,
|
||||
searchInputRef,
|
||||
propertyPicker,
|
||||
handleSortChange,
|
||||
handleCreateNote,
|
||||
onOpenType,
|
||||
toggleSearch,
|
||||
setSearch,
|
||||
handleSearchKeyDown,
|
||||
noteListPanelRef,
|
||||
handleNoteListPanelBlurCapture,
|
||||
handleNoteListPanelFocusCapture,
|
||||
handleListKeyDown,
|
||||
noteListContainerRef,
|
||||
handleNoteListBlur,
|
||||
@@ -230,8 +236,11 @@ export function NoteListLayout({
|
||||
}: NoteListLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
ref={noteListPanelRef}
|
||||
className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground"
|
||||
style={{ height: '100%' }}
|
||||
onBlurCapture={handleNoteListPanelBlurCapture}
|
||||
onFocusCapture={handleNoteListPanelFocusCapture}
|
||||
>
|
||||
<NoteListHeader
|
||||
title={title}
|
||||
@@ -243,12 +252,15 @@ export function NoteListLayout({
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
searchVisible={searchVisible}
|
||||
search={search}
|
||||
isSearching={isSearching}
|
||||
searchInputRef={searchInputRef}
|
||||
propertyPicker={propertyPicker}
|
||||
onSortChange={handleSortChange}
|
||||
onCreateNote={handleCreateNote}
|
||||
onOpenType={onOpenType}
|
||||
onToggleSearch={toggleSearch}
|
||||
onSearchChange={setSearch}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<NoteListBody
|
||||
handleListKeyDown={handleListKeyDown}
|
||||
|
||||
91
src/components/note-list/NoteListSearchKeyboard.test.tsx
Normal file
91
src/components/note-list/NoteListSearchKeyboard.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeIndexedEntry, renderNoteList } from '../../test-utils/noteListTestUtils'
|
||||
|
||||
function installAnimationFrameStub() {
|
||||
let nextId = 1
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
callbacks.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
|
||||
return {
|
||||
flushAnimationFrame: () => {
|
||||
const pending = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
pending.forEach((callback) => callback(0))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteList search keyboard behavior', () => {
|
||||
let flushAnimationFrame: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
;({ flushAnimationFrame } = installAnimationFrameStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('toggles note-list search with Cmd+F while the note list is active', () => {
|
||||
renderNoteList()
|
||||
const noteList = screen.getByTestId('note-list-container')
|
||||
|
||||
act(() => {
|
||||
noteList.focus()
|
||||
fireEvent.focus(noteList)
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true })
|
||||
})
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search notes...')
|
||||
act(() => {
|
||||
flushAnimationFrame()
|
||||
})
|
||||
expect(searchInput).toHaveFocus()
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true })
|
||||
flushAnimationFrame()
|
||||
})
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
||||
expect(noteList).toHaveFocus()
|
||||
})
|
||||
|
||||
it('debounces note-list filtering and shows loading feedback while waiting', () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 200 }, (_, index) => makeIndexedEntry(index + 1)),
|
||||
makeIndexedEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search notes...')
|
||||
fireEvent.change(searchInput, { target: { value: 'Strategy' } })
|
||||
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
expect(screen.getByText('Note 1')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(180)
|
||||
flushAnimationFrame()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Note 1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -264,22 +264,27 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi
|
||||
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
|
||||
}
|
||||
|
||||
function persistOrSaveGroupSort(
|
||||
groupLabel: string,
|
||||
option: SortOption,
|
||||
direction: SortDirection,
|
||||
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>,
|
||||
typeDocument: VaultEntry | null,
|
||||
selectedView: ViewFile | null,
|
||||
persistence: SortPersistence | null,
|
||||
) {
|
||||
const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence)
|
||||
if (!persistenceTarget || !persistence) {
|
||||
saveGroupSort(groupLabel, option, direction, setSortPrefs)
|
||||
function persistOrSaveGroupSort(params: {
|
||||
groupLabel: string
|
||||
option: SortOption
|
||||
direction: SortDirection
|
||||
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>
|
||||
typeDocument: VaultEntry | null
|
||||
selectedView: ViewFile | null
|
||||
persistence: SortPersistence | null
|
||||
}) {
|
||||
const persistenceTarget = resolveSortPersistenceTarget(
|
||||
params.groupLabel,
|
||||
params.typeDocument,
|
||||
params.selectedView,
|
||||
params.persistence,
|
||||
)
|
||||
if (!persistenceTarget || !params.persistence) {
|
||||
saveGroupSort(params.groupLabel, params.option, params.direction, params.setSortPrefs)
|
||||
return
|
||||
}
|
||||
|
||||
persistListSort(persistenceTarget, { option, direction }, persistence)
|
||||
persistListSort(persistenceTarget, { option: params.option, direction: params.direction }, params.persistence)
|
||||
}
|
||||
|
||||
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
|
||||
@@ -332,7 +337,7 @@ export function useNoteListSort({
|
||||
}, [typeDocument, sortPrefs, persistence])
|
||||
|
||||
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
|
||||
persistOrSaveGroupSort(
|
||||
persistOrSaveGroupSort({
|
||||
groupLabel,
|
||||
option,
|
||||
direction,
|
||||
@@ -340,7 +345,7 @@ export function useNoteListSort({
|
||||
typeDocument,
|
||||
selectedView,
|
||||
persistence,
|
||||
)
|
||||
})
|
||||
}, [typeDocument, selectedView, persistence])
|
||||
|
||||
const filteredEntries = useFilteredEntries({
|
||||
@@ -735,6 +740,59 @@ interface UseListPropertyPickerParams {
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
function resolvePropertyPicker(options: {
|
||||
selectedView: ViewFile | null
|
||||
viewAvailableProperties: string[]
|
||||
viewDefaultDisplay: string[]
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
isAllNotesView: boolean
|
||||
allNotesAvailableProperties: string[]
|
||||
hasCustomAllNotesProperties: boolean
|
||||
allNotesNoteListProperties?: string[] | null
|
||||
allNotesDefaultDisplay: string[]
|
||||
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
|
||||
isInboxView: boolean
|
||||
inboxAvailableProperties: string[]
|
||||
hasCustomInboxProperties: boolean
|
||||
inboxNoteListProperties?: string[] | null
|
||||
inboxDefaultDisplay: string[]
|
||||
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
||||
isSectionGroup: boolean
|
||||
typeDocument: VaultEntry | null
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
typeAvailableProperties: string[]
|
||||
}) {
|
||||
return buildViewPropertyPicker({
|
||||
selectedView: options.selectedView,
|
||||
availableProperties: options.viewAvailableProperties,
|
||||
defaultDisplay: options.viewDefaultDisplay,
|
||||
onUpdateViewDefinition: options.onUpdateViewDefinition,
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'all',
|
||||
isActive: options.isAllNotesView,
|
||||
availableProperties: options.allNotesAvailableProperties,
|
||||
hasCustomProperties: options.hasCustomAllNotesProperties,
|
||||
noteListProperties: options.allNotesNoteListProperties,
|
||||
defaultDisplay: options.allNotesDefaultDisplay,
|
||||
onSave: options.onUpdateAllNotesNoteListProperties,
|
||||
triggerTitle: 'Customize All Notes columns',
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'inbox',
|
||||
isActive: options.isInboxView,
|
||||
availableProperties: options.inboxAvailableProperties,
|
||||
hasCustomProperties: options.hasCustomInboxProperties,
|
||||
noteListProperties: options.inboxNoteListProperties,
|
||||
defaultDisplay: options.inboxDefaultDisplay,
|
||||
onSave: options.onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}) ?? buildTypePropertyPicker({
|
||||
isSectionGroup: options.isSectionGroup,
|
||||
typeDocument: options.typeDocument,
|
||||
onUpdateTypeSort: options.onUpdateTypeSort,
|
||||
typeAvailableProperties: options.typeAvailableProperties,
|
||||
})
|
||||
}
|
||||
|
||||
export function useListPropertyPicker({
|
||||
entries,
|
||||
selection,
|
||||
@@ -773,30 +831,23 @@ export function useListPropertyPicker({
|
||||
})
|
||||
|
||||
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
|
||||
return buildViewPropertyPicker({
|
||||
return resolvePropertyPicker({
|
||||
selectedView: viewState.selectedView,
|
||||
availableProperties: viewState.availableProperties,
|
||||
defaultDisplay: viewState.defaultDisplay,
|
||||
viewAvailableProperties: viewState.availableProperties,
|
||||
viewDefaultDisplay: viewState.defaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'all',
|
||||
isActive: isAllNotesView,
|
||||
availableProperties: allNotesState.availableProperties,
|
||||
hasCustomProperties: hasCustomAllNotesProperties,
|
||||
noteListProperties: allNotesNoteListProperties,
|
||||
defaultDisplay: allNotesState.defaultDisplay,
|
||||
onSave: onUpdateAllNotesNoteListProperties,
|
||||
triggerTitle: 'Customize All Notes columns',
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'inbox',
|
||||
isActive: isInboxView,
|
||||
availableProperties: inboxState.availableProperties,
|
||||
hasCustomProperties: hasCustomInboxProperties,
|
||||
noteListProperties: inboxNoteListProperties,
|
||||
defaultDisplay: inboxState.defaultDisplay,
|
||||
onSave: onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}) ?? buildTypePropertyPicker({
|
||||
isAllNotesView,
|
||||
allNotesAvailableProperties: allNotesState.availableProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
allNotesDefaultDisplay: allNotesState.defaultDisplay,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
isInboxView,
|
||||
inboxAvailableProperties: inboxState.availableProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
inboxDefaultDisplay: inboxState.defaultDisplay,
|
||||
onUpdateInboxNoteListProperties,
|
||||
isSectionGroup,
|
||||
typeDocument,
|
||||
onUpdateTypeSort,
|
||||
@@ -838,6 +889,8 @@ interface UseNoteListInteractionsParams {
|
||||
noteListFilter: NoteListFilter
|
||||
isChangesView: boolean
|
||||
entityEntry: VaultEntry | null
|
||||
searchVisible: boolean
|
||||
toggleSearch: () => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
|
||||
@@ -886,6 +939,8 @@ function useKeyboardInteractionState({
|
||||
searchedGroups,
|
||||
entityEntry,
|
||||
selectedNotePath,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -895,6 +950,8 @@ function useKeyboardInteractionState({
|
||||
| 'searchedGroups'
|
||||
| 'entityEntry'
|
||||
| 'selectedNotePath'
|
||||
| 'searchVisible'
|
||||
| 'toggleSearch'
|
||||
| 'onReplaceActiveTab'
|
||||
| 'onEnterNeighborhood'
|
||||
| 'onOpenDeletedNote'
|
||||
@@ -928,6 +985,8 @@ function useKeyboardInteractionState({
|
||||
onOpen: handleKeyboardOpen,
|
||||
onEnterNeighborhood: handleNeighborhoodOpen,
|
||||
onPrefetch: handleKeyboardPrefetch,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
enabled: true,
|
||||
})
|
||||
const multiSelect = useMultiSelect(keyboardEntries, selectedNotePath)
|
||||
@@ -1025,6 +1084,8 @@ export function useNoteListInteractions({
|
||||
noteListFilter,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -1040,6 +1101,8 @@ export function useNoteListInteractions({
|
||||
searchedGroups,
|
||||
entityEntry,
|
||||
selectedNotePath,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useEffect, useMemo, useCallback } from 'react'
|
||||
import type {
|
||||
VaultEntry,
|
||||
SidebarSelection,
|
||||
@@ -15,18 +15,19 @@ import { prefetchNoteContent } from '../../hooks/useTabManagement'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
|
||||
import { filterEntriesByNoteListQuery, filterGroupsByNoteListQuery } from './noteListSearch'
|
||||
import { useNoteListSearchState } from './useNoteListSearchState'
|
||||
import {
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
useModifiedFilesState,
|
||||
useNoteListData,
|
||||
useNoteListInteractions,
|
||||
useNoteListSearch,
|
||||
useNoteListSort,
|
||||
useTypeEntryMap,
|
||||
useVisibleNotesSync,
|
||||
} from './noteListHooks'
|
||||
import { useChangesContextMenu } from './NoteListChangesMenu'
|
||||
import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents'
|
||||
|
||||
type EntitySelection = Extract<SidebarSelection, { kind: 'entity' }>
|
||||
|
||||
@@ -138,7 +139,16 @@ function useNoteListContent({
|
||||
onUpdateViewDefinition,
|
||||
updateEntry,
|
||||
})
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const {
|
||||
closeSearch,
|
||||
isSearching,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
setSearch,
|
||||
toggleSearch,
|
||||
} = useNoteListSearchState()
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { displayPropsOverride, propertyPicker } = useListPropertyPicker({
|
||||
entries,
|
||||
@@ -191,15 +201,18 @@ function useNoteListContent({
|
||||
entityEntry,
|
||||
handleSortChange,
|
||||
isArchivedView,
|
||||
isSearching,
|
||||
isEntityView,
|
||||
listDirection,
|
||||
listSort,
|
||||
propertyPicker,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
searched,
|
||||
searchedGroups,
|
||||
closeSearch,
|
||||
setSearch,
|
||||
sortPrefs,
|
||||
toggleSearch,
|
||||
@@ -217,6 +230,8 @@ interface UseNoteListInteractionStateParams {
|
||||
isArchivedView: boolean
|
||||
isChangesView: boolean
|
||||
entityEntry: VaultEntry | null
|
||||
searchVisible: boolean
|
||||
toggleSearch: () => void
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
@@ -238,6 +253,8 @@ function useNoteListInteractionState({
|
||||
isArchivedView,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
modifiedFiles,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
@@ -266,6 +283,8 @@ function useNoteListInteractionState({
|
||||
noteListFilter,
|
||||
isChangesView,
|
||||
entityEntry,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
@@ -401,7 +420,9 @@ function buildNoteListLayoutModel(params: {
|
||||
filterCounts: ReturnType<typeof useFilterCounts>
|
||||
onNoteListFilterChange: (filter: NoteListFilter) => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
content: ReturnType<typeof useNoteListContent>
|
||||
content: ReturnType<typeof useNoteListContent> & {
|
||||
handleSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}
|
||||
interaction: ReturnType<typeof useNoteListInteractionState> & {
|
||||
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
|
||||
entitySelection: EntitySelection | null
|
||||
@@ -417,13 +438,19 @@ function buildNoteListLayoutModel(params: {
|
||||
sidebarCollapsed: params.sidebarCollapsed,
|
||||
searchVisible: params.content.searchVisible,
|
||||
search: params.content.search,
|
||||
isSearching: params.content.isSearching,
|
||||
searchInputRef: params.content.searchInputRef,
|
||||
propertyPicker: params.content.propertyPicker,
|
||||
handleSortChange: params.content.handleSortChange,
|
||||
handleCreateNote: params.interaction.handleCreateNote,
|
||||
onOpenType: params.onOpenType,
|
||||
toggleSearch: params.content.toggleSearch,
|
||||
setSearch: params.content.setSearch,
|
||||
handleSearchKeyDown: params.content.handleSearchKeyDown,
|
||||
handleListKeyDown: params.interaction.handleListKeyDown,
|
||||
noteListPanelRef: params.interaction.noteListKeyboard.panelRef,
|
||||
handleNoteListPanelBlurCapture: params.interaction.noteListKeyboard.handlePanelBlurCapture,
|
||||
handleNoteListPanelFocusCapture: params.interaction.noteListKeyboard.handlePanelFocusCapture,
|
||||
noteListContainerRef: params.interaction.noteListKeyboard.containerRef,
|
||||
handleNoteListBlur: params.interaction.noteListKeyboard.handleBlur,
|
||||
handleNoteListFocus: params.interaction.noteListKeyboard.handleFocus,
|
||||
@@ -518,6 +545,8 @@ export function useNoteListModel({
|
||||
isArchivedView: content.isArchivedView,
|
||||
isChangesView: selection.kind === 'filter' && selection.filter === 'changes',
|
||||
entityEntry: content.entityEntry,
|
||||
searchVisible: content.searchVisible,
|
||||
toggleSearch: content.toggleSearch,
|
||||
modifiedFiles,
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
@@ -543,6 +572,27 @@ export function useNoteListModel({
|
||||
multiSelect: interaction.multiSelect,
|
||||
noteListKeyboard: interaction.noteListKeyboard,
|
||||
})
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
|
||||
event.preventDefault()
|
||||
content.closeSearch()
|
||||
requestAnimationFrame(() => {
|
||||
interaction.noteListKeyboard.focusList()
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dispatchNoteListSearchAvailability(interaction.noteListKeyboard.isPanelActive)
|
||||
return () => dispatchNoteListSearchAvailability(false)
|
||||
}, [interaction.noteListKeyboard.isPanelActive])
|
||||
|
||||
useEffect(() => {
|
||||
return addNoteListSearchToggleListener(() => {
|
||||
if (!interaction.noteListKeyboard.isPanelActive) return
|
||||
interaction.noteListKeyboard.toggleSearchShortcut()
|
||||
})
|
||||
}, [interaction.noteListKeyboard.isPanelActive, interaction.noteListKeyboard.toggleSearchShortcut])
|
||||
|
||||
return buildNoteListLayoutModel({
|
||||
selection,
|
||||
@@ -553,7 +603,10 @@ export function useNoteListModel({
|
||||
noteListFilter,
|
||||
filterCounts,
|
||||
onNoteListFilterChange,
|
||||
content,
|
||||
content: {
|
||||
...content,
|
||||
handleSearchKeyDown,
|
||||
},
|
||||
interaction: {
|
||||
...interaction,
|
||||
renderItem,
|
||||
|
||||
70
src/components/note-list/useNoteListSearchState.ts
Normal file
70
src/components/note-list/useNoteListSearchState.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const NOTE_LIST_SEARCH_DEBOUNCE_MS = 180
|
||||
|
||||
function normalizeSearch(search: string): string {
|
||||
return search.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function useNoteListSearchState() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const normalizedSearch = normalizeSearch(search)
|
||||
const query = useDeferredValue(debouncedQuery)
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDebouncedQuery(normalizedSearch)
|
||||
}, NOTE_LIST_SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [normalizedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchVisible) return
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus()
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frameId)
|
||||
}, [searchVisible])
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearch('')
|
||||
setDebouncedQuery('')
|
||||
}, [])
|
||||
|
||||
const openSearch = useCallback(() => {
|
||||
setSearchVisible(true)
|
||||
}, [])
|
||||
|
||||
const closeSearch = useCallback(() => {
|
||||
setSearchVisible(false)
|
||||
clearSearch()
|
||||
}, [clearSearch])
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchVisible((visible) => {
|
||||
if (visible) clearSearch()
|
||||
return !visible
|
||||
})
|
||||
}, [clearSearch])
|
||||
|
||||
const isSearching = normalizedSearch.length > 0
|
||||
&& (normalizedSearch !== debouncedQuery || debouncedQuery !== query)
|
||||
|
||||
return {
|
||||
closeSearch,
|
||||
isSearching,
|
||||
openSearch,
|
||||
query,
|
||||
search,
|
||||
searchInputRef,
|
||||
searchVisible,
|
||||
setSearch,
|
||||
toggleSearch,
|
||||
}
|
||||
}
|
||||
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
104
src/hooks/useMenuEvents.noteListSearch.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchMenuEvent, useMenuEvents, type MenuEventHandlers } from './useMenuEvents'
|
||||
|
||||
const isTauriMock = vi.fn(() => false)
|
||||
const listenMock = vi.fn()
|
||||
const invokeMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => isTauriMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: (...args: unknown[]) => listenMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}))
|
||||
|
||||
function makeHandlers(): MenuEventHandlers {
|
||||
return {
|
||||
onSetViewMode: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onCreateType: vi.fn(),
|
||||
onQuickOpen: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onToggleInspector: vi.fn(),
|
||||
onCommandPalette: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
onToggleRawEditor: vi.fn(),
|
||||
onToggleDiff: vi.fn(),
|
||||
onToggleAIChat: vi.fn(),
|
||||
onGoBack: vi.fn(),
|
||||
onGoForward: vi.fn(),
|
||||
onCheckForUpdates: vi.fn(),
|
||||
onSelectFilter: vi.fn(),
|
||||
onOpenVault: vi.fn(),
|
||||
onRemoveActiveVault: vi.fn(),
|
||||
onRestoreGettingStarted: vi.fn(),
|
||||
onAddRemote: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
onRestoreDeletedNote: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
multiSelectionCommandRef: { current: null },
|
||||
activeTabPath: '/vault/test.md',
|
||||
hasRestorableDeletedNote: false,
|
||||
hasNoRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMenuEvents note-list search bridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isTauriMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('dispatches the note-list search toggle event for the native Cmd+F menu item', () => {
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('laputa:toggle-note-list-search', listener)
|
||||
|
||||
dispatchMenuEvent('edit-toggle-note-list-search', makeHandlers())
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
window.removeEventListener('laputa:toggle-note-list-search', listener)
|
||||
})
|
||||
|
||||
it('syncs note-list search availability into the native menu state', async () => {
|
||||
isTauriMock.mockReturnValue(true)
|
||||
listenMock.mockResolvedValue(vi.fn())
|
||||
|
||||
renderHook(() => useMenuEvents(makeHandlers()))
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:note-list-search-availability', {
|
||||
detail: { enabled: true },
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenLastCalledWith('update_menu_state', expect.objectContaining({
|
||||
state: expect.objectContaining({ noteListSearchEnabled: true }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
isAppCommandId,
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
import {
|
||||
NOTE_LIST_SEARCH_AVAILABILITY_EVENT,
|
||||
dispatchNoteListSearchToggle,
|
||||
readNoteListSearchAvailability,
|
||||
} from '../utils/noteListSearchEvents'
|
||||
|
||||
const NOTE_LIST_SEARCH_MENU_ID = 'edit-toggle-note-list-search'
|
||||
|
||||
export interface MenuEventHandlers extends AppCommandHandlers {
|
||||
activeTabPath: string | null
|
||||
@@ -21,6 +28,7 @@ interface MenuStatePayload {
|
||||
hasConflicts?: boolean
|
||||
hasRestorableDeletedNote?: boolean
|
||||
hasNoRemote?: boolean
|
||||
noteListSearchEnabled?: boolean
|
||||
}
|
||||
|
||||
function readCustomEventDetail(event: Event): string | null {
|
||||
@@ -118,8 +126,28 @@ function useNativeMenuStateSync(state: MenuStatePayload) {
|
||||
}, [state])
|
||||
}
|
||||
|
||||
function useNoteListSearchMenuState() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleAvailabilityEvent = (event: Event) => {
|
||||
const nextEnabled = readNoteListSearchAvailability(event)
|
||||
if (nextEnabled !== null) setEnabled(nextEnabled)
|
||||
}
|
||||
|
||||
window.addEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
return () => window.removeEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent)
|
||||
}, [])
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
|
||||
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
if (id === NOTE_LIST_SEARCH_MENU_ID) {
|
||||
dispatchNoteListSearchToggle()
|
||||
return
|
||||
}
|
||||
if (!isAppCommandId(id)) return
|
||||
executeAppCommand(id, h, 'native-menu')
|
||||
}
|
||||
@@ -127,6 +155,7 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
|
||||
export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
const ref = useRef(handlers)
|
||||
const noteListSearchEnabled = useNoteListSearchMenuState()
|
||||
const hasActiveNote = handlers.activeTabPath !== null
|
||||
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
|
||||
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
|
||||
@@ -146,5 +175,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
|
||||
hasConflicts,
|
||||
hasRestorableDeletedNote,
|
||||
hasNoRemote,
|
||||
noteListSearchEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface NoteListKeyboardOptions {
|
||||
onOpen: (entry: VaultEntry) => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
onPrefetch?: (entry: VaultEntry) => void
|
||||
searchVisible?: boolean
|
||||
toggleSearch?: () => void
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
@@ -55,6 +57,12 @@ function isListActive(container: HTMLDivElement | null): boolean {
|
||||
return activeElement instanceof Node && container.contains(activeElement)
|
||||
}
|
||||
|
||||
function isPanelActive(panel: HTMLDivElement | null): boolean {
|
||||
if (!panel) return false
|
||||
const activeElement = document.activeElement
|
||||
return activeElement instanceof Node && panel.contains(activeElement)
|
||||
}
|
||||
|
||||
function isEditableElement(element: Element | null): boolean {
|
||||
if (!element) return false
|
||||
if (
|
||||
@@ -119,6 +127,13 @@ function usesCommandModifier(event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey'>):
|
||||
return event.metaKey || event.ctrlKey
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function isNeighborhoodKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
|
||||
return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey
|
||||
}
|
||||
@@ -334,6 +349,7 @@ function useProcessKeyDown({
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
onToggleSearchShortcut,
|
||||
}: {
|
||||
enabled: boolean
|
||||
items: VaultEntry[]
|
||||
@@ -342,34 +358,25 @@ function useProcessKeyDown({
|
||||
flushOpen: (entry?: VaultEntry) => void
|
||||
cancelOpen: () => void
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
|
||||
onToggleSearchShortcut?: () => void
|
||||
}) {
|
||||
return useCallback((event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
|
||||
if (!enabled || items.length === 0) return
|
||||
return useCallback((event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>) => {
|
||||
if (!enabled) return
|
||||
|
||||
if (isNeighborhoodKey(event)) {
|
||||
handleNeighborhoodActivation({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (usesCommandModifier(event) || event.altKey) return
|
||||
|
||||
if (handleArrowNavigation(event, moveHighlight)) return
|
||||
|
||||
if (event.key !== 'Enter') return
|
||||
|
||||
handleHighlightedOpen({
|
||||
if (handleSearchShortcutEvent(event, onToggleSearchShortcut)) return
|
||||
if (items.length === 0) return
|
||||
if (handleNeighborhoodShortcutEvent({
|
||||
event,
|
||||
items,
|
||||
highlightedPathRef,
|
||||
flushOpen,
|
||||
})
|
||||
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood])
|
||||
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])
|
||||
}
|
||||
|
||||
function useFocusHandlers({
|
||||
@@ -402,44 +409,194 @@ function useFocusHandlers({
|
||||
return { focusList, handleBlur, handleFocus }
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function useGlobalKeyboardHandling({
|
||||
enabled,
|
||||
panelRef,
|
||||
containerRef,
|
||||
processKeyDown,
|
||||
}: {
|
||||
enabled: boolean
|
||||
panelRef: React.RefObject<HTMLDivElement | null>
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
processKeyDown: (event: KeyboardEvent) => void
|
||||
}) {
|
||||
const shouldSkipGlobalKeyDown = useCallback((activeElement: Element | null) => {
|
||||
if (isEditableElement(activeElement)) return true
|
||||
return (
|
||||
activeElement !== containerRef.current
|
||||
&& containerRef.current?.contains(activeElement)
|
||||
&& isInteractiveElement(activeElement)
|
||||
)
|
||||
}, [containerRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
const activeElement = document.activeElement
|
||||
if (isEditableElement(activeElement)) return
|
||||
if (
|
||||
activeElement !== containerRef.current
|
||||
&& containerRef.current?.contains(activeElement)
|
||||
&& isInteractiveElement(activeElement)
|
||||
) return
|
||||
processKeyDown(event)
|
||||
}
|
||||
const handleWindowKeyDown = createGlobalKeyDownHandler(panelRef, shouldSkipGlobalKeyDown, processKeyDown)
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [containerRef, enabled, processKeyDown])
|
||||
}, [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)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteListKeyboard({
|
||||
items, selectedNotePath, onOpen, onEnterNeighborhood, onPrefetch, enabled,
|
||||
items,
|
||||
selectedNotePath,
|
||||
onOpen,
|
||||
onEnterNeighborhood,
|
||||
onPrefetch,
|
||||
searchVisible = false,
|
||||
toggleSearch,
|
||||
enabled,
|
||||
}: NoteListKeyboardOptions) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath)
|
||||
const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath()
|
||||
const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath)
|
||||
const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled)
|
||||
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
})
|
||||
const { handlePanelBlurCapture, handlePanelFocusCapture, isPanelActive: isPanelActiveState } = usePanelFocusState(panelRef)
|
||||
const handleToggleSearchShortcut = useSearchToggleShortcut({
|
||||
focusList,
|
||||
searchVisible,
|
||||
toggleSearch,
|
||||
})
|
||||
const moveHighlight = useMoveHighlight({
|
||||
items,
|
||||
selectedNotePath,
|
||||
@@ -457,32 +614,28 @@ export function useNoteListKeyboard({
|
||||
flushOpen,
|
||||
cancelOpen,
|
||||
onEnterNeighborhood,
|
||||
onToggleSearchShortcut: handleToggleSearchShortcut,
|
||||
})
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
|
||||
processKeyDown(event)
|
||||
}, [processKeyDown])
|
||||
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
|
||||
containerRef,
|
||||
syncToCurrentSelection,
|
||||
syncHighlightedPath,
|
||||
})
|
||||
useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown })
|
||||
const handleKeyDown = useDirectKeyDownHandler(processKeyDown)
|
||||
useGlobalKeyboardHandling({ enabled, panelRef, containerRef, processKeyDown })
|
||||
useEffect(() => {
|
||||
cancelOpen()
|
||||
}, [cancelOpen, selectedNotePath])
|
||||
|
||||
const highlightedPath = getItemIndex(items).entryByPath.has(highlightedPathState ?? '')
|
||||
? highlightedPathState
|
||||
: null
|
||||
const highlightedPath = resolveStableHighlightedPath(items, highlightedPathState)
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
focusList,
|
||||
handlePanelBlurCapture,
|
||||
handlePanelFocusCapture,
|
||||
highlightedPath,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
isPanelActive: isPanelActiveState,
|
||||
panelRef,
|
||||
toggleSearchShortcut: handleToggleSearchShortcut,
|
||||
virtuosoRef,
|
||||
}
|
||||
}
|
||||
|
||||
34
src/utils/noteListSearchEvents.ts
Normal file
34
src/utils/noteListSearchEvents.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const NOTE_LIST_SEARCH_AVAILABILITY_EVENT = 'laputa:note-list-search-availability'
|
||||
export const NOTE_LIST_SEARCH_TOGGLE_EVENT = 'laputa:toggle-note-list-search'
|
||||
|
||||
interface NoteListSearchAvailabilityDetail {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function isAvailabilityDetail(detail: unknown): detail is NoteListSearchAvailabilityDetail {
|
||||
return typeof detail === 'object'
|
||||
&& detail !== null
|
||||
&& 'enabled' in detail
|
||||
&& typeof (detail as { enabled?: unknown }).enabled === 'boolean'
|
||||
}
|
||||
|
||||
export function dispatchNoteListSearchAvailability(enabled: boolean) {
|
||||
window.dispatchEvent(new CustomEvent<NoteListSearchAvailabilityDetail>(
|
||||
NOTE_LIST_SEARCH_AVAILABILITY_EVENT,
|
||||
{ detail: { enabled } },
|
||||
))
|
||||
}
|
||||
|
||||
export function readNoteListSearchAvailability(event: Event): boolean | null {
|
||||
if (!(event instanceof CustomEvent) || !isAvailabilityDetail(event.detail)) return null
|
||||
return event.detail.enabled
|
||||
}
|
||||
|
||||
export function dispatchNoteListSearchToggle() {
|
||||
window.dispatchEvent(new Event(NOTE_LIST_SEARCH_TOGGLE_EVENT))
|
||||
}
|
||||
|
||||
export function addNoteListSearchToggleListener(listener: () => void): () => void {
|
||||
window.addEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener)
|
||||
return () => window.removeEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener)
|
||||
}
|
||||
41
tests/smoke/note-list-search-keyboard-toggle.spec.ts
Normal file
41
tests/smoke/note-list-search-keyboard-toggle.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
|
||||
const FIND_SHORTCUT = process.platform === 'darwin' ? 'Meta+F' : 'Control+F'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.describe('Note-list search keyboard toggle', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('Cmd+F toggles note-list search and debounces filtering @smoke', async ({ page }) => {
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
|
||||
await noteList.focus()
|
||||
await page.keyboard.press(FIND_SHORTCUT)
|
||||
|
||||
const searchInput = page.getByPlaceholder('Search notes...')
|
||||
await expect(searchInput).toBeFocused()
|
||||
|
||||
await page.keyboard.type('Team')
|
||||
await expect(page.getByTestId('note-list-search-loading')).toBeVisible()
|
||||
await expect(noteList.getByText('Team Meeting', { exact: true })).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.keyboard.press(FIND_SHORTCUT)
|
||||
await expect(searchInput).toHaveCount(0)
|
||||
await expect(noteList).toBeFocused()
|
||||
})
|
||||
})
|
||||
@@ -31,11 +31,14 @@ async function searchAndOpenByKeyboard(
|
||||
expectedTitle: string,
|
||||
expectedFilenameStem: string,
|
||||
) {
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
const searchInput = page.getByPlaceholder('Search notes...')
|
||||
await searchInput.focus()
|
||||
await page.keyboard.press(SELECT_ALL_SHORTCUT)
|
||||
await page.keyboard.type(query)
|
||||
await expect(page.getByTestId('note-list-container').getByText(expectedTitle, { exact: true })).toBeVisible()
|
||||
await expect(page.getByTestId('note-list-search-loading')).toBeVisible()
|
||||
await expect(page.getByTestId('note-list-search-loading')).toHaveCount(0)
|
||||
await expect(noteList.getByText(expectedTitle, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
Reference in New Issue
Block a user