feat: add searchable filter field combobox

This commit is contained in:
lucaronin
2026-04-08 21:15:49 +02:00
parent c3a9400f35
commit cffac2881a
3 changed files with 295 additions and 40 deletions

View File

@@ -27,6 +27,12 @@ describe('FilterBuilder value inputs', () => {
)
}
function openFieldCombobox() {
const input = screen.getByTestId('filter-field-combobox-input')
fireEvent.focus(input)
return input
}
it('renders a plain text input for text operators', () => {
renderBuilder()
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
@@ -129,7 +135,74 @@ describe('FilterBuilder value inputs', () => {
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
})
it('shows body field in field dropdown separated from property fields', () => {
it('filters the field combobox as the user types', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
onChange={vi.fn()}
availableFields={['type', 'status', 'title', 'Owner']}
/>,
)
const input = openFieldCombobox()
fireEvent.change(input, { target: { value: 'tit' } })
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
expect(screen.queryByTestId('filter-field-option-status')).not.toBeInTheDocument()
expect(screen.queryByTestId('filter-field-option-Owner')).not.toBeInTheDocument()
})
it('supports keyboard navigation and Enter selection in the field combobox', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
onChange={onChange}
availableFields={['title', 'status', 'Owner']}
/>,
)
const input = openFieldCombobox()
fireEvent.change(input, { target: { value: 'sta' } })
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
all: [{ field: 'status', op: 'contains', value: '' }],
}),
)
})
it('shows an empty state when no field matches the search text', () => {
renderBuilder()
const input = openFieldCombobox()
fireEvent.change(input, { target: { value: 'zzz' } })
expect(screen.getByTestId('filter-field-combobox-empty')).toHaveTextContent('No results')
})
it('reopens the field combobox with the selected field prefilled and all options visible', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'status', op: 'contains', value: '' }] }}
onChange={vi.fn()}
availableFields={['type', 'status', 'title']}
/>,
)
const input = openFieldCombobox()
expect(input).toHaveValue('status')
expect(screen.getByTestId('filter-field-option-type')).toBeInTheDocument()
expect(screen.getByTestId('filter-field-option-status')).toBeInTheDocument()
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('filter-field-combobox-options')).not.toBeInTheDocument()
})
it('shows body field in the searchable field combobox', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'body', op: 'contains', value: 'test' }] }}
@@ -138,6 +211,8 @@ describe('FilterBuilder value inputs', () => {
/>,
)
expect(screen.getByText('body')).toBeInTheDocument()
openFieldCombobox()
expect(screen.getByTestId('filter-field-option-body')).toBeInTheDocument()
})
})

View File

