Skip to content

Commit eeb47d8

Browse files
committed
fix: snap initial preSelection to first enabled day in month
When openToDate (or the bounded default) lands on a disabled day - such as a gap between includedDates - preSelection became a disabled date, leaving no day with tabindex="0" in the grid. This breaks keyboard and screen-reader navigation, as there is no focusable day to enter the calendar. calcInitialState now snaps the bounded preSelection to the first enabled day in its month, reusing a new shared getEnabledDateInMonth util. The same logic already backed month navigation (Calendar.getEnabledPreSelectionDateForMonth), which is refactored to use the shared util so both paths stay consistent. Fixes #6286
1 parent 548a1f3 commit eeb47d8

6 files changed

Lines changed: 2313 additions & 2473 deletions

File tree

src/calendar.tsx

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { clsx } from "clsx";
2-
import { differenceInDays } from "date-fns";
32
import React, { Component, createRef } from "react";
43

54
import CalendarContainer from "./calendar_container";
@@ -41,9 +40,7 @@ import {
4140
DEFAULT_YEAR_ITEM_NUMBER,
4241
getMonthInLocale,
4342
type Locale,
44-
getStartOfMonth,
45-
getEndOfMonth,
46-
isDayDisabled,
43+
getEnabledDateInMonth,
4744
} from "./date_utils";
4845
import InputTime from "./input_time";
4946
import Month from "./month";
@@ -413,29 +410,8 @@ export default class Calendar extends Component<CalendarProps, CalendarState> {
413410
this.props.setPreSelection && this.props.setPreSelection(date);
414411
};
415412

416-
getEnabledPreSelectionDateForMonth = (date: Date) => {
417-
if (!isDayDisabled(date, this.props)) {
418-
return date;
419-
}
420-
421-
const startOfMonth = getStartOfMonth(date);
422-
const endOfMonth = getEndOfMonth(date);
423-
424-
const totalDays = differenceInDays(endOfMonth, startOfMonth);
425-
426-
let preSelectedDate = null;
427-
428-
for (let dayIdx = 0; dayIdx <= totalDays; dayIdx++) {
429-
const processingDate = addDays(startOfMonth, dayIdx);
430-
431-
if (!isDayDisabled(processingDate, this.props)) {
432-
preSelectedDate = processingDate;
433-
break;
434-
}
435-
}
436-
437-
return preSelectedDate;
438-
};
413+
getEnabledPreSelectionDateForMonth = (date: Date) =>
414+
getEnabledDateInMonth(date, this.props);
439415

440416
handleMonthChange = (date: Date): void => {
441417
const enabledPreSelectionDate =

src/date_utils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,41 @@ export function isDayDisabled(
10361036
);
10371037
}
10381038

