diff --git a/CHANGELOG.md b/CHANGELOG.md index 7264663f93..40ccec00a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## UNRELEASED + +## Changed + +- Dropdown API changes + * default value of optionHeight is now 'auto' which supports text wrapping of lengthy text on small screens; you can still specify a numeric pixel height if desired + * new `labels` prop to customize strings used within the component + * default value for closeOnSelect is now `True` for single-select dropdowns and `False` for multi-select + +- Slider API changes + * default value of `step` is now only set to `1` if the `min` and `max` props are both integers. Otherwise, it will be dynamically computed according to the available space for the slider + ## [4.0.0rc1] - 2025-09-22 ## Added diff --git a/components/dash-core-components/src/components/Dropdown.tsx b/components/dash-core-components/src/components/Dropdown.tsx index dc076e025c..f434c04ed2 100644 --- a/components/dash-core-components/src/components/Dropdown.tsx +++ b/components/dash-core-components/src/components/Dropdown.tsx @@ -1,9 +1,19 @@ -import React, {Component, lazy, Suspense} from 'react'; +import React, {lazy, Suspense} from 'react'; import {DropdownProps, PersistedProps, PersistenceTypes} from '../types'; import dropdown from '../utils/LazyLoader/dropdown'; const RealDropdown = lazy(dropdown); +const defaultLabels: DropdownProps['labels'] = { + select_all: 'Select All', + deselect_all: 'Deselect All', + selected_count: '{num_selected} selected', + search: 'Search', + clear_search: 'Clear search', + clear_selection: 'Clear selection', + no_options_found: 'No options found', +}; + /** * Dropdown is an interactive dropdown element for selecting one or more * items. @@ -19,8 +29,8 @@ export default function Dropdown({ disabled = false, multi = false, searchable = true, - // eslint-disable-next-line no-magic-numbers - optionHeight = 36, + labels = defaultLabels, + optionHeight = 'auto', // eslint-disable-next-line no-magic-numbers maxHeight = 200, closeOnSelect = !multi, @@ -30,11 +40,17 @@ export default function Dropdown({ persistence_type = PersistenceTypes.local, ...props }: DropdownProps) { + labels = { + ...defaultLabels, + ...labels, + }; + return ( { return typeof value === 'number' ? [value] : value; }, [value]); diff --git a/components/dash-core-components/src/components/css/Dropdown.css b/components/dash-core-components/src/components/css/Dropdown.css deleted file mode 100644 index ffba1990f1..0000000000 --- a/components/dash-core-components/src/components/css/Dropdown.css +++ /dev/null @@ -1,8 +0,0 @@ -.dash-dropdown .Select-menu-outer { - z-index: 1000; - max-height: none; -} - -.dash-dropdown .Select-menu { - max-height: none; -} diff --git a/components/dash-core-components/src/components/css/dcc.css b/components/dash-core-components/src/components/css/dcc.css index fc03ab4424..66579edab0 100644 --- a/components/dash-core-components/src/components/css/dcc.css +++ b/components/dash-core-components/src/components/css/dcc.css @@ -11,4 +11,6 @@ --Dash-Fill-Primary-Hover: rgba(0, 18, 77, 0.04); --Dash-Fill-Primary-Active: rgba(0, 18, 77, 0.08); --Dash-Fill-Disabled: rgba(0, 24, 102, 0.1); + --Dash-Shading-Strong: rgba(22, 23, 24, 0.35); + --Dash-Shading-Weak: rgba(22, 23, 24, 0.2); } diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index 5788c3d31c..a95dcc0bb6 100644 --- a/components/dash-core-components/src/components/css/dropdown.css +++ b/components/dash-core-components/src/components/css/dropdown.css @@ -1,6 +1,16 @@ .dash-dropdown { + display: block; box-sizing: border-box; margin: calc(var(--Dash-Spacing) * 2) 0; + padding: 0; + background: inherit; + border: none; + width: 100%; + cursor: pointer; + font-size: inherit; + overflow: hidden; + accent-color: var(--Dash-Fill-Interactive-Strong); + outline-color: var(--Dash-Fill-Interactive-Strong); } .dash-dropdown-grid-container { @@ -19,7 +29,7 @@ grid-template-columns: auto 1fr auto auto; } -.dash-dropdown-trigger, +.dash-dropdown, .dash-dropdown-content { border-radius: var(--Dash-Spacing); border: 1px solid var(--Dash-Stroke-Strong); @@ -28,17 +38,12 @@ } .dash-dropdown-trigger { - background: inherit; - padding: 6px 12px; - width: 100%; + padding: 0 12px; min-height: 32px; height: 100%; - cursor: pointer; - font-size: inherit; - overflow: hidden; } -.dash-dropdown-trigger:disabled { +.dash-dropdown:disabled { opacity: 0.6; cursor: not-allowed; } @@ -61,12 +66,13 @@ .dash-dropdown-content { background: var(--Dash-Fill-Inverse-Strong); - min-width: fit-content; - width: var(--radix-popover-trigger-width); + width: fit-content; + min-width: var(--radix-popover-trigger-width); + max-width: 98vw; overflow-y: auto; - z-index: 50; - box-shadow: 0px 10px 38px -10px rgba(22, 23, 24, 0.35), - 0px 10px 20px -15px rgba(22, 23, 24, 0.2); + z-index: 500; + box-shadow: 0px 10px 38px -10px var(--Dash-Shading-Strong), + 0px 10px 20px -15px var(--Dash-Shading-Weak); } .dash-dropdown-value-count, @@ -175,6 +181,8 @@ text-decoration: none; color: var(--Dash-Text-Disabled); white-space: nowrap; + accent-color: var(--Dash-Fill-Interactive-Strong); + outline-color: var(--Dash-Fill-Interactive-Strong); } .dash-dropdown-action-button:hover { @@ -190,6 +198,6 @@ } .dash-dropdown-option { - padding: var(--Dash-Spacing) calc(var(--Dash-Spacing) * 3); + padding: calc(var(--Dash-Spacing) * 2) calc(var(--Dash-Spacing) * 3); box-shadow: 0 -1px 0 0 var(--Dash-Fill-Disabled) inset; } diff --git a/components/dash-core-components/src/components/css/input.css b/components/dash-core-components/src/components/css/input.css index ef4e444c1e..6ca5d898fe 100644 --- a/components/dash-core-components/src/components/css/input.css +++ b/components/dash-core-components/src/components/css/input.css @@ -15,6 +15,19 @@ overflow: hidden; } +.dash-input-container:focus-within { + outline: 1px solid var(--Dash-Fill-Interactive-Strong); +} + +.dash-input-container:has(.dash-input-element:disabled) { + opacity: 0.6; + cursor: not-allowed; +} + +.dash-input-container input:focus { + outline: none; +} + .dash-input-container:has(input[type='range']) { background: inherit; } @@ -32,11 +45,17 @@ box-sizing: border-box; z-index: 1; order: 2; + accent-color: var(--Dash-Fill-Interactive-Strong); +} + +.dash-input-element:disabled { + cursor: not-allowed; } /* Hide native steppers for number inputs */ .dash-input-element[type='number'] { -moz-appearance: textfield; + border-radius: 0; } .dash-input-element[type='number']::-webkit-outer-spin-button, @@ -70,10 +89,11 @@ cursor: pointer; font-size: 16px; font-weight: bold; - color: var(--Dash-Text-Primary); + color: var(--Dash -Text-Primary); } -.dash-input-stepper:hover { +.dash-input-stepper:hover, +.dash-input-stepper:focus { background: var(--Dash-Fill-Primary-Hover); } @@ -115,7 +135,3 @@ input.dash-input-element:invalid { outline: solid red; } - -input.dash-input-element:valid { - outline: none black; -} diff --git a/components/dash-core-components/src/components/css/optionslist.css b/components/dash-core-components/src/components/css/optionslist.css index 1a73ece770..ba285604fa 100644 --- a/components/dash-core-components/src/components/css/optionslist.css +++ b/components/dash-core-components/src/components/css/optionslist.css @@ -20,7 +20,6 @@ } .dash-options-list-option-text { - white-space: pre; display: flex; align-items: center; } @@ -30,10 +29,6 @@ margin: 0 calc(var(--Dash-Spacing) * 2) 0 0; box-sizing: border-box; border: 1px solid var(--Dash-Stroke-Strong); -} - -.dash-options-list-option-checkbox:hover, -.dash-options-list-option-checkbox:focus, -.dash-options-list-option-checkbox:checked { accent-color: var(--Dash-Fill-Interactive-Strong); + outline-color: var(--Dash-Fill-Interactive-Strong); } diff --git a/components/dash-core-components/src/components/css/sliders.css b/components/dash-core-components/src/components/css/sliders.css index b36cf71785..2254054041 100644 --- a/components/dash-core-components/src/components/css/sliders.css +++ b/components/dash-core-components/src/components/css/sliders.css @@ -62,7 +62,7 @@ background-color: var(--Dash-Fill-Interactive-Strong); border: 2px solid var(--Dash-Fill-Inverse-Strong); border-radius: 50%; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 3px 0 var(--Dash-Shading-Weak); cursor: pointer; outline: none; transition: all 0.15s ease-in-out; @@ -71,7 +71,7 @@ .dash-slider-thumb:focus, .dash-slider-thumb:hover { transform: scale(1.125); /* Scale to make 16px thumb appear as 18px */ - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 6px -1px var(--Dash-Shading-Weak); } .dash-slider-thumb:focus .dash-slider-tooltip, @@ -129,7 +129,7 @@ padding: calc(var(--Dash-Spacing) * 3); font-size: 12px; line-height: 1; - box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 8px var(--Dash-Shading-Strong); background-color: var(--Dash-Fill-Inverse-Strong); user-select: none; z-index: 1000; diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 7464d11d75..1a89754b8d 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -19,6 +19,7 @@ import '../components/css/dropdown.css'; import isEqual from 'react-fast-compare'; import {DetailedOption, DropdownProps, OptionValue} from '../types'; import {OptionsList, OptionLabel} from '../utils/optionRendering'; +import uuid from 'uniqid'; const Dropdown = (props: DropdownProps) => { const { @@ -27,6 +28,7 @@ const Dropdown = (props: DropdownProps) => { closeOnSelect, clearable, disabled, + labels, maxHeight, multi, options, @@ -41,7 +43,7 @@ const Dropdown = (props: DropdownProps) => { const [isOpen, setIsOpen] = useState(false); const [displayOptions, setDisplayOptions] = useState([]); const persistentOptions = useRef([]); - const dropdownContainerRef = useRef(null); + const dropdownContainerRef = useRef(null); const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); @@ -343,40 +345,54 @@ const Dropdown = (props: DropdownProps) => { [filteredOptions, sanitizedValues] ); + const accessibleId = id ?? uuid(); + return ( -
- - - - - - - e.preventDefault()} - onKeyDown={handleKeyDown} - style={{ - maxHeight: maxHeight ? `${maxHeight}px` : 'auto', - }} - > - {searchable && ( -
- - - onInputChange(e.target.value) - } - autoFocus - /> - {search_value && ( - - )} -
- )} - {multi && ( -
+ + + + + + e.preventDefault()} + onKeyDown={handleKeyDown} + style={{ + maxHeight: maxHeight ? `${maxHeight}px` : 'auto', + }} + > + {searchable && ( +
+ + onInputChange(e.target.value)} + autoFocus + /> + {search_value && ( + + )} +
+ )} + {multi && ( +
+ + {canDeselectAll && ( - {canDeselectAll && ( - - )} -
- )} - {isOpen && !!displayOptions.length && ( - <> - - - )} - {isOpen && search_value && !displayOptions.length && ( -
- - No options found - -
- )} -
-
- -
+ )} +
+ )} + {isOpen && !!displayOptions.length && ( + <> + + + )} + {isOpen && search_value && !displayOptions.length && ( +
+ + {labels?.no_options_found} + +
+ )} + + + ); }; diff --git a/components/dash-core-components/src/fragments/RangeSlider.tsx b/components/dash-core-components/src/fragments/RangeSlider.tsx index 01c414d5d3..90d8e7ccb3 100644 --- a/components/dash-core-components/src/fragments/RangeSlider.tsx +++ b/components/dash-core-components/src/fragments/RangeSlider.tsx @@ -222,14 +222,12 @@ export default function RangeSlider(props: RangeSliderProps) { } }; + const classNames = ['dash-slider-container', className].filter(Boolean); + return ( {loadingProps => ( -
+
{showInputs && value.length === 2 && !vertical && ( { /** * height of each option. Can be increased when label lengths would wrap around */ - optionHeight?: number; + optionHeight?: 'auto' | number; /** * height of the options dropdown. @@ -414,6 +414,19 @@ export interface DropdownProps extends BaseComponentProps { * Defines CSS styles which will override styles previously set. */ style?: React.CSSProperties; + + /** + * Text for customizing the labels rendered by this component. + */ + labels?: { + select_all?: string; + deselect_all?: string; + selected_count?: string; + search?: string; + clear_search?: string; + clear_selection?: string; + no_options_found?: string; + }; } export interface ChecklistProps extends BaseComponentProps { diff --git a/components/dash-core-components/src/utils/optionRendering.tsx b/components/dash-core-components/src/utils/optionRendering.tsx index 3875c3b458..10b62cec84 100644 --- a/components/dash-core-components/src/utils/optionRendering.tsx +++ b/components/dash-core-components/src/utils/optionRendering.tsx @@ -135,7 +135,12 @@ export const OptionsList: React.FC = ({ }) => { const classNames = ['dash-options-list', className].filter(Boolean); return ( -
+
{options.map((option, i) => { const isSelected = includes(option.value, selected); return ( diff --git a/components/dash-core-components/tests/integration/dropdown/test_a11y.py b/components/dash-core-components/tests/integration/dropdown/test_a11y.py new file mode 100644 index 0000000000..df2971ce12 --- /dev/null +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -0,0 +1,128 @@ +import pytest +from dash import Dash +from dash.dcc import Dropdown +from dash.html import Div, Label, P +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains +from time import sleep + + +def test_a11y001_label_focuses_dropdown(dash_duo): + app = Dash(__name__) + app.layout = Label( + [ + P("Click me", id="label"), + Dropdown( + id="dropdown", + options=[1, 2, 3], + multi=True, + placeholder="Testing label that wraps a dropdown can trigger the dropdown", + ), + ], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#dropdown") + + with pytest.raises(TimeoutException): + dash_duo.wait_for_element(".dash-dropdown-options", timeout=0.25) + + dash_duo.find_element("#label").click() + dash_duo.wait_for_element(".dash-dropdown-options") + + assert dash_duo.get_logs() == [] + + +def test_a11y002_label_with_htmlFor_can_focus_dropdown(dash_duo): + app = Dash(__name__) + app.layout = Div( + [ + Label("Click me", htmlFor="dropdown", id="label"), + Dropdown( + id="dropdown", + options=[1, 2, 3], + multi=True, + placeholder="Testing label with `htmlFor` triggers the dropdown", + ), + ], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#dropdown") + + with pytest.raises(TimeoutException): + dash_duo.wait_for_element(".dash-dropdown-options", timeout=0.25) + + dash_duo.find_element("#label").click() + dash_duo.wait_for_element(".dash-dropdown-options") + + assert dash_duo.get_logs() == [] + + +def test_a11y003_keyboard_navigation(dash_duo): + def send_keys(key): + actions = ActionChains(dash_duo.driver) + actions.send_keys(key) + actions.perform() + + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=[i for i in range(0, 100)], + multi=True, + placeholder="Testing keyboard navigation", + ), + ], + ) + + dash_duo.start_server(app) + + dropdown = dash_duo.find_element("#dropdown") + dropdown.click() + dash_duo.wait_for_element(".dash-dropdown-options") + + send_keys( + Keys.ESCAPE + ) # Expecting focus to remain on the dropdown after escaping out + with pytest.raises(TimeoutException): + dash_duo.wait_for_element(".dash-dropdown-options", timeout=0.25) + + send_keys(Keys.ARROW_DOWN) # Expecting the dropdown to open up + dash_duo.wait_for_element(".dash-dropdown-search") + + num_elements = len(dash_duo.find_elements(".dash-dropdown-option")) + assert num_elements == 100 + + send_keys(1) # Expecting to be typing into the searh bar + num_elements = len(dash_duo.find_elements(".dash-dropdown-option")) + assert num_elements == 19 + + send_keys(Keys.ARROW_DOWN) # Expecting to be navigating through the options + send_keys(Keys.SPACE) # Expecting to be selecting + assert dash_duo.find_element(".dash-dropdown-value").text == "1" + + send_keys(Keys.ARROW_DOWN) # Expecting to be navigating through the options + send_keys(Keys.SPACE) # Expecting to be selecting + assert dash_duo.find_element(".dash-dropdown-value").text == "1, 10" + + send_keys(Keys.SPACE) # Expecting to be de-selecting + assert dash_duo.find_element(".dash-dropdown-value").text == "1" + + send_keys(Keys.ARROW_UP) + send_keys(Keys.ARROW_UP) + send_keys(Keys.ARROW_UP) # Expecting to wrap over to the last item + send_keys(Keys.SPACE) + assert dash_duo.find_element(".dash-dropdown-value").text == "1, 91" + + send_keys( + Keys.ESCAPE + ) # Expecting focus to remain on the dropdown after escaping out + sleep(0.25) + assert dash_duo.find_element(".dash-dropdown-value").text == "1, 91" + + assert dash_duo.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py index dfb3f7696d..e918c2c597 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py +++ b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py @@ -44,7 +44,7 @@ def update_value(val): # Clicking the selected item should not de-select it. selected_item = dash_duo.find_element( - f'#my-unclearable-dropdown input[value="{output_text}"]' + f'.dash-dropdown-options input[value="{output_text}"]' ) selected_item.click() assert dash_duo.find_element("#dropdown-value").text == output_text @@ -93,7 +93,7 @@ def update_value(val): # Attempt to deselect all items. Everything should deselect until we get to # the last item which cannot be cleared. - selected = dash_duo.find_elements("#my-unclearable-dropdown input[checked]") + selected = dash_duo.find_elements(".dash-dropdown-options input[checked]") [el.click() for el in selected] assert dash_duo.find_element("#dropdown-value").text == "SF" diff --git a/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py b/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py index 6a0ce8bf0c..e8ca3c3992 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py +++ b/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py @@ -31,7 +31,7 @@ def update_options(search_value): dropdown.click() # Get the inner input used for search value. - input_ = dash_dcc.find_element("#my-dynamic-dropdown input") + input_ = dash_dcc.find_element(".dash-dropdown-content input") # Focus on the input to open the options menu input_.send_keys("x") @@ -43,7 +43,7 @@ def update_options(search_value): input_.send_keys("o") time.sleep(0.25) - options = dash_dcc.find_elements("#my-dynamic-dropdown .dash-dropdown-option") + options = dash_dcc.find_elements(".dash-dropdown-options .dash-dropdown-option") # Should show all options. assert len(options) == 3 @@ -52,7 +52,7 @@ def update_options(search_value): input_.send_keys("n") time.sleep(0.25) - options = dash_dcc.find_elements("#my-dynamic-dropdown .dash-dropdown-option") + options = dash_dcc.find_elements(".dash-dropdown-options .dash-dropdown-option") assert len(options) == 1 assert options[0].text == "Montreal" diff --git a/components/dash-core-components/tests/integration/dropdown/test_localization.py b/components/dash-core-components/tests/integration/dropdown/test_localization.py new file mode 100644 index 0000000000..e4be2b3491 --- /dev/null +++ b/components/dash-core-components/tests/integration/dropdown/test_localization.py @@ -0,0 +1,103 @@ +from dash import Dash +from dash.dcc import Dropdown +from dash.html import Div + + +def test_ddlo001_translations(dash_duo): + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=[1, 2, 3], + multi=True, + labels={ + "select_all": "Sélectionner tout", + "deselect_all": "Désélectionner tout", + "selected_count": "{num_selected} sélections", + "search": "Rechercher", + "clear_search": "Annuler", + "clear_selection": "Effacer les sélections", + "no_options_found": "Aucun d'options", + }, + ), + ] + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#dropdown").click() + dash_duo.wait_for_contains_text( + ".dash-dropdown-action-button:first-child", "Sélectionner tout" + ) + dash_duo.wait_for_contains_text( + ".dash-dropdown-action-button:last-child", "Désélectionner tout" + ) + + assert ( + dash_duo.find_element(".dash-dropdown-search").accessible_name == "Rechercher" + ) + + dash_duo.find_element(".dash-dropdown-search").send_keys(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) + assert dash_duo.find_element(".dash-dropdown-option").text == "Aucun d'options" + + assert ( + dash_duo.find_element( + ".dash-dropdown-trigger .dash-dropdown-clear" + ).accessible_name + == "Effacer les sélections" + ) + + assert dash_duo.get_logs() == [] + + +def test_ddlo002_partial_translations(dash_duo): + app = Dash(__name__) + app.layout = Div( + [ + Dropdown( + id="dropdown", + options=[1, 2, 3], + multi=True, + labels={ + "search": "Lookup", + }, + ), + ] + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#dropdown").click() + dash_duo.wait_for_contains_text( + ".dash-dropdown-action-button:first-child", "Select All" + ) + dash_duo.wait_for_contains_text( + ".dash-dropdown-action-button:last-child", "Deselect All" + ) + + assert dash_duo.find_element(".dash-dropdown-search").accessible_name == "Lookup" + + dash_duo.find_element(".dash-dropdown-search").send_keys(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) + assert dash_duo.find_element(".dash-dropdown-option").text == "No options found" + + assert ( + dash_duo.find_element( + ".dash-dropdown-trigger .dash-dropdown-clear" + ).accessible_name + == "Clear selection" + ) + + assert dash_duo.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/dropdown/test_remove_option.py b/components/dash-core-components/tests/integration/dropdown/test_remove_option.py index 89114562ab..fbf615d29e 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_remove_option.py +++ b/components/dash-core-components/tests/integration/dropdown/test_remove_option.py @@ -183,9 +183,9 @@ def on_value(value): dropdown = dash_dcc.find_element("#drop") dropdown.click() - select_input = dash_dcc.find_element("#drop .dash-dropdown-search") + select_input = dash_dcc.find_element(".dash-dropdown-search") select_input.send_keys("a") - dash_dcc.find_element("#drop .dash-dropdown-option").send_keys(Keys.SPACE) + dash_dcc.find_element(".dash-dropdown-option").send_keys(Keys.SPACE) dash_dcc.wait_for_text_to_equal("#output", "Value=a") dash_dcc.wait_for_text_to_equal("#count-output", "2") diff --git a/components/dash-core-components/tests/integration/dropdown/test_styles.py b/components/dash-core-components/tests/integration/dropdown/test_styles.py index 67b9e8d418..85894c351c 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_styles.py +++ b/components/dash-core-components/tests/integration/dropdown/test_styles.py @@ -2,8 +2,8 @@ from dash.dcc import Dropdown from dash.html import Div from dash.dash_table import DataTable - from flaky import flaky +from selenium.webdriver.common.action_chains import ActionChains @flaky(max_runs=3) @@ -40,10 +40,16 @@ def test_ddst001_cursor_should_be_pointer(dash_duo): dash_duo.start_server(app) dash_duo.find_element("#dropdown").click() - dash_duo.wait_for_element("#dropdown .dash-dropdown-options") + dash_duo.wait_for_element(".dash-dropdown-options") - items = dash_duo.find_elements( - "#dropdown .dash-dropdown-options .dash-dropdown-option" - ) + items = dash_duo.find_elements(".dash-dropdown-options .dash-dropdown-option") assert items[0].value_of_css_property("cursor") == "pointer" + + # If the search element is visible, then we should be able to click on it. + search_element = dash_duo.find_element(".dash-dropdown-search") + actions = ActionChains(dash_duo.driver) + actions.move_to_element_with_offset(search_element, 8, 8).click().perform() + + # The dropdown should remain open after clicking into the search bar + dash_duo.wait_for_element(".dash-dropdown-options") diff --git a/components/dash-core-components/tests/integration/dropdown/test_visibility.py b/components/dash-core-components/tests/integration/dropdown/test_visibility.py index f4bbea8d6e..b05209cd54 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_visibility.py +++ b/components/dash-core-components/tests/integration/dropdown/test_visibility.py @@ -40,7 +40,7 @@ def test_ddvi001_fixed_table(dash_duo): dash_duo.start_server(app) dash_duo.find_element("#dropdown").click() - dash_duo.wait_for_element("#dropdown .dash-dropdown-options") + dash_duo.wait_for_element(".dash-dropdown-options") dash_duo.percy_snapshot("dcc.Dropdown dropdown overlaps table fixed rows/columns") @@ -59,7 +59,7 @@ def test_ddvi002_maxHeight(dash_duo): dash_duo.start_server(app) dash_duo.find_element("#dropdown").click() - dash_duo.wait_for_element("#dropdown .dash-dropdown-options") + dash_duo.wait_for_element(".dash-dropdown-options") dash_duo.percy_snapshot("dcc.Dropdown dropdown menu maxHeight 800px") assert dash_duo.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/input/test_a11y_input.py b/components/dash-core-components/tests/integration/input/test_a11y_input.py new file mode 100644 index 0000000000..cf557dc3da --- /dev/null +++ b/components/dash-core-components/tests/integration/input/test_a11y_input.py @@ -0,0 +1,69 @@ +import pytest +from dash import Dash +from dash.dcc import Input +from dash.html import Div, Label, P + +input_types = [ + "text", + "number", +] + + +@pytest.mark.parametrize("input_type", input_types) +def test_a11y001_label_focuses_input(dash_duo, input_type): + app = Dash(__name__) + app.layout = Label( + [ + P("Click me", id="label"), + Input( + type=input_type, + id="input", + placeholder="Testing label that wraps a input can trigger the input", + ), + ], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#input") + + dash_duo.find_element("#label").click() + assert input_has_focus(dash_duo, "#input"), "Input element is not focused" + + assert dash_duo.get_logs() == [] + + +@pytest.mark.parametrize("input_type", input_types) +def test_a11y002_label_with_htmlFor_can_focus_input(dash_duo, input_type): + app = Dash(__name__) + app.layout = Div( + [ + Label("Click me", htmlFor="input", id="label"), + Input( + type=input_type, + id="input", + placeholder="Testing label with `htmlFor` triggers the dropdown", + ), + ], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#input") + + dash_duo.find_element("#label").click() + assert input_has_focus(dash_duo, "#input"), "Input element is not focused" + + assert dash_duo.get_logs() == [] + + +def input_has_focus(dash_duo, id): + element = dash_duo.find_element(id) + return dash_duo.driver.execute_script( + """ + const container = arguments[0]; + const activeElement = document.activeElement; + return container === activeElement || container.contains(activeElement); + """, + element, + ) diff --git a/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py b/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py index df9191ae03..5298328e80 100644 --- a/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py +++ b/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py @@ -49,13 +49,13 @@ def test_mdcap001_dcc_components_as_props(dash_dcc): dash_dcc.wait_for_text_to_equal("#radio-items p", "off") dash_dcc.find_element("#dropdown").click() - dash_dcc.wait_for_text_to_equal("#dropdown h4", "h4") - dash_dcc.wait_for_text_to_equal("#dropdown h6", "h6") + dash_dcc.wait_for_text_to_equal(".dash-dropdown-content h4", "h4") + dash_dcc.wait_for_text_to_equal(".dash-dropdown-content h6", "h6") - search_input = dash_dcc.find_element("#dropdown .dash-dropdown-search") + search_input = dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-search") search_input.send_keys("4") sleep(0.25) - options = dash_dcc.find_elements("#dropdown .dash-dropdown-option") + options = dash_dcc.find_elements(".dash-dropdown-content .dash-dropdown-option") wait.until(lambda: len(options) == 1, 1) wait.until(lambda: options[0].text == "h4", 1) @@ -64,11 +64,11 @@ def test_mdcap001_dcc_components_as_props(dash_dcc): dash_dcc.find_element("#indexed-search").click() def search_indexed(value, length, texts): - search = dash_dcc.find_element("#indexed-search .dash-dropdown-search") + search = dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-search") dash_dcc.clear_input(search) search.send_keys(value) sleep(0.25) - opts = dash_dcc.find_elements("#indexed-search .dash-dropdown-option") + opts = dash_dcc.find_elements(".dash-dropdown-content .dash-dropdown-option") assert len(opts) == length assert [o.text for o in opts] == texts diff --git a/components/dash-core-components/tests/integration/misc/test_persistence.py b/components/dash-core-components/tests/integration/misc/test_persistence.py index f0df98c18d..afe610bce2 100644 --- a/components/dash-core-components/tests/integration/misc/test_persistence.py +++ b/components/dash-core-components/tests/integration/misc/test_persistence.py @@ -131,18 +131,18 @@ def make_output(*args): dash_dcc.select_date_single("datepickersingle", day="20") dash_dcc.find_element("#dropdownsingle").click() - dash_dcc.find_element("#dropdownsingle .dash-dropdown-search").send_keys( + dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-search").send_keys( "one" + Keys.ENTER ) sleep(0.2) - dash_dcc.find_element("#dropdownsingle .dash-dropdown-option").click() + dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-option").click() dash_dcc.find_element("#dropdownmulti").click() - dash_dcc.find_element("#dropdownmulti .dash-dropdown-search").send_keys( + dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-search").send_keys( "six" + Keys.ENTER ) sleep(0.2) - dash_dcc.find_element("#dropdownmulti .dash-dropdown-option").click() + dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-option").click() dash_dcc.find_element("#input").send_keys(" maybe") diff --git a/components/dash-core-components/tests/integration/misc/test_platter.py b/components/dash-core-components/tests/integration/misc/test_platter.py index a5a49230d6..4689c7ce04 100644 --- a/components/dash-core-components/tests/integration/misc/test_platter.py +++ b/components/dash-core-components/tests/integration/misc/test_platter.py @@ -6,7 +6,6 @@ def test_mspl001_dcc_components_platter(platter_app, dash_dcc): - dash_dcc.start_server(platter_app) dash_dcc.wait_for_element("#waitfor") @@ -17,7 +16,7 @@ def test_mspl001_dcc_components_platter(platter_app, dash_dcc): dash_dcc.percy_snapshot("gallery") dash_dcc.find_element("#dropdown").click() - dash_dcc.find_element("#dropdown .dash-dropdown-search").send_keys("北") + dash_dcc.find_element(".dash-dropdown-content .dash-dropdown-search").send_keys("北") dash_dcc.percy_snapshot("gallery - chinese character") text_input = dash_dcc.find_element("#textinput") diff --git a/components/dash-core-components/tests/integration/sliders/test_sliders_step.py b/components/dash-core-components/tests/integration/sliders/test_sliders_step.py new file mode 100644 index 0000000000..8839049377 --- /dev/null +++ b/components/dash-core-components/tests/integration/sliders/test_sliders_step.py @@ -0,0 +1,65 @@ +import pytest +from dash import Dash, Input, Output, dcc, html +from humanfriendly import parse_size + +test_cases = [ + {"step": 2, "min": 0, "max": 10, "value": 6}, + {"step": 3, "min": 0, "max": 100, "value": 33}, + {"step": 0.05, "min": 0, "max": 1, "value": 0.5}, + {"step": 1_000_000, "min": 1e9, "max": 1e10, "value": 1e10}, +] + + +def slider_value_divisible_by_step(slider_args, slider_value) -> bool: + if type(slider_value) is str: + slider_value = float(slider_value.split()[-1]) + + if slider_value == slider_args["min"] or slider_value == slider_args["max"]: + return True + + step = slider_args["step"] + remainder = slider_value % step + + # For float equality, we check if the remainder is close to 0 or close to step + return remainder < 1e-10 or abs(remainder - step) < 1e-10 + + +@pytest.mark.parametrize("test_case", test_cases) +def test_slst001_step_params(dash_dcc, test_case): + + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider(id="slider", **test_case), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), [Input("slider", "value")]) + def update_output(value): + return f"{value}" + + dash_dcc.start_server(app) + dash_dcc.driver.set_window_size(800, 600) + + slider = dash_dcc.find_element("#slider") + marks = dash_dcc.find_elements(".dash-slider-mark") + + # Expect to find some amount of marks in between the first and last mark + assert len(marks) > 2 + + # Every mark must be divisible by the given `step`. + for mark in marks: + value = parse_size(mark.text) + assert slider_value_divisible_by_step(test_case, value) + + # Perform multiple clicks along the slider track. After every click, the + # resulting slider value must be divisible by the step + i = 0 + while i < 1: + dash_dcc.click_at_coord_fractions(slider, i, 0.25) + value = dash_dcc.find_element("#out").text + assert slider_value_divisible_by_step(test_case, value) + i += 0.05 + + assert dash_dcc.get_logs() == [] diff --git a/dash/testing/browser.py b/dash/testing/browser.py index b93bfd2f9a..8a82399a97 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -432,7 +432,7 @@ def select_dcc_dropdown(self, elem_or_selector, value=None, index=None): dropdown = self._get_element(elem_or_selector) dropdown.click() - menu = dropdown.find_element(By.CSS_SELECTOR, ".dash-dropdown-options") + menu = self._get_element(".dash-dropdown-options") logger.debug("the available options are %s", "|".join(menu.text.split("\n"))) options = menu.find_elements(By.CSS_SELECTOR, ".dash-dropdown-option") diff --git a/tests/integration/renderer/test_children_reorder.py b/tests/integration/renderer/test_children_reorder.py index 49ac0eacc7..e4d07ee541 100644 --- a/tests/integration/renderer/test_children_reorder.py +++ b/tests/integration/renderer/test_children_reorder.py @@ -63,19 +63,13 @@ def swap_button_action(n_clicks, children): for i in range(2): dash_duo.wait_for_text_to_equal("h1", f"I am section {i}") dash_duo.find_element(f".dropdown_{i}").click() - dash_duo.find_element( - f".dropdown_{i} .dash-dropdown-option:nth-child(1)" - ).click() + dash_duo.find_element(".dash-dropdown-option:nth-child(1)").click() dash_duo.wait_for_text_to_equal(f".dropdown_{i} .dash-dropdown-trigger", "A") - dash_duo.find_element( - f".dropdown_{i} .dash-dropdown-option:nth-child(2)" - ).click() + dash_duo.find_element(".dash-dropdown-option:nth-child(2)").click() dash_duo.wait_for_text_to_equal( f".dropdown_{i} .dash-dropdown-trigger", "A, B\n2 selected" ) - dash_duo.find_element( - f".dropdown_{i} .dash-dropdown-option:nth-child(3)" - ).click() + dash_duo.find_element(".dash-dropdown-option:nth-child(3)").click() dash_duo.wait_for_text_to_equal( f".dropdown_{i} .dash-dropdown-trigger", "A, B, C\n3 selected" ) diff --git a/tests/integration/renderer/test_component_as_prop.py b/tests/integration/renderer/test_component_as_prop.py index 5478c33493..f272d02d7c 100644 --- a/tests/integration/renderer/test_component_as_prop.py +++ b/tests/integration/renderer/test_component_as_prop.py @@ -459,7 +459,7 @@ def on_button(n_clicks): dash_duo.wait_for_text_to_equal("#counter", "1") dash_duo.wait_for_element("#my-dynamic-dropdown").click() - search = dash_duo.wait_for_element("#my-dynamic-dropdown .dash-dropdown-search") + search = dash_duo.wait_for_element(".dash-dropdown-search") search.send_keys("a")