fix: make note-list property pickers searchable

This commit is contained in:
lucaronin
2026-04-16 10:25:56 +02:00
parent f5b1d763c1
commit 4e66f09eef
2 changed files with 263 additions and 31 deletions

View File

@@ -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()
})

View File

@@ -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<NoteListPropertyKey>, 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<HTMLInputElement | null>
query: string
open: boolean
listboxId: string
onQueryChange: (value: string) => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
}) {
return (
<div className="mb-2">
<Input
ref={inputRef}
value={query}
onChange={(event) => 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"
/>
</div>
)
}
function ListPropertiesOptionsList({
listboxId,
filteredItems,
selectedSet,
sensors,
onDragEnd,
onToggle,
}: {
listboxId: string
filteredItems: NoteListPropertyKey[]
selectedSet: Set<NoteListPropertyKey>
sensors: ReturnType<typeof useSensors>
onDragEnd: (event: DragEndEvent) => void
onToggle: (key: string) => void
}) {
return (
<ScrollArea className="max-h-60" data-testid="list-properties-scroll-area">
{filteredItems.length === 0 ? (
<div className="px-1 py-2 text-[12px] text-muted-foreground">
No properties match this search.
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<div id={listboxId} role="listbox" aria-multiselectable="true" className="pr-3">
<SortableContext items={filteredItems} strategy={verticalListSortingStrategy}>
{filteredItems.map((key) => (
<SortablePropertyItem
key={key}
id={key}
checked={selectedSet.has(key)}
onToggle={onToggle}
/>
))}
</SortableContext>
</div>
</DndContext>
)}
</ScrollArea>
)
}
function ListPropertiesPopoverPanel({
inputRef,
query,
open,
listboxId,
filteredItems,
selectedSet,
sensors,
onQueryChange,
onSearchKeyDown,
onPanelKeyDown,
onDragEnd,
onToggle,
}: {
inputRef: RefObject<HTMLInputElement | null>
query: string
open: boolean
listboxId: string
filteredItems: NoteListPropertyKey[]
selectedSet: Set<NoteListPropertyKey>
sensors: ReturnType<typeof useSensors>
onQueryChange: (value: string) => void
onSearchKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
onPanelKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
onDragEnd: (event: DragEndEvent) => void
onToggle: (key: string) => void
}) {
return (
<PopoverContent
align="end"
className="w-64 p-2"
onOpenAutoFocus={(event) => event.preventDefault()}
data-testid="list-properties-popover"
>
<div onKeyDownCapture={onPanelKeyDown}>
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">
Show in note list
</div>
<ListPropertiesSearchInput
inputRef={inputRef}
query={query}
open={open}
listboxId={listboxId}
onQueryChange={onQueryChange}
onKeyDown={onSearchKeyDown}
/>
<ListPropertiesOptionsList
listboxId={listboxId}
filteredItems={filteredItems}
selectedSet={selectedSet}
sensors={sensors}
onDragEnd={onDragEnd}
onToggle={onToggle}
/>
</div>
</PopoverContent>
)
}
function handleEscapeKey(event: KeyboardEvent<HTMLInputElement | HTMLDivElement>, closePopover: () => void) {
if (event.key !== 'Escape') return
event.preventDefault()
closePopover()
}
function useListPropertiesPopoverState({
scope,
availableProperties,
currentDisplay,
onSave,
triggerTitle,
triggerClassName,
}: ListPropertiesPopoverProps) {
}: Pick<ListPropertiesPopoverProps, 'scope' | 'availableProperties' | 'currentDisplay' | 'onSave'>) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
handleEscapeKey(event, closePopover)
}, [closePopover])
const handlePanelKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
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 (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
@@ -164,27 +386,24 @@ export function ListPropertiesPopover({
title={triggerTitle}
aria-label={triggerTitle}
data-testid="list-properties-btn"
>
<SlidersHorizontal size={16} />
</Button>
>
<SlidersHorizontal size={16} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-2" data-testid="list-properties-popover">
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">
Show in note list
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={orderedItems} strategy={verticalListSortingStrategy}>
{orderedItems.map((key) => (
<SortablePropertyItem
key={key}
id={key}
checked={selectedSet.has(key)}
onToggle={handleToggle}
/>
))}
</SortableContext>
</DndContext>
</PopoverContent>
<ListPropertiesPopoverPanel
inputRef={inputRef}
query={query}
open={open}
listboxId={listboxId}
filteredItems={filteredItems}
selectedSet={selectedSet}
sensors={sensors}
onQueryChange={setQuery}
onSearchKeyDown={handleSearchKeyDown}
onPanelKeyDown={handlePanelKeyDown}
onDragEnd={handleDragEnd}
onToggle={handleToggle}
/>
</Popover>
)
}