1039+
/**
1040+
* Returns a selectable day in the same month as the given date.
1041+
*
1042+
* If the date itself is enabled it is returned unchanged. Otherwise the month
1043+
* is scanned from its first day and the first enabled day is returned. This
1044+
* keeps a day keyboard-focusable when the requested date (e.g. `openToDate`)
1045+
* falls on a disabled day, such as a gap between `includeDates`. Returns `null`
1046+
* when the month has no selectable day.
1047+
*
1048+
* @param date - The reference date whose month is searched.
1049+
* @param options - The options used to determine whether a day is disabled.
1050+
* @returns - The original date, the first enabled day in its month, or `null`.
1051+
*/
1052+
export function getEnabledDateInMonth(
1053+
date: Date,
1054+
options: DateFilterOptionsWithDisabled = {},
1055+
): Date | null {
1056+
if (!isDayDisabled(date, options)) {
1057+
return date;
1058+
}
1059+
1060+
const startOfMonth = getStartOfMonth(date);
1061+
const endOfMonth = getEndOfMonth(date);
1062+
const totalDays = differenceInCalendarDays(endOfMonth, startOfMonth);
1063+
1064+
for (let dayIdx = 0; dayIdx <= totalDays; dayIdx++) {
1065+
const processingDate = addDays(startOfMonth, dayIdx);
1066+
if (!isDayDisabled(processingDate, options)) {
1067+
return processingDate;
1068+
}
1069+
}
1070+
1071+
return null;
1072+
}
1073+
10391074
/**
10401075
* Checks if a day is excluded.
10411076
*

src/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
subYears,
2626
isDayDisabled,
2727
isDayInRange,
28+
getEnabledDateInMonth,
2829
getEffectiveMinDate,
2930
getEffectiveMaxDate,
3031
parseDate,
@@ -496,6 +497,13 @@ export class DatePicker extends Component<DatePickerProps, DatePickerState> {
496497
? maxDate
497498
: defaultPreSelection;
498499

500+
// If the bounded date lands on a disabled day (e.g. openToDate points at a
501+
// gap between includeDates), snap to the first enabled day in that month so
502+
// a day stays keyboard-focusable. See issue #6286.
503+
const safePreSelection =
504+
getEnabledDateInMonth(boundedPreSelection, this.props) ??
505+
boundedPreSelection;
506+
499507
// Convert selected/startDate to zoned time for display if timezone is specified
500508
let initialPreSelection = this.props.selectsRange
501509
? this.props.startDate
@@ -509,7 +517,7 @@ export class DatePicker extends Component<DatePickerProps, DatePickerState> {
509517
open: this.props.startOpen || false,
510518
preventFocus: false,
511519
inputValue: null,
512-
preSelection: initialPreSelection ?? boundedPreSelection,
520+
preSelection: initialPreSelection ?? safePreSelection,
513521
// transforming highlighted days (perhaps nested array)
514522
// to flat Map for faster access in day.jsx
515523
highlightDates: getHighLightDaysMap(this.props.highlightDates),

src/test/date_utils_test.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
isSameQuarter,
2020
isSameYear,
2121
isDayDisabled,
22+
getEnabledDateInMonth,
2223
isDayExcluded,
24+
formatDate,
2325
isMonthDisabled,
2426
isQuarterDisabled,
2527
isYearDisabled,
@@ -346,6 +348,28 @@ describe("date_utils", () => {
346348
});
347349
});
348350

351+
describe("getEnabledDateInMonth", () => {
352+
it("returns the date unchanged when it is enabled", () => {
353+
const day = newDate("2024-06-15");
354+
expect(getEnabledDateInMonth(day, {})).toBe(day);
355+
});
356+
357+
it("returns the first enabled day in the month when the date is disabled", () => {
358+
const includeDates = [newDate("2024-06-10"), newDate("2024-06-20")];
359+
const result = getEnabledDateInMonth(newDate("2024-06-15"), {
360+
includeDates,
361+
});
362+
expect(formatDate(result!, "yyyy-MM-dd")).toBe("2024-06-10");
363+
});
364+
365+
it("returns null when no day in the month is enabled", () => {
366+
const includeDates = [newDate("2024-07-01")];
367+
expect(
368+
getEnabledDateInMonth(newDate("2024-06-15"), { includeDates }),
369+
).toBe(null);
370+
});
371+
});
372+
349373
describe("isDayExcluded", () => {
350374
it("should not be excluded by default", () => {
351375
const day = newDate();

src/test/datepicker_test.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,42 @@ describe("DatePicker", () => {
166166
expect(instance?.state.preSelection).toBe(originalPreSelection);
167167
});
168168

169+
describe("initial preSelection when openToDate is a disabled day (issue #6286)", () => {
170+
it("snaps preSelection to the first enabled day in the month so a day stays keyboard-focusable", () => {
171+
const includeDates = [newDate("2024-06-10"), newDate("2024-06-20")];
172+
const { instance, container } = renderDatePickerWithRef({
173+
inline: true,
174+
includeDates,
175+
// a gap day: inside the available range but not itself selectable
176+
openToDate: newDate("2024-06-15"),
177+
});
178+
179+
// a roving-tabindex day must exist, otherwise keyboard and screen-reader
180+
// users cannot move into the grid
181+
expect(
182+
container.querySelector('.react-datepicker__day[tabindex="0"]'),
183+
).toBeTruthy();
184+
185+
expect(instance?.state.preSelection).toBeTruthy();
186+
expect(formatDate(instance!.state.preSelection!, "yyyy-MM-dd")).toBe(
187+
"2024-06-10",
188+
);
189+
});
190+
191+
it("keeps an enabled openToDate as the preSelection", () => {
192+
const includeDates = [newDate("2024-06-10"), newDate("2024-06-20")];
193+
const { instance } = renderDatePickerWithRef({
194+
inline: true,
195+
includeDates,
196+
openToDate: newDate("2024-06-20"),
197+
});
198+
199+
expect(formatDate(instance!.state.preSelection!, "yyyy-MM-dd")).toBe(
200+
"2024-06-20",
201+
);
202+
});
203+
});
204+
169205
it("short-circuits day key navigation when keyboard navigation is disabled", () => {
170206
const onKeyDown = jest.fn();
171207
const preSelection = newDate("2024-06-15");

0 commit comments

Comments
 (0)