11import { Popover } from '@radix-ui/react-popover' ;
2+ import * as PopoverPrimitive from '@radix-ui/react-popover' ;
23import { Check as DefaultCheckIcon , ChevronDown as DefaultChevronIcon } from 'lucide-react' ;
34import * as React from 'react' ;
45import { useOverlayTriggerState } from 'react-stately' ;
5- import { PopoverContent , PopoverTrigger } from './popover' ;
6+ import { PopoverTrigger } from './popover' ;
67import { cn } from './utils' ;
78
89export interface SelectOption {
@@ -49,14 +50,13 @@ export function Select({
4950 const popoverState = useOverlayTriggerState ( { } ) ;
5051 const listboxId = React . useId ( ) ;
5152 const [ query , setQuery ] = React . useState ( '' ) ;
53+ const [ activeIndex , setActiveIndex ] = React . useState ( 0 ) ;
54+ const [ isInitialized , setIsInitialized ] = React . useState ( false ) ;
5255 const triggerRef = React . useRef < HTMLButtonElement > ( null ) ;
5356 const popoverRef = React . useRef < HTMLDivElement > ( null ) ;
5457 const selectedItemRef = React . useRef < HTMLButtonElement > ( null ) ;
55- const [ menuWidth , setMenuWidth ] = React . useState < number | undefined > ( undefined ) ;
56-
57- React . useEffect ( ( ) => {
58- if ( triggerRef . current ) setMenuWidth ( triggerRef . current . offsetWidth ) ;
59- } , [ ] ) ;
58+ const listContainerRef = React . useRef < HTMLUListElement > ( null ) ;
59+ // No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable
6060
6161 // Scroll to selected item when dropdown opens
6262 React . useEffect ( ( ) => {
@@ -75,13 +75,29 @@ export function Select({
7575 [ options , query ] ,
7676 ) ;
7777
78- // Candidate that would be chosen on Enter (exact match else first filtered)
79- const enterCandidate = React . useMemo ( ( ) => {
80- const q = query . trim ( ) . toLowerCase ( ) ;
81- if ( filtered . length === 0 ) return undefined ;
82- const exact = q ? filtered . find ( ( o ) => o . label . toLowerCase ( ) === q ) : undefined ;
83- return exact ?? filtered [ 0 ] ;
84- } , [ filtered , query ] ) ;
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+ } , [ filtered , 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 ] ) ;
85101
86102 const Trigger =
87103 components ?. Trigger ||
@@ -130,15 +146,27 @@ export function Select({
130146 < ChevronIcon className = "w-4 h-4 opacity-50" />
131147 </ Trigger >
132148 </ PopoverTrigger >
133- < PopoverContent
134- ref = { popoverRef }
135- className = { cn ( 'z-50 p-0 shadow-md border-0' , contentClassName ) }
136- // biome-ignore lint/a11y/useSemanticElements: using <div> for PopoverContent to ensure keyboard accessibility and focus management
137- role = "listbox"
138- id = { listboxId }
139- style = { { width : menuWidth ? `${ menuWidth } px` : undefined } }
140- >
141- < div className = "bg-white p-1.5 rounded-md focus:outline-none sm:text-sm" >
149+ < PopoverPrimitive . Portal >
150+ < PopoverPrimitive . Content
151+ ref = { popoverRef }
152+ align = "start"
153+ sideOffset = { 4 }
154+ className = { cn (
155+ 'z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none' ,
156+ 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0' ,
157+ 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95' ,
158+ 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2' ,
159+ 'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2' ,
160+ 'p-0 shadow-md border-0' ,
161+ contentClassName
162+ ) }
163+ // biome-ignore lint/a11y/useSemanticElements: using <div> for PopoverContent to ensure keyboard accessibility and focus management
164+ role = "listbox"
165+ id = { listboxId }
166+ style = { { width : 'var(--radix-popover-trigger-width)' } }
167+ data-slot = "popover-content"
168+ >
169+ < div className = "bg-white p-1.5 rounded-md focus:outline-none sm:text-sm w-full" >
142170 < div className = "px-1.5 pb-1.5" >
143171 < SearchInput
144172 type = "text"
@@ -148,10 +176,11 @@ export function Select({
148176 ref = { ( el ) => {
149177 if ( el ) queueMicrotask ( ( ) => el . focus ( ) ) ;
150178 } }
179+ aria-activedescendant = { filtered . length > 0 ? `${ listboxId } -option-${ activeIndex } ` : undefined }
151180 onKeyDown = { ( e ) => {
152181 if ( e . key === 'Enter' ) {
153182 e . preventDefault ( ) ;
154- const toSelect = enterCandidate ;
183+ const toSelect = filtered [ activeIndex ] ;
155184 if ( toSelect ) {
156185 onValueChange ?.( toSelect . value ) ;
157186 setQuery ( '' ) ;
@@ -163,16 +192,24 @@ export function Select({
163192 setQuery ( '' ) ;
164193 popoverState . close ( ) ;
165194 triggerRef . current ?. focus ( ) ;
195+ } else if ( e . key === 'ArrowDown' ) {
196+ e . preventDefault ( ) ;
197+ if ( filtered . length === 0 ) return ;
198+ setActiveIndex ( ( prev ) => Math . min ( prev + 1 , filtered . length - 1 ) ) ;
199+ } else if ( e . key === 'ArrowUp' ) {
200+ e . preventDefault ( ) ;
201+ if ( filtered . length === 0 ) return ;
202+ setActiveIndex ( ( prev ) => Math . max ( prev - 1 , 0 ) ) ;
166203 }
167204 } }
168205 className = "w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border-0"
169206 />
170207 </ div >
171- < ul className = "max-h-[200px] overflow-y-auto rounded-md" >
208+ < ul ref = { listContainerRef } className = "max-h-[200px] overflow-y-auto rounded-md w-full " >
172209 { filtered . length === 0 && < li className = "px-3 py-2 text-sm text-gray-500" > No results.</ li > }
173- { filtered . map ( ( option ) => {
210+ { filtered . map ( ( option , index ) => {
174211 const isSelected = option . value === value ;
175- const isEnterCandidate = query . trim ( ) !== '' && enterCandidate ?. value === option . value && ! isSelected ;
212+ const isActive = index === activeIndex ;
176213 return (
177214 < li key = { option . value } className = "list-none" >
178215 < Item
@@ -186,14 +223,17 @@ export function Select({
186223 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded' ,
187224 'text-gray-900' ,
188225 isSelected ? 'bg-gray-100' : 'hover:bg-gray-100' ,
189- isEnterCandidate && 'bg-gray-50' ,
226+ isActive && ! isSelected && 'bg-gray-50' ,
190227 itemClassName ,
191228 ) }
192229 // biome-ignore lint/a11y/useSemanticElements: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
193230 // biome-ignore lint/a11y/useAriaPropsForRole: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
194231 role = "option"
195232 aria-selected = { isSelected }
233+ id = { `${ listboxId } -option-${ index } ` }
196234 data-selected = { isSelected ? 'true' : 'false' }
235+ data-active = { isActive ? 'true' : 'false' }
236+ data-index = { index }
197237 data-value = { option . value }
198238 data-testid = { `select-option-${ option . value } ` }
199239 selected = { isSelected }
@@ -208,7 +248,8 @@ export function Select({
208248 } ) }
209249 </ ul >
210250 </ div >
211- </ PopoverContent >
251+ </ PopoverPrimitive . Content >
252+ </ PopoverPrimitive . Portal >
212253 </ Popover >
213254 ) ;
214255}
0 commit comments