From 5fe96b74b9ab7fdd9e658fc967139f5d182d9305 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Mar 2026 09:42:38 -0400 Subject: [PATCH] fix(ui): restore searchable combobox keyboard navigation --- ui/src/components/ui/searchable-select.tsx | 164 ++++++++++++++++-- .../components/ui/searchable-select.test.tsx | 50 +++++- 2 files changed, 201 insertions(+), 13 deletions(-) diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx index a110441f..9da3602e 100644 --- a/ui/src/components/ui/searchable-select.tsx +++ b/ui/src/components/ui/searchable-select.tsx @@ -40,6 +40,10 @@ function normalizeSearch(value: string): string { return value.trim().toLowerCase(); } +function getOptionId(listboxId: string, value: string): string { + return `${listboxId}-option-${value.replace(/[^a-z0-9_-]+/gi, '-')}`; +} + export function SearchableSelect({ value, onChange, @@ -55,7 +59,10 @@ export function SearchableSelect({ }: SearchableSelectProps) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); + const [activeOptionValue, setActiveOptionValue] = React.useState(); + const listboxId = React.useId(); const searchInputRef = React.useRef(null); + const optionRefs = React.useRef>({}); const selectedOption = React.useMemo( () => options.find((option) => option.value === value), @@ -90,11 +97,83 @@ export function SearchableSelect({ return [{ key: '__default', options: ungrouped }, ...grouped]; }, [filteredOptions, groups]); + const enabledFilteredOptions = React.useMemo( + () => filteredOptions.filter((option) => !option.disabled), + [filteredOptions] + ); + const selectedContent = selectedOption?.triggerContent ?? selectedOption?.itemContent; const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); - if (!nextOpen) setQuery(''); + if (!nextOpen) { + setQuery(''); + setActiveOptionValue(undefined); + } + }; + + const focusSearchInput = () => searchInputRef.current?.focus(); + + React.useEffect(() => { + if (!open) return; + if (enabledFilteredOptions.length === 0) { + setActiveOptionValue(undefined); + return; + } + + setActiveOptionValue((currentValue) => { + if (currentValue && enabledFilteredOptions.some((option) => option.value === currentValue)) { + return currentValue; + } + + return ( + enabledFilteredOptions.find((option) => option.value === value)?.value ?? + enabledFilteredOptions[0]?.value + ); + }); + }, [enabledFilteredOptions, open, value]); + + React.useEffect(() => { + if (!open || !activeOptionValue) return; + optionRefs.current[activeOptionValue]?.scrollIntoView({ block: 'nearest' }); + }, [activeOptionValue, open]); + + const moveActiveOption = (direction: 'next' | 'previous' | 'first' | 'last') => { + if (enabledFilteredOptions.length === 0) return; + if (direction === 'first') { + setActiveOptionValue(enabledFilteredOptions[0]?.value); + return; + } + if (direction === 'last') { + setActiveOptionValue(enabledFilteredOptions.at(-1)?.value); + return; + } + + const currentIndex = enabledFilteredOptions.findIndex( + (option) => option.value === activeOptionValue + ); + const fallbackIndex = direction === 'next' ? -1 : enabledFilteredOptions.length; + const startIndex = currentIndex >= 0 ? currentIndex : fallbackIndex; + const nextIndex = + direction === 'next' + ? Math.min(startIndex + 1, enabledFilteredOptions.length - 1) + : Math.max(startIndex - 1, 0); + + setActiveOptionValue(enabledFilteredOptions[nextIndex]?.value); + }; + + const selectOption = (nextValue: string) => { + onChange(nextValue); + handleOpenChange(false); + }; + + const selectActiveOption = () => { + if (!activeOptionValue) return; + const activeOption = enabledFilteredOptions.find( + (option) => option.value === activeOptionValue + ); + if (!activeOption) return; + selectOption(activeOption.value); }; return ( @@ -103,10 +182,27 @@ export function SearchableSelect({