diff --git a/src/components/NoteList.rendering.test.tsx b/src/components/NoteList.rendering.test.tsx index 9ad3530c..515d6cc1 100644 --- a/src/components/NoteList.rendering.test.tsx +++ b/src/components/NoteList.rendering.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents' import { @@ -209,7 +209,7 @@ describe('NoteList rendering', () => { expect(screen.queryByText('Luca')).not.toBeInTheDocument() }) - it('opens the all-notes column picker from the global event and saves new columns', () => { + it('opens the all-notes column picker as a searchable combobox and saves new columns', async () => { const onUpdateAllNotesNoteListProperties = vi.fn() const archivedOwnerEntry = makeEntry({ path: '/vault/book-archive.md', @@ -235,11 +235,22 @@ describe('NoteList rendering', () => { }) expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + expect(screen.getByTestId('list-properties-scroll-area')).toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked() expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument() + const combobox = screen.getByRole('combobox', { name: 'Search note-list properties' }) + await waitFor(() => expect(combobox).toHaveFocus()) + + fireEvent.change(combobox, { target: { value: 'Owner' } }) + expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: 'Priority' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' })) expect(onUpdateAllNotesNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner']) + + fireEvent.keyDown(combobox, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByTestId('list-properties-popover')).not.toBeInTheDocument()) }) it('opens the inbox column picker from the global event and saves new columns', () => { @@ -257,6 +268,7 @@ describe('NoteList rendering', () => { }) expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Search note-list properties' })).toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked() fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' })) @@ -275,6 +287,7 @@ describe('NoteList rendering', () => { }) expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Search note-list properties' })).toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'status' })).toBeInTheDocument() }) diff --git a/src/components/note-list/ListPropertiesPopover.tsx b/src/components/note-list/ListPropertiesPopover.tsx index 7c574f15..2b305e1a 100644 --- a/src/components/note-list/ListPropertiesPopover.tsx +++ b/src/components/note-list/ListPropertiesPopover.tsx @@ -1,8 +1,10 @@ -import { useState, useMemo, useCallback, useEffect } from 'react' +import { useState, useMemo, useCallback, useEffect, useId, useRef, type KeyboardEvent, type RefObject } from 'react' import { SlidersHorizontal, DotsSixVertical } from '@phosphor-icons/react' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import { OPEN_NOTE_LIST_PROPERTIES_EVENT, @@ -44,6 +46,16 @@ function getOrderedItems(currentDisplay: NoteListPropertyKey[], availablePropert return [...selected, ...unselected] } +function normalizePropertyQuery(query: string) { + return query.trim().toLowerCase() +} + +function filterOrderedItems(orderedItems: NoteListPropertyKey[], query: string) { + const normalized = normalizePropertyQuery(query) + if (normalized === '') return orderedItems + return orderedItems.filter((property) => property.toLowerCase().includes(normalized)) +} + function toggleDisplayProperty(currentDisplay: NoteListPropertyKey[], selectedSet: Set, key: NoteListPropertyKey) { if (selectedSet.has(key)) { const filtered = currentDisplay.filter((property) => property !== key) @@ -109,23 +121,174 @@ function SortablePropertyItem({ id, checked, onToggle }: { id: NoteListPropertyK ) } -export function ListPropertiesPopover({ +function ListPropertiesSearchInput({ + inputRef, + query, + open, + listboxId, + onQueryChange, + onKeyDown, +}: { + inputRef: RefObject + query: string + open: boolean + listboxId: string + onQueryChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void +}) { + return ( +
+ onQueryChange(event.target.value)} + onKeyDown={onKeyDown} + placeholder="Search properties..." + role="combobox" + aria-autocomplete="list" + aria-controls={listboxId} + aria-expanded={open} + aria-label="Search note-list properties" + className="h-8 text-[13px]" + data-testid="list-properties-combobox-input" + /> +
+ ) +} + +function ListPropertiesOptionsList({ + listboxId, + filteredItems, + selectedSet, + sensors, + onDragEnd, + onToggle, +}: { + listboxId: string + filteredItems: NoteListPropertyKey[] + selectedSet: Set + sensors: ReturnType + onDragEnd: (event: DragEndEvent) => void + onToggle: (key: string) => void +}) { + return ( + + {filteredItems.length === 0 ? ( +
+ No properties match this search. +
+ ) : ( + +
+ + {filteredItems.map((key) => ( + + ))} + +
+
+ )} +
+ ) +} + +function ListPropertiesPopoverPanel({ + inputRef, + query, + open, + listboxId, + filteredItems, + selectedSet, + sensors, + onQueryChange, + onSearchKeyDown, + onPanelKeyDown, + onDragEnd, + onToggle, +}: { + inputRef: RefObject + query: string + open: boolean + listboxId: string + filteredItems: NoteListPropertyKey[] + selectedSet: Set + sensors: ReturnType + onQueryChange: (value: string) => void + onSearchKeyDown: (event: KeyboardEvent) => void + onPanelKeyDown: (event: KeyboardEvent) => void + onDragEnd: (event: DragEndEvent) => void + onToggle: (key: string) => void +}) { + return ( + event.preventDefault()} + data-testid="list-properties-popover" + > +
+
+ Show in note list +
+ + +
+
+ ) +} + +function handleEscapeKey(event: KeyboardEvent, closePopover: () => void) { + if (event.key !== 'Escape') return + event.preventDefault() + closePopover() +} + +function useListPropertiesPopoverState({ scope, availableProperties, currentDisplay, onSave, - triggerTitle, - triggerClassName, -}: ListPropertiesPopoverProps) { +}: Pick) { const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const inputRef = useRef(null) + const listboxId = useId() + const closePopover = useCallback(() => { + setQuery('') + setOpen(false) + }, []) const orderedItems = useMemo( () => getOrderedItems(currentDisplay, availableProperties), [availableProperties, currentDisplay], ) - - const selectedSet = useMemo(() => new Set(currentDisplay), [currentDisplay]) - + const filteredItems = useMemo( + () => filterOrderedItems(orderedItems, query), + [orderedItems, query], + ) + const selectedSet = useMemo( + () => new Set(getSelectedProperties(currentDisplay, availableProperties)), + [availableProperties, currentDisplay], + ) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ) @@ -139,6 +302,11 @@ export function ListPropertiesPopover({ return () => window.removeEventListener(OPEN_NOTE_LIST_PROPERTIES_EVENT, handler) }, [scope]) + useEffect(() => { + if (!open) return + requestAnimationFrame(() => inputRef.current?.focus()) + }, [open]) + const handleToggle = useCallback((key: string) => { const nextSelected = toggleDisplayProperty(currentDisplay, selectedSet, key) onSave(nextSelected) @@ -151,10 +319,64 @@ export function ListPropertiesPopover({ onSave(reordered) }, [availableProperties, currentDisplay, onSave]) + const handleSearchKeyDown = useCallback((event: KeyboardEvent) => { + handleEscapeKey(event, closePopover) + }, [closePopover]) + + const handlePanelKeyDown = useCallback((event: KeyboardEvent) => { + handleEscapeKey(event, closePopover) + }, [closePopover]) + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) setQuery('') + }, []) + + return { + open, + query, + inputRef, + listboxId, + filteredItems, + selectedSet, + sensors, + setQuery, + handleSearchKeyDown, + handlePanelKeyDown, + handleDragEnd, + handleOpenChange, + handleToggle, + } +} + +export function ListPropertiesPopover({ + scope, + availableProperties, + currentDisplay, + onSave, + triggerTitle, + triggerClassName, +}: ListPropertiesPopoverProps) { + const { + open, + query, + inputRef, + listboxId, + filteredItems, + selectedSet, + sensors, + setQuery, + handleSearchKeyDown, + handlePanelKeyDown, + handleDragEnd, + handleOpenChange, + handleToggle, + } = useListPropertiesPopoverState({ scope, availableProperties, currentDisplay, onSave }) + if (availableProperties.length === 0) return null return ( - + + > + + - -
- Show in note list -
- - - {orderedItems.map((key) => ( - - ))} - - -
+
) }