@@ -237,12 +237,23 @@ const Dropdown = (props: DropdownProps) => {
237237
238238 // Focus first selected item or search input when dropdown opens
239239 useEffect ( ( ) => {
240- if ( ! isOpen || search_value ) {
240+ if ( ! isOpen ) {
241241 return ;
242242 }
243243
244244 // waiting for the DOM to be ready after the dropdown renders
245245 requestAnimationFrame ( ( ) => {
246+ // If opened with search value (auto-open on typing), focus search input
247+ if ( search_value && searchable && searchInputRef . current ) {
248+ searchInputRef . current . focus ( ) ;
249+ // Move cursor to end of input
250+ searchInputRef . current . setSelectionRange (
251+ search_value . length ,
252+ search_value . length
253+ ) ;
254+ return ;
255+ }
256+
246257 // Try to focus the first selected item (for single-select)
247258 if ( ! multi ) {
248259 const selectedValue = sanitizedValues [ 0 ] ;
@@ -264,94 +275,123 @@ const Dropdown = (props: DropdownProps) => {
264275 searchInputRef . current . focus ( ) ;
265276 }
266277 } ) ;
267- } , [ isOpen , multi , displayOptions ] ) ;
278+ } , [ isOpen , multi , displayOptions , search_value , searchable ] ) ;
268279
269280 // Handle keyboard navigation in popover
270- const handleKeyDown = useCallback ( ( e : React . KeyboardEvent ) => {
271- const relevantKeys = [
272- 'ArrowDown' ,
273- 'ArrowUp' ,
274- 'PageDown' ,
275- 'PageUp' ,
276- 'Home' ,
277- 'End' ,
278- ] ;
279- if ( ! relevantKeys . includes ( e . key ) ) {
280- return ;
281- }
281+ const handleKeyDown = useCallback (
282+ ( e : React . KeyboardEvent ) => {
283+ // Handle TAB to select first option and close dropdown
284+ if ( e . key === 'Tab' && ! e . shiftKey ) {
285+ // If we have filtered options and search is active, select the first one
286+ if ( displayOptions . length > 0 ) {
287+ const firstOption = displayOptions [ 0 ] ;
288+ if ( ! firstOption . disabled ) {
289+ if ( multi ) {
290+ // For multi-select, toggle the first option if not already selected
291+ if ( ! sanitizedValues . includes ( firstOption . value ) ) {
292+ updateSelection ( [
293+ ...sanitizedValues ,
294+ firstOption . value ,
295+ ] ) ;
296+ }
297+ } else {
298+ // For single-select, select the first option
299+ updateSelection ( [ firstOption . value ] ) ;
300+ }
301+ }
302+ }
303+ // Close dropdown and let TAB naturally move focus
304+ setIsOpen ( false ) ;
305+ setProps ( { search_value : undefined } ) ;
306+ return ;
307+ }
282308
283- // Don't interfere with the event if the user is using Home/End keys on the search input
284- if (
285- [ 'Home' , 'End' ] . includes ( e . key ) &&
286- document . activeElement === searchInputRef . current
287- ) {
288- return ;
289- }
309+ const relevantKeys = [
310+ 'ArrowDown' ,
311+ 'ArrowUp' ,
312+ 'PageDown' ,
313+ 'PageUp' ,
314+ 'Home' ,
315+ 'End' ,
316+ ] ;
317+ if ( ! relevantKeys . includes ( e . key ) ) {
318+ return ;
319+ }
290320
291- const focusableElements = e . currentTarget . querySelectorAll (
292- 'input[type="search"], input:not([disabled])'
293- ) as NodeListOf < HTMLElement > ;
321+ // Don't interfere with the event if the user is using Home/End keys on the search input
322+ if (
323+ [ 'Home' , 'End' ] . includes ( e . key ) &&
324+ document . activeElement === searchInputRef . current
325+ ) {
326+ return ;
327+ }
294328
295- // Don't interfere with the event if there aren't any options that the user can interact with
296- if ( focusableElements . length === 0 ) {
297- return ;
298- }
329+ const focusableElements = e . currentTarget . querySelectorAll (
330+ 'input[type="search"], input:not([disabled])'
331+ ) as NodeListOf < HTMLElement > ;
299332
300- e . preventDefault ( ) ;
333+ // Don't interfere with the event if there aren't any options that the user can interact with
334+ if ( focusableElements . length === 0 ) {
335+ return ;
336+ }
301337
302- const currentIndex = Array . from ( focusableElements ) . indexOf (
303- document . activeElement as HTMLElement
304- ) ;
305- let nextIndex = - 1 ;
306-
307- switch ( e . key ) {
308- case 'ArrowDown' :
309- nextIndex =
310- currentIndex < focusableElements . length - 1
311- ? currentIndex + 1
312- : 0 ;
313- break ;
314-
315- case 'ArrowUp' :
316- nextIndex =
317- currentIndex > 0
318- ? currentIndex - 1
319- : focusableElements . length - 1 ;
320-
321- break ;
322- case 'PageDown' :
323- nextIndex = Math . min (
324- currentIndex + 10 ,
325- focusableElements . length - 1
326- ) ;
327- break ;
328- case 'PageUp' :
329- nextIndex = Math . max ( currentIndex - 10 , 0 ) ;
330- break ;
331- case 'Home' :
332- nextIndex = 0 ;
333- break ;
334- case 'End' :
335- nextIndex = focusableElements . length - 1 ;
336- break ;
337- default :
338- break ;
339- }
338+ e . preventDefault ( ) ;
340339
341- if ( nextIndex > - 1 ) {
342- focusableElements [ nextIndex ] . focus ( ) ;
343- if ( nextIndex === 0 ) {
344- // first element is a sticky search bar, so if we are focusing
345- // on that, also move the scroll to the top
346- dropdownContentRef . current ?. scrollTo ( { top : 0 } ) ;
347- } else {
348- focusableElements [ nextIndex ] . scrollIntoView ( {
349- behavior : 'auto' ,
350- block : 'nearest' ,
351- } ) ;
340+ const currentIndex = Array . from ( focusableElements ) . indexOf (
341+ document . activeElement as HTMLElement
342+ ) ;
343+ let nextIndex = - 1 ;
344+
345+ switch ( e . key ) {
346+ case 'ArrowDown' :
347+ nextIndex =
348+ currentIndex < focusableElements . length - 1
349+ ? currentIndex + 1
350+ : 0 ;
351+ break ;
352+
353+ case 'ArrowUp' :
354+ nextIndex =
355+ currentIndex > 0
356+ ? currentIndex - 1
357+ : focusableElements . length - 1 ;
358+
359+ break ;
360+ case 'PageDown' :
361+ nextIndex = Math . min (
362+ currentIndex + 10 ,
363+ focusableElements . length - 1
364+ ) ;
365+ break ;
366+ case 'PageUp' :
367+ nextIndex = Math . max ( currentIndex - 10 , 0 ) ;
368+ break ;
369+ case 'Home' :
370+ nextIndex = 0 ;
371+ break ;
372+ case 'End' :
373+ nextIndex = focusableElements . length - 1 ;
374+ break ;
375+ default :
376+ break ;
352377 }
353- }
354- } , [ ] ) ;
378+
379+ if ( nextIndex > - 1 ) {
380+ focusableElements [ nextIndex ] . focus ( ) ;
381+ if ( nextIndex === 0 ) {
382+ // first element is a sticky search bar, so if we are focusing
383+ // on that, also move the scroll to the top
384+ dropdownContentRef . current ?. scrollTo ( { top : 0 } ) ;
385+ } else {
386+ focusableElements [ nextIndex ] . scrollIntoView ( {
387+ behavior : 'auto' ,
388+ block : 'nearest' ,
389+ } ) ;
390+ }
391+ }
392+ } ,
393+ [ displayOptions , multi , sanitizedValues , updateSelection ]
394+ ) ;
355395
356396 // Handle popover open/close
357397 const handleOpenChange = useCallback (
@@ -381,6 +421,18 @@ const Dropdown = (props: DropdownProps) => {
381421 if ( [ 'ArrowDown' , 'Enter' ] . includes ( e . key ) ) {
382422 e . preventDefault ( ) ;
383423 }
424+ // Auto-open on typing: detect printable characters
425+ if (
426+ searchable &&
427+ e . key . length === 1 &&
428+ ! e . ctrlKey &&
429+ ! e . metaKey &&
430+ ! e . altKey
431+ ) {
432+ e . preventDefault ( ) ;
433+ setProps ( { search_value : e . key } ) ;
434+ setIsOpen ( true ) ;
435+ }
384436 } }
385437 onKeyUp = { e => {
386438 if ( [ 'ArrowDown' , 'Enter' ] . includes ( e . key ) ) {
0 commit comments