Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 3 additions & 27 deletions src/calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { clsx } from "clsx";
import { differenceInDays } from "date-fns";
import React, { Component, createRef } from "react";

import CalendarContainer from "./calendar_container";
Expand Down Expand Up @@ -41,9 +40,7 @@ import {
DEFAULT_YEAR_ITEM_NUMBER,
getMonthInLocale,
type Locale,
getStartOfMonth,
getEndOfMonth,
isDayDisabled,
getEnabledDateInMonth,
} from "./date_utils";
import InputTime from "./input_time";
import Month from "./month";
Expand Down Expand Up @@ -413,29 +410,8 @@ export default class Calendar extends Component<CalendarProps, CalendarState> {
this.props.setPreSelection && this.props.setPreSelection(date);
};

getEnabledPreSelectionDateForMonth = (date: Date) => {
if (!isDayDisabled(date, this.props)) {
return date;
}

const startOfMonth = getStartOfMonth(date);
const endOfMonth = getEndOfMonth(date);

const totalDays = differenceInDays(endOfMonth, startOfMonth);

let preSelectedDate = null;

for (let dayIdx = 0; dayIdx <= totalDays; dayIdx++) {
const processingDate = addDays(startOfMonth, dayIdx);

if (!isDayDisabled(processingDate, this.props)) {
preSelectedDate = processingDate;
break;
}
}

return preSelectedDate;
};
getEnabledPreSelectionDateForMonth = (date: Date) =>
getEnabledDateInMonth(date, this.props);

handleMonthChange = (date: Date): void => {
const enabledPreSelectionDate =
Expand Down
35 changes: 35 additions & 0 deletions src/date_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,41 @@ export function isDayDisabled(
);
}

/**
* Returns a selectable day in the same month as the given date.
*
* If the date itself is enabled it is returned unchanged. Otherwise the month
* is scanned from its first day and the first enabled day is returned. This
* keeps a day keyboard-focusable when the requested date (e.g. `openToDate`)
* falls on a disabled day, such as a gap between `includeDates`. Returns `null`
* when the month has no selectable day.
*
* @param date - The reference date whose month is searched.
* @param options - The options used to determine whether a day is disabled.
* @returns - The original date, the first enabled day in its month, or `null`.
*/
export function getEnabledDateInMonth(
date: Date,
options: DateFilterOptionsWithDisabled = {},
): Date | null {
if (!isDayDisabled(date, options)) {
return date;
}

const startOfMonth = getStartOfMonth(date);
const endOfMonth = getEndOfMonth(date);
const totalDays = differenceInCalendarDays(endOfMonth, startOfMonth);

for (let dayIdx = 0; dayIdx <= totalDays; dayIdx++) {
const processingDate = addDays(startOfMonth, dayIdx);
if (!isDayDisabled(processingDate, options)) {
return processingDate;
}
}

return null;
}

/**
* Checks if a day is excluded.
*
Expand Down
10 changes: 9 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
subYears,
isDayDisabled,
isDayInRange,
getEnabledDateInMonth,
getEffectiveMinDate,
getEffectiveMaxDate,
parseDate,
Expand Down Expand Up @@ -496,6 +497,13 @@ export class DatePicker extends Component<DatePickerProps, DatePickerState> {
? maxDate
: defaultPreSelection;

// If the bounded date lands on a disabled day (e.g. openToDate points at a
// gap between includeDates), snap to the first enabled day in that month so
// a day stays keyboard-focusable. See issue #6286.
const safePreSelection =
getEnabledDateInMonth(boundedPreSelection, this.props) ??
boundedPreSelection;

// Convert selected/startDate to zoned time for display if timezone is specified
let initialPreSelection = this.props.selectsRange
? this.props.startDate
Expand All @@ -509,7 +517,7 @@ export class DatePicker extends Component<DatePickerProps, DatePickerState> {
open: this.props.startOpen || false,
preventFocus: false,
inputValue: null,
preSelection: initialPreSelection ?? boundedPreSelection,
preSelection: initialPreSelection ?? safePreSelection,
// transforming highlighted days (perhaps nested array)
// to flat Map for faster access in day.jsx
highlightDates: getHighLightDaysMap(this.props.highlightDates),
Expand Down
24 changes: 24 additions & 0 deletions src/test/date_utils_test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
isSameQuarter,
isSameYear,
isDayDisabled,
getEnabledDateInMonth,
isDayExcluded,
formatDate,
isMonthDisabled,
isQuarterDisabled,
isYearDisabled,
Expand Down Expand Up @@ -346,6 +348,28 @@ describe("date_utils", () => {
});
});

describe("getEnabledDateInMonth", () => {
it("returns the date unchanged when it is enabled", () => {
const day = newDate("2024-06-15");
expect(getEnabledDateInMonth(day, {})).toBe(day);
});

it("returns the first enabled day in the month when the date is disabled", () => {
const includeDates = [newDate("2024-06-10"), newDate("2024-06-20")];
const result = getEnabledDateInMonth(newDate("2024-06-15"), {
includeDates,
});
expect(formatDate(result!, "yyyy-MM-dd")).toBe("2024-06-10");
});

it("returns null when no day in the month is enabled", () => {
const includeDates = [newDate("2024-07-01")];
expect(
getEnabledDateInMonth(newDate("2024-06-15"), { includeDates }),
).toBe(null);
});
});

describe("isDayExcluded", () => {
it("should not be excluded by default", () => {
const day = newDate();
Expand Down
36 changes: 36 additions & 0 deletions src/test/datepicker_test.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,42 @@ describe("DatePicker", () => {
expect(instance?.state.preSelection).toBe(originalPreSelection);
});

describe("initial preSelection when openToDate is a disabled day (issue #6286)", () => {
it("snaps preSelection to the first enabled day in the month so a day stays keyboard-focusable", () => {
const includeDates = [newDate("2024-06-10"), newDate("2024-06-20")];
const { instance, container } = renderDatePickerWithRef({
inline: true,
includeDates,
// a gap day: inside the available range but not itself selectable
openToDate: newDate("2024-06-15"),
});

// a roving-tabindex day must exist, otherwise keyboard and screen-reader
// users cannot move into the grid
expect(
container.querySelector('.react-datepicker__day[tabindex="0"]'),
).toBeTruthy();

expect(instance?.state.preSelection).toBeTruthy();
expect(formatDate(instance!.state.preSelection!, "yyyy-MM-dd")).toBe(
"2024-06-10",
);
});

it("keeps an enabled openToDate as the preSelection", () => {
const includeDates = [newDate("2024-06-10"), newDate("2024-06-20")];
const { instance } = renderDatePickerWithRef({
inline: true,
includeDates,
openToDate: newDate("2024-06-20"),
});

expect(formatDate(instance!.state.preSelection!, "yyyy-MM-dd")).toBe(
"2024-06-20",
);
});
});

it("short-circuits day key navigation when keyboard navigation is disabled", () => {
const onKeyDown = jest.fn();
const preSelection = newDate("2024-06-15");
Expand Down
Loading