Skip to content

Commit 7eebb4e

Browse files
authored
Merge pull request #3469 from plotly/feature/dcc-redesign-dropdown-sorting
dcc redesign: Do not resort dropdown when `multi=False`
2 parents 391f0c8 + f90b1f5 commit 7eebb4e

File tree

4 files changed

+144
-44
lines changed

4 files changed

+144
-44
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
padding: 0;
66
background: inherit;
77
border: none;
8+
outline: none;
89
width: 100%;
910
cursor: pointer;
1011
font-size: inherit;
@@ -98,6 +99,8 @@
9899
}
99100

100101
.dash-dropdown-search-container {
102+
position: sticky;
103+
top: calc(var(--Dash-Spacing) * 2);
101104
margin: calc(var(--Dash-Spacing) * 2);
102105
padding: var(--Dash-Spacing);
103106
border-radius: 4px;

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

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const Dropdown = (props: DropdownProps) => {
4444
const [displayOptions, setDisplayOptions] = useState<DetailedOption[]>([]);
4545
const persistentOptions = useRef<DropdownProps['options']>([]);
4646
const dropdownContainerRef = useRef<HTMLButtonElement>(null);
47+
const dropdownContentRef = useRef<HTMLDivElement>(
48+
document.createElement('div')
49+
);
4750

4851
const ctx = window.dash_component_api.useDashContext();
4952
const loading = ctx.useLoading();
@@ -208,23 +211,46 @@ const Dropdown = (props: DropdownProps) => {
208211
// Update display options when filtered options or selection changes
209212
useEffect(() => {
210213
if (isOpen) {
211-
// Sort filtered options: selected first, then unselected
212-
const sortedOptions = [...filteredOptions].sort((a, b) => {
213-
const aSelected = sanitizedValues.includes(a.value);
214-
const bSelected = sanitizedValues.includes(b.value);
214+
let sortedOptions = filteredOptions;
215+
if (multi) {
216+
// Sort filtered options: selected first, then unselected
217+
sortedOptions = [...filteredOptions].sort((a, b) => {
218+
const aSelected = sanitizedValues.includes(a.value);
219+
const bSelected = sanitizedValues.includes(b.value);
215220

216-
if (aSelected && !bSelected) {
217-
return -1;
218-
}
219-
if (!aSelected && bSelected) {
220-
return 1;
221-
}
222-
return 0; // Maintain original order within each group
223-
});
221+
if (aSelected && !bSelected) {
222+
return -1;
223+
}
224+
if (!aSelected && bSelected) {
225+
return 1;
226+
}
227+
return 0; // Maintain original order within each group
228+
});
229+
}
224230

225231
setDisplayOptions(sortedOptions);
226232
}
227-
}, [filteredOptions, isOpen]); // Removed sanitizedValues to prevent re-sorting on selection changes
233+
}, [filteredOptions, isOpen]);
234+
235+
// Focus (and scroll) the first selected item when dropdown opens
236+
useEffect(() => {
237+
if (!isOpen || multi || search_value) {
238+
return;
239+
}
240+
241+
// waiting for the DOM to be ready after the dropdown renders
242+
requestAnimationFrame(() => {
243+
const selectedValue = sanitizedValues[0];
244+
245+
const selectedElement = dropdownContentRef.current.querySelector(
246+
`.dash-options-list-option-checkbox[value="${selectedValue}"]`
247+
);
248+
249+
if (selectedElement instanceof HTMLElement) {
250+
selectedElement?.focus();
251+
}
252+
});
253+
}, [isOpen, multi, displayOptions, sanitizedValues]);
228254

229255
// Handle keyboard navigation in popover
230256
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -300,10 +326,16 @@ const Dropdown = (props: DropdownProps) => {
300326

301327
if (nextIndex > -1) {
302328
focusableElements[nextIndex].focus();
303-
focusableElements[nextIndex].scrollIntoView({
304-
behavior: 'auto',
305-
block: 'center',
306-
});
329+
if (nextIndex === 0) {
330+
// first element is a sticky search bar, so if we are focusing
331+
// on that, also move the scroll to the top
332+
dropdownContentRef.current?.scrollTo({top: 0});
333+
} else {
334+
focusableElements[nextIndex].scrollIntoView({
335+
behavior: 'auto',
336+
block: 'center',
337+
});
338+
}
307339
}
308340
}, []);
309341

