From 3c3c1a5fa0a9196ab12e65ccd507591ba51ce5e6 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 13:43:33 -0600 Subject: [PATCH 1/4] Fix single-date selection in DatePickerRange --- .../src/fragments/DatePickerRange.tsx | 37 +++++++- .../src/utils/calendar/Calendar.tsx | 4 +- .../calendar/test_a11y_date_picker_range.py | 6 +- .../calendar/test_date_picker_range.py | 92 +++++++++++++++++++ 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index 86f4b52607..d3529774b5 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -6,7 +6,7 @@ import { CaretDownIcon, Cross1Icon, } from '@radix-ui/react-icons'; -import {addDays, subDays} from 'date-fns'; +import {addDays, subDays, differenceInCalendarDays} from 'date-fns'; import AutosizeInput from 'react-input-autosize'; import uuid from 'uniqid'; @@ -108,6 +108,7 @@ const DatePickerRange = ({ const startAutosizeRef = useRef(null); const endAutosizeRef = useRef(null); const calendarRef = useRef(null); + const isNewRangeRef = useRef(false); const hasPortal = with_portal || with_full_screen_portal; // Capture CSS variables for portal mode @@ -161,16 +162,20 @@ const DatePickerRange = ({ end_date: dateAsStr(internalEndDate), }); } else if (!internalStartDate && !internalEndDate) { - // Both dates cleared - send undefined for both + // Both dates cleared - send both setProps({ start_date: dateAsStr(internalStartDate), end_date: dateAsStr(internalEndDate), }); + } else if (endChanged && !internalEndDate) { + // End date was cleared (user started a new range). + setProps({ + start_date: dateAsStr(internalStartDate) ?? null, + end_date: null, + }); } else if (updatemode === 'singledate' && internalStartDate) { - // Only start changed - send just that one setProps({start_date: dateAsStr(internalStartDate)}); } else if (updatemode === 'singledate' && internalEndDate) { - // Only end changed - send just that one setProps({end_date: dateAsStr(internalEndDate)}); } }, [internalStartDate, internalEndDate, updatemode]); @@ -311,6 +316,23 @@ const DatePickerRange = ({ setInternalStartDate(start); setInternalEndDate(undefined); } else { + // Skip the mouseUp from the same click that started this range + if (isNewRangeRef.current && isSameDay(start, end)) { + isNewRangeRef.current = false; + return; + } + isNewRangeRef.current = !!(start && !end); + + if ( + start && + end && + minimum_nights && + Math.abs(differenceInCalendarDays(end, start)) < + minimum_nights + ) { + return; + } + // Normalize dates: ensure start <= end if (start && end && start > end) { setInternalStartDate(end); @@ -325,7 +347,12 @@ const DatePickerRange = ({ } } }, - [internalStartDate, internalEndDate, stay_open_on_select] + [ + internalStartDate, + internalEndDate, + stay_open_on_select, + minimum_nights, + ] ); return ( diff --git a/components/dash-core-components/src/utils/calendar/Calendar.tsx b/components/dash-core-components/src/utils/calendar/Calendar.tsx index d35590da29..367ff1daeb 100644 --- a/components/dash-core-components/src/utils/calendar/Calendar.tsx +++ b/components/dash-core-components/src/utils/calendar/Calendar.tsx @@ -188,9 +188,7 @@ const CalendarComponent = ({ // Complete the selection with an end date if (selectionStart && !selectionEnd) { // Incomplete selection exists (range picker mid-selection) - if (!isSameDay(selectionStart, date)) { - onSelectionChange(selectionStart, date); - } + onSelectionChange(selectionStart, date); } else { // Complete selection exists or a single date was chosen onSelectionChange(date, date); diff --git a/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py index 9d591ca1a8..6c1254effe 100644 --- a/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py +++ b/components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py @@ -151,11 +151,11 @@ def update_output(start_date, end_date): assert get_focused_text(dash_dcc.driver) == "12" # Press Space to start a NEW range selection with Jan 12 as start_date - # This should clear end_date and set only start_date + # In singledate mode (default), end_date is cleared immediately send_keys(dash_dcc.driver, Keys.SPACE) - # Verify new start date was selected (only start_date, no end_date) - dash_dcc.wait_for_text_to_equal("#output-dates", "2021-01-12 to 2021-01-20") + # Output updates: new start_date sent, old end_date cleared + dash_dcc.wait_for_text_to_equal("#output-dates", "Start: 2021-01-12") # Navigate to new end date: Arrow Down + Arrow Right (Jan 12 -> 19 -> 20) send_keys(dash_dcc.driver, Keys.ARROW_DOWN) diff --git a/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py b/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py index e66f978dab..16ac1ba927 100644 --- a/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py +++ b/components/dash-core-components/tests/integration/calendar/test_date_picker_range.py @@ -6,6 +6,7 @@ ElementClickInterceptedException, TimeoutException, ) +from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -377,6 +378,97 @@ def test_dtpr008_input_click_opens_but_keeps_focus(dash_dcc): assert dash_dcc.get_logs() == [] +def test_dtpr009_same_date_selection_minimum_nights_zero(dash_dcc): + """Bug #3645: With minimum_nights=0, selecting the same date for start and end should work.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + min_date_allowed=datetime(2021, 1, 1), + max_date_allowed=datetime(2021, 1, 31), + initial_visible_month=datetime(2021, 1, 1), + minimum_nights=0, + display_format="MM/DD/YYYY", + ), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def display_dates(start_date, end_date): + return f"Start: {start_date}, End: {end_date}" + + dash_dcc.start_server(app) + + # Select day 10 for both start and end (same date) + result = dash_dcc.select_date_range("dpr", day_range=(10, 10)) + assert result == ( + "01/10/2021", + "01/10/2021", + ), f"Same date selection should work with minimum_nights=0, got {result}" + + dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-10, End: 2021-01-10") + + assert dash_dcc.get_logs() == [] + + +def test_dtpr010_new_start_date_clears_end_date(dash_dcc): + """Bug #3645: When a new start date is selected after a range, end_date should be cleared.""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.DatePickerRange( + id="dpr", + min_date_allowed=datetime(2021, 1, 1), + max_date_allowed=datetime(2021, 1, 31), + initial_visible_month=datetime(2021, 1, 1), + minimum_nights=0, + display_format="MM/DD/YYYY", + ), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("dpr", "start_date"), + Input("dpr", "end_date"), + ) + def display_dates(start_date, end_date): + return f"Start: {start_date}, End: {end_date}" + + dash_dcc.start_server(app) + + # First, select a range: Jan 2 to Jan 11 + dash_dcc.select_date_range("dpr", day_range=(2, 11)) + dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-02, End: 2021-01-11") + + # Now click just a new start date (Jan 4) without selecting an end date + date = dash_dcc.find_element("#dpr") + date.click() + dash_dcc._wait_until_day_is_clickable() + days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator) + day_4 = [d for d in days if d.find_element(By.CSS_SELECTOR, "span").text == "4"][0] + day_4.click() + + # The calendar should still be open (waiting for end date). + # The old end_date (Jan 11) should NOT be retained. + # Click outside to close the calendar. + time.sleep(0.3) + dash_dcc.find_element("body").click() + time.sleep(0.3) + + # end_date must be cleared, not silently retained from previous selection + dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-04, End: None") + + assert dash_dcc.get_logs() == [] + + def test_dtpr030_external_date_range_update(dash_dcc): """Test that DatePickerRange accepts external date updates via callback without resetting.""" app = Dash(__name__) From 76511d83dd85b37d0e6376770b954153ce8a4f37 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 14:32:06 -0600 Subject: [PATCH 2/4] code cleanup --- .../src/fragments/DatePickerRange.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index d3529774b5..11bd7baa74 100644 --- a/components/dash-core-components/src/fragments/DatePickerRange.tsx +++ b/components/dash-core-components/src/fragments/DatePickerRange.tsx @@ -174,8 +174,10 @@ const DatePickerRange = ({ end_date: null, }); } else if (updatemode === 'singledate' && internalStartDate) { + // Only start changed - send just that one setProps({start_date: dateAsStr(internalStartDate)}); } else if (updatemode === 'singledate' && internalEndDate) { + // Only end changed - send just that one setProps({end_date: dateAsStr(internalEndDate)}); } }, [internalStartDate, internalEndDate, updatemode]); @@ -323,14 +325,13 @@ const DatePickerRange = ({ } isNewRangeRef.current = !!(start && !end); - if ( - start && - end && - minimum_nights && - Math.abs(differenceInCalendarDays(end, start)) < - minimum_nights - ) { - return; + if (start && end && minimum_nights) { + const numNights = Math.abs( + differenceInCalendarDays(end, start) + ); + if (numNights < minimum_nights) { + return; + } } // Normalize dates: ensure start <= end From 1c6db3e2a81e553f5f1919a6b90001d00062e679 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 14:55:12 -0600 Subject: [PATCH 3/4] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a57736f98f..5a2b947901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container. - [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable wheen focused, without requiring to open them first +- [#3660][(](https://github.com/plotly/dash/pull/3660)) Allow same date to be selected for both start and end in DatePickerRange components From 2bc7ea1aaacfbe4c9182aad3d2e55c61c1489172 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 15:16:09 -0600 Subject: [PATCH 4/4] empty commit for ci