Skip to content

Commit 1c10c88

Browse files
authored
Merge pull request #3660 from plotly/bugfix/3645
Fix single-date selection in DatePickerRange
2 parents bd8d378 + 2bc7ea1 commit 1c10c88

File tree

5 files changed

+128
-9
lines changed

5 files changed

+128
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1010
## Fixed
1111
- [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container.
1212
- [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable wheen focused, without requiring to open them first
13+
- [#3660][(](https://github.com/plotly/dash/pull/3660)) Allow same date to be selected for both start and end in DatePickerRange components
1314

1415

1516

components/dash-core-components/src/fragments/DatePickerRange.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
CaretDownIcon,
77
Cross1Icon,
88
} from '@radix-ui/react-icons';
9-
import {addDays, subDays} from 'date-fns';
9+
import {addDays, subDays, differenceInCalendarDays} from 'date-fns';
1010
import AutosizeInput from 'react-input-autosize';
1111
import uuid from 'uniqid';
1212

@@ -108,6 +108,7 @@ const DatePickerRange = ({
108108
const startAutosizeRef = useRef<any>(null);
109109
const endAutosizeRef = useRef<any>(null);
110110
const calendarRef = useRef<CalendarHandle>(null);
111+
const isNewRangeRef = useRef(false);
111112
const hasPortal = with_portal || with_full_screen_portal;
112113

113114
// Capture CSS variables for portal mode
@@ -161,11 +162,17 @@ const DatePickerRange = ({
161162
end_date: dateAsStr(internalEndDate),
162163
});
163164
} else if (!internalStartDate && !internalEndDate) {
164-
// Both dates cleared - send undefined for both
165+
// Both dates cleared - send both
165166
setProps({
166167
start_date: dateAsStr(internalStartDate),
167168
end_date: dateAsStr(internalEndDate),
168169
});
170+
} else if (endChanged && !internalEndDate) {
171+
// End date was cleared (user started a new range).
172+
setProps({
173+
start_date: dateAsStr(internalStartDate) ?? null,
174+
end_date: null,
175+
});
169176
} else if (updatemode === 'singledate' && internalStartDate) {
170177
// Only start changed - send just that one
171178
setProps({start_date: dateAsStr(internalStartDate)});
@@ -311,6 +318,22 @@ const DatePickerRange = ({
311318
setInternalStartDate(start);
312319
setInternalEndDate(undefined);
313320
} else {
321+
// Skip the mouseUp from the same click that started this range
322+
if (isNewRangeRef.current && isSameDay(start, end)) {
323+
isNewRangeRef.current = false;
324+
return;
325+
}
326+
isNewRangeRef.current = !!(start && !end);
327+
328+
if (start && end && minimum_nights) {
329+
const numNights = Math.abs(
330+
differenceInCalendarDays(end, start)
331+
);
332+
if (numNights < minimum_nights) {
333+
return;
334+
}
335+
}
336+
314337
// Normalize dates: ensure start <= end
315338
if (start && end && start > end) {
316339
setInternalStartDate(end);
@@ -325,7 +348,12 @@ const DatePickerRange = ({
325348
}
326349
}
327350
},
328-
[internalStartDate, internalEndDate, stay_open_on_select]
351+
[
352+
internalStartDate,
353+
internalEndDate,
354+
stay_open_on_select,
355+
minimum_nights,
356+
]
329357
);
330358

331359
return (

components/dash-core-components/src/utils/calendar/Calendar.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,7 @@ const CalendarComponent = ({
188188
// Complete the selection with an end date
189189
if (selectionStart && !selectionEnd) {
190190
// Incomplete selection exists (range picker mid-selection)
191-
if (!isSameDay(selectionStart, date)) {
192-
onSelectionChange(selectionStart, date);
193-
}
191+
onSelectionChange(selectionStart, date);
194192
} else {
195193
// Complete selection exists or a single date was chosen
196194
onSelectionChange(date, date);

components/dash-core-components/tests/integration/calendar/test_a11y_date_picker_range.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,11 @@ def update_output(start_date, end_date):
151151
assert get_focused_text(dash_dcc.driver) == "12"
152152

153153
# Press Space to start a NEW range selection with Jan 12 as start_date
154-
# This should clear end_date and set only start_date
154+
# In singledate mode (default), end_date is cleared immediately
155155
send_keys(dash_dcc.driver, Keys.SPACE)
156156

157-
# Verify new start date was selected (only start_date, no end_date)
158-
dash_dcc.wait_for_text_to_equal("#output-dates", "2021-01-12 to 2021-01-20")
157+
# Output updates: new start_date sent, old end_date cleared
158+
dash_dcc.wait_for_text_to_equal("#output-dates", "Start: 2021-01-12")
159159

160160
# Navigate to new end date: Arrow Down + Arrow Right (Jan 12 -> 19 -> 20)
161161
send_keys(dash_dcc.driver, Keys.ARROW_DOWN)

components/dash-core-components/tests/integration/calendar/test_date_picker_range.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
ElementClickInterceptedException,
77
TimeoutException,
88
)
9+
from selenium.webdriver.common.by import By
910
from selenium.webdriver.common.keys import Keys
1011

1112

@@ -377,6 +378,97 @@ def test_dtpr008_input_click_opens_but_keeps_focus(dash_dcc):
377378
assert dash_dcc.get_logs() == []
378379

379380

381+
def test_dtpr009_same_date_selection_minimum_nights_zero(dash_dcc):
382+
"""Bug #3645: With minimum_nights=0, selecting the same date for start and end should work."""
383+
app = Dash(__name__)
384+
app.layout = html.Div(
385+
[
386+
dcc.DatePickerRange(
387+
id="dpr",
388+
min_date_allowed=datetime(2021, 1, 1),
389+
max_date_allowed=datetime(2021, 1, 31),
390+
initial_visible_month=datetime(2021, 1, 1),
391+
minimum_nights=0,
392+
display_format="MM/DD/YYYY",
393+
),
394+
html.Div(id="output"),
395+
]
396+
)
397+
398+
@app.callback(
399+
Output("output", "children"),
400+
Input("dpr", "start_date"),
401+
Input("dpr", "end_date"),
402+
)
403+
def display_dates(start_date, end_date):
404+
return f"Start: {start_date}, End: {end_date}"
405+
406+
dash_dcc.start_server(app)
407+
408+
# Select day 10 for both start and end (same date)
409+
result = dash_dcc.select_date_range("dpr", day_range=(10, 10))
410+
assert result == (
411+
"01/10/2021",
412+
"01/10/2021",
413+
), f"Same date selection should work with minimum_nights=0, got {result}"
414+
415+
dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-10, End: 2021-01-10")
416+
417+
assert dash_dcc.get_logs() == []
418+
419+
420+
def test_dtpr010_new_start_date_clears_end_date(dash_dcc):
421+
"""Bug #3645: When a new start date is selected after a range, end_date should be cleared."""
422+
app = Dash(__name__)
423+
app.layout = html.Div(
424+
[
425+
dcc.DatePickerRange(
426+
id="dpr",
427+
min_date_allowed=datetime(2021, 1, 1),
428+
max_date_allowed=datetime(2021, 1, 31),
429+
initial_visible_month=datetime(2021, 1, 1),
430+
minimum_nights=0,
431+
display_format="MM/DD/YYYY",
432+
),
433+
html.Div(id="output"),
434+
]
435+
)
436+
437+
@app.callback(
438+
Output("output", "children"),
439+
Input("dpr", "start_date"),
440+
Input("dpr", "end_date"),
441+
)
442+
def display_dates(start_date, end_date):
443+
return f"Start: {start_date}, End: {end_date}"
444+
445+
dash_dcc.start_server(app)
446+
447+
# First, select a range: Jan 2 to Jan 11
448+
dash_dcc.select_date_range("dpr", day_range=(2, 11))
449+
dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-02, End: 2021-01-11")
450+
451+
# Now click just a new start date (Jan 4) without selecting an end date
452+
date = dash_dcc.find_element("#dpr")
453+
date.click()
454+
dash_dcc._wait_until_day_is_clickable()
455+
days = dash_dcc.find_elements(dash_dcc.date_picker_day_locator)
456+
day_4 = [d for d in days if d.find_element(By.CSS_SELECTOR, "span").text == "4"][0]
457+
day_4.click()
458+
459+
# The calendar should still be open (waiting for end date).
460+
# The old end_date (Jan 11) should NOT be retained.
461+
# Click outside to close the calendar.
462+
time.sleep(0.3)
463+
dash_dcc.find_element("body").click()
464+
time.sleep(0.3)
465+
466+
# end_date must be cleared, not silently retained from previous selection
467+
dash_dcc.wait_for_text_to_equal("#output", "Start: 2021-01-04, End: None")
468+
469+
assert dash_dcc.get_logs() == []
470+
471+
380472
def test_dtpr030_external_date_range_update(dash_dcc):
381473
"""Test that DatePickerRange accepts external date updates via callback without resetting."""
382474
app = Dash(__name__)

0 commit comments

Comments
 (0)