Skip to content

Commit 47da621

Browse files
committed
Modify multi=False dropdowns to show selected item at the top
1 parent e9423f6 commit 47da621

File tree

3 files changed

+139
-44
lines changed

3 files changed

+139
-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;
@@ -96,6 +97,8 @@
9697
}
9798

9899
.dash-dropdown-search-container {
100+
position: sticky;
101+
top: calc(var(--Dash-Spacing) * 2);
99102
margin: calc(var(--Dash-Spacing) * 2);
100103
padding: var(--Dash-Spacing);
101104
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
@@ -43,6 +43,9 @@ const Dropdown = (props: DropdownProps) => {
4343
const [displayOptions, setDisplayOptions] = useState<DetailedOption[]>([]);
4444
const persistentOptions = useRef<DropdownProps['options']>([]);
4545
const dropdownContainerRef = useRef<HTMLButtonElement>(null);
46+
const dropdownContentRef = useRef<HTMLDivElement>(
47+
document.createElement('div')
48+
);
4649

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

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

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

228254
// Handle keyboard navigation in popover
229255
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -299,10 +325,16 @@ const Dropdown = (props: DropdownProps) => {
299325

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

@@ -311,33 +343,7 @@ const Dropdown = (props: DropdownProps) => {
311343
(open: boolean) => {
312344
setIsOpen(open);
313345

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

405411
<Popover.Portal container={dropdownContainerRef.current}>
406412
<Popover.Content
413+
ref={dropdownContentRef}
407414
className="dash-dropdown-content"
408415
align="start"
409416
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("#dropdown .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("#dropdown .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])

0 commit comments

Comments
 (0)