From cd76c0d65d6bb0ff5d6755bde51f08e085e36542 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 14 Apr 2026 18:48:56 +0200 Subject: [PATCH] feat: add searchable type combobox --- src/components/TypeSelector.test.tsx | 90 ++++++++ src/components/TypeSelector.tsx | 329 ++++++++++++++++++++++++--- 2 files changed, 383 insertions(+), 36 deletions(-) create mode 100644 src/components/TypeSelector.test.tsx diff --git a/src/components/TypeSelector.test.tsx b/src/components/TypeSelector.test.tsx new file mode 100644 index 00000000..5cefa3c0 --- /dev/null +++ b/src/components/TypeSelector.test.tsx @@ -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> = {}) { + const onUpdateProperty = vi.fn() + render( + , + ) + 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') + }) +}) diff --git a/src/components/TypeSelector.tsx b/src/components/TypeSelector.tsx index 1ced6812..9f4c5e72 100644 --- a/src/components/TypeSelector.tsx +++ b/src/components/TypeSelector.tsx @@ -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; typeIconKeys: Record +interface TypeSelectorItemProps { + type: string + typeColorKeys: Record + typeIconKeys: Record +} + +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) { + 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 + typeIconKeys: Record +}) { + if (!isA) return None + + return ( + + + + ) +} + 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 typeIconKeys: Record onUpdateProperty?: (key: string, value: FrontmatterValue) => void onNavigate?: (target: string) => void +} + +export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) { + if (!onUpdateProperty) return + + return +} + +function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty }: Omit & { + onUpdateProperty: (key: string, value: FrontmatterValue) => void }) { - if (!onUpdateProperty) return - - 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(null) + const inputRef = useRef(null) + const listRef = useRef(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(`[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) => { + if (event.button !== 0) return + event.preventDefault() + openCombobox() + } + + const handleTriggerKeyDown = (event: KeyboardEvent) => { + 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) => { + 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 (
Type -
- -
+ + +
+ +
+
+ event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + > +
+ handleSearchChange(event.target.value)} + onKeyDown={handleSearchKeyDown} + /> +
+
+ {options.length === 0 ? ( +
+ No matching types +
+ ) : ( +
+ {options.map((type, index) => { + const selected = type === currentValue + const highlighted = index === highlightedIndex + return ( + + ) + })} +
+ )} +
+
+
) }