Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 59 additions & 26 deletions components/dash-core-components/src/fragments/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const Dropdown = (props: DropdownProps) => {
className,
closeOnSelect,
clearable,
debounce,
disabled,
labels,
maxHeight,
Expand All @@ -42,6 +43,7 @@ const Dropdown = (props: DropdownProps) => {
const [optionsCheck, setOptionsCheck] = useState<DetailedOption[]>();
const [isOpen, setIsOpen] = useState(false);
const [displayOptions, setDisplayOptions] = useState<DetailedOption[]>([]);
const [val, setVal] = useState<DropdownProps['value']>(value);
const persistentOptions = useRef<DropdownProps['options']>([]);
const dropdownContainerRef = useRef<HTMLButtonElement>(null);
const dropdownContentRef = useRef<HTMLDivElement>(
Expand All @@ -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;
}
Expand All @@ -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[]) => {
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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<DropdownProps> = {};

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<HTMLDivElement>(null);
Expand Down
5 changes: 5 additions & 0 deletions components/dash-core-components/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,11 @@ export interface DropdownProps extends BaseDccProps<DropdownProps> {
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<ChecklistProps> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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() == []