1- import { isNil , without , isEmpty } from 'ramda' ;
1+ import { isNil , without , append , isEmpty } from 'ramda' ;
22import React , {
33 useState ,
44 useCallback ,
@@ -50,6 +50,7 @@ const Dropdown = (props: DropdownProps) => {
5050 document . createElement ( 'div' )
5151 ) ;
5252 const searchInputRef = useRef < HTMLInputElement > ( null ) ;
53+ const pendingSearchRef = useRef ( '' ) ;
5354
5455 const ctx = window . dash_component_api . useDashContext ( ) ;
5556 const loading = ctx . useLoading ( ) ;
@@ -92,7 +93,7 @@ const Dropdown = (props: DropdownProps) => {
9293 setVal ( newValue ) ;
9394 } else {
9495 setVal ( newValue ) ;
95- setProps ( { value : newValue } ) ;
96+ setProps ( { value : newValue } ) ;
9697 }
9798 } ,
9899 [ debounce , isOpen , setProps ]
@@ -102,6 +103,8 @@ const Dropdown = (props: DropdownProps) => {
102103 ( selection : OptionValue [ ] ) => {
103104 if ( closeOnSelect !== false ) {
104105 setIsOpen ( false ) ;
106+ setProps ( { search_value : undefined } ) ;
107+ pendingSearchRef . current = '' ;
105108 }
106109
107110 if ( multi ) {
@@ -257,12 +260,15 @@ const Dropdown = (props: DropdownProps) => {
257260
258261 // Focus first selected item or search input when dropdown opens
259262 useEffect ( ( ) => {
260- if ( ! isOpen || search_value ) {
263+ if ( ! isOpen ) {
261264 return ;
262265 }
263-
264266 // waiting for the DOM to be ready after the dropdown renders
265267 requestAnimationFrame ( ( ) => {
268+ // Don't steal focus from the search input while the user is typing
269+ if ( pendingSearchRef . current ) {
270+ return ;
271+ }
266272 // Try to focus the first selected item (for single-select)
267273 if ( ! multi ) {
268274 const selectedValue = sanitizedValues [ 0 ] ;
@@ -279,9 +285,14 @@ const Dropdown = (props: DropdownProps) => {
279285 }
280286 }
281287
282- // Fallback: focus search input if available and no selected item was focused
283- if ( searchable && searchInputRef . current ) {
284- searchInputRef . current . focus ( ) ;
288+ if ( searchable ) {
289+ searchInputRef . current ?. focus ( ) ;
290+ } else {
291+ dropdownContentRef . current
292+ . querySelector < HTMLElement > (
293+ 'input.dash-options-list-option-checkbox:not([disabled])'
294+ )
295+ ?. focus ( ) ;
285296 }
286297 } ) ;
287298 } , [ isOpen , multi , displayOptions ] ) ;
@@ -374,29 +385,30 @@ const Dropdown = (props: DropdownProps) => {
374385 } , [ ] ) ;
375386
376387 // Handle popover open/close
377- const handleOpenChange = useCallback (
378- ( open : boolean ) => {
379- setIsOpen ( open ) ;
388+ const handleOpenChange = useCallback (
389+ ( open : boolean ) => {
390+ setIsOpen ( open ) ;
380391
381- if ( ! open ) {
382- const updates : Partial < DropdownProps > = { } ;
392+ if ( ! open ) {
393+ pendingSearchRef . current = '' ;
394+ const updates : Partial < DropdownProps > = { } ;
383395
384- if ( ! isNil ( search_value ) ) {
385- updates . search_value = undefined ;
386- }
396+ if ( ! isNil ( search_value ) ) {
397+ updates . search_value = undefined ;
398+ }
387399
388- // Commit debounced value on close only
389- if ( debounce && ! isEqual ( value , val ) ) {
390- updates . value = val ;
391- }
400+ // Commit debounced value on close only
401+ if ( debounce && ! isEqual ( value , val ) ) {
402+ updates . value = val ;
403+ }
392404
393- if ( Object . keys ( updates ) . length > 0 ) {
394- setProps ( updates ) ;
405+ if ( Object . keys ( updates ) . length > 0 ) {
406+ setProps ( updates ) ;
407+ }
395408 }
396- }
397- } ,
398- [ debounce , value , val , search_value , setProps ]
399- ) ;
409+ } ,
410+ [ debounce , value , val , search_value , setProps ]
411+ ) ;
400412
401413 const accessibleId = id ?? uuid ( ) ;
402414 const positioningContainerRef = useRef < HTMLDivElement > ( null ) ;
@@ -425,6 +437,14 @@ const Dropdown = (props: DropdownProps) => {
425437 ) {
426438 handleClear ( ) ;
427439 }
440+ if ( e . key . length === 1 && searchable ) {
441+ pendingSearchRef . current += e . key ;
442+ setProps ( { search_value : pendingSearchRef . current } ) ;
443+ setIsOpen ( true ) ;
444+ requestAnimationFrame ( ( ) =>
445+ searchInputRef . current ?. focus ( )
446+ ) ;
447+ }
428448 } }
429449 className = { `dash-dropdown ${ className ?? '' } ` }
430450 aria-labelledby = { `${ accessibleId } -value-count ${ accessibleId } -value` }
@@ -508,6 +528,31 @@ const Dropdown = (props: DropdownProps) => {
508528 value = { search_value || '' }
509529 autoComplete = "off"
510530 onChange = { e => onInputChange ( e . target . value ) }
531+ onKeyUp = { e => {
532+ if (
533+ ! search_value ||
534+ e . key !== 'Enter' ||
535+ ! displayOptions . length
536+ ) {
537+ return ;
538+ }
539+ const firstVal = displayOptions [ 0 ] . value ;
540+ const isSelected =
541+ sanitizedValues . includes ( firstVal ) ;
542+ let newSelection ;
543+ if ( isSelected ) {
544+ newSelection = without (
545+ [ firstVal ] ,
546+ sanitizedValues
547+ ) ;
548+ } else {
549+ newSelection = append (
550+ firstVal ,
551+ sanitizedValues
552+ ) ;
553+ }
554+ updateSelection ( newSelection ) ;
555+ } }
511556 ref = { searchInputRef }
512557 />
513558 { search_value && (
0 commit comments