From 8b3b4a35c7d3aa7bfd8aba053a5588870e0d68dd Mon Sep 17 00:00:00 2001 From: Emil Haldrup Eriksen Date: Mon, 19 Jan 2026 22:18:53 +0100 Subject: [PATCH 1/6] Add tab workflow --- .../src/fragments/Dropdown.tsx | 212 +++++++++++------- 1 file changed, 132 insertions(+), 80 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..c79a33ce86 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -237,12 +237,23 @@ 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(() => { + // If opened with search value (auto-open on typing), focus search input + if (search_value && searchable && searchInputRef.current) { + searchInputRef.current.focus(); + // Move cursor to end of input + searchInputRef.current.setSelectionRange( + search_value.length, + search_value.length + ); + return; + } + // Try to focus the first selected item (for single-select) if (!multi) { const selectedValue = sanitizedValues[0]; @@ -264,94 +275,123 @@ const Dropdown = (props: DropdownProps) => { searchInputRef.current.focus(); } }); - }, [isOpen, multi, displayOptions]); + }, [isOpen, multi, displayOptions, search_value, searchable]); // 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; - } + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Handle TAB to select first option and close dropdown + if (e.key === 'Tab' && !e.shiftKey) { + // If we have filtered options and search is active, select the first one + if (displayOptions.length > 0) { + const firstOption = displayOptions[0]; + if (!firstOption.disabled) { + if (multi) { + // For multi-select, toggle the first option if not already selected + if (!sanitizedValues.includes(firstOption.value)) { + updateSelection([ + ...sanitizedValues, + firstOption.value, + ]); + } + } else { + // For single-select, select the first option + updateSelection([firstOption.value]); + } + } + } + // Close dropdown and let TAB naturally move focus + setIsOpen(false); + setProps({search_value: undefined}); + 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; - } + 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; + // 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; + } - // Don't interfere with the event if there aren't any options that the user can interact with - if (focusableElements.length === 0) { - return; - } + const focusableElements = e.currentTarget.querySelectorAll( + 'input[type="search"], input:not([disabled])' + ) as NodeListOf; - e.preventDefault(); + // Don't interfere with the event if there aren't any options that the user can interact with + if (focusableElements.length === 0) { + 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; - } + e.preventDefault(); - 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 - dropdownContentRef.current?.scrollTo({top: 0}); - } else { - focusableElements[nextIndex].scrollIntoView({ - behavior: 'auto', - block: 'nearest', - }); + 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; } - } - }, []); + + 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 + dropdownContentRef.current?.scrollTo({top: 0}); + } else { + focusableElements[nextIndex].scrollIntoView({ + behavior: 'auto', + block: 'nearest', + }); + } + } + }, + [displayOptions, multi, sanitizedValues, updateSelection] + ); // Handle popover open/close const handleOpenChange = useCallback( @@ -381,6 +421,18 @@ const Dropdown = (props: DropdownProps) => { if (['ArrowDown', 'Enter'].includes(e.key)) { e.preventDefault(); } + // Auto-open on typing: detect printable characters + if ( + searchable && + e.key.length === 1 && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + e.preventDefault(); + setProps({search_value: e.key}); + setIsOpen(true); + } }} onKeyUp={e => { if (['ArrowDown', 'Enter'].includes(e.key)) { From 83fe8c97c15dd09e55d0b3961b50ade51d87c3a3 Mon Sep 17 00:00:00 2001 From: Emil Haldrup Eriksen Date: Mon, 19 Jan 2026 23:05:01 +0100 Subject: [PATCH 2/6] Attempt to minimize changes --- components/dash-core-components/src/fragments/Dropdown.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index c79a33ce86..db773e18e0 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -282,12 +282,10 @@ const Dropdown = (props: DropdownProps) => { (e: React.KeyboardEvent) => { // Handle TAB to select first option and close dropdown if (e.key === 'Tab' && !e.shiftKey) { - // If we have filtered options and search is active, select the first one if (displayOptions.length > 0) { const firstOption = displayOptions[0]; if (!firstOption.disabled) { if (multi) { - // For multi-select, toggle the first option if not already selected if (!sanitizedValues.includes(firstOption.value)) { updateSelection([ ...sanitizedValues, @@ -295,12 +293,10 @@ const Dropdown = (props: DropdownProps) => { ]); } } else { - // For single-select, select the first option updateSelection([firstOption.value]); } } } - // Close dropdown and let TAB naturally move focus setIsOpen(false); setProps({search_value: undefined}); return; From a8aaaadf40854a57f695e01c3fe28c6839f2e0a9 Mon Sep 17 00:00:00 2001 From: Emil Haldrup Eriksen Date: Tue, 20 Jan 2026 13:31:47 +0100 Subject: [PATCH 3/6] Add sort relevance --- .../src/utils/dropdownSearch.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/components/dash-core-components/src/utils/dropdownSearch.ts b/components/dash-core-components/src/utils/dropdownSearch.ts index b2d5ed285a..21997a2bd9 100644 --- a/components/dash-core-components/src/utils/dropdownSearch.ts +++ b/components/dash-core-components/src/utils/dropdownSearch.ts @@ -85,8 +85,42 @@ export function createFilteredOptions( const filtered = search.search(searchValue) as DetailedOption[]; + // Convert to lowercase for case insensitive comparison + const searchLower = searchValue.toLowerCase(); + const labelMap = new Map( + filtered.map(opt => [ + opt.value, + String(opt.label ?? opt.value).toLowerCase(), + ]) + ); + // Sort results by match relevance + const sorted = filtered.sort((a, b) => { + const aLabel = labelMap.get(a.value)!; + const bLabel = labelMap.get(b.value)!; + // Label starts with search value + const aStartsWith = aLabel.startsWith(searchLower); + const bStartsWith = bLabel.startsWith(searchLower); + if (aStartsWith && !bStartsWith) { + return -1; + } + if (!aStartsWith && bStartsWith) { + return 1; + } + // Check for word boundary match (space followed by search term) + const aWordStart = aLabel.includes(' ' + searchLower); + const bWordStart = bLabel.includes(' ' + searchLower); + if (aWordStart && !bWordStart) { + return -1; + } + if (!aWordStart && bWordStart) { + return 1; + } + // Everything else (substring match) + return 0; + }); + return { sanitizedOptions: sanitized || [], - filteredOptions: filtered || [], + filteredOptions: sorted || [], }; } From 4978306a60bf0ac9be54605870ec02e9d9cce0a0 Mon Sep 17 00:00:00 2001 From: Emil Haldrup Eriksen Date: Tue, 20 Jan 2026 13:45:16 +0100 Subject: [PATCH 4/6] Improve focus handling --- .../src/fragments/Dropdown.tsx | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index db773e18e0..9f4e4c51d3 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -217,20 +217,19 @@ const Dropdown = (props: DropdownProps) => { let sortedOptions = filteredOptions; if (multi) { // Sort filtered options: selected first, then unselected + // ES2019+ guarantees stable sort, preserving order within groups 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 + return 0; }); } - setDisplayOptions(sortedOptions); } }, [filteredOptions, isOpen]); @@ -280,20 +279,41 @@ const Dropdown = (props: DropdownProps) => { // Handle keyboard navigation in popover const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - // Handle TAB to select first option and close dropdown + // Handle TAB to select highlighted option and close dropdown if (e.key === 'Tab' && !e.shiftKey) { if (displayOptions.length > 0) { - const firstOption = displayOptions[0]; - if (!firstOption.disabled) { + // Check if an option is currently focused + const focusedElement = document.activeElement; + let optionToSelect = displayOptions[0]; + + if ( + focusedElement instanceof HTMLInputElement && + focusedElement.classList.contains( + 'dash-options-list-option-checkbox' + ) + ) { + // Find the option matching the focused element's value + const focusedValue = focusedElement.value; + const focusedOption = displayOptions.find( + opt => String(opt.value) === focusedValue + ); + if (focusedOption) { + optionToSelect = focusedOption; + } + } + + if (!optionToSelect.disabled) { if (multi) { - if (!sanitizedValues.includes(firstOption.value)) { + if ( + !sanitizedValues.includes(optionToSelect.value) + ) { updateSelection([ ...sanitizedValues, - firstOption.value, + optionToSelect.value, ]); } } else { - updateSelection([firstOption.value]); + updateSelection([optionToSelect.value]); } } } From ad5011d7f2ab605147dfded21ae980a7601f76da Mon Sep 17 00:00:00 2001 From: Emil Haldrup Eriksen Date: Tue, 20 Jan 2026 14:28:08 +0100 Subject: [PATCH 5/6] Change minimization --- components/dash-core-components/src/fragments/Dropdown.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 9f4e4c51d3..f89399314a 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -217,19 +217,20 @@ const Dropdown = (props: DropdownProps) => { let sortedOptions = filteredOptions; if (multi) { // Sort filtered options: selected first, then unselected - // ES2019+ guarantees stable sort, preserving order within groups 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; + return 0; // Maintain original order within each group }); } + setDisplayOptions(sortedOptions); } }, [filteredOptions, isOpen]); From 2c09f3c69a296d4b3d9440656acb7ecc8a095d12 Mon Sep 17 00:00:00 2001 From: Emil Haldrup Eriksen Date: Tue, 20 Jan 2026 18:57:10 +0100 Subject: [PATCH 6/6] Add ENTER workflow --- .../src/fragments/Dropdown.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index f89399314a..59dad715cd 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -48,6 +48,8 @@ const Dropdown = (props: DropdownProps) => { document.createElement('div') ); const searchInputRef = useRef(null); + // Track if we just closed with ENTER to prevent trigger's onKeyUp from reopening + const closedWithEnterRef = useRef(false); const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); @@ -280,8 +282,8 @@ const Dropdown = (props: DropdownProps) => { // Handle keyboard navigation in popover const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - // Handle TAB to select highlighted option and close dropdown - if (e.key === 'Tab' && !e.shiftKey) { + // Handle TAB/ENTER to select highlighted option and close dropdown + if ((e.key === 'Tab' && !e.shiftKey) || e.key === 'Enter') { if (displayOptions.length > 0) { // Check if an option is currently focused const focusedElement = document.activeElement; @@ -318,6 +320,11 @@ const Dropdown = (props: DropdownProps) => { } } } + // Prevent default ENTER behavior and mark that we closed with ENTER + if (e.key === 'Enter') { + e.preventDefault(); + closedWithEnterRef.current = true; + } setIsOpen(false); setProps({search_value: undefined}); return; @@ -453,6 +460,14 @@ const Dropdown = (props: DropdownProps) => { }} onKeyUp={e => { if (['ArrowDown', 'Enter'].includes(e.key)) { + // Don't reopen if we just closed with ENTER + if ( + e.key === 'Enter' && + closedWithEnterRef.current + ) { + closedWithEnterRef.current = false; + return; + } setIsOpen(true); } if (