@@ -5,7 +5,7 @@ import * as React from 'react';
55import { useOverlayTriggerState } from 'react-stately' ;
66import { PopoverTrigger } from './popover' ;
77import { cn } from './utils' ;
8-
8+ import { Command , CommandEmpty , CommandGroup , CommandInput , CommandItem , CommandList } from './command' ;
99export 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