From c9dbad0dbbde13d6f116fa55d3e715188f5ef03f Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 30 Dec 2025 12:22:27 -0700 Subject: [PATCH 1/3] Add missing implementation for `with_portal` and `with_full_screen_portal` on datepickers --- .../src/components/css/datepickers.css | 69 +++ .../src/fragments/DatePickerRange.tsx | 30 +- .../src/fragments/DatePickerSingle.tsx | 30 +- .../src/fragments/Dropdown.tsx | 7 +- .../tests/integration/calendar/test_portal.py | 426 ++++++++++++++++++ 5 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 components/dash-core-components/tests/integration/calendar/test_portal.py diff --git a/components/dash-core-components/src/components/css/datepickers.css b/components/dash-core-components/src/components/css/datepickers.css index 375786ca8d..6f0043cf74 100644 --- a/components/dash-core-components/src/components/css/datepickers.css +++ b/components/dash-core-components/src/components/css/datepickers.css @@ -146,6 +146,74 @@ overscroll-behavior: contain; } +.dash-datepicker + [data-radix-popper-content-wrapper]:has(.dash-datepicker-portal) { + transform: none !important; +} + +.dash-datepicker-portal { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + max-width: 100vw; + display: flex; + align-items: center; + justify-content: center; + background: var(--Dash-Shading-Strong); + border: none; + box-shadow: none; + overflow: visible; + padding: 0; + pointer-events: none; +} + +.dash-datepicker-portal .dash-datepicker-calendar-wrapper { + background: var(--Dash-Fill-Inverse-Strong); + border-radius: var(--Dash-Spacing); + border: 1px solid var(--Dash-Stroke-Strong); + padding: 16px; + box-shadow: 0px 10px 38px -10px var(--Dash-Shading-Strong), + 0px 10px 20px -15px var(--Dash-Shading-Weak); + z-index: 1; + pointer-events: auto; + width: fit-content; + max-width: 95vw; +} + +.dash-datepicker-fullscreen { + pointer-events: auto; + background: var(--Dash-Fill-Inverse-Strong); +} + +.dash-datepicker-close-button { + position: fixed; + top: calc(var(--Dash-Spacing) * 2); + right: calc(var(--Dash-Spacing) * 2); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: var(--Dash-Fill-Inverse-Strong); + border: none; + border-radius: var(--Dash-Spacing); + color: var(--Dash-Text-Strong); + cursor: pointer; + z-index: 501; + pointer-events: auto; +} + +.dash-datepicker-close-button:hover { + background: var(--Dash-Fill-Weak); + color: var(--Dash-Fill-Interactive-Strong); +} + +.dash-datepicker-close-button:focus { + outline: 2px solid var(--Dash-Fill-Interactive-Strong); + outline-offset: 2px; +} + .dash-datepicker-calendar-wrapper { display: flex; flex-direction: column; @@ -156,6 +224,7 @@ display: flex; align-items: flex-start; gap: calc(var(--Dash-Spacing) * 4); + flex-wrap: wrap; } .dash-datepicker-controls { diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index bccb719cc7..12c6670608 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -51,6 +51,8 @@ const DatePickerRange = ({ end_date_id, start_date_placeholder_text = 'Start Date', end_date_placeholder_text = 'End Date', + with_portal = false, + with_full_screen_portal = false, }: DatePickerRangeProps) => { const [internalStartDate, setInternalStartDate] = useState( strAsDate(start_date) @@ -102,6 +104,7 @@ const DatePickerRange = ({ const startInputRef = useRef(null); const endInputRef = useRef(null); const calendarRef = useRef(null); + const hasPortal = with_portal || with_full_screen_portal; useEffect(() => { setInternalStartDate(strAsDate(start_date)); @@ -381,9 +384,21 @@ const DatePickerRange = ({ e.preventDefault() + : undefined + } onOpenAutoFocus={e => e.preventDefault()} onCloseAutoFocus={e => { e.preventDefault(); @@ -404,6 +419,15 @@ const DatePickerRange = ({ } }} > + {with_full_screen_portal && ( + + )} { const [internalDate, setInternalDate] = useState(strAsDate(date)); const direction = is_RTL @@ -61,6 +63,7 @@ const DatePickerSingle = ({ const containerRef = useRef(null); const inputRef = useRef(null); const calendarRef = useRef(null); + const hasPortal = with_portal || with_full_screen_portal; useEffect(() => { setInternalDate(strAsDate(date)); @@ -200,9 +203,21 @@ const DatePickerSingle = ({ e.preventDefault() + : undefined + } onOpenAutoFocus={e => e.preventDefault()} onCloseAutoFocus={e => { e.preventDefault(); @@ -212,6 +227,15 @@ const DatePickerSingle = ({ } }} > + {with_full_screen_portal && ( + + )} { } }} className={`dash-dropdown ${className ?? ''}`} - style={style} aria-labelledby={`${accessibleId}-value-count ${accessibleId}-value`} aria-haspopup="listbox" aria-expanded={isOpen} @@ -540,7 +539,11 @@ const Dropdown = (props: DropdownProps) => { ); return ( -
+
{popover}
); diff --git a/components/dash-core-components/tests/integration/calendar/test_portal.py b/components/dash-core-components/tests/integration/calendar/test_portal.py new file mode 100644 index 0000000000..69ebf4b984 --- /dev/null +++ b/components/dash-core-components/tests/integration/calendar/test_portal.py @@ -0,0 +1,426 @@ +from datetime import date +from dash import Dash, html, dcc +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from time import sleep +import pytest + + +def click_everything_in_datepicker(datepicker_id, dash_dcc): + """Click on every clickable element in a datepicker calendar. + + Args: + datepicker_id: CSS selector for the datepicker element (e.g., "#dpr") + dash_dcc: The dash_dcc fixture + """ + # Click on the datepicker to open calendar + datepicker = dash_dcc.find_element(datepicker_id) + datepicker.click() + + # Wait for calendar to open + popover = dash_dcc.find_element(".dash-datepicker-content") + + interactive_elements = [] + interactive_elements.extend(popover.find_elements(By.CSS_SELECTOR, "td")) + interactive_elements.extend(popover.find_elements(By.CSS_SELECTOR, "input")) + + buttons = reversed( + popover.find_elements(By.CSS_SELECTOR, "button") + ) # reversed so that "close" button will be clicked after all other buttons + interactive_elements.extend(buttons) # Add close buttons last + + for el in interactive_elements: + try: + el.click() + sleep(0.05) + except Exception as e: + print(e) + assert not e, f"Unable to click on {el.tag_name})" + + +def test_dppt000_datepicker_single_default(dash_dcc): + """Test DatePickerSingle with default (no portal) configuration. + + Verifies that the calendar opens without portal and all elements are clickable. + """ + app = Dash(__name__) + + app.layout = html.Div( + [ + html.H3("DatePickerSingle Default"), + dcc.DatePickerSingle( + id="dps-default", + date=date(2024, 1, 15), + stay_open_on_select=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + + dash_dcc.wait_for_element("#dps-default") + + click_everything_in_datepicker("#dps-default", dash_dcc) + + dps_input = dash_dcc.find_element("#dps-default") + dps_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + assert dash_dcc.get_logs() == [] + + +def test_dppt001_datepicker_single_with_portal(dash_dcc): + """Test DatePickerSingle with with_portal=True. + + Verifies that the calendar opens in a portal (document.body) and all + elements are clickable. + """ + app = Dash(__name__) + + app.layout = html.Div( + [ + html.H3("DatePickerSingle with Portal"), + dcc.DatePickerSingle( + id="dps-portal", + date=date(2024, 1, 15), + stay_open_on_select=True, + with_portal=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + + # Wait for the page to load + dash_dcc.wait_for_element("#dps-portal") + + # Test DatePickerSingle with portal - click everything to verify all elements are accessible + click_everything_in_datepicker("#dps-portal", dash_dcc) + + # Close the calendar by pressing escape + dps_input = dash_dcc.find_element("#dps-portal") + dps_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + assert dash_dcc.get_logs() == [] + + +def test_dppt006_fullscreen_portal_close_button_keyboard(dash_dcc): + """Test fullscreen portal dismiss behavior and keyboard accessibility. + + Verifies clicking background doesn't close the portal and close button + is keyboard-accessible. + """ + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle( + id="dps-fullscreen", + date=date(2024, 1, 15), + with_full_screen_portal=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + dash_dcc.wait_for_element("#dps-fullscreen") + + dps = dash_dcc.find_element("#dps-fullscreen") + dps.click() + + popover = dash_dcc.find_element(".dash-datepicker-content") + assert popover.is_displayed() + + action = ActionChains(dash_dcc.driver) + action.move_to_element_with_offset(popover, 10, 10).click().perform() + sleep(0.2) + + popover = dash_dcc.find_element(".dash-datepicker-content") + assert ( + popover.is_displayed() + ), "Fullscreen portal should not close when clicking background" + + dash_dcc.find_element(".dash-datepicker-close-button") + + action.send_keys(Keys.TAB).perform() + sleep(0.1) + action.send_keys(Keys.ENTER).perform() + sleep(0.2) + + dash_dcc.wait_for_no_elements(".dash-datepicker-content", timeout=2) + assert dash_dcc.get_logs() == [] + + +def test_dppt007_portal_close_by_clicking_outside(dash_dcc): + """Test regular portal closes when clicking outside the calendar.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerSingle( + id="dps-portal", + date=date(2024, 1, 15), + with_portal=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + dash_dcc.wait_for_element("#dps-portal") + + dps = dash_dcc.find_element("#dps-portal") + dps.click() + + popover = dash_dcc.find_element(".dash-datepicker-content") + assert popover.is_displayed() + + popover.click() + sleep(0.2) + + dash_dcc.wait_for_no_elements(".dash-datepicker-content", timeout=2) + assert dash_dcc.get_logs() == [] + + +def test_dppt001a_datepicker_range_default(dash_dcc): + """Test DatePickerRange with default (no portal) configuration. + + Verifies that the calendar opens without portal and all elements are clickable. + """ + app = Dash(__name__) + + app.layout = html.Div( + [ + html.H3("DatePickerRange Default"), + dcc.DatePickerRange( + id="dpr-default", + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 15), + stay_open_on_select=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + + dash_dcc.wait_for_element("#dpr-default") + + click_everything_in_datepicker("#dpr-default", dash_dcc) + + dpr_input = dash_dcc.find_element("#dpr-default") + dpr_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + assert dash_dcc.get_logs() == [] + + +def test_dppt002_datepicker_range_with_portal(dash_dcc): + """Test DatePickerRange with with_portal=True. + + Verifies that the calendar opens in a portal (document.body) and all + elements are clickable. + """ + app = Dash(__name__) + + app.layout = html.Div( + [ + html.H3("DatePickerRange with Portal"), + dcc.DatePickerRange( + id="dpr-portal", + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 15), + stay_open_on_select=True, + with_portal=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + + # Wait for the page to load + dash_dcc.wait_for_element("#dpr-portal") + + # Test DatePickerRange with portal - click everything to verify all elements are accessible + click_everything_in_datepicker("#dpr-portal", dash_dcc) + + # Close the calendar by pressing escape + dpr_input = dash_dcc.find_element("#dpr-portal") + dpr_input.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + assert dash_dcc.get_logs() == [] + + +def test_dppt003_datepicker_single_with_fullscreen_portal(dash_dcc): + """Test DatePickerSingle with with_full_screen_portal=True. + + Verifies that the calendar opens in a full-screen portal overlay and all + elements are clickable. Also verifies that the fullscreen CSS class is applied. + """ + app = Dash(__name__) + + app.layout = html.Div( + [ + html.H3("DatePickerSingle with Full Screen Portal"), + dcc.DatePickerSingle( + id="dps-fullscreen", + date=date(2024, 1, 15), + stay_open_on_select=True, + with_full_screen_portal=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + + # Wait for the page to load + dash_dcc.wait_for_element("#dps-fullscreen") + + # Click to open the calendar + dps = dash_dcc.find_element("#dps-fullscreen") + dps.click() + + # Wait for calendar to open + popover = dash_dcc.find_element(".dash-datepicker-content") + + # Verify fullscreen class is applied + assert "dash-datepicker-fullscreen" in popover.get_attribute( + "class" + ), "Full screen portal should have dash-datepicker-fullscreen class" + + # Verify the popover has fixed positioning (full screen overlay) + position = popover.value_of_css_property("position") + assert position == "fixed", "Full screen portal should use fixed positioning" + + # Close to prepare for click everything test + dps.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + # Test clicking everything to verify all elements are accessible + click_everything_in_datepicker("#dps-fullscreen", dash_dcc) + + assert dash_dcc.get_logs() == [] + + +@pytest.mark.flaky(max_runs=3) +def test_dppt004_datepicker_range_with_fullscreen_portal(dash_dcc): + """Test DatePickerRange with with_full_screen_portal=True. + + Verifies that the calendar opens in a full-screen portal overlay and all + elements are clickable. Also verifies that the fullscreen CSS class is applied. + + Note: Marked as flaky due to headless Chrome layout issues with wide calendars + (2 months shown by default in DatePickerRange). Test passes consistently in + non-headless mode. + """ + app = Dash(__name__) + + app.layout = html.Div( + [ + html.H3("DatePickerRange with Full Screen Portal"), + dcc.DatePickerRange( + id="dpr-fullscreen", + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 15), + stay_open_on_select=True, + with_full_screen_portal=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + + # Wait for the page to load + dash_dcc.wait_for_element("#dpr-fullscreen") + + # Click to open the calendar + dpr = dash_dcc.find_element("#dpr-fullscreen") + dpr.click() + + # Wait for calendar to open + popover = dash_dcc.find_element(".dash-datepicker-content") + + # Verify fullscreen class is applied + assert "dash-datepicker-fullscreen" in popover.get_attribute( + "class" + ), "Full screen portal should have dash-datepicker-fullscreen class" + + # Verify the popover has fixed positioning (full screen overlay) + position = popover.value_of_css_property("position") + assert position == "fixed", "Full screen portal should use fixed positioning" + + # Close to prepare for click everything test + dpr.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-calendar-container", timeout=2) + + # Test clicking everything to verify all elements are accessible + click_everything_in_datepicker("#dpr-fullscreen", dash_dcc) + + assert dash_dcc.get_logs() == [] + + +def test_dppt005_portal_has_correct_classes(dash_dcc): + """Test that portal datepickers have the correct CSS classes. + + Verifies that default datepickers don't have portal classes, while + with_portal=True datepickers have the portal class but not fullscreen class. + """ + app = Dash(__name__) + + app.layout = html.Div( + [ + html.H3("Default (no portal)"), + dcc.DatePickerSingle( + id="dps-default", + date=date(2024, 1, 15), + ), + html.H3("With portal", style={"marginTop": "50px"}), + dcc.DatePickerSingle( + id="dps-with-portal", + date=date(2024, 1, 15), + with_portal=True, + ), + ] + ) + + dash_dcc.start_server(app, debug=True, use_reloader=False, dev_tools_ui=False) + + # Wait for the page to load + dash_dcc.wait_for_element("#dps-default") + dash_dcc.wait_for_element("#dps-with-portal") + + # Open default datepicker + dps_default = dash_dcc.find_element("#dps-default") + dps_default.click() + + # Wait for calendar to open + popover_default = dash_dcc.find_element(".dash-datepicker-content") + + # Verify it doesn't have fullscreen class + assert "dash-datepicker-fullscreen" not in popover_default.get_attribute( + "class" + ), "Default datepicker should not have fullscreen class" + + # Close default + dps_default.send_keys(Keys.ESCAPE) + dash_dcc.wait_for_no_elements(".dash-datepicker-content", timeout=2) + + # Open portal datepicker + dps_portal = dash_dcc.find_element("#dps-with-portal") + dps_portal.click() + + # Wait for calendar to open + popover_portal = dash_dcc.find_element(".dash-datepicker-content") + + # Verify it has portal class but not fullscreen class + assert "dash-datepicker-portal" in popover_portal.get_attribute( + "class" + ), "Portal should have dash-datepicker-portal class" + assert "dash-datepicker-fullscreen" not in popover_portal.get_attribute( + "class" + ), "Portal (non-fullscreen) should not have fullscreen class" + + # Verify it uses fixed positioning (both portal types use fixed positioning) + position = popover_portal.value_of_css_property("position") + assert position == "fixed", "Portal should use fixed positioning" + + assert dash_dcc.get_logs() == [] From eca3197dc2c325bdfa14b66b0366afe3d66574b7 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 30 Dec 2025 12:33:33 -0700 Subject: [PATCH 2/3] Fix test --- .../tests/integration/misc/test_popover_visibility.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/components/dash-core-components/tests/integration/misc/test_popover_visibility.py b/components/dash-core-components/tests/integration/misc/test_popover_visibility.py index 23a859db83..aaf0d1b20e 100644 --- a/components/dash-core-components/tests/integration/misc/test_popover_visibility.py +++ b/components/dash-core-components/tests/integration/misc/test_popover_visibility.py @@ -74,8 +74,6 @@ def test_mspv001_popover_visibility_when_app_is_smaller_than_popup(dash_dcc): # Test DatePickerRange - click everything to verify all elements are accessible click_everything_in_datepicker("#dpr", dash_dcc) - assert dash_dcc.get_logs() == [] - def test_mspv002_popover_visibility_when_app_is_scrolled_down(dash_dcc): """ @@ -103,7 +101,6 @@ def test_mspv002_popover_visibility_when_app_is_scrolled_down(dash_dcc): dash_dcc.wait_for_element("#dps") click_everything_in_datepicker("#dps", dash_dcc) - assert dash_dcc.get_logs() == [] def test_mspv003_popover_contained_within_dash_app(dash_dcc): @@ -150,8 +147,6 @@ def test_mspv003_popover_contained_within_dash_app(dash_dcc): # Click everything in the datepicker to verify all elements are accessible click_everything_in_datepicker("#dpr", dash_dcc) - assert dash_dcc.get_logs() == [] - def test_mspv004_popover_inherits_container_styles(dash_dcc): """Test that calendar days inherit font color and size from container. @@ -193,5 +188,3 @@ def test_mspv004_popover_inherits_container_styles(dash_dcc): # Font size should be 24px assert font_size == "24px", "Expected calendar day to inherit its font size" - - assert dash_dcc.get_logs() == [] From b56ea7293b9c0d5b4668a7346e6fd222812745b1 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 30 Dec 2025 12:33:33 -0700 Subject: [PATCH 3/3] Fix test --- .../tests/integration/misc/test_popover_visibility.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/components/dash-core-components/tests/integration/misc/test_popover_visibility.py b/components/dash-core-components/tests/integration/misc/test_popover_visibility.py index 23a859db83..34b310f197 100644 --- a/components/dash-core-components/tests/integration/misc/test_popover_visibility.py +++ b/components/dash-core-components/tests/integration/misc/test_popover_visibility.py @@ -56,6 +56,7 @@ def test_mspv001_popover_visibility_when_app_is_smaller_than_popup(dash_dcc): ) dash_dcc.start_server(app, debug=True, use_reloader=False) + dash_dcc.driver.set_window_size(1280, 1024) # Wait for the page to load dash_dcc.wait_for_element("#dps") @@ -74,8 +75,6 @@ def test_mspv001_popover_visibility_when_app_is_smaller_than_popup(dash_dcc): # Test DatePickerRange - click everything to verify all elements are accessible click_everything_in_datepicker("#dpr", dash_dcc) - assert dash_dcc.get_logs() == [] - def test_mspv002_popover_visibility_when_app_is_scrolled_down(dash_dcc): """ @@ -103,7 +102,6 @@ def test_mspv002_popover_visibility_when_app_is_scrolled_down(dash_dcc): dash_dcc.wait_for_element("#dps") click_everything_in_datepicker("#dps", dash_dcc) - assert dash_dcc.get_logs() == [] def test_mspv003_popover_contained_within_dash_app(dash_dcc): @@ -150,8 +148,6 @@ def test_mspv003_popover_contained_within_dash_app(dash_dcc): # Click everything in the datepicker to verify all elements are accessible click_everything_in_datepicker("#dpr", dash_dcc) - assert dash_dcc.get_logs() == [] - def test_mspv004_popover_inherits_container_styles(dash_dcc): """Test that calendar days inherit font color and size from container. @@ -193,5 +189,3 @@ def test_mspv004_popover_inherits_container_styles(dash_dcc): # Font size should be 24px assert font_size == "24px", "Expected calendar day to inherit its font size" - - assert dash_dcc.get_logs() == []