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,10 @@ 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 [ ] > ( [ ] ) ;
62
66
63
67
const handleClick = useCallback ( ( item : IDropdownItem ) => {
64
68
setOpen ( false ) ;
@@ -71,15 +75,92 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
71
75
72
76
const handleClose = useCallback ( ( ) => {
73
77
setOpen ( false ) ;
78
+ setFocusedIndex ( - 1 ) ;
79
+ triggerRef . current ?. focus ( ) ;
74
80
} , [ ] ) ;
75
81
82
+ const handleKeyDown = useCallback ( ( event : KeyboardEvent < HTMLButtonElement > ) => {
83
+ switch ( event . key ) {
84
+ case 'Enter' :
85
+ case ' ' :
86
+ case 'ArrowDown' :
87
+ event . preventDefault ( ) ;
88
+ setOpen ( true ) ;
89
+ setFocusedIndex ( 0 ) ;
90
+ break ;
91
+ case 'ArrowUp' :
92
+ event . preventDefault ( ) ;
93
+ setOpen ( true ) ;
94
+ setFocusedIndex ( props . items . length - 1 ) ;
95
+ break ;
96
+ case 'Escape' :
97
+ handleClose ( ) ;
98
+ break ;
99
+ }
100
+ } , [ props . items . length , handleClose ] ) ;
101
+
102
+ const handleItemKeyDown = useCallback ( ( event : KeyboardEvent < HTMLDivElement > , item : IDropdownItem , index : number ) => {
103
+ switch ( event . key ) {
104
+ case 'Enter' :
105
+ case ' ' :
106
+ event . preventDefault ( ) ;
107
+ handleClick ( item ) ;
108
+ break ;
109
+ case 'ArrowDown' :
110
+ event . preventDefault ( ) ;
111
+ const nextIndex = Math . min ( index + 1 , props . items . length - 1 ) ;
112
+ setFocusedIndex ( nextIndex ) ;
113
+ break ;
114
+ case 'ArrowUp' :
115
+ event . preventDefault ( ) ;
116
+ const prevIndex = Math . max ( index - 1 , 0 ) ;
117
+ setFocusedIndex ( prevIndex ) ;
118
+ break ;
119
+ case 'Escape' :
120
+ event . preventDefault ( ) ;
121
+ handleClose ( ) ;
122
+ break ;
123
+ case 'Tab' :
124
+ handleClose ( ) ;
125
+ break ;
126
+ }
127
+ } , [ handleClick , props . items . length , handleClose ] ) ;
128
+
129
+ useEffect ( ( ) => {
130
+ if ( open && focusedIndex >= 0 && itemsRef . current [ focusedIndex ] ) {
131
+ itemsRef . current [ focusedIndex ] . focus ( ) ;
132
+ }
133
+ } , [ open , focusedIndex ] ) ;
134
+
135
+ useEffect ( ( ) => {
136
+ const handleClickOutside = ( event : MouseEvent ) => {
137
+ if ( dropdownRef . current && ! dropdownRef . current . contains ( event . target as Node ) ) {
138
+ handleClose ( ) ;
139
+ }
140
+ } ;
141
+
142
+ if ( open ) {
143
+ document . addEventListener ( 'mousedown' , handleClickOutside ) ;
144
+ return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside ) ;
145
+ }
146
+ } , [ open , handleClose ] ) ;
147
+
76
148
return (
77
- < div className = { classNames ( "relative" , props . className ) } >
149
+ < div ref = { dropdownRef } className = { classNames ( "relative" , props . className ) } >
78
150
{ open && < div className = "fixed inset-0" onClick = { handleClose } /> }
79
151
{ props . loading ? < div className = "flex h-full w-full items-center justify-center" >
80
152
< Loading hideText = { true } size = "sm" />
81
153
</ 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 } >
154
+ < > < button
155
+ ref = { triggerRef }
156
+ tabIndex = { 0 }
157
+ 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"
158
+ onClick = { handleToggleOpen }
159
+ onKeyDown = { handleKeyDown }
160
+ aria-haspopup = "listbox"
161
+ aria-expanded = { open }
162
+ aria-labelledby = { props . testId ? `${ props . testId } -label` : undefined }
163
+ data-testid = { props . testId } >
83
164
< div className = { classNames ( ClassNames . Text , "flex gap-1 text-sm truncate items-center" ) } >
84
165
{ props . value ?. icon != null && < div className = "flex items-center w-6" >
85
166
{ props . value . icon }
@@ -95,13 +176,27 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
95
176
"block animate-fade" : open ,
96
177
"w-fit min-w-[200px]" : ! props . fullWidth ,
97
178
"w-full" : props . fullWidth ,
98
- } , props . dropdownContainerHeight ) } >
179
+ } , props . dropdownContainerHeight ) }
180
+ role = "listbox"
181
+ aria-labelledby = { props . testId ? `${ props . testId } -label` : undefined } >
99
182
< ul className = { classNames ( ClassNames . Text , "py-1 text-sm nowheel flex flex-col" ) } >
100
183
{
101
184
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 } >
185
+ < div
186
+ role = "option"
187
+ tabIndex = { - 1 }
188
+ key = { `dropdown-item-${ i } ` }
189
+ ref = { el => {
190
+ if ( el ) itemsRef . current [ i ] = el ;
191
+ } }
192
+ className = { classNames ( ITEM_CLASS , {
193
+ "hover:gap-2" : item . icon != null ,
194
+ "bg-blue-100 dark:bg-blue-900/30" : focusedIndex === i ,
195
+ } ) }
196
+ onClick = { ( ) => handleClick ( item ) }
197
+ onKeyDown = { ( e ) => handleItemKeyDown ( e , item , i ) }
198
+ aria-selected = { props . value ?. id === item . id }
199
+ data-value = { item . id } >
105
200
< div > { props . value ?. id === item . id ? Icons . CheckCircle : item . icon } </ div >
106
201
< div className = "whitespace-nowrap flex-1" > { item . label } </ div >
107
202
{ item . info && (
@@ -127,16 +222,36 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
127
222
}
128
223
{
129
224
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 } >
225
+ < div
226
+ role = "option"
227
+ tabIndex = { - 1 }
228
+ className = { classNames ( ITEM_CLASS , {
229
+ "hover:scale-105" : props . defaultItem . icon == null ,
230
+ } , props . defaultItemClassName ) }
231
+ onClick = { props . onDefaultItemClick }
232
+ onKeyDown = { ( e ) => {
233
+ if ( e . key === 'Enter' || e . key === ' ' ) {
234
+ e . preventDefault ( ) ;
235
+ props . onDefaultItemClick ?.( ) ;
236
+ }
237
+ } } >
133
238
< div > { props . defaultItem . icon } </ div >
134
239
< div > { props . defaultItem . label } </ div >
135
240
</ div >
136
241
}
137
242
{
138
243
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 } >
244
+ < div
245
+ role = "option"
246
+ tabIndex = { - 1 }
247
+ className = "flex items-center gap-1 px-2 dark:text-neutral-300"
248
+ onClick = { props . onDefaultItemClick }
249
+ onKeyDown = { ( e ) => {
250
+ if ( e . key === 'Enter' || e . key === ' ' ) {
251
+ e . preventDefault ( ) ;
252
+ props . onDefaultItemClick ?.( ) ;
253
+ }
254
+ } } >
140
255
< div > { Icons . SadSmile } </ div >
141
256
< div > { props . noItemsLabel } </ div >
142
257
</ div >
@@ -149,8 +264,9 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
149
264
}
150
265
151
266
export const DropdownWithLabel : FC < IDropdownProps & { label : string , testId ?: string } > = ( { label, testId, ...props } ) => {
267
+ const dropdownId = testId ? `${ testId } -dropdown` : `dropdown-${ label . toLowerCase ( ) . replace ( / \s + / g, '-' ) } ` ;
152
268
return < div className = "flex flex-col gap-1" data-testid = { testId } >
153
- < Label label = { label } />
154
- < Dropdown { ...props } />
269
+ < Label label = { label } htmlFor = { dropdownId } />
270
+ < Dropdown { ...props } testId = { dropdownId } />
155
271
</ div >
156
272
}
0 commit comments