Skip to content

Commit c2bf83b

Browse files
committed
add debounce to Dropdown
1 parent 50cb3e4 commit c2bf83b

File tree

2 files changed

+59
-24
lines changed

2 files changed

+59
-24
lines changed

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

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const Dropdown = (props: DropdownProps) => {
2727
className,
2828
closeOnSelect,
2929
clearable,
30+
debounce,
3031
disabled,
3132
labels,
3233
maxHeight,
@@ -42,6 +43,7 @@ const Dropdown = (props: DropdownProps) => {
4243
const [optionsCheck, setOptionsCheck] = useState<DetailedOption[]>();
4344
const [isOpen, setIsOpen] = useState(false);
4445
const [displayOptions, setDisplayOptions] = useState<DetailedOption[]>([]);
46+
const [val, setVal] = useState<DropdownProps['value']>(value);
4547
const persistentOptions = useRef<DropdownProps['options']>([]);
4648
const dropdownContainerRef = useRef<HTMLButtonElement>(null);
4749
const dropdownContentRef = useRef<HTMLDivElement>(
@@ -52,6 +54,13 @@ const Dropdown = (props: DropdownProps) => {
5254
const ctx = window.dash_component_api.useDashContext();
5355
const loading = ctx.useLoading();
5456

57+
// Sync val when external value prop changes
58+
useEffect(() => {
59+
if (!isEqual(value, val)) {
60+
setVal(value);
61+
}
62+
}, [value]);
63+
5564
if (!persistentOptions || !isEqual(options, persistentOptions.current)) {
5665
persistentOptions.current = options;
5766
}
@@ -67,14 +76,27 @@ const Dropdown = (props: DropdownProps) => {
6776
);
6877

6978
const sanitizedValues: OptionValue[] = useMemo(() => {
70-
if (value instanceof Array) {
71-
return value;
79+
if (val instanceof Array) {
80+
return val;
7281
}
73-
if (isNil(value)) {
82+
if (isNil(val)) {
7483
return [];
7584
}
76-
return [value];
77-
}, [value]);
85+
return [val];
86+
}, [val]);
87+
88+
const handleSetProps = useCallback(
89+
(newValue: DropdownProps['value']) => {
90+
if (debounce && isOpen) {
91+
// local only
92+
setVal(newValue);
93+
} else {
94+
setVal(newValue);
95+
setProps({ value: newValue });
96+
}
97+
},
98+
[debounce, isOpen, setProps]
99+
);
78100

79101
const updateSelection = useCallback(
80102
(selection: OptionValue[]) => {
@@ -87,30 +109,28 @@ const Dropdown = (props: DropdownProps) => {
87109
if (selection.length === 0) {
88110
// Empty selection: only allow if clearable is true
89111
if (clearable) {
90-
setProps({value: []});
112+
handleSetProps([]);
91113
}
92114
// If clearable is false and trying to set empty, do nothing
93115
// return;
94116
} else {
95-
// Non-empty selection: always allowed in multi-select
96-
setProps({value: selection});
117+
handleSetProps(selection);
97118
}
98119
} else {
99120
// For single-select, take the first value or null
100121
if (selection.length === 0) {
101122
// Empty selection: only allow if clearable is true
102123
if (clearable) {
103-
setProps({value: null});
124+
handleSetProps(null);
104125
}
105126
// If clearable is false and trying to set empty, do nothing
106127
// return;
107128
} else {
108-
// Take the first value for single-select
109-
setProps({value: selection[selection.length - 1]});
129+
handleSetProps(selection[selection.length - 1]);
110130
}
111131
}
112132
},
113-
[multi, clearable, closeOnSelect]
133+
[multi, clearable, closeOnSelect, handleSetProps]
114134
);
115135

116136
const onInputChange = useCallback(
@@ -179,8 +199,8 @@ const Dropdown = (props: DropdownProps) => {
179199

180200
const handleClear = useCallback(() => {
181201
const finalValue: DropdownProps['value'] = multi ? [] : null;
182-
setProps({value: finalValue});
183-
}, [multi]);
202+
handleSetProps(finalValue);
203+
}, [multi, handleSetProps]);
184204

185205
const handleSelectAll = useCallback(() => {
186206
if (multi) {
@@ -189,12 +209,12 @@ const Dropdown = (props: DropdownProps) => {
189209
.filter(option => !sanitizedValues.includes(option.value))
190210
.map(option => option.value)
191211
);
192-
setProps({value: allValues});
212+
handleSetProps(allValues);
193213
}
194214
if (closeOnSelect) {
195215
setIsOpen(false);
196216
}
197-
}, [multi, displayOptions, sanitizedValues, closeOnSelect]);
217+
}, [multi, displayOptions, sanitizedValues, closeOnSelect, handleSetProps]);
198218

199219
const handleDeselectAll = useCallback(() => {
200220
if (multi) {
@@ -203,12 +223,12 @@ const Dropdown = (props: DropdownProps) => {
203223
displayOption => displayOption.value === option
204224
);
205225
});
206-
setProps({value: withDeselected});
226+
handleSetProps(withDeselected);
207227
}
208228
if (closeOnSelect) {
209229
setIsOpen(false);
210230
}
211-
}, [multi, displayOptions, sanitizedValues, closeOnSelect]);
231+
}, [multi, displayOptions, sanitizedValues, closeOnSelect, handleSetProps]);
212232

213233
// Sort options when popover opens - selected options first
214234
// Update display options when filtered options or selection changes
@@ -233,7 +253,7 @@ const Dropdown = (props: DropdownProps) => {
233253

234254
setDisplayOptions(sortedOptions);
235255
}
236-
}, [filteredOptions, isOpen]);
256+
}, [filteredOptions, isOpen, sanitizedValues, multi]);
237257

238258
// Focus first selected item or search input when dropdown opens
239259
useEffect(() => {
@@ -264,7 +284,7 @@ const Dropdown = (props: DropdownProps) => {
264284
searchInputRef.current.focus();
265285
}
266286
});
267-
}, [isOpen, multi, displayOptions]);
287+
}, [isOpen, multi, displayOptions, sanitizedValues]);
268288

269289
// Handle keyboard navigation in popover
270290
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -354,15 +374,25 @@ const Dropdown = (props: DropdownProps) => {
354374
}, []);
355375

356376
// Handle popover open/close
357-
const handleOpenChange = useCallback(
377+
const handleOpenChange = useCallback(
358378
(open: boolean) => {
359379
setIsOpen(open);
360380

361-
if (!open) {
362-
setProps({search_value: undefined});
381+
const updates: Partial<DropdownProps> = {};
382+
383+
if (!isNil(search_value)) {
384+
updates.search_value = undefined;
385+
}
386+
387+
if (!open && debounce && !isEqual(value, val)) {
388+
updates.value = val;
389+
}
390+
391+
if (Object.keys(updates).length > 0) {
392+
setProps(updates);
363393
}
364394
},
365-
[filteredOptions, sanitizedValues]
395+
[debounce, value, val, search_value, setProps]
366396
);
367397

368398
const accessibleId = id ?? uuid();

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,11 @@ export interface DropdownProps extends BaseDccProps<DropdownProps> {
741741
clear_selection?: string;
742742
no_options_found?: string;
743743
};
744+
/**
745+
* If True, changes to input values will be sent back to the Dash server only when dropdown menu closes.
746+
* Use with `closeOnSelect=False`
747+
*/
748+
debounce?: boolean;
744749
}
745750

746751
export interface ChecklistProps extends BaseDccProps<ChecklistProps> {

0 commit comments

Comments
 (0)