@@ -312,33 +344,7 @@ const Dropdown = (props: DropdownProps) => {
312344
(open: boolean) => {
313345
setIsOpen(open);
314346

315-
if (open) {
316-
// Sort options: selected first, then unselected
317-
const selectedOptions: DetailedOption[] = [];
318-
const unselectedOptions: DetailedOption[] = [];
319-
320-
// First, collect selected options in the order they appear in the `value` array
321-
sanitizedValues.forEach(value => {
322-
const option = filteredOptions.find(
323-
opt => opt.value === value
324-
);
325-
if (option) {
326-
selectedOptions.push(option);
327-
}
328-
});
329-
330-
// Then, collect unselected options in the order they appear in `options` array
331-
filteredOptions.forEach(option => {
332-
if (!sanitizedValues.includes(option.value)) {
333-
unselectedOptions.push(option);
334-
}
335-
});
336-
const sortedOptions = [
337-
...selectedOptions,
338-
...unselectedOptions,
339-
];
340-
setDisplayOptions(sortedOptions);
341-
} else {
347+
if (!open) {
342348
setProps({search_value: undefined});
343349
}
344350
},
@@ -416,6 +422,7 @@ const Dropdown = (props: DropdownProps) => {
416422

417423
<Popover.Portal>
418424
<Popover.Content
425+
ref={dropdownContentRef}
419426
className="dash-dropdown-content"
420427
align="start"
421428
sideOffset={5}

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,88 @@ def send_keys(key):
126126
assert dash_duo.find_element(".dash-dropdown-value").text == "1, 91"
127127

128128
assert dash_duo.get_logs() == []
129+
130+
131+
def test_a11y004_selection_visibility_single(dash_duo):
132+
app = Dash(__name__)
133+
app.layout = (
134+
Dropdown(
135+
id="dropdown",
136+
options=[f"Option {i}" for i in range(0, 100)],
137+
value="Option 71",
138+
multi=False,
139+
placeholder="Testing selected item is visible on open",
140+
),
141+
)
142+
143+
dash_duo.start_server(app)
144+
145+
dash_duo.wait_for_element("#dropdown")
146+
147+
dash_duo.find_element("#dropdown").click()
148+
dash_duo.wait_for_element(".dash-dropdown-options")
149+
150+
# Assert that the selected option is visible in the dropdown
151+
selected_option = dash_duo.find_element(".dash-dropdown-option.selected")
152+
assert selected_option.text == "Option 71"
153+
assert selected_option.is_displayed()
154+
155+
assert elements_are_visible(
156+
dash_duo, selected_option
157+
), "Selected option should be visible when the dropdown opens"
158+
159+
assert dash_duo.get_logs() == []
160+
161+
162+
def test_a11y005_selection_visibility_multi(dash_duo):
163+
app = Dash(__name__)
164+
app.layout = (
165+
Dropdown(
166+
id="dropdown",
167+
options=[f"Option {i}" for i in range(0, 100)],
168+
value=[
169+
"Option 71",
170+
"Option 23",
171+
"Option 42",
172+
],
173+
multi=True,
174+
placeholder="Testing selected item is visible on open",
175+
),
176+
)
177+
178+
dash_duo.start_server(app)
179+
180+
dash_duo.wait_for_element("#dropdown")
181+
182+
dash_duo.find_element("#dropdown").click()
183+
dash_duo.wait_for_element(".dash-dropdown-options")
184+
185+
# Assert that the selected option is visible in the dropdown
186+
selected_options = dash_duo.find_elements(".dash-dropdown-option.selected")
187+
assert elements_are_visible(
188+
dash_duo, selected_options
189+
), "Selected options should be visible when the dropdown opens"
190+
191+
assert dash_duo.get_logs() == []
192+
193+
194+
def elements_are_visible(dash_duo, elements):
195+
# Check if the given elements are within the visible viewport of the dropdown
196+
elements = elements if isinstance(elements, list) else [elements]
197+
dropdown_content = dash_duo.find_element(".dash-dropdown-content")
198+
199+
def is_visible(el):
200+
return dash_duo.driver.execute_script(
201+
"""
202+
const option = arguments[0];
203+
const container = arguments[1];
204+
const optionRect = option.getBoundingClientRect();
205+
const containerRect = container.getBoundingClientRect();
206+
return optionRect.top >= containerRect.top &&
207+
optionRect.bottom <= containerRect.bottom;
208+
""",
209+
el,
210+
dropdown_content,
211+
)
212+
213+
return all([is_visible(el) for el in elements])

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from time import sleep
12
from dash import Dash
23
from dash.dcc import Dropdown
34
from dash.html import Div
@@ -39,11 +40,13 @@ def test_ddlo001_translations(dash_duo):
3940
)
4041

4142
dash_duo.find_element(".dash-dropdown-search").send_keys(1)
43+
sleep(0.1)
4244
assert dash_duo.find_element(".dash-dropdown-clear").accessible_name == "Annuler"
4345

4446
dash_duo.find_element(".dash-dropdown-action-button:first-child").click()
4547

4648
dash_duo.find_element(".dash-dropdown-search").send_keys(9)
49+
sleep(0.1)
4750
assert dash_duo.find_element(".dash-dropdown-option").text == "Aucun d'options"
4851

4952
assert (
@@ -84,13 +87,15 @@ def test_ddlo002_partial_translations(dash_duo):
8487
assert dash_duo.find_element(".dash-dropdown-search").accessible_name == "Lookup"
8588

8689
dash_duo.find_element(".dash-dropdown-search").send_keys(1)
90+
sleep(0.1)
8791
assert (
8892
dash_duo.find_element(".dash-dropdown-clear").accessible_name == "Clear search"
8993
)
9094

9195
dash_duo.find_element(".dash-dropdown-action-button:first-child").click()
9296

9397
dash_duo.find_element(".dash-dropdown-search").send_keys(9)
98+
sleep(0.1)
9499
assert dash_duo.find_element(".dash-dropdown-option").text == "No options found"
95100

96101
assert (

0 commit comments

Comments
 (0)