@@ -4,10 +4,11 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
import { parseDateFilterInput } from '../utils/filterDates'
import { FilterFieldCombobox } from './FilterFieldCombobox'
const OPERATORS: { value: FilterOp; label: string }[] = [
{ value: 'equals', label: 'equals' },
@@ -58,42 +59,6 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
return mode === 'all' ? { all: children } : { any: children }
}
const CONTENT_FIELDS = new Set(['body'])
function FieldSelect({ value, fields, onChange }: {
value: string
fields: string[]
onChange: (v: string) => void
}) {
const isCustom = value !== '' && !fields.includes(value)
const propertyFields = fields.filter(f => !CONTENT_FIELDS.has(f))
const contentFields = fields.filter(f => CONTENT_FIELDS.has(f))
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
size="sm"
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
>
<SelectValue placeholder="field" />
</SelectTrigger>
<SelectContent position="popper">
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
{propertyFields.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
{contentFields.length > 0 && (
<>
<SelectSeparator />
{contentFields.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
)
}
function OperatorSelect({ value, onChange }: {
value: FilterOp
onChange: (v: FilterOp) => void
@@ -217,7 +182,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
return (
<div className="flex items-center gap-1.5">
<FieldSelect
<FilterFieldCombobox
value={condition.field}
fields={fields}
onChange={(v) => onUpdate({ ...condition, field: v })}

View File

@@ -0,0 +1,215 @@
import { CaretUpDown } from '@phosphor-icons/react'
import { useId, useMemo, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const CONTENT_FIELDS = new Set(['body'])
interface FilterFieldComboboxProps {
value: string
fields: string[]
onChange: (value: string) => void
}
interface FieldGroup {
key: 'property' | 'content'
label: string
options: string[]
}
function normalizeFieldQuery(query: string): string {
return query.trim().toLowerCase()
}
function buildFieldGroups(fields: string[], currentValue: string, query: string): FieldGroup[] {
const allFields = currentValue !== '' && !fields.includes(currentValue)
? [currentValue, ...fields]
: fields
const normalized = normalizeFieldQuery(query)
const matches = (field: string) => normalized === '' || field.toLowerCase().includes(normalized)
const propertyOptions = allFields.filter((field) => !CONTENT_FIELDS.has(field) && matches(field))
const contentOptions = allFields.filter((field) => CONTENT_FIELDS.has(field) && matches(field))
const groups: FieldGroup[] = []
if (propertyOptions.length > 0) groups.push({ key: 'property', label: 'Properties', options: propertyOptions })
if (contentOptions.length > 0) groups.push({ key: 'content', label: 'Content', options: contentOptions })
return groups
}
function flattenGroups(groups: FieldGroup[]): string[] {
return groups.flatMap((group) => group.options)
}
function initialHighlightIndex(options: string[], currentValue: string): number {
if (options.length === 0) return -1
const currentIndex = options.indexOf(currentValue)
return currentIndex >= 0 ? currentIndex : 0
}
function optionTestId(field: string): string {
return `filter-field-option-${field.replace(/[^a-z0-9_-]+/gi, '-')}`
}
export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComboboxProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState(value)
const [hasTyped, setHasTyped] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listboxId = useId()
const effectiveQuery = hasTyped ? query : ''
const fieldGroups = useMemo(() => buildFieldGroups(fields, value, effectiveQuery), [fields, value, effectiveQuery])
const options = useMemo(() => flattenGroups(fieldGroups), [fieldGroups])
const resetToCurrentValue = () => {
setQuery(value)
setHasTyped(false)
setHighlightedIndex(initialHighlightIndex(flattenGroups(buildFieldGroups(fields, value, '')), value))
}
const openCombobox = () => {
resetToCurrentValue()
setOpen(true)
requestAnimationFrame(() => inputRef.current?.select())
}
const closeCombobox = () => {
setOpen(false)
resetToCurrentValue()
}
const selectOption = (nextValue: string) => {
onChange(nextValue)
setQuery(nextValue)
setHasTyped(false)
setHighlightedIndex(-1)
setOpen(false)
}
return (
<div
ref={rootRef}
className="relative flex-1 min-w-[160px]"
onBlur={(event) => {
if (rootRef.current?.contains(event.relatedTarget as Node | null)) return
closeCombobox()
}}
data-testid="filter-field-combobox"
>
<Input
ref={inputRef}
value={open ? query : value}
onFocus={() => openCombobox()}
onChange={(event) => {
const nextQuery = event.target.value
const nextGroups = buildFieldGroups(fields, value, nextQuery)
const nextOptions = flattenGroups(nextGroups)
setOpen(true)
setQuery(nextQuery)
setHasTyped(true)
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => {
if (current < 0) return 0
return (current + 1) % options.length
})
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => {
if (current < 0) return options.length - 1
return (current - 1 + options.length) % options.length
})
return
}
if (event.key === 'Enter') {
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
event.preventDefault()
selectOption(options[highlightedIndex])
return
}
if (event.key === 'Escape') {
if (!open) return
event.preventDefault()
closeCombobox()
}
}}
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={open}
aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
className="h-8 pr-7 text-sm"
data-testid="filter-field-combobox-input"
/>
<CaretUpDown
size={14}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
{open && (
<div
id={listboxId}
role="listbox"
className="absolute left-0 top-full z-50 mt-1 max-h-60 w-full min-w-[220px] overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md"
data-testid="filter-field-combobox-options"
>
{options.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground" data-testid="filter-field-combobox-empty">
No results
</div>
) : (
fieldGroups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && <div className="my-1 border-t border-border" />}
{group.options.map((field) => {
const optionIndex = options.indexOf(field)
return (
<button
key={field}
id={`${listboxId}-option-${optionIndex}`}
type="button"
role="option"
aria-selected={optionIndex === highlightedIndex}
className={cn(
'flex w-full items-center rounded px-2 py-1.5 text-left text-sm',
optionIndex === highlightedIndex
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
onClick={() => selectOption(field)}
data-testid={optionTestId(field)}
>
<span className="truncate">{field}</span>
</button>
)
})}
</div>
))
)}
</div>
)}
</div>
)
}