Skip to content

Commit 82093ae

Browse files
committed
feat(ui): Phase 1 — SearchableSelect combobox component
Autonomous decisions: - Controlled DropdownMenu open state — enables chevron rotate-on-open matching SpaceSwitcher - Fixed "New…" label on create-new row — no label prop in interface; Phase 2 wraps with i18n - "No matches" empty state — avoids a blank panel when the filter excludes all options - SearchField autoFocus on open — standard typeahead combobox UX Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent b1680a5 commit 82093ae

3 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Tests for SearchableSelect component
3+
*/
4+
5+
import { describe, it, expect, vi } from 'vitest';
6+
import { render, screen, fireEvent } from '@testing-library/react';
7+
import { SearchableSelect } from './SearchableSelect';
8+
9+
const OPTIONS = [
10+
{ value: 'apple', label: 'Apple', icon: '🍎' },
11+
{ value: 'banana', label: 'Banana', icon: '🍌' },
12+
{ value: 'cherry', label: 'Cherry', icon: '🍒' },
13+
] as const;
14+
15+
describe('SearchableSelect', () => {
16+
it('renders placeholder when no value selected', () => {
17+
const mockOnChange = vi.fn();
18+
render(
19+
<SearchableSelect
20+
value=""
21+
onChange={mockOnChange}
22+
options={[...OPTIONS]}
23+
placeholder="Select a fruit"
24+
testId="fruit-select"
25+
/>
26+
);
27+
28+
expect(screen.getByTestId('fruit-select')).toHaveTextContent('Select a fruit');
29+
});
30+
31+
it('filters options by typed text', () => {
32+
const mockOnChange = vi.fn();
33+
render(
34+
<SearchableSelect
35+
value=""
36+
onChange={mockOnChange}
37+
options={[...OPTIONS]}
38+
placeholder="Select a fruit"
39+
testId="fruit-select"
40+
/>
41+
);
42+
43+
fireEvent.click(screen.getByTestId('fruit-select'));
44+
45+
const searchInput = screen.getByTestId('fruit-select-search');
46+
fireEvent.change(searchInput, { target: { value: 'ban' } });
47+
48+
expect(screen.getByTestId('fruit-select-option-banana')).toBeInTheDocument();
49+
expect(screen.queryByTestId('fruit-select-option-apple')).not.toBeInTheDocument();
50+
expect(screen.queryByTestId('fruit-select-option-cherry')).not.toBeInTheDocument();
51+
});
52+
53+
it('calls onChange when an option is clicked', () => {
54+
const mockOnChange = vi.fn();
55+
render(
56+
<SearchableSelect
57+
value=""
58+
onChange={mockOnChange}
59+
options={[...OPTIONS]}
60+
placeholder="Select a fruit"
61+
testId="fruit-select"
62+
/>
63+
);
64+
65+
fireEvent.click(screen.getByTestId('fruit-select'));
66+
fireEvent.click(screen.getByTestId('fruit-select-option-cherry'));
67+
68+
expect(mockOnChange).toHaveBeenCalledWith('cherry');
69+
expect(mockOnChange).toHaveBeenCalledTimes(1);
70+
});
71+
72+
it('calls onCreateNew when the create-new row is clicked', () => {
73+
const mockOnChange = vi.fn();
74+
const mockOnCreateNew = vi.fn();
75+
render(
76+
<SearchableSelect
77+
value=""
78+
onChange={mockOnChange}
79+
options={[...OPTIONS]}
80+
placeholder="Select a fruit"
81+
onCreateNew={mockOnCreateNew}
82+
testId="fruit-select"
83+
/>
84+
);
85+
86+
fireEvent.click(screen.getByTestId('fruit-select'));
87+
fireEvent.click(screen.getByTestId('fruit-select-create-new'));
88+
89+
expect(mockOnCreateNew).toHaveBeenCalledTimes(1);
90+
expect(mockOnChange).not.toHaveBeenCalled();
91+
});
92+
});
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { useMemo, useState } from 'react';
2+
import { ChevronDown, Check, Plus } from 'lucide-react';
3+
import { cn } from '../../lib/cn';
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuSeparator,
8+
DropdownMenuTrigger,
9+
} from './DropdownMenu';
10+
import { SearchField } from './SearchField';
11+
12+
export interface SearchableSelectOption<T extends string> {
13+
value: T;
14+
label: string;
15+
icon?: string;
16+
}
17+
18+
export interface SearchableSelectProps<T extends string> {
19+
value: T;
20+
onChange: (value: T) => void;
21+
options: SearchableSelectOption<T>[];
22+
placeholder: string;
23+
onCreateNew?: () => void;
24+
disabled?: boolean;
25+
testId?: string;
26+
}
27+
28+
/**
29+
* Typeahead-filterable combobox built from DropdownMenu and SearchField.
30+
*/
31+
export function SearchableSelect<T extends string>({
32+
value,
33+
onChange,
34+
options,
35+
placeholder,
36+
onCreateNew,
37+
disabled = false,
38+
testId,
39+
}: SearchableSelectProps<T>) {
40+
const [open, setOpen] = useState(false);
41+
const [filter, setFilter] = useState('');
42+
43+
const selectedOption = options.find((option) => option.value === value);
44+
45+
const filteredOptions = useMemo(() => {
46+
const query = filter.trim().toLowerCase();
47+
if (!query) {
48+
return options;
49+
}
50+
return options.filter((option) => option.label.toLowerCase().includes(query));
51+
}, [filter, options]);
52+
53+
/**
54+
* Sync open state and reset the filter when the panel closes.
55+
*/
56+
const handleOpenChange = (nextOpen: boolean) => {
57+
if (disabled && nextOpen) {
58+
return;
59+
}
60+
setOpen(nextOpen);
61+
if (!nextOpen) {
62+
setFilter('');
63+
}
64+
};
65+
66+
/**
67+
* Select an option, notify the parent, and close the panel.
68+
*/
69+
const handleSelect = (optionValue: T) => {
70+
onChange(optionValue);
71+
setOpen(false);
72+
setFilter('');
73+
};
74+
75+
/**
76+
* Invoke the optional create-new callback and close the panel.
77+
*/
78+
const handleCreateNew = () => {
79+
onCreateNew?.();
80+
setOpen(false);
81+
setFilter('');
82+
};
83+
84+
return (
85+
<DropdownMenu open={open} onOpenChange={handleOpenChange} className="w-full">
86+
<DropdownMenuTrigger
87+
data-testid={testId}
88+
className={cn(
89+
'w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] transition-all duration-150 group',
90+
disabled
91+
? 'opacity-50 cursor-not-allowed'
92+
: 'hover:bg-[rgb(var(--surface-hover))] hover:border-[rgb(var(--primary))/30] cursor-pointer'
93+
)}
94+
aria-disabled={disabled}
95+
>
96+
<span className="flex items-center gap-3 min-w-0">
97+
{selectedOption?.icon && (
98+
<span className="text-xl flex-shrink-0">{selectedOption.icon}</span>
99+
)}
100+
<span
101+
className={cn(
102+
'font-medium text-sm truncate',
103+
selectedOption ? 'text-[rgb(var(--foreground))]' : 'text-[rgb(var(--muted))]'
104+
)}
105+
>
106+
{selectedOption?.label ?? placeholder}
107+
</span>
108+
</span>
109+
<ChevronDown
110+
className={cn(
111+
'h-4 w-4 flex-shrink-0 text-[rgb(var(--muted))] group-hover:text-[rgb(var(--foreground))] transition-all duration-200',
112+
open && 'rotate-180'
113+
)}
114+
/>
115+
</DropdownMenuTrigger>
116+
117+
<DropdownMenuContent align="start" className="w-full min-w-[16rem]">
118+
<div className="p-1.5 border-b border-[rgb(var(--border-subtle))]">
119+
<SearchField
120+
value={filter}
121+
onChange={(event) => setFilter(event.target.value)}
122+
onClear={() => setFilter('')}
123+
placeholder="Search…"
124+
autoFocus
125+
data-testid={testId ? `${testId}-search` : undefined}
126+
onClick={(event) => event.stopPropagation()}
127+
onKeyDown={(event) => event.stopPropagation()}
128+
/>
129+
</div>
130+
131+
<div className="p-1.5 max-h-64 overflow-y-auto">
132+
{filteredOptions.length === 0 ? (
133+
<div className="text-center py-4 text-sm text-[rgb(var(--muted))]">No matches</div>
134+
) : (
135+
filteredOptions.map((option) => {
136+
const isSelected = option.value === value;
137+
return (
138+
<button
139+
key={option.value}
140+
type="button"
141+
role="menuitem"
142+
onClick={() => handleSelect(option.value)}
143+
className={cn(
144+
'w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-left transition-all duration-150',
145+
isSelected
146+
? 'bg-[rgb(var(--primary))/12] text-[rgb(var(--primary))]'
147+
: 'hover:bg-[rgb(var(--surface-hover))]'
148+
)}
149+
data-testid={testId ? `${testId}-option-${option.value}` : undefined}
150+
>
151+
<span className="flex items-center gap-3 min-w-0">
152+
{option.icon && <span className="text-xl flex-shrink-0">{option.icon}</span>}
153+
<span className="font-medium text-sm truncate">{option.label}</span>
154+
</span>
155+
{isSelected && <Check className="h-4 w-4 flex-shrink-0" />}
156+
</button>
157+
);
158+
})
159+
)}
160+
</div>
161+
162+
{onCreateNew && (
163+
<>
164+
<DropdownMenuSeparator />
165+
<div className="p-1.5">
166+
<button
167+
type="button"
168+
role="menuitem"
169+
onClick={handleCreateNew}
170+
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))] transition-all duration-150"
171+
data-testid={testId ? `${testId}-create-new` : undefined}
172+
>
173+
<Plus className="h-4 w-4" />
174+
New…
175+
</button>
176+
</div>
177+
</>
178+
)}
179+
</DropdownMenuContent>
180+
</DropdownMenu>
181+
);
182+
}

packages/ui/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export { Button } from './components/common/Button';
1414
export { Input } from './components/common/Input';
1515
export { SearchField } from './components/common/SearchField';
1616
export type { SearchFieldProps } from './components/common/SearchField';
17+
export { SearchableSelect } from './components/common/SearchableSelect';
18+
export type {
19+
SearchableSelectProps,
20+
SearchableSelectOption,
21+
} from './components/common/SearchableSelect';
1722
export { ChipButton } from './components/common/ChipButton';
1823
export type { ChipButtonProps, ChipButtonVariant } from './components/common/ChipButton';
1924
export {

0 commit comments

Comments
 (0)