mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +00:00
fix(ui): restore searchable combobox keyboard navigation
This commit is contained in:
@@ -40,6 +40,10 @@ function normalizeSearch(value: string): string {
|
|||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOptionId(listboxId: string, value: string): string {
|
||||||
|
return `${listboxId}-option-${value.replace(/[^a-z0-9_-]+/gi, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function SearchableSelect({
|
export function SearchableSelect({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -55,7 +59,10 @@ export function SearchableSelect({
|
|||||||
}: SearchableSelectProps) {
|
}: SearchableSelectProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [activeOptionValue, setActiveOptionValue] = React.useState<string>();
|
||||||
|
const listboxId = React.useId();
|
||||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const optionRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
|
||||||
const selectedOption = React.useMemo(
|
const selectedOption = React.useMemo(
|
||||||
() => options.find((option) => option.value === value),
|
() => options.find((option) => option.value === value),
|
||||||
@@ -90,11 +97,83 @@ export function SearchableSelect({
|
|||||||
return [{ key: '__default', options: ungrouped }, ...grouped];
|
return [{ key: '__default', options: ungrouped }, ...grouped];
|
||||||
}, [filteredOptions, groups]);
|
}, [filteredOptions, groups]);
|
||||||
|
|
||||||
|
const enabledFilteredOptions = React.useMemo(
|
||||||
|
() => filteredOptions.filter((option) => !option.disabled),
|
||||||
|
[filteredOptions]
|
||||||
|
);
|
||||||
|
|
||||||
const selectedContent = selectedOption?.triggerContent ?? selectedOption?.itemContent;
|
const selectedContent = selectedOption?.triggerContent ?? selectedOption?.itemContent;
|
||||||
|
|
||||||
const handleOpenChange = (nextOpen: boolean) => {
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
setOpen(nextOpen);
|
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 (
|
return (
|
||||||
@@ -103,10 +182,27 @@ export function SearchableSelect({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="listbox"
|
aria-controls={open ? listboxId : undefined}
|
||||||
|
aria-haspopup="dialog"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||||
|
event.preventDefault();
|
||||||
|
handleOpenChange(true);
|
||||||
|
|
||||||
|
const setInitialActiveOption = () => {
|
||||||
|
focusSearchInput();
|
||||||
|
moveActiveOption(event.key === 'ArrowDown' ? 'first' : 'last');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
|
requestAnimationFrame(setInitialActiveOption);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(setInitialActiveOption, 0);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-between font-normal',
|
'w-full justify-between font-normal',
|
||||||
className,
|
className,
|
||||||
@@ -125,12 +221,11 @@ export function SearchableSelect({
|
|||||||
className={cn('w-[var(--radix-popover-trigger-width)] p-0', contentClassName)}
|
className={cn('w-[var(--radix-popover-trigger-width)] p-0', contentClassName)}
|
||||||
onOpenAutoFocus={(event) => {
|
onOpenAutoFocus={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const focusInput = () => searchInputRef.current?.focus();
|
|
||||||
if (typeof requestAnimationFrame === 'function') {
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
requestAnimationFrame(focusInput);
|
requestAnimationFrame(focusSearchInput);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTimeout(focusInput, 0);
|
setTimeout(focusSearchInput, 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="border-b p-2">
|
<div className="border-b p-2">
|
||||||
@@ -140,6 +235,45 @@ export function SearchableSelect({
|
|||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
role="combobox"
|
||||||
|
aria-label={searchPlaceholder}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={
|
||||||
|
activeOptionValue ? getOptionId(listboxId, activeOptionValue) : undefined
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveOption('next');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveOption('previous');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Home') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveOption('first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'End') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveOption('last');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectActiveOption();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
@@ -150,7 +284,7 @@ export function SearchableSelect({
|
|||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyText}</div>
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyText}</div>
|
||||||
) : (
|
) : (
|
||||||
<div role="listbox" aria-label={placeholder} className="p-1">
|
<div id={listboxId} role="listbox" aria-label={placeholder} className="p-1">
|
||||||
{groupedOptions.map((group) => (
|
{groupedOptions.map((group) => (
|
||||||
<div key={group.key}>
|
<div key={group.key}>
|
||||||
{group.label && (
|
{group.label && (
|
||||||
@@ -159,21 +293,31 @@ export function SearchableSelect({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{group.options.map((option) => {
|
{group.options.map((option) => {
|
||||||
|
const isActive = option.value === activeOptionValue;
|
||||||
const isSelected = option.value === value;
|
const isSelected = option.value === value;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
id={getOptionId(listboxId, option.value)}
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
onClick={() => {
|
tabIndex={-1}
|
||||||
onChange(option.value);
|
ref={(node) => {
|
||||||
handleOpenChange(false);
|
optionRefs.current[option.value] = node;
|
||||||
}}
|
}}
|
||||||
|
onMouseMove={() => {
|
||||||
|
if (!option.disabled) setActiveOptionValue(option.value);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!option.disabled) setActiveOptionValue(option.value);
|
||||||
|
}}
|
||||||
|
onClick={() => selectOption(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none',
|
'hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none',
|
||||||
'focus-visible:ring-ring focus-visible:ring-1',
|
'focus-visible:ring-ring focus-visible:ring-1',
|
||||||
|
isActive && 'bg-accent text-accent-foreground',
|
||||||
isSelected && 'bg-accent text-accent-foreground',
|
isSelected && 'bg-accent text-accent-foreground',
|
||||||
option.disabled && 'pointer-events-none opacity-50'
|
option.disabled && 'pointer-events-none opacity-50'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -65,12 +65,13 @@ describe('SearchableSelect', () => {
|
|||||||
it('autofocuses the search input, filters options, and updates the selection', async () => {
|
it('autofocuses the search input, filters options, and updates the selection', async () => {
|
||||||
render(<SearchableSelectHarness />);
|
render(<SearchableSelectHarness />);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('combobox'));
|
await userEvent.click(screen.getByRole('button', { name: 'Select model' }));
|
||||||
|
|
||||||
const searchInput = await screen.findByPlaceholderText('Search models...');
|
const searchInput = await screen.findByPlaceholderText('Search models...');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(searchInput).toHaveFocus();
|
expect(searchInput).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
expect(searchInput).toHaveAttribute('role', 'combobox');
|
||||||
|
|
||||||
await userEvent.type(searchInput, 'gpt');
|
await userEvent.type(searchInput, 'gpt');
|
||||||
|
|
||||||
@@ -80,16 +81,59 @@ describe('SearchableSelect', () => {
|
|||||||
await userEvent.click(screen.getByRole('option', { name: 'GPT-5.3 Codex' }));
|
await userEvent.click(screen.getByRole('option', { name: 'GPT-5.3 Codex' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('combobox')).toHaveTextContent('GPT-5.3 Codex');
|
expect(screen.getByRole('button', { name: 'GPT-5.3 Codex' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the empty state when the search query has no matches', async () => {
|
it('shows the empty state when the search query has no matches', async () => {
|
||||||
render(<SearchableSelectHarness />);
|
render(<SearchableSelectHarness />);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('combobox'));
|
await userEvent.click(screen.getByRole('button', { name: 'Select model' }));
|
||||||
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'no-match');
|
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'no-match');
|
||||||
|
|
||||||
expect(screen.getByText('No results found.')).toBeInTheDocument();
|
expect(screen.getByText('No results found.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports keyboard navigation and selection from the search input', async () => {
|
||||||
|
render(<SearchableSelectHarness />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Select model' }));
|
||||||
|
|
||||||
|
const searchInput = await screen.findByRole('combobox', { name: 'Search models...' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(searchInput).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(searchInput).toHaveAttribute(
|
||||||
|
'aria-activedescendant',
|
||||||
|
expect.stringContaining('claude-sonnet-4')
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.keyboard('[ArrowDown]');
|
||||||
|
expect(searchInput).toHaveAttribute(
|
||||||
|
'aria-activedescendant',
|
||||||
|
expect.stringContaining('gpt-5-3-codex')
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.keyboard('[Enter]');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'GPT-5.3 Codex' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens from the trigger with arrow keys', async () => {
|
||||||
|
render(<SearchableSelectHarness />);
|
||||||
|
|
||||||
|
await userEvent.keyboard('[Tab][ArrowDown]');
|
||||||
|
|
||||||
|
const searchInput = await screen.findByRole('combobox', { name: 'Search models...' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(searchInput).toHaveFocus();
|
||||||
|
});
|
||||||
|
expect(searchInput).toHaveAttribute(
|
||||||
|
'aria-activedescendant',
|
||||||
|
expect.stringContaining('claude-sonnet-4')
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user