Skip to content

Commit 2f8a738

Browse files
committed
Fix keyboard quirks in certain edge cases with dropdown component
1 parent bdc971e commit 2f8a738

File tree

3 files changed

+274
-5
lines changed

3 files changed

+274
-5
lines changed

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ const Dropdown = (props: DropdownProps) => {
283283
// Don't interfere with the event if the user is using Home/End keys on the search input
284284
if (
285285
['Home', 'End'].includes(e.key) &&
286-
document.activeElement instanceof HTMLInputElement
286+
document.activeElement === searchInputRef.current
287287
) {
288288
return;
289289
}
@@ -367,6 +367,7 @@ const Dropdown = (props: DropdownProps) => {
367367

368368
const accessibleId = id ?? uuid();
369369
const positioningContainerRef = useRef<HTMLDivElement>(null);
370+
const canClearValues = clearable && !disabled && !!sanitizedValues.length;
370371

371372
const popover = (
372373
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
@@ -377,10 +378,20 @@ const Dropdown = (props: DropdownProps) => {
377378
disabled={disabled}
378379
type="button"
379380
onKeyDown={e => {
380-
if (e.key === 'ArrowDown') {
381+
if (['ArrowDown', 'Enter'].includes(e.key)) {
381382
e.preventDefault();
383+
}
384+
}}
385+
onKeyUp={e => {
386+
if (['ArrowDown', 'Enter'].includes(e.key)) {
382387
setIsOpen(true);
383388
}
389+
if (
390+
['Delete', 'Backspace'].includes(e.key) &&
391+
canClearValues
392+
) {
393+
handleClear();
394+
}
384395
}}
385396
className={`dash-dropdown ${className ?? ''}`}
386397
style={style}
@@ -417,7 +428,7 @@ const Dropdown = (props: DropdownProps) => {
417428
)}
418429
</span>
419430
)}
420-
{clearable && !disabled && !!sanitizedValues.length && (
431+
{canClearValues && (
421432
<a
422433
className="dash-dropdown-clear"
423434
onClick={e => {

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

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def send_keys(key):
8383
dash_duo.start_server(app)
8484

8585
dropdown = dash_duo.find_element("#dropdown")
86-
dropdown.click()
86+
dropdown.send_keys(Keys.ENTER) # Open with Enter key
8787
dash_duo.wait_for_element(".dash-dropdown-options")
8888

8989
send_keys(
@@ -237,14 +237,17 @@ def update_output(value):
237237

238238
# Select 3 items by alternating ArrowDown and Spacebar
239239
send_keys(Keys.ARROW_DOWN) # Move to first option
240+
sleep(0.05)
240241
send_keys(Keys.SPACE) # Select Option 0
241242
dash_duo.wait_for_text_to_equal("#output", "Selected: ['Option 0']")
242243

243244
send_keys(Keys.ARROW_DOWN) # Move to second option
245+
sleep(0.05)
244246
send_keys(Keys.SPACE) # Select Option 1
245247
dash_duo.wait_for_text_to_equal("#output", "Selected: ['Option 0', 'Option 1']")
246248

247249
send_keys(Keys.ARROW_DOWN) # Move to third option
250+
sleep(0.05)
248251
send_keys(Keys.SPACE) # Select Option 2
249252
dash_duo.wait_for_text_to_equal(
250253
"#output", "Selected: ['Option 0', 'Option 1', 'Option 2']"
@@ -253,6 +256,164 @@ def update_output(value):
253256
assert dash_duo.get_logs() == []
254257

255258

259+
def test_a11y007_opens_and_closes_without_races(dash_duo):
260+
def send_keys(key):
261+
actions = ActionChains(dash_duo.driver)
262+
actions.send_keys(key)
263+
actions.perform()
264+
265+
app = Dash(__name__)
266+
app.layout = Div(
267+
[
268+
Dropdown(
269+
id="dropdown",
270+
options=[f"Option {i}" for i in range(0, 10)],
271+
value="Option 5",
272+
multi=False,
273+
),
274+
Div(id="output"),
275+
]
276+
)
277+
278+
def assert_focus_in_dropdown():
279+
# Verify focus is inside the dropdown
280+
assert dash_duo.driver.execute_script(
281+
"""
282+
const activeElement = document.activeElement;
283+
const dropdownContent = document.querySelector('.dash-dropdown-content');
284+
return dropdownContent && dropdownContent.contains(activeElement);
285+
"""
286+
), "Focus must be inside the dropdown when it opens"
287+
288+
@app.callback(
289+
Output("output", "children"),
290+
Input("dropdown", "value"),
291+
)
292+
def update_output(value):
293+
return f"Selected: {value}"
294+
295+
dash_duo.start_server(app)
296+
297+
# Verify initial value is set
298+
dash_duo.wait_for_text_to_equal("#output", "Selected: Option 5")
299+
300+
dropdown = dash_duo.find_element("#dropdown")
301+
302+
# Test repeated open/close to confirm no race conditions or side effects
303+
for i in range(3):
304+
# Open with Enter
305+
dropdown.send_keys(Keys.ENTER)
306+
dash_duo.wait_for_element(".dash-dropdown-options")
307+
assert_focus_in_dropdown()
308+
309+
# Verify the value is still "Option 5" (not cleared)
310+
dash_duo.wait_for_text_to_equal("#output", "Selected: Option 5")
311+
312+
# Close with Escape
313+
send_keys(Keys.ESCAPE)
314+
sleep(0.1)
315+
316+
# Verify the value is still "Option 5"
317+
dash_duo.wait_for_text_to_equal("#output", "Selected: Option 5")
318+
319+
for i in range(3):
320+
# Open with mouse
321+
dropdown.click()
322+
dash_duo.wait_for_element(".dash-dropdown-options")
323+
assert_focus_in_dropdown()
324+
325+
# Verify the value is still "Option 5" (not cleared)
326+
dash_duo.wait_for_text_to_equal("#output", "Selected: Option 5")
327+
328+
# Close with Escape
329+
dropdown.click()
330+
sleep(0.1)
331+
332+
# Verify the value is still "Option 5"
333+
dash_duo.wait_for_text_to_equal("#output", "Selected: Option 5")
334+
335+
assert dash_duo.get_logs() == []
336+
337+
338+
def test_a11y008_home_end_pageup_pagedown_navigation(dash_duo):
339+
def send_keys(key):
340+
actions = ActionChains(dash_duo.driver)
341+
actions.send_keys(key)
342+
actions.perform()
343+
344+
def get_focused_option_text():
345+
return dash_duo.driver.execute_script(
346+
"""
347+
const focused = document.activeElement;
348+
if (focused && focused.closest('.dash-options-list-option')) {
349+
return focused.closest('.dash-options-list-option').textContent.trim();
350+
}
351+
return null;
352+
"""
353+
)
354+
355+
app = Dash(__name__)
356+
app.layout = Div(
357+
[
358+
Dropdown(
359+
id="dropdown",
360+
options=[f"Option {i}" for i in range(0, 50)],
361+
multi=True,
362+
),
363+
]
364+
)
365+
366+
dash_duo.start_server(app)
367+
368+
dropdown = dash_duo.find_element("#dropdown")
369+
dropdown.send_keys(Keys.ENTER) # Open with Enter key
370+
dash_duo.wait_for_element(".dash-dropdown-options")
371+
372+
# Navigate from search input to options
373+
send_keys(Keys.ARROW_DOWN) # Move from search to first option
374+
sleep(0.05)
375+
send_keys(Keys.ARROW_DOWN) # Move to second option
376+
sleep(0.05)
377+
send_keys(Keys.ARROW_DOWN) # Move to third option
378+
sleep(0.05)
379+
send_keys(Keys.ARROW_DOWN) # Move to fourth option
380+
sleep(0.05)
381+
assert get_focused_option_text() == "Option 3"
382+
383+
send_keys(Keys.HOME) # Should go back to search input (index 0)
384+
# Verify we're back at search input
385+
assert dash_duo.driver.execute_script(
386+
"return document.activeElement.type === 'search';"
387+
)
388+
389+
# Now arrow down to first option
390+
send_keys(Keys.ARROW_DOWN)
391+
assert get_focused_option_text() == "Option 0"
392+
393+
# Test End key - should go to last option
394+
send_keys(Keys.END)
395+
assert get_focused_option_text() == "Option 49"
396+
397+
# Test PageUp - should jump up by 10
398+
send_keys(Keys.PAGE_UP)
399+
assert get_focused_option_text() == "Option 39"
400+
401+
# Test PageDown - should jump down by 10
402+
send_keys(Keys.PAGE_DOWN)
403+
assert get_focused_option_text() == "Option 49"
404+
405+
# Test PageUp from middle
406+
send_keys(Keys.HOME) # Back to search input (index 0)
407+
send_keys(Keys.PAGE_DOWN) # Jump to index 10 (Option 9)
408+
send_keys(Keys.PAGE_DOWN) # Jump to index 20 (Option 19)
409+
assert get_focused_option_text() == "Option 19"
410+
411+
send_keys(Keys.PAGE_UP) # Jump to index 10 (Option 9)
412+
assert get_focused_option_text() == "Option 9"
413+
414+
assert dash_duo.get_logs() == []
415+
416+
256417
def elements_are_visible(dash_duo, elements):
257418
# Check if the given elements are within the visible viewport of the dropdown
258419
elements = elements if isinstance(elements, list) else [elements]

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

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,113 @@ def update_value(val):
4141
output_text = dash_duo.find_element("#dropdown-value").text
4242

4343
dash_duo.find_element("#my-unclearable-dropdown ").click()
44+
dash_duo.wait_for_element(".dash-dropdown-options")
4445

4546
# Clicking the selected item should not de-select it.
47+
# Click on the option container instead of the input directly
4648
selected_item = dash_duo.find_element(
47-
f'.dash-dropdown-options input[value="{output_text}"]'
49+
f'.dash-dropdown-option:has(input[value="{output_text}"])'
4850
)
4951
selected_item.click()
5052
assert dash_duo.find_element("#dropdown-value").text == output_text
5153
assert dash_duo.get_logs() == []
5254

5355

56+
def test_ddcf001b_delete_backspace_keys_clearable_false(dash_duo):
57+
from selenium.webdriver.common.keys import Keys
58+
59+
app = Dash(__name__)
60+
app.layout = html.Div(
61+
[
62+
dcc.Dropdown(
63+
id="my-unclearable-dropdown",
64+
options=[
65+
{"label": "New York City", "value": "NYC"},
66+
{"label": "Montreal", "value": "MTL"},
67+
{"label": "San Francisco", "value": "SF"},
68+
],
69+
value="MTL",
70+
clearable=False,
71+
),
72+
html.Div(id="dropdown-value"),
73+
]
74+
)
75+
76+
@app.callback(
77+
Output("dropdown-value", "children"),
78+
Input("my-unclearable-dropdown", "value"),
79+
)
80+
def update_value(val):
81+
return str(val)
82+
83+
dash_duo.start_server(app)
84+
85+
dash_duo.wait_for_text_to_equal("#dropdown-value", "MTL")
86+
87+
dropdown = dash_duo.find_element("#my-unclearable-dropdown")
88+
89+
# Try to clear with Delete key - should not work since clearable=False
90+
dropdown.send_keys(Keys.DELETE)
91+
dash_duo.wait_for_text_to_equal("#dropdown-value", "MTL")
92+
93+
# Try to clear with Backspace key - should not work since clearable=False
94+
dropdown.send_keys(Keys.BACKSPACE)
95+
dash_duo.wait_for_text_to_equal("#dropdown-value", "MTL")
96+
97+
assert dash_duo.get_logs() == []
98+
99+
100+
def test_ddcf001c_delete_backspace_keys_clearable_true(dash_duo):
101+
from selenium.webdriver.common.keys import Keys
102+
103+
app = Dash(__name__)
104+
app.layout = html.Div(
105+
[
106+
dcc.Dropdown(
107+
id="my-clearable-dropdown",
108+
options=[
109+
{"label": "New York City", "value": "NYC"},
110+
{"label": "Montreal", "value": "MTL"},
111+
{"label": "San Francisco", "value": "SF"},
112+
],
113+
value="MTL",
114+
clearable=True,
115+
),
116+
html.Div(id="dropdown-value"),
117+
]
118+
)
119+
120+
@app.callback(
121+
Output("dropdown-value", "children"),
122+
Input("my-clearable-dropdown", "value"),
123+
)
124+
def update_value(val):
125+
return str(val)
126+
127+
dash_duo.start_server(app)
128+
129+
dash_duo.wait_for_text_to_equal("#dropdown-value", "MTL")
130+
131+
dropdown = dash_duo.find_element("#my-clearable-dropdown")
132+
133+
# Clear with Delete key - should work since clearable=True
134+
dropdown.send_keys(Keys.DELETE)
135+
dash_duo.wait_for_text_to_equal("#dropdown-value", "None")
136+
137+
# Set a value again
138+
dropdown.click()
139+
dash_duo.wait_for_element(".dash-dropdown-options")
140+
option = dash_duo.find_element('.dash-dropdown-option:has(input[value="SF"])')
141+
option.click()
142+
dash_duo.wait_for_text_to_equal("#dropdown-value", "SF")
143+
144+
# Clear with Backspace key - should work since clearable=True
145+
dropdown.send_keys(Keys.BACKSPACE)
146+
dash_duo.wait_for_text_to_equal("#dropdown-value", "None")
147+
148+
assert dash_duo.get_logs() == []
149+
150+
54151
def test_ddcf002_clearable_false_multi(dash_duo):
55152
app = Dash(__name__)
56153
app.layout = html.Div(

0 commit comments

Comments
 (0)