⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 149 additions & 80 deletions components/dash-core-components/src/fragments/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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<HTMLElement>;
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<HTMLElement>;

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(
Expand Down Expand Up @@ -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)) {
Expand Down
36 changes: 35 additions & 1 deletion components/dash-core-components/src/utils/dropdownSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [],
};
}
Loading