Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
padding: 0;
background: inherit;
border: none;
outline: none;
width: 100%;
cursor: pointer;
font-size: inherit;
Expand Down Expand Up @@ -98,6 +99,8 @@
}

.dash-dropdown-search-container {
position: sticky;
top: calc(var(--Dash-Spacing) * 2);
margin: calc(var(--Dash-Spacing) * 2);
padding: var(--Dash-Spacing);
border-radius: 4px;
Expand Down
95 changes: 51 additions & 44 deletions components/dash-core-components/src/fragments/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const Dropdown = (props: DropdownProps) => {
const [displayOptions, setDisplayOptions] = useState<DetailedOption[]>([]);
const persistentOptions = useRef<DropdownProps['options']>([]);
const dropdownContainerRef = useRef<HTMLButtonElement>(null);
const dropdownContentRef = useRef<HTMLDivElement>(
document.createElement('div')
);

const ctx = window.dash_component_api.useDashContext();
const loading = ctx.useLoading();
Expand Down Expand Up @@ -208,23 +211,46 @@ const Dropdown = (props: DropdownProps) => {
// Update display options when filtered options or selection changes
useEffect(() => {
if (isOpen) {
// Sort filtered options: selected first, then unselected
const sortedOptions = [...filteredOptions].sort((a, b) => {
const aSelected = sanitizedValues.includes(a.value);
const bSelected = sanitizedValues.includes(b.value);
let sortedOptions = filteredOptions;
if (multi) {
// Sort filtered options: selected first, then unselected
sortedOptions = [...filteredOptions].sort((a, b) => {
const aSelected = sanitizedValues.includes(a.value);
const bSelected = sanitizedValues.includes(b.value);

if (aSelected && !bSelected) {
return -1;
}
if (!aSelected && bSelected) {
return 1;
}
return 0; // Maintain original order within each group
});
if (aSelected && !bSelected) {
return -1;
}
if (!aSelected && bSelected) {
return 1;
}
return 0; // Maintain original order within each group
});
}

setDisplayOptions(sortedOptions);
}
}, [filteredOptions, isOpen]); // Removed sanitizedValues to prevent re-sorting on selection changes
}, [filteredOptions, isOpen]);

// Focus (and scroll) the first selected item when dropdown opens
useEffect(() => {
if (!isOpen || multi || search_value) {
return;
}

// waiting for the DOM to be ready after the dropdown renders
requestAnimationFrame(() => {
const selectedValue = sanitizedValues[0];

const selectedElement = dropdownContentRef.current.querySelector(
`.dash-options-list-option-checkbox[value="${selectedValue}"]`
);

if (selectedElement instanceof HTMLElement) {
selectedElement?.focus();
}
});
}, [isOpen, multi, displayOptions, sanitizedValues]);

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

if (nextIndex > -1) {
focusableElements[nextIndex].focus();
focusableElements[nextIndex].scrollIntoView({
behavior: 'auto',
block: 'center',
});
if (nextIndex === 0) {
// first element is a sticky search bar, so if we are focusing
// on that, also move the scroll to the top
dropdownContentRef.current?.scrollTo({top: 0});
} else {
focusableElements[nextIndex].scrollIntoView({
behavior: 'auto',
block: 'center',
});
}
}
}, []);

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

if (open) {
// Sort options: selected first, then unselected
const selectedOptions: DetailedOption[] = [];
const unselectedOptions: DetailedOption[] = [];

// First, collect selected options in the order they appear in the `value` array
sanitizedValues.forEach(value => {
const option = filteredOptions.find(
opt => opt.value === value
);
if (option) {
selectedOptions.push(option);
}
});

// Then, collect unselected options in the order they appear in `options` array
filteredOptions.forEach(option => {
if (!sanitizedValues.includes(option.value)) {
unselectedOptions.push(option);
}
});
const sortedOptions = [
...selectedOptions,
...unselectedOptions,
];
setDisplayOptions(sortedOptions);
} else {
if (!open) {
setProps({search_value: undefined});
}
},
Expand Down Expand Up @@ -416,6 +422,7 @@ const Dropdown = (props: DropdownProps) => {

<Popover.Portal>
<Popover.Content
ref={dropdownContentRef}
className="dash-dropdown-content"
align="start"
sideOffset={5}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,88 @@ def send_keys(key):
assert dash_duo.find_element(".dash-dropdown-value").text == "1, 91"

assert dash_duo.get_logs() == []


def test_a11y004_selection_visibility_single(dash_duo):
app = Dash(__name__)
app.layout = (
Dropdown(
id="dropdown",
options=[f"Option {i}" for i in range(0, 100)],
value="Option 71",
multi=False,
placeholder="Testing selected item is visible on open",
),
)

dash_duo.start_server(app)

dash_duo.wait_for_element("#dropdown")

dash_duo.find_element("#dropdown").click()
dash_duo.wait_for_element(".dash-dropdown-options")

# Assert that the selected option is visible in the dropdown
selected_option = dash_duo.find_element(".dash-dropdown-option.selected")
assert selected_option.text == "Option 71"
assert selected_option.is_displayed()

assert elements_are_visible(
dash_duo, selected_option
), "Selected option should be visible when the dropdown opens"

assert dash_duo.get_logs() == []


def test_a11y005_selection_visibility_multi(dash_duo):
app = Dash(__name__)
app.layout = (
Dropdown(
id="dropdown",
options=[f"Option {i}" for i in range(0, 100)],
value=[
"Option 71",
"Option 23",
"Option 42",
],
multi=True,
placeholder="Testing selected item is visible on open",
),
)

dash_duo.start_server(app)

dash_duo.wait_for_element("#dropdown")

dash_duo.find_element("#dropdown").click()
dash_duo.wait_for_element(".dash-dropdown-options")

# Assert that the selected option is visible in the dropdown
selected_options = dash_duo.find_elements(".dash-dropdown-option.selected")
assert elements_are_visible(
dash_duo, selected_options
), "Selected options should be visible when the dropdown opens"

assert dash_duo.get_logs() == []


def elements_are_visible(dash_duo, elements):
# Check if the given elements are within the visible viewport of the dropdown
elements = elements if isinstance(elements, list) else [elements]
dropdown_content = dash_duo.find_element(".dash-dropdown-content")

def is_visible(el):
return dash_duo.driver.execute_script(
"""
const option = arguments[0];
const container = arguments[1];
const optionRect = option.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return optionRect.top >= containerRect.top &&
optionRect.bottom <= containerRect.bottom;
""",
el,
dropdown_content,
)

return all([is_visible(el) for el in elements])
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from time import sleep
from dash import Dash
from dash.dcc import Dropdown
from dash.html import Div
Expand Down Expand Up @@ -39,11 +40,13 @@ def test_ddlo001_translations(dash_duo):
)

dash_duo.find_element(".dash-dropdown-search").send_keys(1)
sleep(0.1)
assert dash_duo.find_element(".dash-dropdown-clear").accessible_name == "Annuler"

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

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

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

dash_duo.find_element(".dash-dropdown-search").send_keys(1)
sleep(0.1)
assert (
dash_duo.find_element(".dash-dropdown-clear").accessible_name == "Clear search"
)

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

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

assert (
Expand Down