Skip to content

Commit 20834e2

Browse files
committed
Fix tab key not working in dropdowns in Safari
1 parent 1fbfbac commit 20834e2

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

components/dash-core-components/src/components/css/dropdown.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
cursor: not-allowed;
5656
}
5757

58+
.dash-dropdown-focus-target {
59+
position: absolute;
60+
opacity: 0;
61+
pointer-events: none;
62+
}
63+
5864
.dash-dropdown-value {
5965
max-width: 100%;
6066
text-align: left;

components/dash-core-components/src/fragments/Dropdown.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ const Dropdown = (props: DropdownProps) => {
313313
const relevantKeys = [
314314
'ArrowDown',
315315
'ArrowUp',
316+
'Tab',
316317
'PageDown',
317318
'PageUp',
318319
'Home',
@@ -342,6 +343,19 @@ const Dropdown = (props: DropdownProps) => {
342343
let nextIndex: number;
343344

344345
switch (e.key) {
346+
case 'Tab': {
347+
// Trap Tab inside the popover so Safari (which
348+
// skips non-text inputs) can navigate options.
349+
const next = current + (e.shiftKey ? -1 : 1);
350+
if (next < minIndex) {
351+
nextIndex = maxIndex;
352+
} else if (next > maxIndex) {
353+
nextIndex = minIndex;
354+
} else {
355+
nextIndex = next;
356+
}
357+
break;
358+
}
345359
case 'ArrowDown':
346360
nextIndex = current < maxIndex ? current + 1 : minIndex;
347361
break;
@@ -408,12 +422,37 @@ const Dropdown = (props: DropdownProps) => {
408422

409423
const popover = (
410424
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
425+
{/* Safari skips <button> in the Tab order; this hidden
426+
input receives Tab focus and delegates to the button. */}
427+
<input
428+
className="dash-dropdown-focus-target"
429+
tabIndex={disabled ? -1 : 0}
430+
readOnly
431+
aria-hidden="true"
432+
onFocus={e => {
433+
if (e.relatedTarget !== dropdownContainerRef.current) {
434+
e.currentTarget.tabIndex = -1;
435+
dropdownContainerRef.current?.focus();
436+
}
437+
}}
438+
onClick={() => {
439+
dropdownContainerRef.current?.click();
440+
}}
441+
/>
411442
<Popover.Trigger asChild>
412443
<button
413444
id={id}
414445
ref={dropdownContainerRef}
415446
disabled={disabled}
416447
type="button"
448+
tabIndex={-1}
449+
onBlur={e => {
450+
const dummyInput =
451+
e.currentTarget.previousElementSibling;
452+
if (dummyInput instanceof HTMLElement) {
453+
dummyInput.tabIndex = 0;
454+
}
455+
}}
417456
onKeyDown={e => {
418457
if (['ArrowDown', 'Enter'].includes(e.key)) {
419458
e.preventDefault();

components/dash-core-components/tests/integration/dropdown/test_a11y.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,96 @@ def update_output(search_value):
653653
assert dash_duo.get_logs() == []
654654

655655

656+
def test_a11y012b_tab_from_search_focuses_first_option(dash_duo):
657+
def send_keys(key):
658+
actions = ActionChains(dash_duo.driver)
659+
actions.send_keys(key)
660+
actions.perform()
661+
662+
app = Dash(__name__)
663+
app.layout = Div(
664+
[
665+
Dropdown(
666+
id="dropdown",
667+
options=["Apple", "Banana", "Cherry"],
668+
searchable=True,
669+
),
670+
Div(id="output"),
671+
]
672+
)
673+
674+
@app.callback(Output("output", "children"), Input("dropdown", "value"))
675+
def update_output(value):
676+
return f"Selected: {value}"
677+
678+
dash_duo.start_server(app)
679+
680+
dropdown = dash_duo.find_element("#dropdown")
681+
dropdown.send_keys("b")
682+
683+
dash_duo.wait_for_element(".dash-dropdown-search")
684+
685+
# Tab from search input should focus the first option
686+
send_keys(Keys.TAB)
687+
sleep(0.1)
688+
689+
# The dropdown should still be open
690+
dash_duo.wait_for_element(".dash-dropdown-options")
691+
692+
# Enter selects the focused option
693+
send_keys(Keys.ENTER)
694+
dash_duo.wait_for_text_to_equal("#output", "Selected: Banana")
695+
696+
assert dash_duo.get_logs() == []
697+
698+
699+
def test_a11y012c_shift_tab_between_dropdowns(dash_duo):
700+
"""Shift+Tab should move between dropdowns in a single press,
701+
just like forward Tab does."""
702+
from dash.html import Button as HtmlButton
703+
704+
def shift_tab():
705+
actions = ActionChains(dash_duo.driver)
706+
actions.key_down(Keys.SHIFT).send_keys(Keys.TAB).key_up(Keys.SHIFT)
707+
actions.perform()
708+
709+
app = Dash(__name__)
710+
app.layout = Div(
711+
[
712+
Dropdown(
713+
id="dd1",
714+
options=["Apple", "Banana"],
715+
),
716+
Dropdown(
717+
id="dd2",
718+
options=["Cherry", "Date"],
719+
),
720+
HtmlButton("after", id="after"),
721+
]
722+
)
723+
724+
dash_duo.start_server(app)
725+
dash_duo.wait_for_element("#dd1")
726+
727+
# Focus the button at the end
728+
dash_duo.find_element("#after").click()
729+
sleep(0.1)
730+
731+
# Shift+Tab once should reach dd2
732+
shift_tab()
733+
sleep(0.1)
734+
active = dash_duo.driver.execute_script("return document.activeElement.id;")
735+
assert active == "dd2", f"Expected dd2 but got {active}"
736+
737+
# Shift+Tab once more should reach dd1
738+
shift_tab()
739+
sleep(0.1)
740+
active = dash_duo.driver.execute_script("return document.activeElement.id;")
741+
assert active == "dd1", f"Expected dd1 but got {active}"
742+
743+
assert dash_duo.get_logs() == []
744+
745+
656746
def test_a11y013_enter_on_search_after_reopen_selects_correctly(dash_duo):
657747
def send_keys(key):
658748
actions = ActionChains(dash_duo.driver)

0 commit comments

Comments
 (0)