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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## Fixed
- [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None
- [#3723](https://github.com/plotly/dash/pull/3723) Fix misaligned `dcc.Slider` marks when some labels are empty strings
- [#3740](https://github.com/plotly/dash/pull/3740) Fix cannot tab into dropdowns in Safari

## [4.1.0] - 2026-03-23

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
cursor: not-allowed;
}

.dash-dropdown-focus-target {
position: absolute;
opacity: 0;
pointer-events: none;
}

.dash-dropdown-value {
max-width: 100%;
text-align: left;
Expand Down
39 changes: 39 additions & 0 deletions components/dash-core-components/src/fragments/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ const Dropdown = (props: DropdownProps) => {
const relevantKeys = [
'ArrowDown',
'ArrowUp',
'Tab',
'PageDown',
'PageUp',
'Home',
Expand Down Expand Up @@ -342,6 +343,19 @@ const Dropdown = (props: DropdownProps) => {
let nextIndex: number;

switch (e.key) {
case 'Tab': {
// Trap Tab inside the popover so Safari (which
// skips non-text inputs) can navigate options.
const next = current + (e.shiftKey ? -1 : 1);
if (next < minIndex) {
nextIndex = maxIndex;
} else if (next > maxIndex) {
nextIndex = minIndex;
} else {
nextIndex = next;
}
break;
}
case 'ArrowDown':
nextIndex = current < maxIndex ? current + 1 : minIndex;
break;
Expand Down Expand Up @@ -408,12 +422,37 @@ const Dropdown = (props: DropdownProps) => {

const popover = (
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
{/* Safari skips <button> in the Tab order; this hidden
input receives Tab focus and delegates to the button. */}
<input
className="dash-dropdown-focus-target"
tabIndex={disabled ? -1 : 0}
readOnly
aria-hidden="true"
onFocus={e => {
if (e.relatedTarget !== dropdownContainerRef.current) {
e.currentTarget.tabIndex = -1;
dropdownContainerRef.current?.focus();
}
}}
onClick={() => {
dropdownContainerRef.current?.click();
}}
/>
<Popover.Trigger asChild>
<button
id={id}
ref={dropdownContainerRef}
disabled={disabled}
type="button"
tabIndex={-1}
onBlur={e => {
const dummyInput =
e.currentTarget.previousElementSibling;
if (dummyInput instanceof HTMLElement) {
dummyInput.tabIndex = 0;
}
}}
onKeyDown={e => {
if (['ArrowDown', 'Enter'].includes(e.key)) {
e.preventDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ def test_dtps012_initial_visible_month(dash_dcc):

# Check that calendar shows January 2010 (initial_visible_month), not June 2020 (date)
month_dropdown = dash_dcc.find_element(".dash-datepicker-controls .dash-dropdown")
year_input = dash_dcc.find_element(".dash-datepicker-controls input")
year_input = dash_dcc.find_element(
".dash-datepicker-controls input:not([aria-hidden])"
)

assert "January" in month_dropdown.text, "Calendar should show January"
assert year_input.get_attribute("value") == "2010", "Calendar should show year 2010"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ def click_everything_in_datepicker(datepicker_id, dash_dcc):
)
)

interactive_elements.extend(popover.find_elements(By.CSS_SELECTOR, "input"))
interactive_elements.extend(
popover.find_elements(By.CSS_SELECTOR, "input:not([aria-hidden])")
)

buttons = reversed(
popover.find_elements(By.CSS_SELECTOR, "button")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,96 @@ def update_output(search_value):
assert dash_duo.get_logs() == []


def test_a11y012b_tab_from_search_focuses_first_option(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"],
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("b")

dash_duo.wait_for_element(".dash-dropdown-search")

# Tab from search input should focus the first option
send_keys(Keys.TAB)
sleep(0.1)

# The dropdown should still be open
dash_duo.wait_for_element(".dash-dropdown-options")

# Enter selects the focused option
send_keys(Keys.ENTER)
dash_duo.wait_for_text_to_equal("#output", "Selected: Banana")

assert dash_duo.get_logs() == []


def test_a11y012c_shift_tab_between_dropdowns(dash_duo):
"""Shift+Tab should move between dropdowns in a single press,
just like forward Tab does."""
from dash.html import Button as HtmlButton

def shift_tab():
actions = ActionChains(dash_duo.driver)
actions.key_down(Keys.SHIFT).send_keys(Keys.TAB).key_up(Keys.SHIFT)
actions.perform()

app = Dash(__name__)
app.layout = Div(
[
Dropdown(
id="dd1",
options=["Apple", "Banana"],
),
Dropdown(
id="dd2",
options=["Cherry", "Date"],
),
HtmlButton("after", id="after"),
]
)

dash_duo.start_server(app)
dash_duo.wait_for_element("#dd1")

# Focus the button at the end
dash_duo.find_element("#after").click()
sleep(0.1)

# Shift+Tab once should reach dd2
shift_tab()
sleep(0.1)
active = dash_duo.driver.execute_script("return document.activeElement.id;")
assert active == "dd2", f"Expected dd2 but got {active}"

# Shift+Tab once more should reach dd1
shift_tab()
sleep(0.1)
active = dash_duo.driver.execute_script("return document.activeElement.id;")
assert active == "dd1", f"Expected dd1 but got {active}"

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def click_everything_in_datepicker(datepicker_id, dash_dcc):
)
)
interactive_elements.extend(popover.find_elements(By.CSS_SELECTOR, "button"))
interactive_elements.extend(popover.find_elements(By.CSS_SELECTOR, "input"))
interactive_elements.extend(
popover.find_elements(By.CSS_SELECTOR, "input:not([aria-hidden])")
)
for el in interactive_elements:
try:
el.click()
Expand Down
Loading