Skip to content

Commit 343c8c4

Browse files
authored
Merge pull request #3627 from plotly/bugfix/3572
Make focused dropdowns searchable without first opening
2 parents 8366cfc + 69b3628 commit 343c8c4

File tree

5 files changed

+360
-30
lines changed

5 files changed

+360
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
99

1010
## Fixed
1111
- [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container.
12+
- [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable wheen focused, without requiring to open them first
1213

1314

1415

components/dash-core-components/src/fragments/Dropdown.tsx

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {isNil, without, isEmpty} from 'ramda';
1+
import {isNil, without, append, isEmpty} from 'ramda';
22
import 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 && (

components/dash-core-components/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -741,7 +741,7 @@ export interface DropdownProps extends BaseDccProps<DropdownProps> {
741741
clear_selection?: string;
742742
no_options_found?: string;
743743
};
744-
/**
744+
/**
745745
* If True, changes to input values will be sent back to the Dash server only when dropdown menu closes.
746746
* Use with `closeOnSelect=False`
747747
*/

0 commit comments

Comments
 (0)