feat: add searchable type combobox
This commit is contained in:
90
src/components/TypeSelector.test.tsx
Normal file
90
src/components/TypeSelector.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TypeSelector } from './TypeSelector'
|
||||
|
||||
const AVAILABLE_TYPES = ['Project', 'Person', 'Topic']
|
||||
|
||||
function renderTypeSelector(overrides: Partial<ComponentProps<typeof TypeSelector>> = {}) {
|
||||
const onUpdateProperty = vi.fn()
|
||||
render(
|
||||
<TypeSelector
|
||||
isA={null}
|
||||
customColorKey={null}
|
||||
availableTypes={AVAILABLE_TYPES}
|
||||
typeColorKeys={{ Project: null, Person: null, Topic: null }}
|
||||
typeIconKeys={{ Project: null, Person: null, Topic: null }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
return { onUpdateProperty }
|
||||
}
|
||||
|
||||
function openTypeCombobox() {
|
||||
fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' })
|
||||
}
|
||||
|
||||
describe('TypeSelector', () => {
|
||||
it('opens from the keyboard and focuses the search input', async () => {
|
||||
renderTypeSelector({ isA: 'Project' })
|
||||
|
||||
const trigger = screen.getByRole('combobox')
|
||||
trigger.focus()
|
||||
fireEvent.keyDown(trigger, { key: 'Enter' })
|
||||
|
||||
const searchInput = screen.getByTestId('type-selector-search-input')
|
||||
await waitFor(() => expect(searchInput).toHaveFocus())
|
||||
expect(screen.getByRole('option', { name: 'Project' })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('filters available types as the user types', () => {
|
||||
renderTypeSelector()
|
||||
|
||||
openTypeCombobox()
|
||||
fireEvent.change(screen.getByTestId('type-selector-search-input'), { target: { value: 'per' } })
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Person' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Project' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Topic' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects the highlighted type with ArrowDown and Enter', () => {
|
||||
const { onUpdateProperty } = renderTypeSelector()
|
||||
|
||||
openTypeCombobox()
|
||||
const searchInput = screen.getByTestId('type-selector-search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'p' } })
|
||||
fireEvent.keyDown(searchInput, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(searchInput, { key: 'Enter' })
|
||||
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('type', 'Project')
|
||||
})
|
||||
|
||||
it('clears the current type when None is selected', () => {
|
||||
const { onUpdateProperty } = renderTypeSelector({ isA: 'Project' })
|
||||
|
||||
openTypeCombobox()
|
||||
fireEvent.click(screen.getByRole('option', { name: 'None' }))
|
||||
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('type', null)
|
||||
})
|
||||
|
||||
it('closes on Escape without changing the type', () => {
|
||||
const { onUpdateProperty } = renderTypeSelector({ isA: 'Project' })
|
||||
|
||||
openTypeCombobox()
|
||||
fireEvent.keyDown(screen.getByTestId('type-selector-search-input'), { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByTestId('type-selector-search-input')).not.toBeInTheDocument()
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a custom current type even when it is not in the available list', () => {
|
||||
renderTypeSelector({ isA: 'Custom Type' })
|
||||
|
||||
openTypeCombobox()
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Custom Type' })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,68 @@
|
||||
import { CaretUpDown, Check } from '@phosphor-icons/react'
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
|
||||
import { PROPERTY_PANEL_ROW_STYLE } from './propertyPanelLayout'
|
||||
|
||||
const TYPE_NONE = '__none__'
|
||||
const MIN_POPOVER_WIDTH = 220
|
||||
const OPEN_COMBOBOX_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', ' '])
|
||||
|
||||
function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
type: string; typeColorKeys: Record<string, string | null>; typeIconKeys: Record<string, string | null>
|
||||
interface TypeSelectorItemProps {
|
||||
type: string
|
||||
typeColorKeys: Record<string, string | null>
|
||||
typeIconKeys: Record<string, string | null>
|
||||
}
|
||||
|
||||
function normalizeTypeQuery({ query }: { query: string }): string {
|
||||
return query.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function buildTypeOptions({
|
||||
availableTypes,
|
||||
currentType,
|
||||
query,
|
||||
}: {
|
||||
availableTypes: string[]
|
||||
currentType: string | null | undefined
|
||||
query: string
|
||||
}) {
|
||||
const normalized = normalizeTypeQuery({ query })
|
||||
const types = currentType && !availableTypes.includes(currentType)
|
||||
? [...availableTypes, currentType]
|
||||
: [...availableTypes]
|
||||
const sortedTypes = types.sort((left, right) => left.localeCompare(right))
|
||||
const filteredTypes = sortedTypes.filter((type) => normalized === '' || type.toLowerCase().includes(normalized))
|
||||
|
||||
if (normalized !== '') return filteredTypes
|
||||
return [TYPE_NONE, ...filteredTypes]
|
||||
}
|
||||
|
||||
function initialHighlightedIndex({ options, currentValue }: { options: string[]; currentValue: string }): number {
|
||||
if (options.length === 0) return -1
|
||||
const currentIndex = options.indexOf(currentValue)
|
||||
return currentIndex >= 0 ? currentIndex : 0
|
||||
}
|
||||
|
||||
function stepHighlightedIndex(current: number, optionsLength: number, direction: 'next' | 'previous') {
|
||||
if (optionsLength === 0) return -1
|
||||
if (current < 0) return direction === 'next' ? 0 : optionsLength - 1
|
||||
return direction === 'next'
|
||||
? (current + 1) % optionsLength
|
||||
: (current - 1 + optionsLength) % optionsLength
|
||||
}
|
||||
|
||||
function shouldOpenCombobox(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
return OPEN_COMBOBOX_KEYS.has(event.key)
|
||||
}
|
||||
|
||||
function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: TypeSelectorItemProps) {
|
||||
const Icon = getTypeIcon(type, typeIconKeys[type])
|
||||
const color = getTypeColor(type, typeColorKeys[type])
|
||||
return (
|
||||
@@ -21,6 +74,24 @@ function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
)
|
||||
}
|
||||
|
||||
function TypeSelectorValue({
|
||||
isA,
|
||||
typeColorKeys,
|
||||
typeIconKeys,
|
||||
}: {
|
||||
isA?: string | null
|
||||
typeColorKeys: Record<string, string | null>
|
||||
typeIconKeys: Record<string, string | null>
|
||||
}) {
|
||||
if (!isA) return <span className="truncate text-muted-foreground">None</span>
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<TypeSelectorItem type={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
@@ -47,50 +118,236 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
|
||||
)
|
||||
}
|
||||
|
||||
export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty, onNavigate }: {
|
||||
isA?: string | null; customColorKey?: string | null; availableTypes: string[]
|
||||
interface TypeSelectorProps {
|
||||
isA?: string | null
|
||||
customColorKey?: string | null
|
||||
availableTypes: string[]
|
||||
typeColorKeys: Record<string, string | null>
|
||||
typeIconKeys: Record<string, string | null>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
}
|
||||
|
||||
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {
|
||||
if (!onUpdateProperty) return <ReadOnlyType isA={props.isA} customColorKey={props.customColorKey} onNavigate={props.onNavigate} />
|
||||
|
||||
return <EditableTypeSelector {...props} onUpdateProperty={onUpdateProperty} />
|
||||
}
|
||||
|
||||
function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty }: Omit<TypeSelectorProps, 'onUpdateProperty'> & {
|
||||
onUpdateProperty: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
if (!onUpdateProperty) return <ReadOnlyType isA={isA} customColorKey={customColorKey} onNavigate={onNavigate} />
|
||||
|
||||
const currentValue = isA || TYPE_NONE
|
||||
const options = isA && !availableTypes.includes(isA)
|
||||
? [...availableTypes, isA].sort((a, b) => a.localeCompare(b))
|
||||
: availableTypes
|
||||
|
||||
const currentValue = isA ?? TYPE_NONE
|
||||
const typeColor = isA ? getTypeColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
|
||||
const typeLightColor = isA ? getTypeLightColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const [contentWidth, setContentWidth] = useState(MIN_POPOVER_WIDTH)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const listboxId = useId()
|
||||
const options = useMemo(
|
||||
() => buildTypeOptions({ availableTypes, currentType: isA, query }),
|
||||
[availableTypes, isA, query],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = rootRef.current?.getBoundingClientRect().width ?? MIN_POPOVER_WIDTH
|
||||
setContentWidth(Math.max(nextWidth, MIN_POPOVER_WIDTH))
|
||||
}
|
||||
|
||||
updateWidth()
|
||||
const frame = requestAnimationFrame(() => inputRef.current?.focus())
|
||||
window.addEventListener('resize', updateWidth)
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
window.removeEventListener('resize', updateWidth)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const openCombobox = () => {
|
||||
const nextOptions = buildTypeOptions({ availableTypes, currentType: isA, query: '' })
|
||||
setQuery('')
|
||||
setHighlightedIndex(initialHighlightedIndex({ options: nextOptions, currentValue }))
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const closeCombobox = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
|
||||
const selectType = (value: string) => {
|
||||
onUpdateProperty('type', value === TYPE_NONE ? null : value)
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
const scrollHighlightedOptionIntoView = (index: number) => {
|
||||
if (index < 0) return
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
const moveHighlight = (direction: 'next' | 'previous') => {
|
||||
setHighlightedIndex((current) => {
|
||||
const nextIndex = stepHighlightedIndex(current, options.length, direction)
|
||||
scrollHighlightedOptionIntoView(nextIndex)
|
||||
return nextIndex
|
||||
})
|
||||
}
|
||||
|
||||
const handleTriggerPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
openCombobox()
|
||||
}
|
||||
|
||||
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!shouldOpenCombobox(event)) return
|
||||
event.preventDefault()
|
||||
openCombobox()
|
||||
}
|
||||
|
||||
const handleSearchChange = (query: string) => {
|
||||
const nextOptions = buildTypeOptions({ availableTypes, currentType: isA, query })
|
||||
setQuery(query)
|
||||
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
|
||||
}
|
||||
|
||||
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
moveHighlight('next')
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
moveHighlight('previous')
|
||||
return
|
||||
case 'Enter':
|
||||
if (highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectType(options[highlightedIndex])
|
||||
return
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" style={PROPERTY_PANEL_ROW_STYLE} data-testid="type-selector">
|
||||
<span className="text-[12px] shrink-0 text-muted-foreground">Type</span>
|
||||
<div className="min-w-0">
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={`h-auto max-w-full gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
|
||||
style={{
|
||||
...PROPERTY_CHIP_STYLE,
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
<SelectItem value={TYPE_NONE}>None</SelectItem>
|
||||
<SelectSeparator />
|
||||
{options.map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<div ref={rootRef} className="min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
'h-auto max-w-full justify-between gap-1 border-none px-2 shadow-none ring-inset [&_svg]:text-current',
|
||||
isA ? 'hover:ring-1 hover:ring-current' : 'bg-muted hover:bg-muted/80',
|
||||
)}
|
||||
style={{
|
||||
...PROPERTY_CHIP_STYLE,
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
}}
|
||||
onPointerDown={handleTriggerPointerDown}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1 truncate">
|
||||
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</span>
|
||||
<CaretUpDown size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="left"
|
||||
sideOffset={4}
|
||||
className="overflow-hidden p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="border-b border-border p-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder="Search types..."
|
||||
autoComplete="off"
|
||||
aria-label="Search types"
|
||||
className="h-8 text-sm"
|
||||
data-testid="type-selector-search-input"
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
No matching types
|
||||
</div>
|
||||
) : (
|
||||
<div id={listboxId} role="listbox">
|
||||
{options.map((type, index) => {
|
||||
const selected = type === currentValue
|
||||
const highlighted = index === highlightedIndex
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'h-auto w-full justify-between px-2 py-1.5 text-left font-normal',
|
||||
highlighted && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onClick={() => selectType(type)}
|
||||
>
|
||||
{type === TYPE_NONE ? (
|
||||
<span className="truncate text-muted-foreground">None</span>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2 truncate">
|
||||
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</span>
|
||||
)}
|
||||
{selected ? <Check size={14} aria-hidden="true" /> : null}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user