diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..f89399314a 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,140 @@ 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 highlighted option and close dropdown + if (e.key === 'Tab' && !e.shiftKey) { + if (displayOptions.length > 0) { + // 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; + } + } - // 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; - } + if (!optionToSelect.disabled) { + if (multi) { + if ( + !sanitizedValues.includes(optionToSelect.value) + ) { + updateSelection([ + ...sanitizedValues, + optionToSelect.value, + ]); + } + } else { + updateSelection([optionToSelect.value]); + } + } + } + setIsOpen(false); + setProps({search_value: undefined}); + return; + } - const focusableElements = e.currentTarget.querySelectorAll( - 'input[type="search"], input:not([disabled])' - ) as NodeListOf; + const relevantKeys = [ + 'ArrowDown', + 'ArrowUp', + 'PageDown', + 'PageUp', + 'Home', + 'End', + ]; + if (!relevantKeys.includes(e.key)) { + return; + } - // Don't interfere with the event if there aren't any options that the user can interact with - if (focusableElements.length === 0) { - 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; + } - e.preventDefault(); + const focusableElements = e.currentTarget.querySelectorAll( + 'input[type="search"], input:not([disabled])' + ) as NodeListOf; - 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; - } + // Don't interfere with the event if there aren't any options that the user can interact with + if (focusableElements.length === 0) { + return; + } - 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', - }); + e.preventDefault(); + + 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 +438,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)) { 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 || [], }; }