diff --git a/CHANGELOG.md b/CHANGELOG.md index c46576d2c0..6b470b0cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,14 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] +## Added +- [#3637](https://github.com/plotly/dash/pull/3637) Added `debounce` prop to `Dropdown`. + ## Fixed - [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container. + ## [4.0.0] - 2026-02-03 ## Added diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..1860977eab 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -27,6 +27,7 @@ const Dropdown = (props: DropdownProps) => { className, closeOnSelect, clearable, + debounce, disabled, labels, maxHeight, @@ -42,6 +43,7 @@ const Dropdown = (props: DropdownProps) => { const [optionsCheck, setOptionsCheck] = useState(); const [isOpen, setIsOpen] = useState(false); const [displayOptions, setDisplayOptions] = useState([]); + const [val, setVal] = useState(value); const persistentOptions = useRef([]); const dropdownContainerRef = useRef(null); const dropdownContentRef = useRef( @@ -52,6 +54,13 @@ const Dropdown = (props: DropdownProps) => { const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); + // Sync val when external value prop changes + useEffect(() => { + if (!isEqual(value, val)) { + setVal(value); + } + }, [value]); + if (!persistentOptions || !isEqual(options, persistentOptions.current)) { persistentOptions.current = options; } @@ -67,14 +76,27 @@ const Dropdown = (props: DropdownProps) => { ); const sanitizedValues: OptionValue[] = useMemo(() => { - if (value instanceof Array) { - return value; + if (val instanceof Array) { + return val; } - if (isNil(value)) { + if (isNil(val)) { return []; } - return [value]; - }, [value]); + return [val]; + }, [val]); + + const handleSetProps = useCallback( + (newValue: DropdownProps['value']) => { + if (debounce && isOpen) { + // local only + setVal(newValue); + } else { + setVal(newValue); + setProps({ value: newValue }); + } + }, + [debounce, isOpen, setProps] + ); const updateSelection = useCallback( (selection: OptionValue[]) => { @@ -87,30 +109,28 @@ const Dropdown = (props: DropdownProps) => { if (selection.length === 0) { // Empty selection: only allow if clearable is true if (clearable) { - setProps({value: []}); + handleSetProps([]); } // If clearable is false and trying to set empty, do nothing // return; } else { - // Non-empty selection: always allowed in multi-select - setProps({value: selection}); + handleSetProps(selection); } } else { // For single-select, take the first value or null if (selection.length === 0) { // Empty selection: only allow if clearable is true if (clearable) { - setProps({value: null}); + handleSetProps(null); } // If clearable is false and trying to set empty, do nothing // return; } else { - // Take the first value for single-select - setProps({value: selection[selection.length - 1]}); + handleSetProps(selection[selection.length - 1]); } } }, - [multi, clearable, closeOnSelect] + [multi, clearable, closeOnSelect, handleSetProps] ); const onInputChange = useCallback( @@ -179,8 +199,8 @@ const Dropdown = (props: DropdownProps) => { const handleClear = useCallback(() => { const finalValue: DropdownProps['value'] = multi ? [] : null; - setProps({value: finalValue}); - }, [multi]); + handleSetProps(finalValue); + }, [multi, handleSetProps]); const handleSelectAll = useCallback(() => { if (multi) { @@ -189,12 +209,12 @@ const Dropdown = (props: DropdownProps) => { .filter(option => !sanitizedValues.includes(option.value)) .map(option => option.value) ); - setProps({value: allValues}); + handleSetProps(allValues); } if (closeOnSelect) { setIsOpen(false); } - }, [multi, displayOptions, sanitizedValues, closeOnSelect]); + }, [multi, displayOptions, sanitizedValues, closeOnSelect, handleSetProps]); const handleDeselectAll = useCallback(() => { if (multi) { @@ -203,12 +223,12 @@ const Dropdown = (props: DropdownProps) => { displayOption => displayOption.value === option ); }); - setProps({value: withDeselected}); + handleSetProps(withDeselected); } if (closeOnSelect) { setIsOpen(false); } - }, [multi, displayOptions, sanitizedValues, closeOnSelect]); + }, [multi, displayOptions, sanitizedValues, closeOnSelect, handleSetProps]); // Sort options when popover opens - selected options first // Update display options when filtered options or selection changes @@ -354,16 +374,29 @@ const Dropdown = (props: DropdownProps) => { }, []); // Handle popover open/close - const handleOpenChange = useCallback( - (open: boolean) => { - setIsOpen(open); + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); - if (!open) { - setProps({search_value: undefined}); + if (!open) { + const updates: Partial = {}; + + if (!isNil(search_value)) { + updates.search_value = undefined; } - }, - [filteredOptions, sanitizedValues] - ); + + // Commit debounced value on close only + if (debounce && !isEqual(value, val)) { + updates.value = val; + } + + if (Object.keys(updates).length > 0) { + setProps(updates); + } + } + }, + [debounce, value, val, search_value, setProps] +); const accessibleId = id ?? uuid(); const positioningContainerRef = useRef(null); diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index 0948d474cf..c993676cb0 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -741,6 +741,11 @@ export interface DropdownProps extends BaseDccProps { clear_selection?: string; no_options_found?: string; }; + /** + * If True, changes to input values will be sent back to the Dash server only when dropdown menu closes. + * Use with `closeOnSelect=False` + */ + debounce?: boolean; } export interface ChecklistProps extends BaseDccProps { diff --git a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py new file mode 100644 index 0000000000..e5e97cbd91 --- /dev/null +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py @@ -0,0 +1,60 @@ +from dash import Dash, Input, Output, dcc, html +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains +import time + + +def test_ddde001_dropdown_debounce(dash_duo): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown( + id="dropdown", + options=[ + {"label": "New York City", "value": "NYC"}, + {"label": "Montreal", "value": "MTL"}, + {"label": "San Francisco", "value": "SF"}, + ], + value=["MTL", "SF"], + multi=True, + closeOnSelect=False, + debounce=True, + ), + html.Div( + id="dropdown-value-out", style={"height": "10px", "width": "10px"} + ), + ] + ) + + @app.callback( + Output("dropdown-value-out", "children"), + Input("dropdown", "value"), + ) + def update_value(val): + return ", ".join(val) + + dash_duo.start_server(app) + + assert dash_duo.find_element("#dropdown-value-out").text == "MTL, SF" + + dash_duo.find_element("#dropdown").click() + + # deselect first item + selected = dash_duo.find_elements(".dash-dropdown-options input[checked]") + selected[0].click() + + # UI should update immediately (local state updated) + assert dash_duo.find_element("#dropdown-value").text == "San Francisco" + + # Callback output should not change while dropdown is still open + assert dash_duo.find_element("#dropdown-value-out").text == "MTL, SF" + + # Close the dropdown (ESC simulates user dismiss) + actions = ActionChains(dash_duo.driver) + actions.send_keys(Keys.ESCAPE).perform() + time.sleep(0.1) + + # After closing, the callback output should be updated + assert dash_duo.find_element("#dropdown-value-out").text == "SF" + + assert dash_duo.get_logs() == []