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 diff --git a/components/dash-core-components/src/fragments/DatePickerRange.tsx b/components/dash-core-components/src/fragments/DatePickerRange.tsx index 919c6b32b7..11bd7baa74 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,11 +162,17 @@ 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)}); @@ -311,6 +318,22 @@ 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) { + const numNights = Math.abs( + differenceInCalendarDays(end, start) + ); + if (numNights < minimum_nights) { + return; + } + } + // Normalize dates: ensure start <= end if (start && end && start > end) { setInternalStartDate(end); @@ -325,161 +348,167 @@ const DatePickerRange = ({ } } }, - [internalStartDate, internalEndDate, stay_open_on_select] + [ + internalStartDate, + internalEndDate, + stay_open_on_select, + minimum_nights, + ] ); return ( - -
- - -
{ - e.preventDefault(); - if (!isCalendarOpen && !disabled) { - setIsCalendarOpen(true); - } - }} - > - { - startInputRef.current = node; - }} - type="text" - id={start_date_id || accessibleId} - inputClassName="dash-datepicker-input dash-datepicker-start-date" - value={startInputValue} - onChange={e => setStartInputValue(e.target?.value)} - onKeyDown={handleStartInputKeyDown} - onFocus={() => { - if (isCalendarOpen) { - sendStartInputAsDate(); + +
+ + +
{ + e.preventDefault(); + if (!isCalendarOpen && !disabled) { + setIsCalendarOpen(true); } }} - placeholder={start_date_placeholder_text} - disabled={disabled} - dir={direction} - aria-label={start_date_placeholder_text} - /> - - { - endInputRef.current = node; - }} - type="text" - id={end_date_id || accessibleId + '-end-date'} - inputClassName="dash-datepicker-input dash-datepicker-end-date" - value={endInputValue} - onChange={e => setEndInputValue(e.target?.value)} - onKeyDown={handleEndInputKeyDown} - onFocus={() => { - if (isCalendarOpen) { - sendEndInputAsDate(); + > + { + startInputRef.current = node; + }} + type="text" + id={start_date_id || accessibleId} + inputClassName="dash-datepicker-input dash-datepicker-start-date" + value={startInputValue} + onChange={e => + setStartInputValue(e.target?.value) } - }} - placeholder={end_date_placeholder_text} - disabled={disabled} - dir={direction} - aria-label={end_date_placeholder_text} - /> - {clearable && !disabled && ( - - - - )} - -
-
- - - e.preventDefault() - : undefined - } - onOpenAutoFocus={e => e.preventDefault()} - onCloseAutoFocus={e => { - e.preventDefault(); - // Only focus if focus is not already on one of the inputs - const inputs: (Element | null)[] = [ - startInputRef.current, - endInputRef.current, - ]; - if (inputs.includes(document.activeElement)) { - return; + onKeyDown={handleStartInputKeyDown} + onFocus={() => { + if (isCalendarOpen) { + sendStartInputAsDate(); + } + }} + placeholder={start_date_placeholder_text} + disabled={disabled} + dir={direction} + aria-label={start_date_placeholder_text} + /> + + { + endInputRef.current = node; + }} + type="text" + id={end_date_id || accessibleId + '-end-date'} + inputClassName="dash-datepicker-input dash-datepicker-end-date" + value={endInputValue} + onChange={e => + setEndInputValue(e.target?.value) + } + onKeyDown={handleEndInputKeyDown} + onFocus={() => { + if (isCalendarOpen) { + sendEndInputAsDate(); + } + }} + placeholder={end_date_placeholder_text} + disabled={disabled} + dir={direction} + aria-label={end_date_placeholder_text} + /> + {clearable && !disabled && ( + + + + )} + +
+ + + + e.preventDefault() + : undefined } + onOpenAutoFocus={e => e.preventDefault()} + onCloseAutoFocus={e => { + e.preventDefault(); + // Only focus if focus is not already on one of the inputs + const inputs: (Element | null)[] = [ + startInputRef.current, + endInputRef.current, + ]; + if (inputs.includes(document.activeElement)) { + return; + } - // Keeps focus on the component when the calendar closes - if (!startInputValue) { - startInputRef.current?.focus(); - } else { - endInputRef.current?.focus(); - } - }} - > - {with_full_screen_portal && ( - - )} - - - - -
+ // Keeps focus on the component when the calendar closes + if (!startInputValue) { + startInputRef.current?.focus(); + } else { + endInputRef.current?.focus(); + } + }} + > + {with_full_screen_portal && ( + + )} + + + +
+
); }; diff --git a/components/dash-core-components/src/fragments/DatePickerSingle.tsx b/components/dash-core-components/src/fragments/DatePickerSingle.tsx index eff91e6515..527e654b0d 100644 --- a/components/dash-core-components/src/fragments/DatePickerSingle.tsx +++ b/components/dash-core-components/src/fragments/DatePickerSingle.tsx @@ -93,7 +93,6 @@ const DatePickerSingle = ({ autosizeRef.current?.updateInputWidth?.(); }, []); - useEffect(() => { autosizeRef.current?.updateInputWidth?.(); }, [inputValue]); @@ -169,124 +168,126 @@ const DatePickerSingle = ({ return ( -
- - -
{ - e.preventDefault(); - if (!isCalendarOpen && !disabled) { - setIsCalendarOpen(true); - } - }} - > - { - inputRef.current = node; +
+ + +
{ + e.preventDefault(); + if (!isCalendarOpen && !disabled) { + setIsCalendarOpen(true); + } }} - type="text" - id={accessibleId} - inputClassName="dash-datepicker-input dash-datepicker-end-date" - value={inputValue} - onChange={e => setInputValue(e.target?.value)} - onKeyDown={handleInputKeyDown} - placeholder={placeholder} - disabled={disabled} - dir={direction} - aria-label={placeholder} - /> - {clearable && !disabled && !!date && ( - - - - )} + > + { + inputRef.current = node; + }} + type="text" + id={accessibleId} + inputClassName="dash-datepicker-input dash-datepicker-end-date" + value={inputValue} + onChange={e => setInputValue(e.target?.value)} + onKeyDown={handleInputKeyDown} + placeholder={placeholder} + disabled={disabled} + dir={direction} + aria-label={placeholder} + /> + {clearable && !disabled && !!date && ( + + + + )} - -
-
+ +
+ - - e.preventDefault() - : undefined - } - onOpenAutoFocus={e => e.preventDefault()} - onCloseAutoFocus={e => { - e.preventDefault(); - // Only focus if focus is not already on the input - if (document.activeElement !== inputRef.current) { - inputRef.current?.focus(); - } - }} + - {with_full_screen_portal && ( - - )} - { - if (!selection) { - return; - } - setInternalDate(selection); - if (!stay_open_on_select) { - setIsCalendarOpen(false); + e.preventDefault() + : undefined + } + onOpenAutoFocus={e => e.preventDefault()} + onCloseAutoFocus={e => { + e.preventDefault(); + // Only focus if focus is not already on the input + if ( + document.activeElement !== inputRef.current + ) { + inputRef.current?.focus(); } }} - /> - - - -
+ > + {with_full_screen_portal && ( + + )} + { + if (!selection) { + return; + } + setInternalDate(selection); + if (!stay_open_on_select) { + setIsCalendarOpen(false); + } + }} + /> + + +
+
); }; 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__)