Skip to content

Commit 4ddfe81

Browse files
committed
Allow user to start searching dropdowns by typing (without opening first)
1 parent 50cb3e4 commit 4ddfe81

File tree

2 files changed

+323
-6
lines changed

2 files changed

+323
-6
lines changed

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {isNil, without, isEmpty} from 'ramda';
1+
import {isNil, without, append, isEmpty} from 'ramda';
22
import React, {
33
useState,
44
useCallback,
@@ -48,6 +48,7 @@ const Dropdown = (props: DropdownProps) => {
4848
document.createElement('div')
4949
);
5050
const searchInputRef = useRef<HTMLInputElement>(null);
51+
const pendingSearchRef = useRef('');
5152

5253
const ctx = window.dash_component_api.useDashContext();
5354
const loading = ctx.useLoading();
@@ -80,6 +81,8 @@ const Dropdown = (props: DropdownProps) => {
8081
(selection: OptionValue[]) => {
8182
if (closeOnSelect !== false) {
8283
setIsOpen(false);
84+
setProps({search_value: undefined});
85+
pendingSearchRef.current = '';
8386
}
8487

8588
if (multi) {
@@ -237,12 +240,15 @@ const Dropdown = (props: DropdownProps) => {
237240

238241
// Focus first selected item or search input when dropdown opens
239242
useEffect(() => {
240-
if (!isOpen || search_value) {
243+
if (!isOpen) {
241244
return;
242245
}
243-
244246
// waiting for the DOM to be ready after the dropdown renders
245247
requestAnimationFrame(() => {
248+
// Don't steal focus from the search input while the user is typing
249+
if (pendingSearchRef.current) {
250+
return;
251+
}
246252
// Try to focus the first selected item (for single-select)
247253
if (!multi) {
248254
const selectedValue = sanitizedValues[0];
@@ -259,9 +265,14 @@ const Dropdown = (props: DropdownProps) => {
259265
}
260266
}
261267

262-
// Fallback: focus search input if available and no selected item was focused
263-
if (searchable && searchInputRef.current) {
264-
searchInputRef.current.focus();
268+
if (searchable) {
269+
searchInputRef.current?.focus();
270+
} else {
271+
dropdownContentRef.current
272+
.querySelector<HTMLElement>(
273+
'input.dash-options-list-option-checkbox:not([disabled])'
274+
)
275+
?.focus();
265276
}
266277
});
267278
}, [isOpen, multi, displayOptions]);
@@ -360,6 +371,7 @@ const Dropdown = (props: DropdownProps) => {
360371

361372
if (!open) {
362373
setProps({search_value: undefined});
374+
pendingSearchRef.current = '';
363375
}
364376
},
365377
[filteredOptions, sanitizedValues]
@@ -392,6 +404,14 @@ const Dropdown = (props: DropdownProps) => {
392404
) {
393405
handleClear();
394406
}
407+
if (e.key.length === 1 && searchable) {
408+
pendingSearchRef.current += e.key;
409+
setProps({search_value: pendingSearchRef.current});
410+
setIsOpen(true);
411+
requestAnimationFrame(() =>
412+
searchInputRef.current?.focus()
413+
);
414+
}
395415
}}
396416
className={`dash-dropdown ${className ?? ''}`}
397417
aria-labelledby={`${accessibleId}-value-count ${accessibleId}-value`}
@@ -475,6 +495,22 @@ const Dropdown = (props: DropdownProps) => {
475495
value={search_value || ''}
476496
autoComplete="off"
477497
onChange={e => onInputChange(e.target.value)}
498+
onKeyUp={e => {
499+
if (
500+
!search_value ||
501+
e.key !== 'Enter' ||
502+
!displayOptions.length
503+
) {
504+
return;
505+
}
506+
const firstVal = displayOptions[0].value;
507+
const isSelected =
508+
sanitizedValues.includes(firstVal);
509+
const newSelection = isSelected
510+
? without([firstVal], sanitizedValues)
511+
: append(firstVal, sanitizedValues);
512+
updateSelection(newSelection);
513+
}}
478514
ref={searchInputRef}
479515
/>
480516
{search_value && (

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

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,63 @@ def send_keys(key):
139139
assert dash_duo.get_logs() == []
140140

141141

142+
def test_a11y003b_keyboard_navigation_not_searchable(dash_duo):
143+
def send_keys(key):
144+
actions = ActionChains(dash_duo.driver)
145+
actions.send_keys(key)
146+
actions.perform()
147+
148+
app = Dash(__name__)
149+
app.layout = Div(
150+
[
151+
Dropdown(
152+
id="dropdown",
153+
options=[i for i in range(0, 100)],
154+
multi=True,
155+
searchable=False,
156+
placeholder="Testing keyboard navigation without search",
157+
),
158+
],
159+
)
160+
161+
dash_duo.start_server(app)
162+
163+
dropdown = dash_duo.find_element("#dropdown")
164+
dropdown.send_keys(Keys.ENTER) # Open with Enter key
165+
dash_duo.wait_for_element(".dash-dropdown-options")
166+
167+
send_keys(Keys.ESCAPE)
168+
with pytest.raises(TimeoutException):
169+
dash_duo.wait_for_element(".dash-dropdown-options", timeout=0.25)
170+
171+
send_keys(Keys.ARROW_DOWN) # Expecting the dropdown to open up
172+
dash_duo.wait_for_element(".dash-dropdown-options")
173+
174+
send_keys(Keys.SPACE) # Expecting to be selecting the focused first option
175+
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
176+
assert len(value_items) == 1
177+
assert value_items[0].text == "0"
178+
179+
send_keys(Keys.ARROW_DOWN)
180+
send_keys(Keys.SPACE)
181+
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
182+
assert len(value_items) == 2
183+
assert [item.text for item in value_items] == ["0", "1"]
184+
185+
send_keys(Keys.SPACE) # Expecting to be de-selecting
186+
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
187+
assert len(value_items) == 1
188+
assert value_items[0].text == "0"
189+
190+
send_keys(Keys.ESCAPE)
191+
sleep(0.25)
192+
value_items = dash_duo.find_elements(".dash-dropdown-value-item")
193+
assert len(value_items) == 1
194+
assert value_items[0].text == "0"
195+
196+
assert dash_duo.get_logs() == []
197+
198+
142199
def test_a11y004_selection_visibility_single(dash_duo):
143200
app = Dash(__name__)
144201
app.layout = (
@@ -414,6 +471,230 @@ def get_focused_option_text():
414471
assert dash_duo.get_logs() == []
415472

416473

474+
def test_a11y009_enter_on_search_selects_first_option_multi(dash_duo):
475+
def send_keys(key):
476+
actions = ActionChains(dash_duo.driver)
477+
actions.send_keys(key)
478+
actions.perform()
479+
480+
app = Dash(__name__)
481+
app.layout = Div(
482+
[
483+
Dropdown(
484+
id="dropdown",
485+
options=["Apple", "Banana", "Cherry"],
486+
multi=True,
487+
searchable=True,
488+
),
489+
Div(id="output"),
490+
]
491+
)
492+
493+
@app.callback(Output("output", "children"), Input("dropdown", "value"))
494+
def update_output(value):
495+
return f"Selected: {value}"
496+
497+
dash_duo.start_server(app)
498+
499+
dropdown = dash_duo.find_element("#dropdown")
500+
dropdown.click()
501+
dash_duo.wait_for_element(".dash-dropdown-search")
502+
503+
# Type to filter, then Enter selects the first visible option
504+
send_keys("a")
505+
sleep(0.1)
506+
send_keys(Keys.ENTER)
507+
dash_duo.wait_for_text_to_equal("#output", "Selected: ['Apple']")
508+
assert dash_duo.driver.execute_script(
509+
"return document.activeElement.type === 'search';"
510+
), "Focus should remain on the search input after Enter"
511+
512+
# Enter again deselects it
513+
send_keys(Keys.ENTER)
514+
dash_duo.wait_for_text_to_equal("#output", "Selected: []")
515+
assert dash_duo.driver.execute_script(
516+
"return document.activeElement.type === 'search';"
517+
), "Focus should remain on the search input after deselect"
518+
519+
# Filtering to a different option selects that one
520+
send_keys(Keys.BACKSPACE)
521+
send_keys("b")
522+
sleep(0.1)
523+
send_keys(Keys.ENTER)
524+
dash_duo.wait_for_text_to_equal("#output", "Selected: ['Banana']")
525+
526+
assert dash_duo.get_logs() == []
527+
528+
529+
def test_a11y010_enter_on_search_selects_first_option_single(dash_duo):
530+
def send_keys(key):
531+
actions = ActionChains(dash_duo.driver)
532+
actions.send_keys(key)
533+
actions.perform()
534+
535+
app = Dash(__name__)
536+
app.layout = Div(
537+
[
538+
Dropdown(
539+
id="dropdown",
540+
options=["Apple", "Banana", "Cherry"],
541+
multi=False,
542+
searchable=True,
543+
),
544+
Div(id="output"),
545+
]
546+
)
547+
548+
@app.callback(Output("output", "children"), Input("dropdown", "value"))
549+
def update_output(value):
550+
return f"Selected: {value}"
551+
552+
dash_duo.start_server(app)
553+
554+
dropdown = dash_duo.find_element("#dropdown")
555+
dropdown.click()
556+
dash_duo.wait_for_element(".dash-dropdown-search")
557+
558+
send_keys("a")
559+
sleep(0.1)
560+
send_keys(Keys.ENTER)
561+
dash_duo.wait_for_text_to_equal("#output", "Selected: Apple")
562+
563+
assert dash_duo.get_logs() == []
564+
565+
566+
def test_a11y011_enter_on_search_no_deselect_when_not_clearable(dash_duo):
567+
def send_keys(key):
568+
actions = ActionChains(dash_duo.driver)
569+
actions.send_keys(key)
570+
actions.perform()
571+
572+
app = Dash(__name__)
573+
app.layout = Div(
574+
[
575+
Dropdown(
576+
id="dropdown",
577+
options=["Apple", "Banana", "Cherry"],
578+
value="Apple",
579+
multi=False,
580+
searchable=True,
581+
clearable=False,
582+
),
583+
Div(id="output"),
584+
]
585+
)
586+
587+
@app.callback(Output("output", "children"), Input("dropdown", "value"))
588+
def update_output(value):
589+
return f"Selected: {value}"
590+
591+
dash_duo.start_server(app)
592+
593+
dash_duo.wait_for_text_to_equal("#output", "Selected: Apple")
594+
595+
dropdown = dash_duo.find_element("#dropdown")
596+
dropdown.click()
597+
dash_duo.wait_for_element(".dash-dropdown-search")
598+
599+
# Apple is the first option and already selected; Enter should not deselect it
600+
send_keys(Keys.ENTER)
601+
sleep(0.1)
602+
dash_duo.wait_for_text_to_equal("#output", "Selected: Apple")
603+
604+
assert dash_duo.get_logs() == []
605+
606+
607+
def test_a11y012_typing_on_trigger_opens_dropdown_with_search(dash_duo):
608+
app = Dash(__name__)
609+
app.layout = Div(
610+
[
611+
Dropdown(
612+
id="dropdown",
613+
options=["Apple", "Banana", "Cherry"],
614+
searchable=True,
615+
),
616+
Div(id="output"),
617+
]
618+
)
619+
620+
@app.callback(Output("output", "children"), Input("dropdown", "search_value"))
621+
def update_output(search_value):
622+
return f"Search: {search_value}"
623+
624+
dash_duo.start_server(app)
625+
626+
dropdown = dash_duo.find_element("#dropdown")
627+
dropdown.send_keys("b")
628+
629+
dash_duo.wait_for_element(".dash-dropdown-search")
630+
dash_duo.wait_for_text_to_equal("#output", "Search: b")
631+
632+
# Only Banana should be visible
633+
options = dash_duo.find_elements(".dash-dropdown-option")
634+
assert len(options) == 1
635+
assert options[0].text == "Banana"
636+
637+
# Focus should be on the search input
638+
assert dash_duo.driver.execute_script(
639+
"return document.activeElement.type === 'search';"
640+
), "Focus should be on the search input after typing on the trigger"
641+
642+
assert dash_duo.get_logs() == []
643+
644+
645+
def test_a11y013_enter_on_search_after_reopen_selects_correctly(dash_duo):
646+
def send_keys(key):
647+
actions = ActionChains(dash_duo.driver)
648+
actions.send_keys(key)
649+
actions.perform()
650+
651+
app = Dash(__name__)
652+
app.layout = Div(
653+
[
654+
Dropdown(
655+
id="dropdown",
656+
options=["Cambodia", "Cameroon", "Canada"],
657+
multi=False,
658+
searchable=True,
659+
),
660+
Div(id="output"),
661+
]
662+
)
663+
664+
@app.callback(Output("output", "children"), Input("dropdown", "value"))
665+
def update_output(value):
666+
return f"Selected: {value}"
667+
668+
dash_duo.start_server(app)
669+
670+
dropdown = dash_duo.find_element("#dropdown")
671+
dropdown.send_keys("c")
672+
dash_duo.wait_for_element(".dash-dropdown-search")
673+
sleep(0.1)
674+
675+
# Enter selects Cambodia (first result)
676+
send_keys(Keys.ENTER)
677+
dash_duo.wait_for_text_to_equal("#output", "Selected: Cambodia")
678+
679+
# Type "can" — should filter to only Canada
680+
send_keys("can")
681+
sleep(0.1)
682+
options = dash_duo.find_elements(".dash-dropdown-option")
683+
assert len(options) == 1
684+
assert options[0].text == "Canada"
685+
686+
# Focus should still be on the search input, not the selected option
687+
assert dash_duo.driver.execute_script(
688+
"return document.activeElement.type === 'search';"
689+
), "Focus should remain on the search input while typing"
690+
691+
# Enter selects Canada
692+
send_keys(Keys.ENTER)
693+
dash_duo.wait_for_text_to_equal("#output", "Selected: Canada")
694+
695+
assert dash_duo.get_logs() == []
696+
697+
417698
def elements_are_visible(dash_duo, elements):
418699
# Check if the given elements are within the visible viewport of the dropdown
419700
elements = elements if isinstance(elements, list) else [elements]

0 commit comments

Comments
 (0)