From 47da6218d6885b00c2d5824841d4e23e437d2fb7 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Oct 2025 14:39:50 -0600 Subject: [PATCH 1/3] Modify multi=False dropdowns to show selected item at the top --- .../src/components/css/dropdown.css | 3 + .../src/fragments/Dropdown.tsx | 95 ++++++++++--------- .../tests/integration/dropdown/test_a11y.py | 85 +++++++++++++++++ 3 files changed, 139 insertions(+), 44 deletions(-) diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index 08fddb8774..066d6f40e5 100644 --- a/components/dash-core-components/src/components/css/dropdown.css +++ b/components/dash-core-components/src/components/css/dropdown.css @@ -5,6 +5,7 @@ padding: 0; background: inherit; border: none; + outline: none; width: 100%; cursor: pointer; font-size: inherit; @@ -96,6 +97,8 @@ } .dash-dropdown-search-container { + position: sticky; + top: calc(var(--Dash-Spacing) * 2); margin: calc(var(--Dash-Spacing) * 2); padding: var(--Dash-Spacing); border-radius: 4px; diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index ccb882331f..cff5778013 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -43,6 +43,9 @@ const Dropdown = (props: DropdownProps) => { const [displayOptions, setDisplayOptions] = useState([]); const persistentOptions = useRef([]); const dropdownContainerRef = useRef(null); + const dropdownContentRef = useRef( + document.createElement('div') + ); const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); @@ -207,23 +210,46 @@ const Dropdown = (props: DropdownProps) => { // Update display options when filtered options or selection changes useEffect(() => { if (isOpen) { - // Sort filtered options: selected first, then unselected - const sortedOptions = [...filteredOptions].sort((a, b) => { - const aSelected = sanitizedValues.includes(a.value); - const bSelected = sanitizedValues.includes(b.value); + let sortedOptions = filteredOptions; + if (multi) { + // Sort filtered options: selected first, then unselected + sortedOptions = [...filteredOptions].sort((a, b) => { + const aSelected = sanitizedValues.includes(a.value); + const bSelected = sanitizedValues.includes(b.value); - if (aSelected && !bSelected) { - return -1; - } - if (!aSelected && bSelected) { - return 1; - } - return 0; // Maintain original order within each group - }); + if (aSelected && !bSelected) { + return -1; + } + if (!aSelected && bSelected) { + return 1; + } + return 0; // Maintain original order within each group + }); + } setDisplayOptions(sortedOptions); } - }, [filteredOptions, isOpen]); // Removed sanitizedValues to prevent re-sorting on selection changes + }, [filteredOptions, isOpen]); + + // Focus (and scroll) the first selected item when dropdown opens + useEffect(() => { + if (!isOpen || multi || search_value) { + return; + } + + // waiting for the DOM to be ready after the dropdown renders + requestAnimationFrame(() => { + const selectedValue = sanitizedValues[0]; + + const selectedElement = dropdownContentRef.current.querySelector( + `.dash-options-list-option-checkbox[value="${selectedValue}"]` + ); + + if (selectedElement instanceof HTMLElement) { + selectedElement?.focus(); + } + }); + }, [isOpen, multi, displayOptions, sanitizedValues]); // Handle keyboard navigation in popover const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -299,10 +325,16 @@ const Dropdown = (props: DropdownProps) => { if (nextIndex > -1) { focusableElements[nextIndex].focus(); - focusableElements[nextIndex].scrollIntoView({ - behavior: 'auto', - block: 'center', - }); + if (nextIndex === 0) { + // first element is a sticky search bar, so if we are focusing + // on that, also move the scroll to the top + dropdownContentRef.current?.scrollTo({top: 0}); + } else { + focusableElements[nextIndex].scrollIntoView({ + behavior: 'auto', + block: 'center', + }); + } } }, []); @@ -311,33 +343,7 @@ const Dropdown = (props: DropdownProps) => { (open: boolean) => { setIsOpen(open); - if (open) { - // Sort options: selected first, then unselected - const selectedOptions: DetailedOption[] = []; - const unselectedOptions: DetailedOption[] = []; - - // First, collect selected options in the order they appear in the `value` array - sanitizedValues.forEach(value => { - const option = filteredOptions.find( - opt => opt.value === value - ); - if (option) { - selectedOptions.push(option); - } - }); - - // Then, collect unselected options in the order they appear in `options` array - filteredOptions.forEach(option => { - if (!sanitizedValues.includes(option.value)) { - unselectedOptions.push(option); - } - }); - const sortedOptions = [ - ...selectedOptions, - ...unselectedOptions, - ]; - setDisplayOptions(sortedOptions); - } else { + if (!open) { setProps({search_value: undefined}); } }, @@ -404,6 +410,7 @@ const Dropdown = (props: DropdownProps) => { = containerRect.top && + optionRect.bottom <= containerRect.bottom; + """, + el, + dropdown_content, + ) + + return all([is_visible(el) for el in elements]) From 769744256c256f3cefc41740949a7e07d43c8602 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 3 Oct 2025 16:45:47 -0600 Subject: [PATCH 2/3] fix test --- .../tests/integration/dropdown/test_a11y.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 cd0cd8181e..85e859bbbb 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_a11y.py +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -145,7 +145,7 @@ def test_a11y004_selection_visibility_single(dash_duo): dash_duo.wait_for_element("#dropdown") dash_duo.find_element("#dropdown").click() - dash_duo.wait_for_element("#dropdown .dash-dropdown-options") + dash_duo.wait_for_element(".dash-dropdown-options") # Assert that the selected option is visible in the dropdown selected_option = dash_duo.find_element(".dash-dropdown-option.selected") @@ -180,7 +180,7 @@ def test_a11y005_selection_visibility_multi(dash_duo): dash_duo.wait_for_element("#dropdown") dash_duo.find_element("#dropdown").click() - dash_duo.wait_for_element("#dropdown .dash-dropdown-options") + dash_duo.wait_for_element(".dash-dropdown-options") # Assert that the selected option is visible in the dropdown selected_options = dash_duo.find_elements(".dash-dropdown-option.selected") From f90b1f51b11b25a9211c482afd9af119969cd88d Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 3 Oct 2025 17:07:14 -0600 Subject: [PATCH 3/3] fix test reliability --- .../tests/integration/dropdown/test_localization.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/components/dash-core-components/tests/integration/dropdown/test_localization.py b/components/dash-core-components/tests/integration/dropdown/test_localization.py index e4be2b3491..e07f99ef79 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_localization.py +++ b/components/dash-core-components/tests/integration/dropdown/test_localization.py @@ -1,3 +1,4 @@ +from time import sleep from dash import Dash from dash.dcc import Dropdown from dash.html import Div @@ -39,11 +40,13 @@ def test_ddlo001_translations(dash_duo): ) dash_duo.find_element(".dash-dropdown-search").send_keys(1) + sleep(0.1) assert dash_duo.find_element(".dash-dropdown-clear").accessible_name == "Annuler" dash_duo.find_element(".dash-dropdown-action-button:first-child").click() dash_duo.find_element(".dash-dropdown-search").send_keys(9) + sleep(0.1) assert dash_duo.find_element(".dash-dropdown-option").text == "Aucun d'options" assert ( @@ -84,6 +87,7 @@ def test_ddlo002_partial_translations(dash_duo): assert dash_duo.find_element(".dash-dropdown-search").accessible_name == "Lookup" dash_duo.find_element(".dash-dropdown-search").send_keys(1) + sleep(0.1) assert ( dash_duo.find_element(".dash-dropdown-clear").accessible_name == "Clear search" ) @@ -91,6 +95,7 @@ def test_ddlo002_partial_translations(dash_duo): dash_duo.find_element(".dash-dropdown-action-button:first-child").click() dash_duo.find_element(".dash-dropdown-search").send_keys(9) + sleep(0.1) assert dash_duo.find_element(".dash-dropdown-option").text == "No options found" assert (