Skip to content

Commit 9ab7970

Browse files
authored
Merge pull request #3637 from AnnMarieW/add-debounce-to-dropdown
add debounce to Dropdown
2 parents ddcd3f9 + ba99851 commit 9ab7970

File tree

4 files changed

+128
-26
lines changed

4 files changed

+128
-26
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
44

55
## [UNRELEASED]
66

7+
## Added
8+
- [#3637](https://github.com/plotly/dash/pull/3637) Added `debounce` prop to `Dropdown`.
9+
710
## Fixed
811
- [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container.
912

1013

14+
1115
## [4.0.0] - 2026-02-03
1216

1317
## Added

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

Lines changed: 59 additions & 26 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
@@ -354,16 +374,29 @@ const Dropdown = (props: DropdownProps) => {
354374
}, []);
355375

356376
// Handle popover open/close
357-
const handleOpenChange = useCallback(
358-
(open: boolean) => {
359-
setIsOpen(open);
377+
const handleOpenChange = useCallback(
378+
(open: boolean) => {
379+
setIsOpen(open);
360380

361-
if (!open) {
362-
setProps({search_value: undefined});
381+
if (!open) {
382+
const updates: Partial<DropdownProps> = {};
383+
384+
if (!isNil(search_value)) {
385+
updates.search_value = undefined;
363386
}
364-
},
365-
[filteredOptions, sanitizedValues]
366-
);
387+
388+
// Commit debounced value on close only
389+
if (debounce && !isEqual(value, val)) {
390+
updates.value = val;
391+
}
392+
393+
if (Object.keys(updates).length > 0) {
394+
setProps(updates);
395+
}
396+
}
397+
},
398+
[debounce, value, val, search_value, setProps]
399+
);
367400

368401
const accessibleId = id ?? uuid();
369402
const positioningContainerRef = useRef<HTMLDivElement>(null);

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> {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from dash import Dash, Input, Output, dcc, html
2+
from selenium.webdriver.common.keys import Keys
3+
from selenium.webdriver.common.action_chains import ActionChains
4+
import time
5+
6+
7+
def test_ddde001_dropdown_debounce(dash_duo):
8+
app = Dash(__name__)
9+
app.layout = html.Div(
10+
[
11+
dcc.Dropdown(
12+
id="dropdown",
13+
options=[
14+
{"label": "New York City", "value": "NYC"},
15+
{"label": "Montreal", "value": "MTL"},
16+
{"label": "San Francisco", "value": "SF"},
17+
],
18+
value=["MTL", "SF"],
19+
multi=True,
20+
closeOnSelect=False,
21+
debounce=True,
22+
),
23+
html.Div(
24+
id="dropdown-value-out", style={"height": "10px", "width": "10px"}
25+
),
26+
]
27+
)
28+
29+
@app.callback(
30+
Output("dropdown-value-out", "children"),
31+
Input("dropdown", "value"),
32+
)
33+
def update_value(val):
34+
return ", ".join(val)
35+
36+
dash_duo.start_server(app)
37+
38+
assert dash_duo.find_element("#dropdown-value-out").text == "MTL, SF"
39+
40+
dash_duo.find_element("#dropdown").click()
41+
42+
# deselect first item
43+
selected = dash_duo.find_elements(".dash-dropdown-options input[checked]")
44+
selected[0].click()
45+
46+
# UI should update immediately (local state updated)
47+
assert dash_duo.find_element("#dropdown-value").text == "San Francisco"
48+
49+
# Callback output should not change while dropdown is still open
50+
assert dash_duo.find_element("#dropdown-value-out").text == "MTL, SF"
51+
52+
# Close the dropdown (ESC simulates user dismiss)
53+
actions = ActionChains(dash_duo.driver)
54+
actions.send_keys(Keys.ESCAPE).perform()
55+
time.sleep(0.1)
56+
57+
# After closing, the callback output should be updated
58+
assert dash_duo.find_element("#dropdown-value-out").text == "SF"
59+
60+
assert dash_duo.get_logs() == []

0 commit comments

Comments
 (0)