From 358a0451285feb585f12f76735375bc3cec963f3 Mon Sep 17 00:00:00 2001 From: Corebit <123189979+corebit-nl@users.noreply.github.com> Date: Sat, 22 Nov 2025 11:22:59 +0100 Subject: [PATCH 01/74] Fallback on background callback function names if source cannot be found --- dash/background_callback/managers/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dash/background_callback/managers/__init__.py b/dash/background_callback/managers/__init__.py index 6bea3765d3..4145f27f63 100644 --- a/dash/background_callback/managers/__init__.py +++ b/dash/background_callback/managers/__init__.py @@ -56,7 +56,11 @@ def get_updated_props(self, key): raise NotImplementedError def build_cache_key(self, fn, args, cache_args_to_ignore, triggered): - fn_source = inspect.getsource(fn) + try: + fn_source = inspect.getsource(fn) + fn_str = fn_source + except OSError: # pylint: disable=too-broad-exception + fn_str = getattr(fn, "__name__", "") if not isinstance(cache_args_to_ignore, (list, tuple)): cache_args_to_ignore = [cache_args_to_ignore] @@ -69,7 +73,7 @@ def build_cache_key(self, fn, args, cache_args_to_ignore, triggered): arg for i, arg in enumerate(args) if i not in cache_args_to_ignore ] - hash_dict = dict(args=args, fn_source=fn_source, triggered=triggered) + hash_dict = dict(args=args, fn_source=fn_str, triggered=triggered) if self.cache_by is not None: # Caching enabled From 5932df4bbff199247545920bbce9f33baf10debf Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:02:04 -0500 Subject: [PATCH 02/74] fixes issue where components wouldnt remount when passed as a children prop (cherry picked from commit cdd40ed1f23eaea4d84f717a206f3cfc1ab2f8fd) --- dash/dash-renderer/src/wrapper/DashWrapper.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 57e152ebcc..40f5817f36 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -65,6 +65,7 @@ function DashWrapper({ const dispatch = useDispatch(); const memoizedKeys: MutableRefObject = useRef({}); const newRender = useRef(false); + const freshRenders = useRef(0); const renderedPath = useRef(componentPath); let renderComponent: any = null; let renderComponentProps: any = null; @@ -85,6 +86,7 @@ function DashWrapper({ if (_newRender) { newRender.current = true; renderH = 0; + freshRenders.current += 1; if (renderH in memoizedKeys.current) { delete memoizedKeys.current[renderH]; } @@ -498,6 +500,7 @@ function DashWrapper({ } error={_dashprivate_error} dispatch={dispatch} + key={freshRenders.current} > {React.isValidElement(hydrated) ? hydrated :
} From cb1f299b7909774eded40e1871f3aa1950ca7faf Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:13:46 -0500 Subject: [PATCH 03/74] update to remove hashes of decendents upon `newRender` --- dash/dash-renderer/src/actions/constants.js | 3 ++- dash/dash-renderer/src/actions/index.js | 1 + dash/dash-renderer/src/reducers/reducer.js | 11 +++++++++++ dash/dash-renderer/src/wrapper/DashWrapper.tsx | 6 +++++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/dash/dash-renderer/src/actions/constants.js b/dash/dash-renderer/src/actions/constants.js index 9c744f1655..47406b6627 100644 --- a/dash/dash-renderer/src/actions/constants.js +++ b/dash/dash-renderer/src/actions/constants.js @@ -10,7 +10,8 @@ const actionList = { ON_ERROR: 1, SET_HOOKS: 1, INSERT_COMPONENT: 1, - REMOVE_COMPONENT: 1 + REMOVE_COMPONENT: 1, + RESET_COMPONENT_STATE: 1 }; export const getAction = action => { diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index 5df75d708e..ee7f8fd9db 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -22,6 +22,7 @@ export const insertComponent = createAction(getAction('INSERT_COMPONENT')); export const removeComponent = createAction(getAction('REMOVE_COMPONENT')); export const onPropChange = createAction(getAction('ON_PROP_CHANGE')); +export const resetComponentState = createAction(getAction('RESET_COMPONENT_STATE')); export function updateProps(payload) { return (dispatch, getState) => { diff --git a/dash/dash-renderer/src/reducers/reducer.js b/dash/dash-renderer/src/reducers/reducer.js index 56d2c82ad2..b9704c96b7 100644 --- a/dash/dash-renderer/src/reducers/reducer.js +++ b/dash/dash-renderer/src/reducers/reducer.js @@ -49,6 +49,17 @@ const layoutHashes = (state = {}, action) => { }, state ); + } else if (action.type === 'RESET_COMPONENT_STATE') { + const { itempath } = action.payload; + if (itempath) { + const prefixStr = stringifyPath(itempath); + // Remove all hashes for keys starting with prefixStr + return Object.fromEntries( + Object.entries(state).filter( + ([key]) => !key.startsWith(prefixStr) + ) + ); + }; } return state; }; diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 40f5817f36..43fccdb2c3 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -21,7 +21,7 @@ import {useSelector, useDispatch, batch} from 'react-redux'; import ComponentErrorBoundary from '../components/error/ComponentErrorBoundary.react'; import {DashLayoutPath, UpdatePropsPayload} from '../types/component'; import {DashConfig} from '../config'; -import {notifyObservers, onError, updateProps} from '../actions'; +import {notifyObservers, onError, updateProps, resetComponentState} from '../actions'; import {getWatchedKeys, stringifyId} from '../actions/dependencies'; import { createElement, @@ -90,6 +90,10 @@ function DashWrapper({ if (renderH in memoizedKeys.current) { delete memoizedKeys.current[renderH]; } + // Reset hashes and layout for this component and all descendants + dispatch(resetComponentState({ + itempath: componentPath, + })); } else { newRender.current = false; } From 8e960df105276a5cc657c0eb9a66ab8486258105 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:46:44 -0500 Subject: [PATCH 04/74] fix for lint --- dash/dash-renderer/src/actions/index.js | 4 +++- dash/dash-renderer/src/wrapper/DashWrapper.tsx | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index ee7f8fd9db..7c92d17afc 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -22,7 +22,9 @@ export const insertComponent = createAction(getAction('INSERT_COMPONENT')); export const removeComponent = createAction(getAction('REMOVE_COMPONENT')); export const onPropChange = createAction(getAction('ON_PROP_CHANGE')); -export const resetComponentState = createAction(getAction('RESET_COMPONENT_STATE')); +export const resetComponentState = createAction( + getAction('RESET_COMPONENT_STATE') +); export function updateProps(payload) { return (dispatch, getState) => { diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 43fccdb2c3..80f973ba91 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -21,7 +21,12 @@ import {useSelector, useDispatch, batch} from 'react-redux'; import ComponentErrorBoundary from '../components/error/ComponentErrorBoundary.react'; import {DashLayoutPath, UpdatePropsPayload} from '../types/component'; import {DashConfig} from '../config'; -import {notifyObservers, onError, updateProps, resetComponentState} from '../actions'; +import { + notifyObservers, + onError, + updateProps, + resetComponentState +} from '../actions'; import {getWatchedKeys, stringifyId} from '../actions/dependencies'; import { createElement, @@ -91,9 +96,11 @@ function DashWrapper({ delete memoizedKeys.current[renderH]; } // Reset hashes and layout for this component and all descendants - dispatch(resetComponentState({ - itempath: componentPath, - })); + dispatch( + resetComponentState({ + itempath: componentPath + }) + ); } else { newRender.current = false; } From fd25ea8484eb8c4ec8587b864fea9a472caa5053 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:04:15 -0500 Subject: [PATCH 05/74] fixing issue with bad setState --- .../dash-renderer/src/wrapper/DashWrapper.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 80f973ba91..8a017ed09d 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -1,4 +1,10 @@ -import React, {useCallback, MutableRefObject, useRef, useMemo} from 'react'; +import React, { + useCallback, + MutableRefObject, + useRef, + useMemo, + useEffect +} from 'react'; import { path, concat, @@ -95,12 +101,6 @@ function DashWrapper({ if (renderH in memoizedKeys.current) { delete memoizedKeys.current[renderH]; } - // Reset hashes and layout for this component and all descendants - dispatch( - resetComponentState({ - itempath: componentPath - }) - ); } else { newRender.current = false; } @@ -445,6 +445,16 @@ function DashWrapper({ return props; }; + useEffect(() => { + if (_newRender) { + dispatch( + resetComponentState({ + itempath: componentPath + }) + ); + } + }, [_newRender]); + const hydrateFunc = () => { if (newRender.current) { renderComponent = _passedComponent; From dc2e3ad2693426326d8f58f7f0442502b3db4eb6 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:04:47 -0500 Subject: [PATCH 06/74] fixing for lint --- dash/dash-renderer/src/reducers/reducer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash-renderer/src/reducers/reducer.js b/dash/dash-renderer/src/reducers/reducer.js index b9704c96b7..5952bab271 100644 --- a/dash/dash-renderer/src/reducers/reducer.js +++ b/dash/dash-renderer/src/reducers/reducer.js @@ -50,7 +50,7 @@ const layoutHashes = (state = {}, action) => { state ); } else if (action.type === 'RESET_COMPONENT_STATE') { - const { itempath } = action.payload; + const {itempath} = action.payload; if (itempath) { const prefixStr = stringifyPath(itempath); // Remove all hashes for keys starting with prefixStr From 316b9ac9b303cca21eb46200a910e21391783346 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:28:30 -0500 Subject: [PATCH 07/74] fix for lint --- dash/dash-renderer/src/reducers/reducer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash-renderer/src/reducers/reducer.js b/dash/dash-renderer/src/reducers/reducer.js index 5952bab271..759c198989 100644 --- a/dash/dash-renderer/src/reducers/reducer.js +++ b/dash/dash-renderer/src/reducers/reducer.js @@ -59,7 +59,7 @@ const layoutHashes = (state = {}, action) => { ([key]) => !key.startsWith(prefixStr) ) ); - }; + } } return state; }; From e7f46668eabfb8b3d2ac3c9a26d0dfb1ee08387c Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:49:15 -0500 Subject: [PATCH 08/74] adjustment for failing test --- tests/integration/renderer/test_redraw.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/integration/renderer/test_redraw.py b/tests/integration/renderer/test_redraw.py index 4f1dc814e0..99d1141985 100644 --- a/tests/integration/renderer/test_redraw.py +++ b/tests/integration/renderer/test_redraw.py @@ -29,6 +29,11 @@ def on_click(_): dash_duo.wait_for_text_to_equal("#counter", "1") dash_duo.find_element("#redraw").click() - dash_duo.wait_for_text_to_equal("#counter", "2") + # dash_duo.wait_for_text_to_equal("#counter", "2") + # time.sleep(1) + # dash_duo.wait_for_text_to_equal("#counter", "2") + + ## the above was changed due to a mechanism change that generates a new React component, thus resetting the counter + dash_duo.wait_for_text_to_equal("#counter", "1") time.sleep(1) - dash_duo.wait_for_text_to_equal("#counter", "2") + dash_duo.wait_for_text_to_equal("#counter", "1") From 4ddfe8103aee284e1781b4dda31f473d8b4b56eb Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 24 Feb 2026 15:36:32 -0700 Subject: [PATCH 09/74] Allow user to start searching dropdowns by typing (without opening first) --- .../src/fragments/Dropdown.tsx | 48 ++- .../tests/integration/dropdown/test_a11y.py | 281 ++++++++++++++++++ 2 files changed, 323 insertions(+), 6 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..d3f141d7f1 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -1,4 +1,4 @@ -import {isNil, without, isEmpty} from 'ramda'; +import {isNil, without, append, isEmpty} from 'ramda'; import React, { useState, useCallback, @@ -48,6 +48,7 @@ const Dropdown = (props: DropdownProps) => { document.createElement('div') ); const searchInputRef = useRef(null); + const pendingSearchRef = useRef(''); const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); @@ -80,6 +81,8 @@ const Dropdown = (props: DropdownProps) => { (selection: OptionValue[]) => { if (closeOnSelect !== false) { setIsOpen(false); + setProps({search_value: undefined}); + pendingSearchRef.current = ''; } if (multi) { @@ -237,12 +240,15 @@ const Dropdown = (props: DropdownProps) => { // Focus first selected item or search input when dropdown opens useEffect(() => { - if (!isOpen || search_value) { + if (!isOpen) { return; } - // waiting for the DOM to be ready after the dropdown renders requestAnimationFrame(() => { + // Don't steal focus from the search input while the user is typing + if (pendingSearchRef.current) { + return; + } // Try to focus the first selected item (for single-select) if (!multi) { const selectedValue = sanitizedValues[0]; @@ -259,9 +265,14 @@ const Dropdown = (props: DropdownProps) => { } } - // Fallback: focus search input if available and no selected item was focused - if (searchable && searchInputRef.current) { - searchInputRef.current.focus(); + if (searchable) { + searchInputRef.current?.focus(); + } else { + dropdownContentRef.current + .querySelector( + 'input.dash-options-list-option-checkbox:not([disabled])' + ) + ?.focus(); } }); }, [isOpen, multi, displayOptions]); @@ -360,6 +371,7 @@ const Dropdown = (props: DropdownProps) => { if (!open) { setProps({search_value: undefined}); + pendingSearchRef.current = ''; } }, [filteredOptions, sanitizedValues] @@ -392,6 +404,14 @@ const Dropdown = (props: DropdownProps) => { ) { handleClear(); } + if (e.key.length === 1 && searchable) { + pendingSearchRef.current += e.key; + setProps({search_value: pendingSearchRef.current}); + setIsOpen(true); + requestAnimationFrame(() => + searchInputRef.current?.focus() + ); + } }} className={`dash-dropdown ${className ?? ''}`} aria-labelledby={`${accessibleId}-value-count ${accessibleId}-value`} @@ -475,6 +495,22 @@ const Dropdown = (props: DropdownProps) => { value={search_value || ''} autoComplete="off" onChange={e => onInputChange(e.target.value)} + onKeyUp={e => { + if ( + !search_value || + e.key !== 'Enter' || + !displayOptions.length + ) { + return; + } + const firstVal = displayOptions[0].value; + const isSelected = + sanitizedValues.includes(firstVal); + const newSelection = isSelected + ? without([firstVal], sanitizedValues) + : append(firstVal, sanitizedValues); + updateSelection(newSelection); + }} ref={searchInputRef} /> {search_value && ( diff --git a/components/dash-core-components/tests/integration/dropdown/test_a11y.py b/components/dash-core-components/tests/integration/dropdown/test_a11y.py index d547390ca4..b04d3a6d51 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_a11y.py +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -139,6 +139,63 @@ def send_keys(key): assert dash_duo.get_logs() == [] +def test_a11y003b_keyboard_navigation_not_searchable(dash_duo): + def send_keys(key): + actions = ActionChains(dash_duo.driver) + actions.send_keys(key) + actions.perform() + + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=[i for i in range(0, 100)], + multi=True, + searchable=False, + placeholder="Testing keyboard navigation without search", + ), + ], + ) + + dash_duo.start_server(app) + + dropdown = dash_duo.find_element("#dropdown") + dropdown.send_keys(Keys.ENTER) # Open with Enter key + dash_duo.wait_for_element(".dash-dropdown-options") + + send_keys(Keys.ESCAPE) + with pytest.raises(TimeoutException): + dash_duo.wait_for_element(".dash-dropdown-options", timeout=0.25) + + send_keys(Keys.ARROW_DOWN) # Expecting the dropdown to open up + dash_duo.wait_for_element(".dash-dropdown-options") + + send_keys(Keys.SPACE) # Expecting to be selecting the focused first option + value_items = dash_duo.find_elements(".dash-dropdown-value-item") + assert len(value_items) == 1 + assert value_items[0].text == "0" + + send_keys(Keys.ARROW_DOWN) + send_keys(Keys.SPACE) + value_items = dash_duo.find_elements(".dash-dropdown-value-item") + assert len(value_items) == 2 + assert [item.text for item in value_items] == ["0", "1"] + + send_keys(Keys.SPACE) # Expecting to be de-selecting + value_items = dash_duo.find_elements(".dash-dropdown-value-item") + assert len(value_items) == 1 + assert value_items[0].text == "0" + + send_keys(Keys.ESCAPE) + sleep(0.25) + value_items = dash_duo.find_elements(".dash-dropdown-value-item") + assert len(value_items) == 1 + assert value_items[0].text == "0" + + assert dash_duo.get_logs() == [] + + def test_a11y004_selection_visibility_single(dash_duo): app = Dash(__name__) app.layout = ( @@ -414,6 +471,230 @@ def get_focused_option_text(): assert dash_duo.get_logs() == [] +def test_a11y009_enter_on_search_selects_first_option_multi(dash_duo): + def send_keys(key): + actions = ActionChains(dash_duo.driver) + actions.send_keys(key) + actions.perform() + + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=["Apple", "Banana", "Cherry"], + multi=True, + searchable=True, + ), + Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("dropdown", "value")) + def update_output(value): + return f"Selected: {value}" + + dash_duo.start_server(app) + + dropdown = dash_duo.find_element("#dropdown") + dropdown.click() + dash_duo.wait_for_element(".dash-dropdown-search") + + # Type to filter, then Enter selects the first visible option + send_keys("a") + sleep(0.1) + send_keys(Keys.ENTER) + dash_duo.wait_for_text_to_equal("#output", "Selected: ['Apple']") + assert dash_duo.driver.execute_script( + "return document.activeElement.type === 'search';" + ), "Focus should remain on the search input after Enter" + + # Enter again deselects it + send_keys(Keys.ENTER) + dash_duo.wait_for_text_to_equal("#output", "Selected: []") + assert dash_duo.driver.execute_script( + "return document.activeElement.type === 'search';" + ), "Focus should remain on the search input after deselect" + + # Filtering to a different option selects that one + send_keys(Keys.BACKSPACE) + send_keys("b") + sleep(0.1) + send_keys(Keys.ENTER) + dash_duo.wait_for_text_to_equal("#output", "Selected: ['Banana']") + + assert dash_duo.get_logs() == [] + + +def test_a11y010_enter_on_search_selects_first_option_single(dash_duo): + def send_keys(key): + actions = ActionChains(dash_duo.driver) + actions.send_keys(key) + actions.perform() + + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=["Apple", "Banana", "Cherry"], + multi=False, + searchable=True, + ), + Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("dropdown", "value")) + def update_output(value): + return f"Selected: {value}" + + dash_duo.start_server(app) + + dropdown = dash_duo.find_element("#dropdown") + dropdown.click() + dash_duo.wait_for_element(".dash-dropdown-search") + + send_keys("a") + sleep(0.1) + send_keys(Keys.ENTER) + dash_duo.wait_for_text_to_equal("#output", "Selected: Apple") + + assert dash_duo.get_logs() == [] + + +def test_a11y011_enter_on_search_no_deselect_when_not_clearable(dash_duo): + def send_keys(key): + actions = ActionChains(dash_duo.driver) + actions.send_keys(key) + actions.perform() + + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=["Apple", "Banana", "Cherry"], + value="Apple", + multi=False, + searchable=True, + clearable=False, + ), + Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("dropdown", "value")) + def update_output(value): + return f"Selected: {value}" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#output", "Selected: Apple") + + dropdown = dash_duo.find_element("#dropdown") + dropdown.click() + dash_duo.wait_for_element(".dash-dropdown-search") + + # Apple is the first option and already selected; Enter should not deselect it + send_keys(Keys.ENTER) + sleep(0.1) + dash_duo.wait_for_text_to_equal("#output", "Selected: Apple") + + assert dash_duo.get_logs() == [] + + +def test_a11y012_typing_on_trigger_opens_dropdown_with_search(dash_duo): + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=["Apple", "Banana", "Cherry"], + searchable=True, + ), + Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("dropdown", "search_value")) + def update_output(search_value): + return f"Search: {search_value}" + + dash_duo.start_server(app) + + dropdown = dash_duo.find_element("#dropdown") + dropdown.send_keys("b") + + dash_duo.wait_for_element(".dash-dropdown-search") + dash_duo.wait_for_text_to_equal("#output", "Search: b") + + # Only Banana should be visible + options = dash_duo.find_elements(".dash-dropdown-option") + assert len(options) == 1 + assert options[0].text == "Banana" + + # Focus should be on the search input + assert dash_duo.driver.execute_script( + "return document.activeElement.type === 'search';" + ), "Focus should be on the search input after typing on the trigger" + + assert dash_duo.get_logs() == [] + + +def test_a11y013_enter_on_search_after_reopen_selects_correctly(dash_duo): + def send_keys(key): + actions = ActionChains(dash_duo.driver) + actions.send_keys(key) + actions.perform() + + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=["Cambodia", "Cameroon", "Canada"], + multi=False, + searchable=True, + ), + Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("dropdown", "value")) + def update_output(value): + return f"Selected: {value}" + + dash_duo.start_server(app) + + dropdown = dash_duo.find_element("#dropdown") + dropdown.send_keys("c") + dash_duo.wait_for_element(".dash-dropdown-search") + sleep(0.1) + + # Enter selects Cambodia (first result) + send_keys(Keys.ENTER) + dash_duo.wait_for_text_to_equal("#output", "Selected: Cambodia") + + # Type "can" — should filter to only Canada + send_keys("can") + sleep(0.1) + options = dash_duo.find_elements(".dash-dropdown-option") + assert len(options) == 1 + assert options[0].text == "Canada" + + # Focus should still be on the search input, not the selected option + assert dash_duo.driver.execute_script( + "return document.activeElement.type === 'search';" + ), "Focus should remain on the search input while typing" + + # Enter selects Canada + send_keys(Keys.ENTER) + dash_duo.wait_for_text_to_equal("#output", "Selected: Canada") + + assert dash_duo.get_logs() == [] + + def elements_are_visible(dash_duo, elements): # Check if the given elements are within the visible viewport of the dropdown elements = elements if isinstance(elements, list) else [elements] From c2bf83b1e6cde08c9a8f5725f5306eb088c1a079 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 14:21:21 -0800 Subject: [PATCH 10/74] add debounce to Dropdown --- .../src/fragments/Dropdown.tsx | 78 +++++++++++++------ components/dash-core-components/src/types.ts | 5 ++ 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..deb73031fa 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 @@ -233,7 +253,7 @@ const Dropdown = (props: DropdownProps) => { setDisplayOptions(sortedOptions); } - }, [filteredOptions, isOpen]); + }, [filteredOptions, isOpen, sanitizedValues, multi]); // Focus first selected item or search input when dropdown opens useEffect(() => { @@ -264,7 +284,7 @@ const Dropdown = (props: DropdownProps) => { searchInputRef.current.focus(); } }); - }, [isOpen, multi, displayOptions]); + }, [isOpen, multi, displayOptions, sanitizedValues]); // Handle keyboard navigation in popover const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -354,15 +374,25 @@ const Dropdown = (props: DropdownProps) => { }, []); // Handle popover open/close - const handleOpenChange = useCallback( + const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - if (!open) { - setProps({search_value: undefined}); + const updates: Partial = {}; + + if (!isNil(search_value)) { + updates.search_value = undefined; + } + + if (!open && debounce && !isEqual(value, val)) { + updates.value = val; + } + + if (Object.keys(updates).length > 0) { + setProps(updates); } }, - [filteredOptions, sanitizedValues] + [debounce, value, val, search_value, setProps] ); const accessibleId = id ?? uuid(); 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 { From 2adad3a065ada5281f15de28064d0ce18a08fc3e Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 15:03:32 -0800 Subject: [PATCH 11/74] fix search_value --- .../src/fragments/Dropdown.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index deb73031fa..1374d14978 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -375,25 +375,28 @@ const Dropdown = (props: DropdownProps) => { // Handle popover open/close const handleOpenChange = useCallback( - (open: boolean) => { - setIsOpen(open); + (open: boolean) => { + setIsOpen(open); + if (!open) { const updates: Partial = {}; - if (!isNil(search_value)) { + if (!isNil(search_value)) { updates.search_value = undefined; } - if (!open && debounce && !isEqual(value, val)) { + // 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] - ); + } + }, + [debounce, value, val, search_value, setProps] +); const accessibleId = id ?? uuid(); const positioningContainerRef = useRef(null); From a47d354949967425b38912d840a91d716f358da5 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 16:04:47 -0800 Subject: [PATCH 12/74] fix tests --- components/dash-core-components/src/fragments/Dropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 1374d14978..1860977eab 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -253,7 +253,7 @@ const Dropdown = (props: DropdownProps) => { setDisplayOptions(sortedOptions); } - }, [filteredOptions, isOpen, sanitizedValues, multi]); + }, [filteredOptions, isOpen]); // Focus first selected item or search input when dropdown opens useEffect(() => { @@ -284,7 +284,7 @@ const Dropdown = (props: DropdownProps) => { searchInputRef.current.focus(); } }); - }, [isOpen, multi, displayOptions, sanitizedValues]); + }, [isOpen, multi, displayOptions]); // Handle keyboard navigation in popover const handleKeyDown = useCallback((e: React.KeyboardEvent) => { From 060fe3c8a58dd59677df86147c4a14f09d824289 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 17:18:56 -0800 Subject: [PATCH 13/74] add test --- .../dropdown/test_dropdown_debounce.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py 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..5421aaa4c0 --- /dev/null +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py @@ -0,0 +1,59 @@ +import pytest +from dash import Dash, Input, Output, dcc, html +from selenium.common.exceptions import TimeoutException +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() == [] From 36d6cd67088802ade20cd73a6f3ef4c11a7c4259 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 17:23:09 -0800 Subject: [PATCH 14/74] add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46576d2c0..516bbc8ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ 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. From bac54a85905a153364e6439ae1262d4e0b812955 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 18:41:21 -0800 Subject: [PATCH 15/74] lint --- .../tests/integration/dropdown/test_dropdown_debounce.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 5421aaa4c0..1ceb09afed 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py @@ -5,6 +5,7 @@ from selenium.webdriver.common.action_chains import ActionChains import time + def test_ddde001_dropdown_debounce(dash_duo): app = Dash(__name__) app.layout = html.Div( @@ -21,7 +22,9 @@ def test_ddde001_dropdown_debounce(dash_duo): closeOnSelect=False, debounce=True, ), - html.Div(id="dropdown-value-out", style={"height": "10px", "width": "10px"}), + html.Div( + id="dropdown-value-out", style={"height": "10px", "width": "10px"} + ), ] ) From 737370f70edb13a14fa9a2e6bda56f6850dc8e19 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 19:37:59 -0800 Subject: [PATCH 16/74] lint --- .../tests/integration/dropdown/test_dropdown_debounce.py | 2 -- 1 file changed, 2 deletions(-) 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 index 1ceb09afed..e5e97cbd91 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py @@ -1,6 +1,4 @@ -import pytest from dash import Dash, Input, Output, dcc, html -from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains import time From 0b23cb701a9851e6a213a0dcd9b7cc4ff409df1e Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Tue, 3 Mar 2026 06:33:26 -0800 Subject: [PATCH 17/74] re-run tests --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 516bbc8ce6..6b470b0cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#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 From 633416dae6dbb8e1aff6735be14caeb3b521a00c Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:33:22 -0500 Subject: [PATCH 18/74] Update CODEOWNERS --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 167ecd988e..1c92d72420 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence -* @T4rk1n @ndrezn @emilykl @camdecoster +* @T4rk1n @ndrezn @camdecoster From 7ea7c65e9c90bdc09b4934afdd92274e35d7d861 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Wed, 4 Mar 2026 12:47:14 -0800 Subject: [PATCH 19/74] fix multiselect dropdown with components as labels --- .../src/fragments/Dropdown.tsx | 2 +- .../tests/integration/dropdown/test_a11y.py | 39 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..8fd163bfe9 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -233,7 +233,7 @@ const Dropdown = (props: DropdownProps) => { setDisplayOptions(sortedOptions); } - }, [filteredOptions, isOpen]); + }, [filteredOptions, isOpen, sanitizedValues]); // Focus first selected item or search input when dropdown opens useEffect(() => { diff --git a/components/dash-core-components/tests/integration/dropdown/test_a11y.py b/components/dash-core-components/tests/integration/dropdown/test_a11y.py index d547390ca4..a581558d11 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_a11y.py +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -1,7 +1,7 @@ import pytest from dash import Dash, Input, Output from dash.dcc import Dropdown -from dash.html import Div, Label, P +from dash.html import Div, Label, P, Span from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains @@ -434,3 +434,40 @@ def is_visible(el): ) return all([is_visible(el) for el in elements]) + + +def test_a11y009_dropdown_component_labels_render_correctly(dash_duo): + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + options=[ + {"label": Span("red"), "value": "red"}, + {"label": Span("yellow"), "value": "yellow"}, + {"label": Span("blue"), "value": "blue"}, + ], + value=["red", "yellow", "blue"], + id="components-label-dropdown", + multi=True, + ), + ] + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#components-label-dropdown").click() + dash_duo.wait_for_element(".dash-dropdown-options") + + # Click on the "yellow" option + yellow_option = dash_duo.find_element( + '.dash-dropdown-option:has(input[value="yellow"])' + ) + yellow_option.click() + + # After interaction, verify the options render correctly + option_elements = dash_duo.find_elements(".dash-dropdown-option") + rendered_labels = [el.text.strip() for el in option_elements] + + assert rendered_labels == ["red", "blue", "yellow"] + + assert dash_duo.get_logs() == [] From ff8961fd20b80bb8b7694b39eef5c503659aba45 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Wed, 4 Mar 2026 13:50:38 -0800 Subject: [PATCH 20/74] fixed test --- .../integration/dropdown/test_clearable_false.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py index 7d2e3bbcc1..7681de98fe 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py +++ b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py @@ -190,8 +190,17 @@ def update_value(val): # Attempt to deselect all items. Everything should deselect until we get to # the last item which cannot be cleared. - selected = dash_duo.find_elements(".dash-dropdown-options input[checked]") - [el.click() for el in selected] + # Click MTL option container + mtl_option = dash_duo.find_element( + '.dash-dropdown-option:has(input[value="MTL"])' + ) + mtl_option.click() + + # Click SF option container + sf_option = dash_duo.find_element( + '.dash-dropdown-option:has(input[value="SF"])' + ) + sf_option.click() assert dash_duo.find_element("#dropdown-value").text == "SF" From 52162a004efdbb5853b897505fbabf4216fcc189 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 11 Mar 2026 14:24:01 -0400 Subject: [PATCH 21/74] group up weekly dependabot updates --- .github/dependabot.yml | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..ad62dcef38 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,70 @@ +version: 2 +updates: + # Root package dependencies + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + npm-dependencies: + patterns: + - "*" + ignore: + # Ignore @plotly packages + - dependency-name: "@plotly/*" + + # Dash renderer + - package-ecosystem: "npm" + directory: "/dash/dash-renderer" + schedule: + interval: "weekly" + day: "monday" + groups: + npm-dependencies: + patterns: + - "*" + + # Components - dash-core-components + - package-ecosystem: "npm" + directory: "/components/dash-core-components" + schedule: + interval: "weekly" + day: "monday" + groups: + npm-dependencies: + patterns: + - "*" + + # Components - dash-html-components + - package-ecosystem: "npm" + directory: "/components/dash-html-components" + schedule: + interval: "weekly" + day: "monday" + groups: + npm-dependencies: + patterns: + - "*" + + # Components - dash-table + - package-ecosystem: "npm" + directory: "/components/dash-table" + schedule: + interval: "weekly" + day: "monday" + groups: + npm-dependencies: + patterns: + - "*" + + # Python dependencies + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + pip-dependencies: + patterns: + - "*" From 2cfaafbbc523550d8c1c667b027d0a36648c5b26 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 26 Feb 2026 15:05:03 -0700 Subject: [PATCH 22/74] Implement virtualized lists for large collections of options --- .../dash-core-components/package-lock.json | 124 ++------ components/dash-core-components/package.json | 5 +- .../src/components/Checklist.tsx | 2 +- .../src/components/RadioItems.tsx | 2 +- .../src/components/css/dropdown.css | 12 + .../src/fragments/Dropdown.tsx | 208 +++++++------ .../src/utils/dropdownSearch.ts | 81 +++-- .../src/utils/optionRendering.tsx | 280 +++++++++++++++--- .../src/utils/optionTypes.ts | 36 ++- .../tests/integration/dropdown/test_a11y.py | 23 +- .../dash-core-components/webpack.config.js | 2 +- 11 files changed, 462 insertions(+), 313 deletions(-) diff --git a/components/dash-core-components/package-lock.json b/components/dash-core-components/package-lock.json index c55972d3aa..298daceb32 100644 --- a/components/dash-core-components/package-lock.json +++ b/components/dash-core-components/package-lock.json @@ -35,7 +35,7 @@ "react-fast-compare": "^3.2.2", "react-input-autosize": "^3.0.0", "react-markdown": "^4.3.1", - "react-virtualized-select": "^3.1.3", + "react-window": "^1.8.11", "remark-math": "^3.0.1", "uniqid": "^5.4.0" }, @@ -59,6 +59,7 @@ "@types/react": "^16.14.8", "@types/react-dom": "^16.9.13", "@types/react-input-autosize": "^2.2.4", + "@types/react-window": "^1.8.8", "@types/uniqid": "^5.3.4", "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", @@ -4949,6 +4950,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", @@ -6135,20 +6146,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/babel-runtime": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "dependencies": { - "core-js": "^2.4.0", - "regenerator-runtime": "^0.11.0" - } - }, - "node_modules/babel-runtime/node_modules/regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" - }, "node_modules/bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", @@ -6659,11 +6656,6 @@ "dev": true, "license": "MIT" }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -6695,14 +6687,6 @@ "node": ">=6" } }, - "node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "engines": { - "node": ">=6" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7130,6 +7114,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-format": { @@ -7363,15 +7348,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -12593,6 +12569,12 @@ "unist-util-visit-parents": "1.1.2" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -14054,11 +14036,6 @@ "node": ">=0.4.0" } }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" - }, "node_modules/react-markdown": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-4.3.1.tgz", @@ -14124,32 +14101,6 @@ } } }, - "node_modules/react-select": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-1.3.0.tgz", - "integrity": "sha512-g/QAU1HZrzSfxkwMAo/wzi6/ezdWye302RGZevsATec07hI/iSxcpB1hejFIp7V63DJ8mwuign6KmB3VjdlinQ==", - "dependencies": { - "classnames": "^2.2.4", - "prop-types": "^15.5.8", - "react-input-autosize": "^2.1.2" - }, - "peerDependencies": { - "react": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0", - "react-dom": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0" - } - }, - "node_modules/react-select/node_modules/react-input-autosize": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.2.tgz", - "integrity": "sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.5.8" - }, - "peerDependencies": { - "react": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -14172,36 +14123,21 @@ } } }, - "node_modules/react-virtualized": { - "version": "9.22.5", - "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.5.tgz", - "integrity": "sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==", + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.7.2", - "clsx": "^1.0.4", - "dom-helpers": "^5.1.3", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-lifecycles-compat": "^3.0.4" + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0", - "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-virtualized-select": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/react-virtualized-select/-/react-virtualized-select-3.1.3.tgz", - "integrity": "sha512-u6j/EfynCB9s4Lz5GGZhNUCZHvFQdtLZws7W/Tcd/v03l19OjpQs3eYjK82iYS0FgD2+lDIBpqS8LpD/hjqDRQ==", - "dependencies": { - "babel-runtime": "^6.11.6", - "prop-types": "^15.5.8", - "react-select": "^1.0.0-rc.2", - "react-virtualized": "^9.0.0" + "engines": { + "node": ">8.0.0" }, "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha", - "react-dom": "^15.3.0 || ^16.0.0-alpha" + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/read-pkg": { diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index 79a35daaac..04ae289e65 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -47,12 +47,12 @@ "@radix-ui/react-tooltip": "^1.2.8", "base64-js": "^1.5.1", "d3-format": "^1.4.5", + "date-fns": "^4.1.0", "fast-isnumeric": "^1.1.4", "file-saver": "^2.0.5", "highlight.js": "^11.8.0", "js-search": "^2.0.1", "mathjax": "^3.2.2", - "date-fns": "^4.1.0", "node-polyfill-webpack-plugin": "^2.0.1", "prop-types": "^15.8.1", "ramda": "^0.30.1", @@ -62,7 +62,7 @@ "react-fast-compare": "^3.2.2", "react-input-autosize": "^3.0.0", "react-markdown": "^4.3.1", - "react-virtualized-select": "^3.1.3", + "react-window": "^1.8.11", "remark-math": "^3.0.1", "uniqid": "^5.4.0" }, @@ -86,6 +86,7 @@ "@types/react": "^16.14.8", "@types/react-dom": "^16.9.13", "@types/react-input-autosize": "^2.2.4", + "@types/react-window": "^1.8.8", "@types/uniqid": "^5.3.4", "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", diff --git a/components/dash-core-components/src/components/Checklist.tsx b/components/dash-core-components/src/components/Checklist.tsx index f7b6481adc..c1f2a5a526 100644 --- a/components/dash-core-components/src/components/Checklist.tsx +++ b/components/dash-core-components/src/components/Checklist.tsx @@ -30,7 +30,7 @@ export default function Checklist({ inline = false, }: ChecklistProps) { const sanitizedOptions = useMemo(() => { - return sanitizeOptions(options); + return sanitizeOptions(options).options; }, [options]); const stylingProps = { diff --git a/components/dash-core-components/src/components/RadioItems.tsx b/components/dash-core-components/src/components/RadioItems.tsx index 093108073b..e4e575543f 100644 --- a/components/dash-core-components/src/components/RadioItems.tsx +++ b/components/dash-core-components/src/components/RadioItems.tsx @@ -31,7 +31,7 @@ export default function RadioItems({ inline = false, }: RadioItemsProps) { const sanitizedOptions = useMemo(() => { - return sanitizeOptions(options); + return sanitizeOptions(options).options; }, [options]); const stylingProps = { diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index a224163d37..5667b85060 100644 --- a/components/dash-core-components/src/components/css/dropdown.css +++ b/components/dash-core-components/src/components/css/dropdown.css @@ -218,6 +218,18 @@ overflow-y: auto; } +.dash-dropdown-content:has(.dash-options-list-virtualized) { + overflow-y: hidden; + display: flex; + flex-direction: column; +} + +.dash-dropdown-options:has(.dash-options-list-virtualized) { + overflow-y: visible; + flex: 1; + min-height: 0; +} + .dash-dropdown-option { padding: calc(var(--Dash-Spacing) * 2) calc(var(--Dash-Spacing) * 3); box-shadow: 0 -1px 0 0 var(--Dash-Fill-Disabled) inset; diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..44f1cdbc30 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -7,7 +7,7 @@ import React, { useRef, MouseEvent, } from 'react'; -import {createFilteredOptions} from '../utils/dropdownSearch'; +import {sanitizeDropdownOptions, filterOptions} from '../utils/dropdownSearch'; import { CaretDownIcon, MagnifyingGlassIcon, @@ -18,7 +18,11 @@ import '../components/css/dropdown.css'; import isEqual from 'react-fast-compare'; import {DetailedOption, DropdownProps, OptionValue} from '../types'; -import {OptionsList, OptionLabel} from '../utils/optionRendering'; +import { + OptionsList, + OptionsListHandle, + OptionLabel, +} from '../utils/optionRendering'; import uuid from 'uniqid'; const Dropdown = (props: DropdownProps) => { @@ -48,6 +52,8 @@ const Dropdown = (props: DropdownProps) => { document.createElement('div') ); const searchInputRef = useRef(null); + const optionsListRef = useRef(null); + const focusedIndexRef = useRef(-1); const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); @@ -56,14 +62,18 @@ const Dropdown = (props: DropdownProps) => { persistentOptions.current = options; } - const {sanitizedOptions, filteredOptions} = useMemo( + const sanitized = useMemo( + () => sanitizeDropdownOptions(persistentOptions.current), + [persistentOptions.current] + ); + const sanitizedOptions = sanitized.options; + + const filteredOptions = useMemo( () => - createFilteredOptions( - persistentOptions.current, - !!searchable, - search_value - ), - [persistentOptions.current, searchable, search_value] + searchable + ? filterOptions(sanitized, search_value) + : sanitizedOptions, + [sanitized, searchable, search_value] ); const sanitizedValues: OptionValue[] = useMemo(() => { @@ -134,16 +144,16 @@ const Dropdown = (props: DropdownProps) => { !isNil(value) && !isEmpty(value) ) { - const values = sanitizedOptions.map(option => option.value); + const {valueSet} = sanitized; if (Array.isArray(value)) { if (multi) { - const invalids = value.filter(v => !values.includes(v)); + const invalids = value.filter(v => !valueSet.has(v)); if (invalids.length) { setProps({value: without(invalids, value)}); } } } else { - if (!values.includes(value)) { + if (!valueSet.has(value)) { setProps({value: null}); } } @@ -235,128 +245,108 @@ const Dropdown = (props: DropdownProps) => { } }, [filteredOptions, isOpen]); - // Focus first selected item or search input when dropdown opens + // Focus first selected item or search input when dropdown opens. + // Depends on displayOptions so it fires after OptionsList is mounted. useEffect(() => { - if (!isOpen || search_value) { + if (!isOpen || search_value || !displayOptions.length) { return; } - // waiting for the DOM to be ready after the dropdown renders requestAnimationFrame(() => { - // Try to focus the first selected item (for single-select) if (!multi) { const selectedValue = sanitizedValues[0]; if (selectedValue) { - const selectedElement = - dropdownContentRef.current.querySelector( - `.dash-options-list-option-checkbox[value="${selectedValue}"]` - ); - - if (selectedElement instanceof HTMLElement) { - selectedElement.focus(); + const selectedIndex = displayOptions.findIndex( + o => o.value === selectedValue + ); + if (selectedIndex >= 0) { + focusedIndexRef.current = selectedIndex; + optionsListRef.current?.focusItem(selectedIndex); return; } } } - // Fallback: focus search input if available and no selected item was focused if (searchable && searchInputRef.current) { searchInputRef.current.focus(); } }); }, [isOpen, multi, displayOptions]); - // Handle keyboard navigation in popover - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - const relevantKeys = [ - 'ArrowDown', - 'ArrowUp', - 'PageDown', - 'PageUp', - 'Home', - 'End', - ]; - if (!relevantKeys.includes(e.key)) { - return; - } - - // Don't interfere with the event if the user is using Home/End keys on the search input - if ( - ['Home', 'End'].includes(e.key) && - document.activeElement === searchInputRef.current - ) { - return; - } + // Handle keyboard navigation in popover. + // Index -1 = search input, 0..N-1 = option index in displayOptions. + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const relevantKeys = [ + 'ArrowDown', + 'ArrowUp', + 'PageDown', + 'PageUp', + 'Home', + 'End', + ]; + if (!relevantKeys.includes(e.key)) { + return; + } - const focusableElements = e.currentTarget.querySelectorAll( - 'input[type="search"], input:not([disabled])' - ) as NodeListOf; + if ( + ['Home', 'End'].includes(e.key) && + document.activeElement === searchInputRef.current + ) { + return; + } - // Don't interfere with the event if there aren't any options that the user can interact with - if (focusableElements.length === 0) { - return; - } + if (displayOptions.length === 0) { + return; + } - e.preventDefault(); + e.preventDefault(); + + const hasSearch = !!searchable; + const current = focusedIndexRef.current; + const maxIndex = displayOptions.length - 1; + const minIndex = hasSearch ? -1 : 0; + let nextIndex: number; + + switch (e.key) { + case 'ArrowDown': + nextIndex = current < maxIndex ? current + 1 : minIndex; + break; + case 'ArrowUp': + nextIndex = current > minIndex ? current - 1 : maxIndex; + break; + case 'PageDown': + nextIndex = Math.min(current + 10, maxIndex); + break; + case 'PageUp': + nextIndex = Math.max(current - 10, minIndex); + break; + case 'Home': + nextIndex = minIndex; + break; + case 'End': + nextIndex = maxIndex; + break; + default: + return; + } - const currentIndex = Array.from(focusableElements).indexOf( - document.activeElement as HTMLElement - ); - let nextIndex = -1; - - switch (e.key) { - case 'ArrowDown': - nextIndex = - currentIndex < focusableElements.length - 1 - ? currentIndex + 1 - : 0; - break; - - case 'ArrowUp': - nextIndex = - currentIndex > 0 - ? currentIndex - 1 - : focusableElements.length - 1; - - break; - case 'PageDown': - nextIndex = Math.min( - currentIndex + 10, - focusableElements.length - 1 - ); - break; - case 'PageUp': - nextIndex = Math.max(currentIndex - 10, 0); - break; - case 'Home': - nextIndex = 0; - break; - case 'End': - nextIndex = focusableElements.length - 1; - break; - default: - break; - } + focusedIndexRef.current = nextIndex; - if (nextIndex > -1) { - focusableElements[nextIndex].focus(); - if (nextIndex === 0) { - // first element is a sticky search bar, so if we are focusing - // on that, also move the scroll to the top + if (nextIndex === -1) { + searchInputRef.current?.focus(); dropdownContentRef.current?.scrollTo({top: 0}); } else { - focusableElements[nextIndex].scrollIntoView({ - behavior: 'auto', - block: 'nearest', - }); + optionsListRef.current?.focusItem(nextIndex); } - } - }, []); + }, + [displayOptions.length, searchable] + ); - // Handle popover open/close const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); + focusedIndexRef.current = -1; if (!open) { setProps({search_value: undefined}); @@ -512,17 +502,19 @@ const Dropdown = (props: DropdownProps) => { {isOpen && !!displayOptions.length && ( <> )} diff --git a/components/dash-core-components/src/utils/dropdownSearch.ts b/components/dash-core-components/src/utils/dropdownSearch.ts index b2d5ed285a..41ad649bd3 100644 --- a/components/dash-core-components/src/utils/dropdownSearch.ts +++ b/components/dash-core-components/src/utils/dropdownSearch.ts @@ -5,7 +5,7 @@ import { UnorderedSearchIndex, } from 'js-search'; import {sanitizeOptions} from './optionTypes'; -import {DetailedOption, DropdownProps} from '../types'; +import {DetailedOption, DropdownProps, OptionValue} from '../types'; // Custom tokenizer, see https://github.com/bvaughn/js-search/issues/43 // Split on spaces @@ -19,38 +19,34 @@ const TOKENIZER = { }, }; -interface FilteredOptionsResult { - sanitizedOptions: DetailedOption[]; - filteredOptions: DetailedOption[]; +export interface SanitizedOptions { + options: DetailedOption[]; + indexes: string[]; + valueSet: Set; } -/** - * Creates filtered dropdown options using js-search with the exact same behavior - * as react-select-fast-filter-options - */ -export function createFilteredOptions( - options: DropdownProps['options'], - searchable: boolean, - searchValue?: string -): FilteredOptionsResult { - // Sanitize and prepare options - let sanitized = sanitizeOptions(options); +// Single-pass sanitization via sanitizeOptions, plus detection of +// search/element labels for indexing. +export function sanitizeDropdownOptions( + options: DropdownProps['options'] +): SanitizedOptions { + const {options: sanitized, valueSet} = sanitizeOptions(options); const indexes = ['value']; let hasElement = false, hasSearch = false; - sanitized = Array.isArray(sanitized) - ? sanitized.map(option => { - if (option.search) { - hasSearch = true; - } - if (React.isValidElement(option.label)) { - hasElement = true; - } - return option; - }) - : sanitized; + for (const option of sanitized) { + if (option.search) { + hasSearch = true; + } + if (React.isValidElement(option.label)) { + hasElement = true; + } + if (hasSearch && hasElement) { + break; + } + } if (!hasElement) { indexes.push('label'); @@ -59,34 +55,29 @@ export function createFilteredOptions( indexes.push('search'); } - // If not searchable or no search value, return all sanitized options - if (!searchable || !searchValue) { - return { - sanitizedOptions: sanitized || [], - filteredOptions: sanitized || [], - }; + return {options: sanitized, indexes, valueSet}; +} + +export function filterOptions( + options: SanitizedOptions, + searchValue?: string +): DetailedOption[] { + if (!searchValue) { + return options.options; } - // Create js-search instance exactly like react-select-fast-filter-options - const search = new Search('value'); // valueKey defaults to 'value' + const search = new Search('value'); search.searchIndex = new UnorderedSearchIndex(); search.indexStrategy = new AllSubstringsIndexStrategy(); search.tokenizer = TOKENIZER; - // Add indexes - indexes.forEach(index => { + options.indexes.forEach(index => { search.addIndex(index); }); - // Add documents - if (sanitized && sanitized.length > 0) { - search.addDocuments(sanitized); + if (options.options.length > 0) { + search.addDocuments(options.options); } - const filtered = search.search(searchValue) as DetailedOption[]; - - return { - sanitizedOptions: sanitized || [], - filteredOptions: filtered || [], - }; + return (search.search(searchValue) as DetailedOption[]) || []; } diff --git a/components/dash-core-components/src/utils/optionRendering.tsx b/components/dash-core-components/src/utils/optionRendering.tsx index 014f42e6b8..fa546c7b36 100644 --- a/components/dash-core-components/src/utils/optionRendering.tsx +++ b/components/dash-core-components/src/utils/optionRendering.tsx @@ -1,8 +1,20 @@ -import React from 'react'; +import React, { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import {append, includes, without} from 'ramda'; +import {VariableSizeList, ListChildComponentProps} from 'react-window'; import {DetailedOption, OptionValue} from 'src/types'; import '../components/css/optionslist.css'; +const DEFAULT_ITEM_HEIGHT = 35; + interface StylingProps { id?: string; className?: string; @@ -91,6 +103,7 @@ export const Option: React.FC = ({ role="option" aria-selected={isSelected} style={optionStyle} + data-option-index={index} > = ({ ); }; -interface OptionsListProps extends StylingProps { +interface RowData { options: DetailedOption[]; selected: OptionValue[]; - onSelectionChange: (selected: OptionValue[]) => void; + onChange: (option: DetailedOption) => void; + passThruProps: StylingProps; + setOptionHeight: (index: number, height: number) => void; } -export const OptionsList: React.FC = ({ - options, - selected, - onSelectionChange, - id, - className, - style, - ...passThruProps -}) => { - const classNames = ['dash-options-list', className].filter(Boolean); +const Row = memo(({index, style, data}: ListChildComponentProps) => { + const {options, selected, onChange, passThruProps, setOptionHeight} = data; + const option = options[index]; + const isSelected = includes(option.value, selected); + return ( -
- {options.map((option, i) => { - const isSelected = includes(option.value, selected); - return ( -
); } diff --git a/components/dash-core-components/tests/integration/dropdown/test_a11y.py b/components/dash-core-components/tests/integration/dropdown/test_a11y.py index f7f8756bdf..115174d968 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_a11y.py +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -389,6 +389,7 @@ def get_focused_option_text(): # Now arrow down to first option send_keys(Keys.ARROW_DOWN) + sleep(0.1) assert get_focused_option_text() == "Option 0" # Test End key - should go to last option diff --git a/components/dash-core-components/tests/integration/misc/test_persistence.py b/components/dash-core-components/tests/integration/misc/test_persistence.py index 890626841f..bdeca8ea0f 100644 --- a/components/dash-core-components/tests/integration/misc/test_persistence.py +++ b/components/dash-core-components/tests/integration/misc/test_persistence.py @@ -121,7 +121,7 @@ def make_output(*args): dash_dcc.driver.set_window_size(1024, 768) dash_dcc.wait_for_text_to_equal("#settings", json.dumps(initial_settings)) - dash_dcc.find_element("#checklist label:last-child input").click() # 🚀 + dash_dcc.find_element('#checklist [data-option-index="2"] input').click() # 🚀 dash_dcc.select_date_range("datepickerrange", day_range=(4,)) dash_dcc.select_date_range("datepickerrange", day_range=(14,), start_first=False) @@ -145,7 +145,7 @@ def make_output(*args): dash_dcc.find_element("#input").send_keys(" maybe") - dash_dcc.find_element("#radioitems label:first-child input").click() # red + dash_dcc.find_element('#radioitems [data-option-index="0"] input').click() # red range_slider = dash_dcc.find_element("#rangeslider") dash_dcc.click_at_coord_fractions(range_slider, 0.5, 0.25) # 5 diff --git a/tests/integration/renderer/test_children_reorder.py b/tests/integration/renderer/test_children_reorder.py index 11c1f0e660..af2c4a6154 100644 --- a/tests/integration/renderer/test_children_reorder.py +++ b/tests/integration/renderer/test_children_reorder.py @@ -63,12 +63,12 @@ def swap_button_action(n_clicks, children): for i in range(2): dash_duo.wait_for_text_to_equal("h1", f"I am section {i}") dash_duo.find_element(f".dropdown_{i}").click() - dash_duo.find_element(".dash-dropdown-option:nth-child(1)").click() + dash_duo.find_element('.dash-dropdown-option[data-option-index="0"]').click() dash_duo.wait_for_text_to_equal(f".dropdown_{i} .dash-dropdown-trigger", "A") - dash_duo.find_element(".dash-dropdown-option:nth-child(2)").click() + dash_duo.find_element('.dash-dropdown-option[data-option-index="1"]').click() value_items = dash_duo.find_elements(f".dropdown_{i} .dash-dropdown-value-item") assert [item.text for item in value_items] == ["A", "B"] - dash_duo.find_element(".dash-dropdown-option:nth-child(3)").click() + dash_duo.find_element('.dash-dropdown-option[data-option-index="2"]').click() value_items = dash_duo.find_elements(f".dropdown_{i} .dash-dropdown-value-item") assert [item.text for item in value_items] == ["A", "B", "C"] diff --git a/tests/integration/renderer/test_component_as_prop.py b/tests/integration/renderer/test_component_as_prop.py index 8c620977d5..f623b645bd 100644 --- a/tests/integration/renderer/test_component_as_prop.py +++ b/tests/integration/renderer/test_component_as_prop.py @@ -357,13 +357,13 @@ def demo(n_clicks): dash_duo.start_server(app) dash_duo.wait_for_element("#add-option").click() - for i in range(1, n + 2): + for i in range(n + 1): dash_duo.wait_for_text_to_equal( - f"#options label:nth-child({i}) span.label-result", "" + f'#options [data-option-index="{i}"] span.label-result', "" ) - dash_duo.wait_for_element(f"#options label:nth-child({i}) button").click() + dash_duo.wait_for_element(f'#options [data-option-index="{i}"] button').click() dash_duo.wait_for_text_to_equal( - f"#options label:nth-child({i}) span.label-result", "1" + f'#options [data-option-index="{i}"] span.label-result', "1" ) @@ -393,13 +393,17 @@ def opts(n): dash_duo.wait_for_text_to_equal("#counter", "0") dash_duo.find_element("#a").click() - assert len(dash_duo.find_elements("#b label input")) == 2 + assert ( + len(dash_duo.find_elements('#b label:not([data-option-index="-1"]) input')) == 2 + ) dash_duo.wait_for_text_to_equal("#counter", "0") dash_duo.find_element("#a").click() - assert len(dash_duo.find_elements("#b label input")) == 3 + assert ( + len(dash_duo.find_elements('#b label:not([data-option-index="-1"]) input')) == 3 + ) dash_duo.wait_for_text_to_equal("#counter", "0") - dash_duo.find_elements("#b label input")[0].click() + dash_duo.find_elements('#b label:not([data-option-index="-1"]) input')[0].click() dash_duo.wait_for_text_to_equal("#counter", "1") From a4315b28e53ff69e75ff5bef2e212c5a88524fd4 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 11 Mar 2026 15:42:34 -0600 Subject: [PATCH 25/74] empty commit for ci From 69b36281dee2ebe56494478b575c9d76e78385e9 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 11 Mar 2026 17:00:26 -0600 Subject: [PATCH 26/74] fix integration test --- .../tests/integration/misc/test_persistence.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/dash-core-components/tests/integration/misc/test_persistence.py b/components/dash-core-components/tests/integration/misc/test_persistence.py index 890626841f..c2c9f4cc95 100644 --- a/components/dash-core-components/tests/integration/misc/test_persistence.py +++ b/components/dash-core-components/tests/integration/misc/test_persistence.py @@ -133,12 +133,10 @@ def make_output(*args): dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-search").send_keys( "one" + Keys.ENTER ) - sleep(0.2) - dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-option").click() dash_dcc.find_element("#dropdownmulti").click() dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-search").send_keys( - "six" + Keys.ENTER + "six" ) sleep(0.2) dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-option").click() From 98f206463b4030c9a89611e6349889f9c9859a95 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Wed, 11 Mar 2026 16:29:47 -0700 Subject: [PATCH 27/74] update after review --- components/dash-core-components/src/fragments/Dropdown.tsx | 2 +- components/dash-core-components/src/utils/optionRendering.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8fd163bfe9..8338d03689 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -233,7 +233,7 @@ const Dropdown = (props: DropdownProps) => { setDisplayOptions(sortedOptions); } - }, [filteredOptions, isOpen, sanitizedValues]); + }, [filteredOptions, isOpen]); // Focus first selected item or search input when dropdown opens useEffect(() => { diff --git a/components/dash-core-components/src/utils/optionRendering.tsx b/components/dash-core-components/src/utils/optionRendering.tsx index 014f42e6b8..70c1d2c0d1 100644 --- a/components/dash-core-components/src/utils/optionRendering.tsx +++ b/components/dash-core-components/src/utils/optionRendering.tsx @@ -37,7 +37,7 @@ export const OptionLabel: React.FC = ({ ))} From 1edb877ee2359bef82d9859e82fb0b368b797255 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Wed, 11 Mar 2026 16:30:10 -0700 Subject: [PATCH 28/74] update after review --- .../tests/integration/dropdown/test_a11y.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dash-core-components/tests/integration/dropdown/test_a11y.py b/components/dash-core-components/tests/integration/dropdown/test_a11y.py index a581558d11..39dfce435d 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_a11y.py +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -468,6 +468,6 @@ def test_a11y009_dropdown_component_labels_render_correctly(dash_duo): option_elements = dash_duo.find_elements(".dash-dropdown-option") rendered_labels = [el.text.strip() for el in option_elements] - assert rendered_labels == ["red", "blue", "yellow"] + assert rendered_labels == ["red", "yellow", "blue"] assert dash_duo.get_logs() == [] From c634cf574544605a929e7e0cd30aa464ef95e7df Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Wed, 11 Mar 2026 16:47:43 -0700 Subject: [PATCH 29/74] lint --- .../tests/integration/dropdown/test_clearable_false.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py index 7681de98fe..219b98acb1 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py +++ b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py @@ -191,15 +191,11 @@ def update_value(val): # Attempt to deselect all items. Everything should deselect until we get to # the last item which cannot be cleared. # Click MTL option container - mtl_option = dash_duo.find_element( - '.dash-dropdown-option:has(input[value="MTL"])' - ) + mtl_option = dash_duo.find_element('.dash-dropdown-option:has(input[value="MTL"])') mtl_option.click() # Click SF option container - sf_option = dash_duo.find_element( - '.dash-dropdown-option:has(input[value="SF"])' - ) + sf_option = dash_duo.find_element('.dash-dropdown-option:has(input[value="SF"])') sf_option.click() assert dash_duo.find_element("#dropdown-value").text == "SF" From c5c8a652dc7116a48a88a9949e709b23fc5e12fc Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 09:50:25 -0600 Subject: [PATCH 30/74] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a57736f98f..b1f827b486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container. - [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable wheen focused, without requiring to open them first +- [#3656][(](https://github.com/plotly/dash/pull/3656)) Improved dropdown performance for large collections of options From e05d073d155044f369e04345ee1c61252c769db4 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 11:00:10 -0600 Subject: [PATCH 31/74] empty commit for ci From 9c5a17ece2e2d1af494f6e49bed5ae01435829d3 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 11:03:36 -0600 Subject: [PATCH 32/74] Run linter on Datepickers --- .../src/fragments/DatePickerRange.tsx | 293 +++++++++--------- .../src/fragments/DatePickerSingle.tsx | 227 +++++++------- 2 files changed, 261 insertions(+), 259 deletions(-) diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index 919c6b32b7..86f4b52607 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -329,157 +329,158 @@ const DatePickerRange = ({ ); return ( - -
- - -
{ - e.preventDefault(); - if (!isCalendarOpen && !disabled) { - setIsCalendarOpen(true); - } - }} - > - { - startInputRef.current = node; - }} - type="text" - id={start_date_id || accessibleId} - inputClassName="dash-datepicker-input dash-datepicker-start-date" - value={startInputValue} - onChange={e => setStartInputValue(e.target?.value)} - onKeyDown={handleStartInputKeyDown} - onFocus={() => { - if (isCalendarOpen) { - sendStartInputAsDate(); + +
+ + +
{ + e.preventDefault(); + if (!isCalendarOpen && !disabled) { + setIsCalendarOpen(true); } }} - placeholder={start_date_placeholder_text} - disabled={disabled} - dir={direction} - aria-label={start_date_placeholder_text} - /> - - { - endInputRef.current = node; - }} - type="text" - id={end_date_id || accessibleId + '-end-date'} - inputClassName="dash-datepicker-input dash-datepicker-end-date" - value={endInputValue} - onChange={e => setEndInputValue(e.target?.value)} - onKeyDown={handleEndInputKeyDown} - onFocus={() => { - if (isCalendarOpen) { - sendEndInputAsDate(); + > + { + startInputRef.current = node; + }} + type="text" + id={start_date_id || accessibleId} + inputClassName="dash-datepicker-input dash-datepicker-start-date" + value={startInputValue} + onChange={e => + setStartInputValue(e.target?.value) } - }} - placeholder={end_date_placeholder_text} - disabled={disabled} - dir={direction} - aria-label={end_date_placeholder_text} - /> - {clearable && !disabled && ( - - - - )} - -
-
- - - e.preventDefault() - : undefined - } - onOpenAutoFocus={e => e.preventDefault()} - onCloseAutoFocus={e => { - e.preventDefault(); - // Only focus if focus is not already on one of the inputs - const inputs: (Element | null)[] = [ - startInputRef.current, - endInputRef.current, - ]; - if (inputs.includes(document.activeElement)) { - return; + onKeyDown={handleStartInputKeyDown} + onFocus={() => { + if (isCalendarOpen) { + sendStartInputAsDate(); + } + }} + placeholder={start_date_placeholder_text} + disabled={disabled} + dir={direction} + aria-label={start_date_placeholder_text} + /> + + { + endInputRef.current = node; + }} + type="text" + id={end_date_id || accessibleId + '-end-date'} + inputClassName="dash-datepicker-input dash-datepicker-end-date" + value={endInputValue} + onChange={e => + setEndInputValue(e.target?.value) + } + onKeyDown={handleEndInputKeyDown} + onFocus={() => { + if (isCalendarOpen) { + sendEndInputAsDate(); + } + }} + placeholder={end_date_placeholder_text} + disabled={disabled} + dir={direction} + aria-label={end_date_placeholder_text} + /> + {clearable && !disabled && ( + + + + )} + +
+ + + + e.preventDefault() + : undefined } + onOpenAutoFocus={e => e.preventDefault()} + onCloseAutoFocus={e => { + e.preventDefault(); + // Only focus if focus is not already on one of the inputs + const inputs: (Element | null)[] = [ + startInputRef.current, + endInputRef.current, + ]; + if (inputs.includes(document.activeElement)) { + return; + } - // Keeps focus on the component when the calendar closes - if (!startInputValue) { - startInputRef.current?.focus(); - } else { - endInputRef.current?.focus(); - } - }} - > - {with_full_screen_portal && ( - - )} - - - - -
+ // Keeps focus on the component when the calendar closes + if (!startInputValue) { + startInputRef.current?.focus(); + } else { + endInputRef.current?.focus(); + } + }} + > + {with_full_screen_portal && ( + + )} + + + +
+
); }; diff --git a/components/dash-core-components/src/fragments/DatePickerSingle.tsx b/components/dash-core-components/src/fragments/DatePickerSingle.tsx index eff91e6515..527e654b0d 100644 --- a/components/dash-core-components/src/fragments/DatePickerSingle.tsx +++ b/components/dash-core-components/src/fragments/DatePickerSingle.tsx @@ -93,7 +93,6 @@ const DatePickerSingle = ({ autosizeRef.current?.updateInputWidth?.(); }, []); - useEffect(() => { autosizeRef.current?.updateInputWidth?.(); }, [inputValue]); @@ -169,124 +168,126 @@ const DatePickerSingle = ({ return ( -
- - -
{ - e.preventDefault(); - if (!isCalendarOpen && !disabled) { - setIsCalendarOpen(true); - } - }} - > - { - inputRef.current = node; +
+ + +
{ + e.preventDefault(); + if (!isCalendarOpen && !disabled) { + setIsCalendarOpen(true); + } }} - type="text" - id={accessibleId} - inputClassName="dash-datepicker-input dash-datepicker-end-date" - value={inputValue} - onChange={e => setInputValue(e.target?.value)} - onKeyDown={handleInputKeyDown} - placeholder={placeholder} - disabled={disabled} - dir={direction} - aria-label={placeholder} - /> - {clearable && !disabled && !!date && ( - - - - )} + > + { + inputRef.current = node; + }} + type="text" + id={accessibleId} + inputClassName="dash-datepicker-input dash-datepicker-end-date" + value={inputValue} + onChange={e => setInputValue(e.target?.value)} + onKeyDown={handleInputKeyDown} + placeholder={placeholder} + disabled={disabled} + dir={direction} + aria-label={placeholder} + /> + {clearable && !disabled && !!date && ( + + + + )} - -
-
+ +
+ - - e.preventDefault() - : undefined - } - onOpenAutoFocus={e => e.preventDefault()} - onCloseAutoFocus={e => { - e.preventDefault(); - // Only focus if focus is not already on the input - if (document.activeElement !== inputRef.current) { - inputRef.current?.focus(); - } - }} + - {with_full_screen_portal && ( - - )} - { - if (!selection) { - return; - } - setInternalDate(selection); - if (!stay_open_on_select) { - setIsCalendarOpen(false); + e.preventDefault() + : undefined + } + onOpenAutoFocus={e => e.preventDefault()} + onCloseAutoFocus={e => { + e.preventDefault(); + // Only focus if focus is not already on the input + if ( + document.activeElement !== inputRef.current + ) { + inputRef.current?.focus(); } }} - /> - - - -
+ > + {with_full_screen_portal && ( + + )} + { + if (!selection) { + return; + } + setInternalDate(selection); + if (!stay_open_on_select) { + setIsCalendarOpen(false); + } + }} + /> + + +
+
); }; From 3c3c1a5fa0a9196ab12e65ccd507591ba51ce5e6 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 13:43:33 -0600 Subject: [PATCH 33/74] Fix single-date selection in DatePickerRange --- .../src/fragments/DatePickerRange.tsx | 37 +++++++- .../src/utils/calendar/Calendar.tsx | 4 +- .../calendar/test_a11y_date_picker_range.py | 6 +- .../calendar/test_date_picker_range.py | 92 +++++++++++++++++++ 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index 86f4b52607..d3529774b5 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -6,7 +6,7 @@ import { CaretDownIcon, Cross1Icon, } from '@radix-ui/react-icons'; -import {addDays, subDays} from 'date-fns'; +import {addDays, subDays, differenceInCalendarDays} from 'date-fns'; import AutosizeInput from 'react-input-autosize'; import uuid from 'uniqid'; @@ -108,6 +108,7 @@ const DatePickerRange = ({ const startAutosizeRef = useRef(null); const endAutosizeRef = useRef(null); const calendarRef = useRef(null); + const isNewRangeRef = useRef(false); const hasPortal = with_portal || with_full_screen_portal; // Capture CSS variables for portal mode @@ -161,16 +162,20 @@ const DatePickerRange = ({ end_date: dateAsStr(internalEndDate), }); } else if (!internalStartDate && !internalEndDate) { - // Both dates cleared - send undefined for both + // Both dates cleared - send both setProps({ start_date: dateAsStr(internalStartDate), end_date: dateAsStr(internalEndDate), }); + } else if (endChanged && !internalEndDate) { + // End date was cleared (user started a new range). + setProps({ + start_date: dateAsStr(internalStartDate) ?? null, + end_date: null, + }); } else if (updatemode === 'singledate' && internalStartDate) { - // Only start changed - send just that one setProps({start_date: dateAsStr(internalStartDate)}); } else if (updatemode === 'singledate' && internalEndDate) { - // Only end changed - send just that one setProps({end_date: dateAsStr(internalEndDate)}); } }, [internalStartDate, internalEndDate, updatemode]); @@ -311,6 +316,23 @@ const DatePickerRange = ({ setInternalStartDate(start); setInternalEndDate(undefined); } else { + // Skip the mouseUp from the same click that started this range + if (isNewRangeRef.current && isSameDay(start, end)) { + isNewRangeRef.current = false; + return; + } + isNewRangeRef.current = !!(start && !end); + + if ( + start && + end && + minimum_nights && + Math.abs(differenceInCalendarDays(end, start)) < + minimum_nights + ) { + return; + } + // Normalize dates: ensure start <= end if (start && end && start > end) { setInternalStartDate(end); @@ -325,7 +347,12 @@ const DatePickerRange = ({ } } }, - [internalStartDate, internalEndDate, stay_open_on_select] + [ + internalStartDate, + internalEndDate, + stay_open_on_select, + minimum_nights, + ] ); return ( diff --git a/components/dash-core-components/src/utils/calendar/Calendar.tsx b/components/dash-core-components/src/utils/calendar/Calendar.tsx index d35590da29..367ff1daeb 100644 --- a/components/dash-core-components/src/utils/calendar/Calendar.tsx +++ b/components/dash-core-components/src/utils/calendar/Calendar.tsx @@ -188,9 +188,7 @@ const CalendarComponent = ({ // Complete the selection with an end date if (selectionStart && !selectionEnd) { // Incomplete selection exists (range picker mid-selection) - if (!isSameDay(selectionStart, date)) { - onSelectionChange(selectionStart, date); - } + onSelectionChange(selectionStart, date); } else { // Complete selection exists or a single date was chosen onSelectionChange(date, date); diff --git a/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py index 9d591ca1a8..6c1254effe 100644 --- a/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py +++ b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py @@ -151,11 +151,11 @@ def update_output(start_date, end_date): assert get_focused_text(dash_dcc.driver) == "12" # Press Space to start a NEW range selection with Jan 12 as start_date - # This should clear end_date and set only start_date + # In singledate mode (default), end_date is cleared immediately send_keys(dash_dcc.driver, Keys.SPACE) - # Verify new start date was selected (only start_date, no end_date) - dash_dcc.wait_for_text_to_equal("#output-dates", "2021-01-12 to 2021-01-20") + # Output updates: new start_date sent, old end_date cleared + dash_dcc.wait_for_text_to_equal("#output-dates", "Start: 2021-01-12") # Navigate to new end date: Arrow Down + Arrow Right (Jan 12 -> 19 -> 20) send_keys(dash_dcc.driver, Keys.ARROW_DOWN) diff --git a/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py b/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py index e66f978dab..16ac1ba927 100644 --- a/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py +++ b/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py @@ -6,6 +6,7 @@ ElementClickInterceptedException, TimeoutException, ) +from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -377,6 +378,97 @@ def test_dtpr008_input_click_opens_but_keeps_focus(dash_dcc): assert dash_dcc.get_logs() == [] +def test_dtpr009_same_date_selection_minimum_nights_zero(dash_dcc): + """Bug #3645: With minimum_nights=0, selecting the same date for start and end should work.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + min_date_allowed=datetime(2021, 1, 1), + max_date_allowed=datetime(2021, 1, 31), + initial_visible_month=datetime(2021, 1, 1), + minimum_nights=0, + display_format="MM/DD/YYYY", + ), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def display_dates(start_date, end_date): + return f"Start: {start_date}, End: {end_date}" + + dash_dcc.start_server(app) + + # Select day 10 for both start and end (same date) + result = dash_dcc.select_date_range("dpr", day_range=(10, 10)) + assert result == ( + "01/10/2021", + "01/10/2021", + ), f"Same date selection should work with minimum_nights=0, got {result}" + + dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-10, End: 2021-01-10") + + assert dash_dcc.get_logs() == [] + + +def test_dtpr010_new_start_date_clears_end_date(dash_dcc): + """Bug #3645: When a new start date is selected after a range, end_date should be cleared.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + min_date_allowed=datetime(2021, 1, 1), + max_date_allowed=datetime(2021, 1, 31), + initial_visible_month=datetime(2021, 1, 1), + minimum_nights=0, + display_format="MM/DD/YYYY", + ), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def display_dates(start_date, end_date): + return f"Start: {start_date}, End: {end_date}" + + dash_dcc.start_server(app) + + # First, select a range: Jan 2 to Jan 11 + dash_dcc.select_date_range("dpr", day_range=(2, 11)) + dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-02, End: 2021-01-11") + + # Now click just a new start date (Jan 4) without selecting an end date + date = dash_dcc.find_element("#dpr") + date.click() + dash_dcc._wait_until_day_is_clickable() + days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator) + day_4 = [d for d in days if d.find_element(By.CSS_SELECTOR, "span").text == "4"][0] + day_4.click() + + # The calendar should still be open (waiting for end date). + # The old end_date (Jan 11) should NOT be retained. + # Click outside to close the calendar. + time.sleep(0.3) + dash_dcc.find_element("body").click() + time.sleep(0.3) + + # end_date must be cleared, not silently retained from previous selection + dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-04, End: None") + + assert dash_dcc.get_logs() == [] + + def test_dtpr030_external_date_range_update(dash_dcc): """Test that DatePickerRange accepts external date updates via callback without resetting.""" app = Dash(__name__) From 76511d83dd85b37d0e6376770b954153ce8a4f37 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 14:32:06 -0600 Subject: [PATCH 34/74] code cleanup --- .../src/fragments/DatePickerRange.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index d3529774b5..11bd7baa74 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -174,8 +174,10 @@ const DatePickerRange = ({ end_date: null, }); } else if (updatemode === 'singledate' && internalStartDate) { + // Only start changed - send just that one setProps({start_date: dateAsStr(internalStartDate)}); } else if (updatemode === 'singledate' && internalEndDate) { + // Only end changed - send just that one setProps({end_date: dateAsStr(internalEndDate)}); } }, [internalStartDate, internalEndDate, updatemode]); @@ -323,14 +325,13 @@ const DatePickerRange = ({ } isNewRangeRef.current = !!(start && !end); - if ( - start && - end && - minimum_nights && - Math.abs(differenceInCalendarDays(end, start)) < - minimum_nights - ) { - return; + if (start && end && minimum_nights) { + const numNights = Math.abs( + differenceInCalendarDays(end, start) + ); + if (numNights < minimum_nights) { + return; + } } // Normalize dates: ensure start <= end From 1c6db3e2a81e553f5f1919a6b90001d00062e679 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 14:55:12 -0600 Subject: [PATCH 35/74] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a57736f98f..5a2b947901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container. - [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable wheen focused, without requiring to open them first +- [#3660][(](https://github.com/plotly/dash/pull/3660)) Allow same date to be selected for both start and end in DatePickerRange components From e4cd2f897d99360032fa2d2f7e944089533705e1 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 15:16:00 -0600 Subject: [PATCH 36/74] empty commit for ci From 2bc7ea1aaacfbe4c9182aad3d2e55c61c1489172 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 15:16:09 -0600 Subject: [PATCH 37/74] empty commit for ci From 922c9404bf7491d3df2a2d261729e410eeb1a9cf Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 13 Mar 2026 16:33:51 -0600 Subject: [PATCH 38/74] Fix bug with inline checklists and dynamically sized option labels --- .../src/utils/optionRendering.tsx | 59 ++++++++++++++++--- .../tests/integration/calendar/test_portal.py | 10 ---- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/components/dash-core-components/src/utils/optionRendering.tsx b/components/dash-core-components/src/utils/optionRendering.tsx index 42da2807a4..fc3a21d525 100644 --- a/components/dash-core-components/src/utils/optionRendering.tsx +++ b/components/dash-core-components/src/utils/optionRendering.tsx @@ -151,14 +151,35 @@ const Row = memo(({index, style, data}: ListChildComponentProps) => { const option = options[index]; const isSelected = includes(option.value, selected); + const measureRef = useCallback( + (el: HTMLDivElement | null) => { + if (!el) { + return; + } + // Synchronous measurement for string labels. + const immediateHeight = el.getBoundingClientRect().height; + if (immediateHeight > 0) { + setOptionHeight(index, immediateHeight); + } + + // ResizeObserver catches async Dash component labels + // that render after the initial commit. + const observer = new ResizeObserver(([entry]) => { + const height = + entry.borderBoxSize?.[0]?.blockSize ?? + entry.contentRect.height; + if (height > 0) { + setOptionHeight(index, height); + } + }); + observer.observe(el); + }, + [index, setOptionHeight] + ); + return (
-
- el && - setOptionHeight(index, el.getBoundingClientRect().height) - } - > +