Skip to content
Open
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
48 changes: 42 additions & 6 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 @@ -48,6 +48,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 @@ -80,6 +81,8 @@ const Dropdown = (props: DropdownProps) => {
(selection: OptionValue[]) => {
if (closeOnSelect !== false) {
setIsOpen(false);
setProps({search_value: undefined});
pendingSearchRef.current = '';
}

if (multi) {
Expand Down Expand Up @@ -237,12 +240,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 @@ -259,9 +265,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 @@ -360,6 +371,7 @@ const Dropdown = (props: DropdownProps) => {

if (!open) {
setProps({search_value: undefined});
pendingSearchRef.current = '';
}
},
[filteredOptions, sanitizedValues]
Expand Down Expand Up @@ -392,6 +404,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 @@ -475,6 +495,22 @@ 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);
const newSelection = isSelected
? without([firstVal], sanitizedValues)
: append(firstVal, sanitizedValues);
updateSelection(newSelection);
Comment on lines +507 to +512
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would read better with a standard if else.

}}
ref={searchInputRef}
/>
{search_value && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,63 @@ def send_keys(key):
assert dash_duo.get_logs() == []


def test_a11y003b_keyboard_navigation_not_searchable(dash_duo):
def send_keys(key):
actions = ActionChains(dash_duo.driver)
actions.send_keys(key)
actions.perform()

app = Dash(__name__)
app.layout = Div(
[
Dropdown(
id="dropdown",
options=[i for i in range(0, 100)],
multi=True,
searchable=False,
placeholder="Testing keyboard navigation without search",
),
],
)

dash_duo.start_server(app)

dropdown = dash_duo.find_element("#dropdown")
dropdown.send_keys(Keys.ENTER) # Open with Enter key
dash_duo.wait_for_element(".dash-dropdown-options")

send_keys(Keys.ESCAPE)
with pytest.raises(TimeoutException):
dash_duo.wait_for_element(".dash-dropdown-options", timeout=0.25)

send_keys(Keys.ARROW_DOWN) # Expecting the dropdown to open up
dash_duo.wait_for_element(".dash-dropdown-options")

send_keys(Keys.SPACE) # Expecting to be selecting the focused first option
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
assert len(value_items) == 1
assert value_items[0].text == "0"

send_keys(Keys.ARROW_DOWN)
send_keys(Keys.SPACE)
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
assert len(value_items) == 2
assert [item.text for item in value_items] == ["0", "1"]

send_keys(Keys.SPACE) # Expecting to be de-selecting
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
assert len(value_items) == 1
assert value_items[0].text == "0"

send_keys(Keys.ESCAPE)
sleep(0.25)
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
assert len(value_items) == 1
assert value_items[0].text == "0"

assert dash_duo.get_logs() == []


def test_a11y004_selection_visibility_single(dash_duo):
app = Dash(__name__)
app.layout = (
Expand Down Expand Up @@ -414,6 +471,230 @@ def get_focused_option_text():
assert dash_duo.get_logs() == []


def test_a11y009_enter_on_search_selects_first_option_multi(dash_duo):
def send_keys(key):
actions = ActionChains(dash_duo.driver)
actions.send_keys(key)
actions.perform()

app = Dash(__name__)
app.layout = Div(
[
Dropdown(
id="dropdown",
options=["Apple", "Banana", "Cherry"],
multi=True,
searchable=True,
),
Div(id="output"),
]
)

@app.callback(Output("output", "children"), Input("dropdown", "value"))
def update_output(value):
return f"Selected: {value}"

dash_duo.start_server(app)

dropdown = dash_duo.find_element("#dropdown")
dropdown.click()
dash_duo.wait_for_element(".dash-dropdown-search")

# Type to filter, then Enter selects the first visible option
send_keys("a")
sleep(0.1)
send_keys(Keys.ENTER)
dash_duo.wait_for_text_to_equal("#output", "Selected: ['Apple']")
assert dash_duo.driver.execute_script(
"return document.activeElement.type === 'search';"
), "Focus should remain on the search input after Enter"

# Enter again deselects it
send_keys(Keys.ENTER)
dash_duo.wait_for_text_to_equal("#output", "Selected: []")
assert dash_duo.driver.execute_script(
"return document.activeElement.type === 'search';"
), "Focus should remain on the search input after deselect"

# Filtering to a different option selects that one
send_keys(Keys.BACKSPACE)
send_keys("b")
sleep(0.1)
send_keys(Keys.ENTER)
dash_duo.wait_for_text_to_equal("#output", "Selected: ['Banana']")

assert dash_duo.get_logs() == []


def test_a11y010_enter_on_search_selects_first_option_single(dash_duo):
def send_keys(key):
actions = ActionChains(dash_duo.driver)
actions.send_keys(key)
actions.perform()

app = Dash(__name__)
app.layout = Div(
[
Dropdown(
id="dropdown",
options=["Apple", "Banana", "Cherry"],
multi=False,
searchable=True,
),
Div(id="output"),
]
)

@app.callback(Output("output", "children"), Input("dropdown", "value"))
def update_output(value):
return f"Selected: {value}"

dash_duo.start_server(app)

dropdown = dash_duo.find_element("#dropdown")
dropdown.click()
dash_duo.wait_for_element(".dash-dropdown-search")

send_keys("a")
sleep(0.1)
send_keys(Keys.ENTER)
dash_duo.wait_for_text_to_equal("#output", "Selected: Apple")

assert dash_duo.get_logs() == []


def test_a11y011_enter_on_search_no_deselect_when_not_clearable(dash_duo):
def send_keys(key):
actions = ActionChains(dash_duo.driver)
actions.send_keys(key)
actions.perform()

app = Dash(__name__)
app.layout = Div(
[
Dropdown(
id="dropdown",
options=["Apple", "Banana", "Cherry"],
value="Apple",
multi=False,
searchable=True,
clearable=False,
),
Div(id="output"),
]
)

@app.callback(Output("output", "children"), Input("dropdown", "value"))
def update_output(value):
return f"Selected: {value}"

dash_duo.start_server(app)

dash_duo.wait_for_text_to_equal("#output", "Selected: Apple")

dropdown = dash_duo.find_element("#dropdown")
dropdown.click()
dash_duo.wait_for_element(".dash-dropdown-search")

# Apple is the first option and already selected; Enter should not deselect it
send_keys(Keys.ENTER)
sleep(0.1)
dash_duo.wait_for_text_to_equal("#output", "Selected: Apple")

assert dash_duo.get_logs() == []


def test_a11y012_typing_on_trigger_opens_dropdown_with_search(dash_duo):
app = Dash(__name__)
app.layout = Div(
[
Dropdown(
id="dropdown",
options=["Apple", "Banana", "Cherry"],
searchable=True,
),
Div(id="output"),
]
)

@app.callback(Output("output", "children"), Input("dropdown", "search_value"))
def update_output(search_value):
return f"Search: {search_value}"

dash_duo.start_server(app)

dropdown = dash_duo.find_element("#dropdown")
dropdown.send_keys("b")

dash_duo.wait_for_element(".dash-dropdown-search")
dash_duo.wait_for_text_to_equal("#output", "Search: b")

# Only Banana should be visible
options = dash_duo.find_elements(".dash-dropdown-option")
assert len(options) == 1
assert options[0].text == "Banana"

# Focus should be on the search input
assert dash_duo.driver.execute_script(
"return document.activeElement.type === 'search';"
), "Focus should be on the search input after typing on the trigger"

assert dash_duo.get_logs() == []


def test_a11y013_enter_on_search_after_reopen_selects_correctly(dash_duo):
def send_keys(key):
actions = ActionChains(dash_duo.driver)
actions.send_keys(key)
actions.perform()

app = Dash(__name__)
app.layout = Div(
[
Dropdown(
id="dropdown",
options=["Cambodia", "Cameroon", "Canada"],
multi=False,
searchable=True,
),
Div(id="output"),
]
)

@app.callback(Output("output", "children"), Input("dropdown", "value"))
def update_output(value):
return f"Selected: {value}"

dash_duo.start_server(app)

dropdown = dash_duo.find_element("#dropdown")
dropdown.send_keys("c")
dash_duo.wait_for_element(".dash-dropdown-search")
sleep(0.1)

# Enter selects Cambodia (first result)
send_keys(Keys.ENTER)
dash_duo.wait_for_text_to_equal("#output", "Selected: Cambodia")

# Type "can" — should filter to only Canada
send_keys("can")
sleep(0.1)
options = dash_duo.find_elements(".dash-dropdown-option")
assert len(options) == 1
assert options[0].text == "Canada"

# Focus should still be on the search input, not the selected option
assert dash_duo.driver.execute_script(
"return document.activeElement.type === 'search';"
), "Focus should remain on the search input while typing"

# Enter selects Canada
send_keys(Keys.ENTER)
dash_duo.wait_for_text_to_equal("#output", "Selected: Canada")

assert dash_duo.get_logs() == []


def elements_are_visible(dash_duo, elements):
# Check if the given elements are within the visible viewport of the dropdown
elements = elements if isinstance(elements, list) else [elements]
Expand Down