15
15
*/
16
16
17
17
import classNames from "classnames" ;
18
- import { FC , ReactElement , cloneElement , useCallback , useState } from "react" ;
18
+ import { FC , ReactElement , cloneElement , useCallback , useState , useRef , useEffect , KeyboardEvent } from "react" ;
19
19
import { Icons } from "./icons" ;
20
20
import { Label } from "./input" ;
21
21
import { Loading } from "./loading" ;
@@ -59,6 +59,11 @@ const ITEM_CLASS = "group/item flex items-center gap-1 transition-all cursor-poi
59
59
60
60
export const Dropdown : FC < IDropdownProps > = ( props ) => {
61
61
const [ open , setOpen ] = useState ( false ) ;
62
+ const [ focusedIndex , setFocusedIndex ] = useState ( - 1 ) ;
63
+ const dropdownRef = useRef < HTMLDivElement > ( null ) ;
64
+ const triggerRef = useRef < HTMLButtonElement > ( null ) ;
65
+ const itemsRef = useRef < HTMLDivElement [ ] > ( [ ] ) ;
66
+ const blurTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
62
67
63
68
const handleClick = useCallback ( ( item : IDropdownItem ) => {
64
69
setOpen ( false ) ;
@@ -71,15 +76,128 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
71
76
72
77
const handleClose = useCallback ( ( ) => {
73
78
setOpen ( false ) ;
79
+ setFocusedIndex ( - 1 ) ;
80
+ // Clear any pending blur timeout
81
+ if ( blurTimeoutRef . current ) {
82
+ clearTimeout ( blurTimeoutRef . current ) ;
83
+ blurTimeoutRef . current = null ;
84
+ }
85
+ // Ensure focus returns to trigger button
86
+ setTimeout ( ( ) => {
87
+ triggerRef . current ?. focus ( ) ;
88
+ } , 0 ) ;
74
89
} , [ ] ) ;
75
90
91
+ const handleDropdownBlur = useCallback ( ( event : React . FocusEvent < HTMLDivElement > ) => {
92
+ // Clear any existing timeout
93
+ if ( blurTimeoutRef . current ) {
94
+ clearTimeout ( blurTimeoutRef . current ) ;
95
+ }
96
+
97
+ // Set a timeout to check if focus moved outside the dropdown
98
+ blurTimeoutRef . current = setTimeout ( ( ) => {
99
+ if ( dropdownRef . current && ! dropdownRef . current . contains ( document . activeElement ) ) {
100
+ setOpen ( false ) ;
101
+ setFocusedIndex ( - 1 ) ;
102
+ }
103
+ } , 100 ) ;
104
+ } , [ ] ) ;
105
+
106
+ const handleDropdownFocus = useCallback ( ( ) => {
107
+ // Clear the blur timeout if focus returns to dropdown
108
+ if ( blurTimeoutRef . current ) {
109
+ clearTimeout ( blurTimeoutRef . current ) ;
110
+ blurTimeoutRef . current = null ;
111
+ }
112
+ } , [ ] ) ;
113
+
114
+ const handleKeyDown = useCallback ( ( event : KeyboardEvent < HTMLButtonElement > ) => {
115
+ switch ( event . key ) {
116
+ case 'Enter' :
117
+ case ' ' :
118
+ case 'ArrowDown' :
119
+ event . preventDefault ( ) ;
120
+ setOpen ( true ) ;
121
+ setFocusedIndex ( 0 ) ;
122
+ break ;
123
+ case 'ArrowUp' :
124
+ event . preventDefault ( ) ;
125
+ setOpen ( true ) ;
126
+ setFocusedIndex ( props . items . length - 1 ) ;
127
+ break ;
128
+ case 'Escape' :
129
+ handleClose ( ) ;
130
+ break ;
131
+ }
132
+ } , [ props . items . length , handleClose ] ) ;
133
+
134
+ const handleItemKeyDown = useCallback ( ( event : KeyboardEvent < HTMLDivElement > , item : IDropdownItem , index : number ) => {
135
+ switch ( event . key ) {
136
+ case 'Enter' :
137
+ case ' ' :
138
+ event . preventDefault ( ) ;
139
+ handleClick ( item ) ;
140
+ break ;
141
+ case 'ArrowDown' :
142
+ event . preventDefault ( ) ;
143
+ const nextIndex = Math . min ( index + 1 , props . items . length - 1 ) ;
144
+ setFocusedIndex ( nextIndex ) ;
145
+ break ;
146
+ case 'ArrowUp' :
147
+ event . preventDefault ( ) ;
148
+ const prevIndex = Math . max ( index - 1 , 0 ) ;
149
+ setFocusedIndex ( prevIndex ) ;
150
+ break ;
151
+ case 'Escape' :
152
+ event . preventDefault ( ) ;
153
+ handleClose ( ) ;
154
+ break ;
155
+ case 'Tab' :
156
+ handleClose ( ) ;
157
+ break ;
158
+ }
159
+ } , [ handleClick , props . items . length , handleClose ] ) ;
160
+
161
+ useEffect ( ( ) => {
162
+ if ( open && focusedIndex >= 0 && itemsRef . current [ focusedIndex ] ) {
163
+ itemsRef . current [ focusedIndex ] . focus ( ) ;
164
+ }
165
+ } , [ open , focusedIndex ] ) ;
166
+
167
+ useEffect ( ( ) => {
168
+ const handleClickOutside = ( event : MouseEvent ) => {
169
+ if ( dropdownRef . current && ! dropdownRef . current . contains ( event . target as Node ) ) {
170
+ handleClose ( ) ;
171
+ }
172
+ } ;
173
+
174
+ if ( open ) {
175
+ document . addEventListener ( 'mousedown' , handleClickOutside ) ;
176
+ return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside ) ;
177
+ }
178
+ } , [ open , handleClose ] ) ;
179
+
76
180
return (
77
- < div className = { classNames ( "relative" , props . className ) } >
181
+ < div
182
+ ref = { dropdownRef }
183
+ className = { classNames ( "relative" , props . className ) }
184
+ onBlur = { handleDropdownBlur }
185
+ onFocus = { handleDropdownFocus }
186
+ >
78
187
{ open && < div className = "fixed inset-0" onClick = { handleClose } /> }
79
188
{ props . loading ? < div className = "flex h-full w-full items-center justify-center" >
80
189
< Loading hideText = { true } size = "sm" />
81
190
</ div > :
82
- < > < button tabIndex = { 0 } className = "group/dropdown flex gap-1 justify-between items-center border border-neutral-600/20 rounded-lg w-full p-1 h-[34px] px-2 dark:bg-[#2C2F33] dark:border-white/5" onClick = { handleToggleOpen } data-testid = { props . testId } >
191
+ < > < button
192
+ ref = { triggerRef }
193
+ tabIndex = { 0 }
194
+ className = "group/dropdown flex gap-1 justify-between items-center border border-neutral-600/20 rounded-lg w-full p-1 h-[34px] px-2 dark:bg-[#2C2F33] dark:border-white/5"
195
+ onClick = { handleToggleOpen }
196
+ onKeyDown = { handleKeyDown }
197
+ aria-haspopup = "listbox"
198
+ aria-expanded = { open }
199
+ aria-labelledby = { props . testId ? `${ props . testId } -label` : undefined }
200
+ data-testid = { props . testId } >
83
201
< div className = { classNames ( ClassNames . Text , "flex gap-1 text-sm truncate items-center" ) } >
84
202
{ props . value ?. icon != null && < div className = "flex items-center w-6" >
85
203
{ props . value . icon }
@@ -95,13 +213,27 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
95
213
"block animate-fade" : open ,
96
214
"w-fit min-w-[200px]" : ! props . fullWidth ,
97
215
"w-full" : props . fullWidth ,
98
- } , props . dropdownContainerHeight ) } >
216
+ } , props . dropdownContainerHeight ) }
217
+ role = "listbox"
218
+ aria-labelledby = { props . testId ? `${ props . testId } -label` : undefined } >
99
219
< ul className = { classNames ( ClassNames . Text , "py-1 text-sm nowheel flex flex-col" ) } >
100
220
{
101
221
props . items . map ( ( item , i ) => (
102
- < div role = "button" tabIndex = { 0 } key = { `dropdown-item-${ i } ` } className = { classNames ( ITEM_CLASS , {
103
- "hover:gap-2" : item . icon != null ,
104
- } ) } onClick = { ( ) => handleClick ( item ) } data-value = { item . id } >
222
+ < div
223
+ role = "option"
224
+ tabIndex = { focusedIndex === i ? 0 : - 1 }
225
+ key = { `dropdown-item-${ i } ` }
226
+ ref = { el => {
227
+ if ( el ) itemsRef . current [ i ] = el ;
228
+ } }
229
+ className = { classNames ( ITEM_CLASS , {
230
+ "hover:gap-2" : item . icon != null ,
231
+ "bg-blue-100 dark:bg-blue-900/30" : focusedIndex === i ,
232
+ } ) }
233
+ onClick = { ( ) => handleClick ( item ) }
234
+ onKeyDown = { ( e ) => handleItemKeyDown ( e , item , i ) }
235
+ aria-selected = { props . value ?. id === item . id }
236
+ data-value = { item . id } >
105
237
< div > { props . value ?. id === item . id ? Icons . CheckCircle : item . icon } </ div >
106
238
< div className = "whitespace-nowrap flex-1" > { item . label } </ div >
107
239
{ item . info && (
@@ -127,16 +259,36 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
127
259
}
128
260
{
129
261
props . defaultItem != null &&
130
- < div role = "button" tabIndex = { 0 } className = { classNames ( ITEM_CLASS , {
131
- "hover:scale-105" : props . defaultItem . icon == null ,
132
- } , props . defaultItemClassName ) } onClick = { props . onDefaultItemClick } >
262
+ < div
263
+ role = "option"
264
+ tabIndex = { 0 }
265
+ className = { classNames ( ITEM_CLASS , {
266
+ "hover:scale-105" : props . defaultItem . icon == null ,
267
+ } , props . defaultItemClassName ) }
268
+ onClick = { props . onDefaultItemClick }
269
+ onKeyDown = { ( e ) => {
270
+ if ( e . key === 'Enter' || e . key === ' ' ) {
271
+ e . preventDefault ( ) ;
272
+ props . onDefaultItemClick ?.( ) ;
273
+ }
274
+ } } >
133
275
< div > { props . defaultItem . icon } </ div >
134
276
< div > { props . defaultItem . label } </ div >
135
277
</ div >
136
278
}
137
279
{
138
280
props . items . length === 0 && props . defaultItem == null &&
139
- < div role = "button" tabIndex = { 0 } className = "flex items-center gap-1 px-2 dark:text-neutral-300" onClick = { props . onDefaultItemClick } >
281
+ < div
282
+ role = "option"
283
+ tabIndex = { 0 }
284
+ className = "flex items-center gap-1 px-2 dark:text-neutral-300"
285
+ onClick = { props . onDefaultItemClick }
286
+ onKeyDown = { ( e ) => {
287
+ if ( e . key === 'Enter' || e . key === ' ' ) {
288
+ e . preventDefault ( ) ;
289
+ props . onDefaultItemClick ?.( ) ;
290
+ }
291
+ } } >
140
292
< div > { Icons . SadSmile } </ div >
141
293
< div > { props . noItemsLabel } </ div >
142
294
</ div >
@@ -149,8 +301,9 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
149
301
}
150
302
151
303
export const DropdownWithLabel : FC < IDropdownProps & { label : string , testId ?: string } > = ( { label, testId, ...props } ) => {
304
+ const dropdownId = testId ? `${ testId } -dropdown` : `dropdown-${ label . toLowerCase ( ) . replace ( / \s + / g, '-' ) } ` ;
152
305
return < div className = "flex flex-col gap-1" data-testid = { testId } >
153
- < Label label = { label } />
154
- < Dropdown { ...props } />
306
+ < Label label = { label } htmlFor = { dropdownId } />
307
+ < Dropdown { ...props } testId = { dropdownId } />
155
308
</ div >
156
309
}
0 commit comments