Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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



Expand Down
95 changes: 70 additions & 25 deletions components/dash-core-components/src/fragments/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isNil, without, isEmpty} from 'ramda';
import {isNil, without, append, isEmpty} from 'ramda';
import React, {
useState,
useCallback,
Expand Down Expand Up @@ -50,6 +50,7 @@ const Dropdown = (props: DropdownProps) => {
document.createElement('div')
);
const searchInputRef = useRef<HTMLInputElement>(null);
const pendingSearchRef = useRef('');

const ctx = window.dash_component_api.useDashContext();
const loading = ctx.useLoading();
Expand Down Expand Up @@ -92,7 +93,7 @@ const Dropdown = (props: DropdownProps) => {
setVal(newValue);
} else {
setVal(newValue);
setProps({ value: newValue });
setProps({value: newValue});
}
},
[debounce, isOpen, setProps]
Expand All @@ -102,6 +103,8 @@ const Dropdown = (props: DropdownProps) => {
(selection: OptionValue[]) => {
if (closeOnSelect !== false) {
setIsOpen(false);
setProps({search_value: undefined});
pendingSearchRef.current = '';
}

if (multi) {
Expand Down Expand Up @@ -257,12 +260,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];
Expand All @@ -279,9 +285,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<HTMLElement>(
'input.dash-options-list-option-checkbox:not([disabled])'
)
?.focus();
}
});
}, [isOpen, multi, displayOptions]);
Expand Down Expand Up @@ -374,29 +385,30 @@ const Dropdown = (props: DropdownProps) => {
}, []);

// Handle popover open/close
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);

if (!open) {
const updates: Partial<DropdownProps> = {};
if (!open) {
pendingSearchRef.current = '';
const updates: Partial<DropdownProps> = {};

if (!isNil(search_value)) {
updates.search_value = undefined;
}
if (!isNil(search_value)) {
updates.search_value = undefined;
}

// Commit debounced value on close only
if (debounce && !isEqual(value, val)) {
updates.value = val;
}
// Commit debounced value on close only
if (debounce && !isEqual(value, val)) {
updates.value = val;
}

if (Object.keys(updates).length > 0) {
setProps(updates);
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<HTMLDivElement>(null);
Expand Down Expand Up @@ -425,6 +437,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`}
Expand Down Expand Up @@ -508,6 +528,31 @@ 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);
let newSelection;
if (isSelected) {
newSelection = without(
[firstVal],
sanitizedValues
);
} else {
newSelection = append(
firstVal,
sanitizedValues
);
}
updateSelection(newSelection);
}}
ref={searchInputRef}
/>
{search_value && (
Expand Down
2 changes: 1 addition & 1 deletion components/dash-core-components/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ export interface DropdownProps extends BaseDccProps<DropdownProps> {
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`
*/
Expand Down
Loading
Loading