Skip to content

Commit 9a309be

Browse files
feat(select): switch dropdown to shadcn Command (cmdk) primitives and scroll selected into view on open
Co-authored-by: Jake Ruesink <jaruesink@gmail.com>
1 parent e52941b commit 9a309be

File tree

1 file changed

+64
-116
lines changed

1 file changed

+64
-116
lines changed

packages/components/src/ui/select.tsx

Lines changed: 64 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as React from 'react';
55
import { useOverlayTriggerState } from 'react-stately';
66
import { PopoverTrigger } from './popover';
77
import { cn } from './utils';
8-
8+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command';
99
export interface SelectOption {
1010
label: string;
1111
value: string;
@@ -49,56 +49,21 @@ export function Select({
4949
}: SelectProps) {
5050
const popoverState = useOverlayTriggerState({});
5151
const listboxId = React.useId();
52-
const [query, setQuery] = React.useState('');
53-
const [activeIndex, setActiveIndex] = React.useState(0);
54-
const [isInitialized, setIsInitialized] = React.useState(false);
5552
const triggerRef = React.useRef<HTMLButtonElement>(null);
5653
const popoverRef = React.useRef<HTMLDivElement>(null);
57-
const selectedItemRef = React.useRef<HTMLButtonElement>(null);
58-
const listContainerRef = React.useRef<HTMLUListElement>(null);
54+
const selectedItemRef = React.useRef<HTMLElement>(null);
5955
// No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable
6056

61-
// Scroll to selected item when dropdown opens
57+
// Scroll to selected item when dropdown opens (cmdk also helps with focus/scroll)
6258
React.useEffect(() => {
63-
if (popoverState.isOpen && selectedItemRef.current) {
64-
// Use setTimeout to ensure the DOM is fully rendered
65-
setTimeout(() => {
66-
selectedItemRef.current?.scrollIntoView({ block: 'nearest' });
67-
}, 0);
68-
}
59+
if (!popoverState.isOpen) return;
60+
requestAnimationFrame(() => {
61+
selectedItemRef.current?.scrollIntoView({ block: 'nearest' });
62+
});
6963
}, [popoverState.isOpen]);
7064

7165
const selectedOption = options.find((o) => o.value === value);
7266

73-
const filtered = React.useMemo(
74-
() => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options),
75-
[options, query],
76-
);
77-
78-
// Reset activeIndex when filtered items change or dropdown opens
79-
React.useEffect(() => {
80-
if (popoverState.isOpen) {
81-
setActiveIndex(0);
82-
// Add a small delay to ensure the component is fully initialized
83-
const timer = setTimeout(() => {
84-
setIsInitialized(true);
85-
}, 100);
86-
return () => clearTimeout(timer);
87-
} else {
88-
setIsInitialized(false);
89-
}
90-
}, [popoverState.isOpen]);
91-
92-
// Scroll active item into view when activeIndex changes
93-
React.useEffect(() => {
94-
if (popoverState.isOpen && listContainerRef.current && filtered.length > 0) {
95-
const activeElement = listContainerRef.current.querySelector(`[data-index="${activeIndex}"]`) as HTMLElement;
96-
if (activeElement) {
97-
activeElement.scrollIntoView({ block: 'nearest' });
98-
}
99-
}
100-
}, [activeIndex, popoverState.isOpen, filtered.length]);
101-
10267
const Trigger =
10368
components?.Trigger ||
10469
React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>((props, ref) => (
@@ -113,13 +78,6 @@ export function Select({
11378
);
11479
Item.displayName = Item.displayName || 'SelectItem';
11580

116-
const SearchInput =
117-
components?.SearchInput ||
118-
React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => (
119-
<input ref={ref} {...props} />
120-
));
121-
SearchInput.displayName = SearchInput.displayName || 'SelectSearchInput';
122-
12381
const CheckIcon = components?.CheckIcon || DefaultCheckIcon;
12482
const ChevronIcon = components?.ChevronIcon || DefaultChevronIcon;
12583

@@ -158,91 +116,81 @@ export function Select({
158116
'p-0 shadow-md border-0 min-w-[8rem]',
159117
contentClassName,
160118
)}
161-
role="listbox"
162-
id={listboxId}
163119
style={{ width: 'var(--radix-popover-trigger-width)' }}
164120
data-slot="popover-content"
165121
>
166-
<div className="bg-white p-1.5 rounded-md focus:outline-none sm:text-sm w-full">
167-
<div className="px-1.5 pb-1.5">
168-
<SearchInput
169-
type="text"
170-
value={query}
171-
onChange={(e) => setQuery(e.target.value)}
172-
placeholder="Search..."
173-
ref={(el) => {
174-
if (el) queueMicrotask(() => el.focus());
175-
}}
176-
aria-activedescendant={filtered.length > 0 ? `${listboxId}-option-${activeIndex}` : undefined}
177-
onKeyDown={(e) => {
178-
if (e.key === 'Enter') {
179-
e.preventDefault();
180-
const toSelect = filtered[activeIndex];
181-
if (toSelect) {
182-
onValueChange?.(toSelect.value);
183-
setQuery('');
184-
popoverState.close();
185-
triggerRef.current?.focus();
186-
}
187-
} else if (e.key === 'Escape') {
188-
e.preventDefault();
189-
setQuery('');
190-
popoverState.close();
191-
triggerRef.current?.focus();
192-
} else if (e.key === 'ArrowDown') {
193-
e.preventDefault();
194-
if (filtered.length === 0) return;
195-
setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1));
196-
} else if (e.key === 'ArrowUp') {
197-
e.preventDefault();
198-
if (filtered.length === 0) return;
199-
setActiveIndex((prev) => Math.max(prev - 1, 0));
200-
}
201-
}}
202-
className="w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border-0"
203-
/>
122+
<Command className="bg-white rounded-md focus:outline-none sm:text-sm w-full">
123+
<div className="px-1.5 pb-1.5 pt-1.5">
124+
<CommandInput placeholder="Search..." />
204125
</div>
205-
<ul ref={listContainerRef} className="max-h-[200px] overflow-y-auto rounded-md w-full">
206-
{filtered.length === 0 && <li className="px-3 py-2 text-sm text-gray-500">No results.</li>}
207-
{filtered.map((option, index) => {
208-
const isSelected = option.value === value;
209-
const isActive = index === activeIndex;
210-
return (
211-
<li key={option.value} className="list-none">
212-
<Item
213-
ref={isSelected ? selectedItemRef : undefined}
214-
onClick={() => {
126+
<CommandList id={listboxId} className="max-h-[200px] rounded-md w-full">
127+
<CommandEmpty className="px-3 py-2 text-sm text-gray-500">No results.</CommandEmpty>
128+
<CommandGroup>
129+
{options.map((option, index) => {
130+
const isSelected = option.value === value;
131+
const commonProps = {
132+
'data-selected': isSelected ? 'true' : 'false',
133+
'data-value': option.value,
134+
'data-testid': `select-option-${option.value}`,
135+
} as const;
136+
137+
// When a custom Item is provided, use asChild to let it render as the actual item element
138+
if (components?.Item) {
139+
const CustomItem = Item;
140+
return (
141+
<CommandItem
142+
key={option.value}
143+
onSelect={() => {
144+
onValueChange?.(option.value);
145+
popoverState.close();
146+
}}
147+
value={option.label}
148+
aria-selected={isSelected}
149+
id={`${listboxId}-option-${index}`}
150+
{...commonProps}
151+
className={cn(itemClassName)}
152+
ref={isSelected ? selectedItemRef : undefined}
153+
asChild
154+
>
155+
<CustomItem selected={isSelected}>
156+
{isSelected && <CheckIcon className="h-4 w-4 flex-shrink-0" />}
157+
<span className={cn('block truncate', !isSelected && 'ml-6', isSelected && 'font-semibold')}>
158+
{option.label}
159+
</span>
160+
</CustomItem>
161+
</CommandItem>
162+
);
163+
}
164+
165+
return (
166+
<CommandItem
167+
key={option.value}
168+
onSelect={() => {
215169
onValueChange?.(option.value);
216-
setQuery('');
217170
popoverState.close();
218171
}}
172+
value={option.label}
173+
aria-selected={isSelected}
174+
id={`${listboxId}-option-${index}`}
175+
{...commonProps}
219176
className={cn(
220177
'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded',
221178
'text-gray-900',
222179
isSelected ? 'bg-gray-100' : 'hover:bg-gray-100',
223-
isActive && !isSelected && 'bg-gray-50',
224180
itemClassName,
225181
)}
226-
role="option"
227-
aria-selected={isSelected}
228-
id={`${listboxId}-option-${index}`}
229-
data-selected={isSelected ? 'true' : 'false'}
230-
data-active={isActive ? 'true' : 'false'}
231-
data-index={index}
232-
data-value={option.value}
233-
data-testid={`select-option-${option.value}`}
234-
selected={isSelected}
182+
ref={isSelected ? selectedItemRef : undefined}
235183
>
236184
{isSelected && <CheckIcon className="h-4 w-4 flex-shrink-0" />}
237185
<span className={cn('block truncate', !isSelected && 'ml-6', isSelected && 'font-semibold')}>
238186
{option.label}
239187
</span>
240-
</Item>
241-
</li>
242-
);
243-
})}
244-
</ul>
245-
</div>
188+
</CommandItem>
189+
);
190+
})}
191+
</CommandGroup>
192+
</CommandList>
193+
</Command>
246194
</PopoverPrimitive.Content>
247195
</PopoverPrimitive.Portal>
248196
</Popover>

0 commit comments

Comments
 (0)