From 24c49276376193307ff5c2ea87884b5a1b528841 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Fri, 1 May 2026 11:33:01 +0200 Subject: [PATCH] refactor: add internal date adapter boundary --- .../src/classes/CalendarDay.ts | 15 ++- .../src/helpers/getBroadcastWeeksInMonth.ts | 6 +- .../src/helpers/getDisplayMonths.ts | 8 +- .../src/helpers/getMonthOptions.ts | 6 +- .../src/helpers/getMonths.test.ts | 27 ++++ .../react-day-picker/src/helpers/getMonths.ts | 16 ++- .../src/helpers/startOfBroadcastWeek.ts | 4 +- .../src/internal/dateAdapter.test.ts | 127 ++++++++++++++++++ .../src/internal/dateAdapter.ts | 87 ++++++++++++ .../src/selection/useMulti.test.tsx | 33 ++++- .../src/selection/useMulti.tsx | 9 +- .../src/selection/useRange.test.tsx | 40 +++++- .../src/selection/useRange.tsx | 6 +- .../src/selection/useSingle.test.tsx | 33 ++++- .../src/selection/useSingle.tsx | 12 +- .../react-day-picker/src/useCalendar.test.tsx | 78 +++++++++++ packages/react-day-picker/src/useCalendar.ts | 16 ++- .../react-day-picker/src/utils/addToRange.ts | 19 +-- .../src/utils/dateMatchModifiers.test.ts | 17 +++ .../src/utils/dateMatchModifiers.ts | 22 ++- .../src/utils/rangeContainsDayOfWeek.test.ts | 19 +++ .../src/utils/rangeContainsDayOfWeek.ts | 4 +- 22 files changed, 555 insertions(+), 49 deletions(-) create mode 100644 packages/react-day-picker/src/internal/dateAdapter.test.ts create mode 100644 packages/react-day-picker/src/internal/dateAdapter.ts create mode 100644 packages/react-day-picker/src/useCalendar.test.tsx diff --git a/packages/react-day-picker/src/classes/CalendarDay.ts b/packages/react-day-picker/src/classes/CalendarDay.ts index b72aba2563..faf15bf84f 100644 --- a/packages/react-day-picker/src/classes/CalendarDay.ts +++ b/packages/react-day-picker/src/classes/CalendarDay.ts @@ -1,3 +1,4 @@ +import { createDateAdapter } from "../internal/dateAdapter.js"; import { type DateLib, defaultDateLib } from "./DateLib.js"; /** @@ -13,15 +14,16 @@ export class CalendarDay { displayMonth: Date, dateLib: DateLib = defaultDateLib, ) { + const dateAdapter = createDateAdapter(dateLib); this.date = date; this.displayMonth = displayMonth; this.outside = Boolean( - displayMonth && !dateLib.isSameMonth(date, displayMonth), + displayMonth && !dateAdapter.isSameMonth(date, displayMonth), ); this.dateLib = dateLib; - this.isoDate = dateLib.format(date, "yyyy-MM-dd"); - this.displayMonthId = dateLib.format(displayMonth, "yyyy-MM"); - this.dateMonthId = dateLib.format(date, "yyyy-MM"); + this.isoDate = dateAdapter.dayKey(date); + this.displayMonthId = dateAdapter.monthKey(displayMonth); + this.dateMonthId = dateAdapter.monthKey(date); } /** @@ -80,9 +82,10 @@ export class CalendarDay { * @returns `true` if the days are equal, otherwise `false`. */ isEqualTo(day: CalendarDay) { + const dateAdapter = createDateAdapter(this.dateLib); return ( - this.dateLib.isSameDay(day.date, this.date) && - this.dateLib.isSameMonth(day.displayMonth, this.displayMonth) + dateAdapter.isSameDay(day.date, this.date) && + dateAdapter.isSameMonth(day.displayMonth, this.displayMonth) ); } } diff --git a/packages/react-day-picker/src/helpers/getBroadcastWeeksInMonth.ts b/packages/react-day-picker/src/helpers/getBroadcastWeeksInMonth.ts index 850b473e7d..fad8a9e545 100644 --- a/packages/react-day-picker/src/helpers/getBroadcastWeeksInMonth.ts +++ b/packages/react-day-picker/src/helpers/getBroadcastWeeksInMonth.ts @@ -1,4 +1,5 @@ import type { DateLib } from "../classes/index.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; const FIVE_WEEKS = 5; const FOUR_WEEKS = 4; @@ -16,12 +17,13 @@ const FOUR_WEEKS = 4; * @returns The number of weeks in the broadcast calendar (4 or 5). */ export function getBroadcastWeeksInMonth(month: Date, dateLib: DateLib): 4 | 5 { + const dateAdapter = createDateAdapter(dateLib); // Get the first day of the month const firstDayOfMonth = dateLib.startOfMonth(month); // Get the day of the week for the first day of the month (1-7, where 1 is Monday) - const firstDayOfWeek = - firstDayOfMonth.getDay() > 0 ? firstDayOfMonth.getDay() : 7; + const firstDay = dateAdapter.getDay(firstDayOfMonth); + const firstDayOfWeek = firstDay > 0 ? firstDay : 7; const broadcastStartDate = dateLib.addDays(month, -firstDayOfWeek + 1); diff --git a/packages/react-day-picker/src/helpers/getDisplayMonths.ts b/packages/react-day-picker/src/helpers/getDisplayMonths.ts index 3034ee8aca..0459b91cfa 100644 --- a/packages/react-day-picker/src/helpers/getDisplayMonths.ts +++ b/packages/react-day-picker/src/helpers/getDisplayMonths.ts @@ -1,4 +1,8 @@ import type { DateLib } from "../classes/DateLib.js"; +import { + createDateAdapter, + type DateAdapter, +} from "../internal/dateAdapter.js"; import type { DayPickerProps } from "../types/index.js"; /** @@ -9,6 +13,7 @@ import type { DayPickerProps } from "../types/index.js"; * @param calendarEndMonth The latest month the user can navigate to. * @param props The DayPicker props, including `numberOfMonths`. * @param dateLib The date library to use for date manipulation. + * @param dateAdapter Internal date boundary used for range comparisons. * @returns An array of dates representing the months to display. */ export function getDisplayMonths( @@ -16,12 +21,13 @@ export function getDisplayMonths( calendarEndMonth: Date | undefined, props: Pick, dateLib: DateLib, + dateAdapter: DateAdapter = createDateAdapter(dateLib), ): Date[] { const { numberOfMonths = 1 } = props; const months: Date[] = []; for (let i = 0; i < numberOfMonths; i++) { const month = dateLib.addMonths(firstDisplayedMonth, i); - if (calendarEndMonth && month > calendarEndMonth) { + if (calendarEndMonth && dateAdapter.compare(month, calendarEndMonth) > 0) { break; } months.push(month); diff --git a/packages/react-day-picker/src/helpers/getMonthOptions.ts b/packages/react-day-picker/src/helpers/getMonthOptions.ts index 4b19ece46d..de5c54990b 100644 --- a/packages/react-day-picker/src/helpers/getMonthOptions.ts +++ b/packages/react-day-picker/src/helpers/getMonthOptions.ts @@ -1,5 +1,6 @@ import type { DateLib } from "../classes/DateLib.js"; import type { DropdownOption } from "../components/Dropdown.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; import type { Formatters } from "../types/index.js"; /** @@ -31,6 +32,7 @@ export function getMonthOptions( eachMonthOfInterval, getMonth, } = dateLib; + const dateAdapter = createDateAdapter(dateLib); const months = eachMonthOfInterval({ start: startOfYear(displayMonth), @@ -41,8 +43,8 @@ export function getMonthOptions( const label = formatters.formatMonthDropdown(month, dateLib); const value = getMonth(month); const disabled = - (navStart && month < startOfMonth(navStart)) || - (navEnd && month > startOfMonth(navEnd)) || + (navStart && dateAdapter.compare(month, startOfMonth(navStart)) < 0) || + (navEnd && dateAdapter.compare(month, startOfMonth(navEnd)) > 0) || false; return { value, label, disabled }; }); diff --git a/packages/react-day-picker/src/helpers/getMonths.test.ts b/packages/react-day-picker/src/helpers/getMonths.test.ts index dc4b3a3369..457a67a3d8 100644 --- a/packages/react-day-picker/src/helpers/getMonths.test.ts +++ b/packages/react-day-picker/src/helpers/getMonths.test.ts @@ -86,3 +86,30 @@ test("should handle months with no dates", () => { expect(result[0]).toBeInstanceOf(CalendarMonth); expect(result[0].weeks).toHaveLength(0); // No dates should result in no weeks }); + +describe("when dates fall on displayed week boundaries", () => { + const displayMonth = new Date(2024, 0, 1); + const firstBoundaryDate = dateLib.startOfWeek(displayMonth); + const lastBoundaryDate = dateLib.endOfWeek(dateLib.endOfMonth(displayMonth)); + const outsideDates = [ + dateLib.addDays(firstBoundaryDate, -1), + dateLib.addDays(lastBoundaryDate, 1), + ]; + let monthDates: Date[]; + + beforeEach(() => { + const result = getMonths( + [displayMonth], + [outsideDates[0], firstBoundaryDate, lastBoundaryDate, outsideDates[1]], + mockProps, + dateLib, + ); + monthDates = result[0].weeks.flatMap((week) => + week.days.map((day) => day.date), + ); + }); + + test("includes only the boundary dates", () => { + expect(monthDates).toEqual([firstBoundaryDate, lastBoundaryDate]); + }); +}); diff --git a/packages/react-day-picker/src/helpers/getMonths.ts b/packages/react-day-picker/src/helpers/getMonths.ts index d8f424fc7c..2508c2d85c 100644 --- a/packages/react-day-picker/src/helpers/getMonths.ts +++ b/packages/react-day-picker/src/helpers/getMonths.ts @@ -1,5 +1,9 @@ import type { DateLib } from "../classes/DateLib.js"; import { CalendarDay, CalendarMonth, CalendarWeek } from "../classes/index.js"; +import { + createDateAdapter, + type DateAdapter, +} from "../internal/dateAdapter.js"; import type { DayPickerProps } from "../types/index.js"; /** @@ -13,6 +17,7 @@ import type { DayPickerProps } from "../types/index.js"; * @param dates The dates to display in the calendar. * @param props Options from the DayPicker props context. * @param dateLib The date library to use for date manipulation. + * @param dateAdapter Internal date boundary used for filtering calendar dates. * @returns An array of `CalendarMonth` objects representing the months to * display. */ @@ -24,6 +29,7 @@ export function getMonths( "broadcastCalendar" | "fixedWeeks" | "ISOWeek" | "reverseMonths" >, dateLib: DateLib, + dateAdapter: DateAdapter = createDateAdapter(dateLib), ): CalendarMonth[] { const { addDays, @@ -54,7 +60,10 @@ export function getMonths( /** The dates to display in the month. */ const monthDates = dates.filter((date) => { - return date >= firstDateOfFirstWeek && date <= lastDateOfLastWeek; + return ( + dateAdapter.compare(date, firstDateOfFirstWeek) >= 0 && + dateAdapter.compare(date, lastDateOfLastWeek) <= 0 + ); }); const nrOfDaysWithFixedWeeks = props.broadcastCalendar ? 35 : 42; @@ -63,8 +72,9 @@ export function getMonths( const extraDates = dates.filter((date) => { const daysToAdd = nrOfDaysWithFixedWeeks - monthDates.length; return ( - date > lastDateOfLastWeek && - date <= addDays(lastDateOfLastWeek, daysToAdd) + dateAdapter.compare(date, lastDateOfLastWeek) > 0 && + dateAdapter.compare(date, addDays(lastDateOfLastWeek, daysToAdd)) <= + 0 ); }); monthDates.push(...extraDates); diff --git a/packages/react-day-picker/src/helpers/startOfBroadcastWeek.ts b/packages/react-day-picker/src/helpers/startOfBroadcastWeek.ts index 55e8f2af1f..0e07bc662a 100644 --- a/packages/react-day-picker/src/helpers/startOfBroadcastWeek.ts +++ b/packages/react-day-picker/src/helpers/startOfBroadcastWeek.ts @@ -1,4 +1,5 @@ import type { DateLib } from "../classes/index.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; /** * Returns the start date of the week in the broadcast calendar. @@ -13,8 +14,9 @@ import type { DateLib } from "../classes/index.js"; * @returns The start date of the broadcast week. */ export function startOfBroadcastWeek(date: Date, dateLib: DateLib): Date { + const dateAdapter = createDateAdapter(dateLib); const firstOfMonth = dateLib.startOfMonth(date); - const dayOfWeek = firstOfMonth.getDay(); + const dayOfWeek = dateAdapter.getDay(firstOfMonth); if (dayOfWeek === 1) { return firstOfMonth; diff --git a/packages/react-day-picker/src/internal/dateAdapter.test.ts b/packages/react-day-picker/src/internal/dateAdapter.test.ts new file mode 100644 index 0000000000..6066078291 --- /dev/null +++ b/packages/react-day-picker/src/internal/dateAdapter.test.ts @@ -0,0 +1,127 @@ +import { TZDate } from "@date-fns/tz"; + +import { CalendarDay } from "../classes/CalendarDay.js"; +import { DateLib } from "../classes/DateLib.js"; + +import { createDateAdapter } from "./dateAdapter.js"; + +describe("createDateAdapter", () => { + describe("when creating a time key for a native Date", () => { + const date = new Date(2024, 0, 15); + let result: number; + + beforeEach(() => { + const adapter = createDateAdapter(new DateLib()); + result = adapter.timeKey(date); + }); + + test("uses getTime-compatible values", () => { + expect(result).toBe(date.getTime()); + }); + }); + + describe("when creating a time key for a TZDate", () => { + const date = new TZDate(2024, 0, 15, "Pacific/Honolulu"); + let result: number; + + beforeEach(() => { + const adapter = createDateAdapter(new DateLib()); + result = adapter.timeKey(date); + }); + + test("uses getTime-compatible values", () => { + expect(result).toBe(date.getTime()); + }); + }); + + describe("when comparing dates", () => { + const earlier = new Date(2024, 0, 1); + const later = new Date(2024, 0, 2); + let earlierResult: number; + let laterResult: number; + let matchingResult: number; + + beforeEach(() => { + const adapter = createDateAdapter(new DateLib()); + earlierResult = adapter.compare(earlier, later); + laterResult = adapter.compare(later, earlier); + matchingResult = adapter.compare(earlier, new Date(earlier)); + }); + + test("returns a negative value for earlier dates", () => { + expect(earlierResult).toBeLessThan(0); + }); + + test("returns a positive value for later dates", () => { + expect(laterResult).toBeGreaterThan(0); + }); + + test("returns zero for matching timestamps", () => { + expect(matchingResult).toBe(0); + }); + }); + + describe("when creating stable keys", () => { + const date = new Date(2024, 0, 15); + const displayMonth = new Date(2024, 0, 1); + let calendarDay: CalendarDay; + let dayKey: string; + let displayMonthKey: string; + let dateMonthKey: string; + + beforeEach(() => { + const dateLib = new DateLib(); + const adapter = createDateAdapter(dateLib); + calendarDay = new CalendarDay(date, displayMonth, dateLib); + dayKey = adapter.dayKey(date); + displayMonthKey = adapter.monthKey(displayMonth); + dateMonthKey = adapter.monthKey(date); + }); + + test("matches the CalendarDay day key", () => { + expect(dayKey).toBe(calendarDay.isoDate); + }); + + test("matches the CalendarDay display month key", () => { + expect(displayMonthKey).toBe(calendarDay.displayMonthId); + }); + + test("matches the CalendarDay date month key", () => { + expect(dateMonthKey).toBe(calendarDay.dateMonthId); + }); + }); + + describe("when DateLib overrides are provided", () => { + const nextDate = new Date(2024, 0, 20); + let addDaysResult: Date; + let isSameDayResult: boolean; + let dayKeyResult: string; + + beforeEach(() => { + const dateLib = new DateLib(undefined, { + addDays: () => nextDate, + format: (_date, formatStr) => `formatted:${formatStr}`, + isSameDay: () => true, + }); + const adapter = createDateAdapter(dateLib); + addDaysResult = adapter.addDays(new Date(2024, 0, 15), 5); + isSameDayResult = adapter.isSameDay( + new Date(2024, 0, 15), + new Date(2024, 0, 16), + ); + dayKeyResult = adapter.dayKey(new Date(2024, 0, 15)); + }); + + test("delegates date math", () => { + expect(addDaysResult).toBe(nextDate); + }); + + test("delegates date equality", () => { + expect(isSameDayResult).toBe(true); + }); + + test("delegates stable key formatting", () => { + expect(dayKeyResult).toBe("formatted:yyyy-MM-dd"); + }); + }); +}); diff --git a/packages/react-day-picker/src/internal/dateAdapter.ts b/packages/react-day-picker/src/internal/dateAdapter.ts new file mode 100644 index 0000000000..6f78ad3c31 --- /dev/null +++ b/packages/react-day-picker/src/internal/dateAdapter.ts @@ -0,0 +1,87 @@ +import type { DateLib } from "../classes/DateLib.js"; + +/** + * Internal boundary for calendar date operations. + * + * `react-day-picker` still exposes `Date` everywhere. This adapter keeps the + * implementation from reaching directly for native `Date` methods in core + * algorithms, so a future package can provide the same operations for another + * date type without changing the public API of this package. + */ +export type DateAdapter = { + /** + * Preserves DayPicker's matcher/type-guard behavior for the active date + * implementation. + */ + isDate: (value: unknown) => value is TDate; + /** Compares two dates at DayPicker's selected-day granularity. */ + isSameDay: (dateLeft: TDate, dateRight: TDate) => boolean; + /** Compares two dates at displayed-month granularity. */ + isSameMonth: (dateLeft: TDate, dateRight: TDate) => boolean; + /** Returns whether a date falls before another date in calendar logic. */ + isBefore: (date: TDate, dateToCompare: TDate) => boolean; + /** Returns whether a date falls after another date in calendar logic. */ + isAfter: (date: TDate, dateToCompare: TDate) => boolean; + /** + * Orders dates for navigation clamps and boundary filtering. + * + * The return value follows `Array.prototype.sort` comparator semantics. + */ + compare: (dateLeft: TDate, dateRight: TDate) => number; + /** + * Stable numeric key for memo dependencies. + * + * The `Date` adapter must keep this equivalent to `date.getTime()` so the + * refactor does not change existing rerender behavior. + */ + timeKey: (date: TDate) => number; + /** Stable calendar-day id used by `CalendarDay` keys and data attributes. */ + dayKey: (date: TDate) => string; + /** Stable calendar-month id used by `CalendarDay` keys and data attributes. */ + monthKey: (date: TDate) => string; + /** Adds calendar days using the active date implementation. */ + addDays: (date: TDate, amount: number) => TDate; + /** Adds calendar months using the active date implementation. */ + addMonths: (date: TDate, amount: number) => TDate; + /** Normalizes a date to the first day of its month. */ + startOfMonth: (date: TDate) => TDate; + /** Normalizes a date to the last day of its month. */ + endOfMonth: (date: TDate) => TDate; + /** Returns the weekday number used by DayPicker matchers and week helpers. */ + getDay: (date: TDate) => number; + /** Returns the month index used by dropdown values. */ + getMonth: (date: TDate) => number; + /** Returns the year used by dropdown and caption helpers. */ + getYear: (date: TDate) => number; + /** Formats dates through the active date implementation. */ + format: (date: TDate, formatStr: string) => string; +}; + +/** + * Creates the current `Date`-backed adapter from `DateLib`. + * + * The adapter deliberately delegates to `DateLib` so custom `dateLib` + * overrides, timezone handling, and `TZDate` behavior remain the source of + * truth while internals move behind a future-ready boundary. + */ +export function createDateAdapter(dateLib: DateLib): DateAdapter { + return { + isDate: dateLib.isDate, + isSameDay: dateLib.isSameDay, + isSameMonth: dateLib.isSameMonth, + isBefore: dateLib.isBefore, + isAfter: dateLib.isAfter, + compare: (dateLeft, dateRight) => dateLeft.getTime() - dateRight.getTime(), + timeKey: (date) => date.getTime(), + dayKey: (date) => dateLib.format(date, "yyyy-MM-dd"), + monthKey: (date) => dateLib.format(date, "yyyy-MM"), + addDays: dateLib.addDays, + addMonths: dateLib.addMonths, + startOfMonth: dateLib.startOfMonth, + endOfMonth: dateLib.endOfMonth, + getDay: (date) => date.getDay(), + getMonth: dateLib.getMonth, + getYear: dateLib.getYear, + format: dateLib.format, + }; +} diff --git a/packages/react-day-picker/src/selection/useMulti.test.tsx b/packages/react-day-picker/src/selection/useMulti.test.tsx index 0caa210749..170bb558ec 100644 --- a/packages/react-day-picker/src/selection/useMulti.test.tsx +++ b/packages/react-day-picker/src/selection/useMulti.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from "@/test/render"; -import { defaultDateLib } from "../classes/DateLib"; +import { DateLib, defaultDateLib } from "../classes/DateLib"; import type { DayPickerProps } from "../types"; import { useMulti } from "./useMulti"; @@ -38,4 +38,35 @@ describe("useMulti", () => { new Date(2023, 9, 3), ]); }); + + describe("when DateLib treats the trigger date as already selected", () => { + const selectedDates = [new Date(2023, 9, 1)]; + let selected: unknown; + + beforeEach(() => { + const dateLib = new DateLib(undefined, { + isSameDay: () => true, + }); + const props: DayPickerProps = { + mode: "multiple", + selected: selectedDates, + }; + + const { result } = renderHook(() => useMulti(props, dateLib)); + + act(() => { + result.current.select?.( + new Date(2023, 9, 2), + {}, + {} as React.MouseEvent, + ); + }); + + selected = result.current.selected; + }); + + test("removes the selected value", () => { + expect(selected).toEqual([]); + }); + }); }); diff --git a/packages/react-day-picker/src/selection/useMulti.tsx b/packages/react-day-picker/src/selection/useMulti.tsx index 9336e88b04..6b0dd22b58 100644 --- a/packages/react-day-picker/src/selection/useMulti.tsx +++ b/packages/react-day-picker/src/selection/useMulti.tsx @@ -2,6 +2,7 @@ import type React from "react"; import type { DateLib } from "../classes/DateLib.js"; import { useControlledValue } from "../helpers/useControlledValue.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; import type { DayPickerProps, Modifiers, @@ -35,10 +36,10 @@ export function useMulti( const selected = !onSelect ? internallySelected : initiallySelected; - const { isSameDay } = dateLib; + const dateAdapter = createDateAdapter(dateLib); const isSelected = (date: Date) => { - return selected?.some((d) => isSameDay(d, date)) ?? false; + return selected?.some((d) => dateAdapter.isSameDay(d, date)) ?? false; }; const { min, max } = props as PropsMulti; @@ -58,7 +59,9 @@ export function useMulti( // Required value already selected do nothing return; } - newDates = selected?.filter((d) => !isSameDay(d, triggerDate)); + newDates = selected?.filter( + (d) => !dateAdapter.isSameDay(d, triggerDate), + ); } else { if (selected?.length === max) { // Max value reached, reset the selection to date diff --git a/packages/react-day-picker/src/selection/useRange.test.tsx b/packages/react-day-picker/src/selection/useRange.test.tsx index 548bc5eac1..895265557c 100644 --- a/packages/react-day-picker/src/selection/useRange.test.tsx +++ b/packages/react-day-picker/src/selection/useRange.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from "@/test/render"; -import { defaultDateLib } from "../classes/DateLib"; +import { DateLib, defaultDateLib } from "../classes/DateLib"; import type { DayPickerProps } from "../types"; import { useRange } from "./useRange"; @@ -149,6 +149,44 @@ describe("useRange", () => { }); }); + describe("when DateLib treats the trigger date as a single-day range", () => { + let selected: unknown; + + beforeEach(() => { + const dateLib = new DateLib(undefined, { + isSameDay: () => true, + }); + const { result } = renderHook(() => + useRange( + { + mode: "range", + selected: { + from: new Date(2023, 6, 15), + to: new Date(2023, 6, 16), + }, + required: false, + resetOnSelect: true, + }, + dateLib, + ), + ); + + act(() => { + result.current.select?.( + new Date(2023, 6, 17), + {}, + {} as React.MouseEvent, + ); + }); + + selected = result.current.selected; + }); + + test("clears the selected range", () => { + expect(selected).toBeUndefined(); + }); + }); + test("reset range when full range is selected", () => { const fullRange = { from: new Date(2023, 6, 1), diff --git a/packages/react-day-picker/src/selection/useRange.tsx b/packages/react-day-picker/src/selection/useRange.tsx index b58d533d46..2ad40a3530 100644 --- a/packages/react-day-picker/src/selection/useRange.tsx +++ b/packages/react-day-picker/src/selection/useRange.tsx @@ -2,6 +2,7 @@ import type React from "react"; import type { DateLib } from "../classes/DateLib.js"; import { useControlledValue } from "../helpers/useControlledValue.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; import type { DayPickerProps, Modifiers, @@ -39,6 +40,7 @@ export function useRange( ); const selected = !onSelect ? internallySelected : initiallySelected; + const dateAdapter = createDateAdapter(dateLib); const isSelected = (date: Date) => selected && rangeIncludesDate(selected, date, false, dateLib); @@ -57,8 +59,8 @@ export function useRange( const isClickingSingleDayRange = !!selectedFrom && !!selectedTo && - dateLib.isSameDay(selectedFrom, selectedTo) && - dateLib.isSameDay(triggerDate, selectedFrom); + dateAdapter.isSameDay(selectedFrom, selectedTo) && + dateAdapter.isSameDay(triggerDate, selectedFrom); if (resetOnSelect && (hasFullRange || !selected?.from)) { if (!required && isClickingSingleDayRange) { diff --git a/packages/react-day-picker/src/selection/useSingle.test.tsx b/packages/react-day-picker/src/selection/useSingle.test.tsx index 52207267c9..bc5bce4a89 100644 --- a/packages/react-day-picker/src/selection/useSingle.test.tsx +++ b/packages/react-day-picker/src/selection/useSingle.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from "@/test/render"; -import { defaultDateLib } from "../classes/DateLib"; +import { DateLib, defaultDateLib } from "../classes/DateLib"; import type { DayPickerProps } from "../types"; import { useSingle } from "./useSingle"; @@ -35,4 +35,35 @@ describe("useSingle", () => { expect(result.current.selected).toEqual(new Date(2023, 9, 2)); }); + + describe("when DateLib treats the trigger date as the selected day", () => { + const selectedDate = new Date(2023, 9, 1); + let selected: unknown; + + beforeEach(() => { + const dateLib = new DateLib(undefined, { + isSameDay: () => true, + }); + const props: DayPickerProps = { + mode: "single", + selected: selectedDate, + }; + + const { result } = renderHook(() => useSingle(props, dateLib)); + + act(() => { + result.current.select?.( + new Date(2023, 9, 2), + {}, + {} as React.MouseEvent, + ); + }); + + selected = result.current.selected; + }); + + test("clears the selected value", () => { + expect(selected).toBeUndefined(); + }); + }); }); diff --git a/packages/react-day-picker/src/selection/useSingle.tsx b/packages/react-day-picker/src/selection/useSingle.tsx index 88fe2216b6..6ee75e0886 100644 --- a/packages/react-day-picker/src/selection/useSingle.tsx +++ b/packages/react-day-picker/src/selection/useSingle.tsx @@ -2,6 +2,7 @@ import type React from "react"; import type { DateLib } from "../classes/DateLib.js"; import { useControlledValue } from "../helpers/useControlledValue.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; import type { DayPickerProps, Modifiers, @@ -43,10 +44,10 @@ export function useSingle( const selected = !onSelect ? internallySelected : initiallySelected; - const { isSameDay } = dateLib; + const dateAdapter = createDateAdapter(dateLib); const isSelected = (compareDate: Date) => { - return selected ? isSameDay(selected, compareDate) : false; + return selected ? dateAdapter.isSameDay(selected, compareDate) : false; }; const select = ( @@ -55,7 +56,12 @@ export function useSingle( e: React.MouseEvent | React.KeyboardEvent, ) => { let newDate: Date | undefined = triggerDate; - if (!required && selected && selected && isSameDay(triggerDate, selected)) { + if ( + !required && + selected && + selected && + dateAdapter.isSameDay(triggerDate, selected) + ) { // If the date is the same, clear the selection. newDate = undefined; } diff --git a/packages/react-day-picker/src/useCalendar.test.tsx b/packages/react-day-picker/src/useCalendar.test.tsx new file mode 100644 index 0000000000..8feb3b14ce --- /dev/null +++ b/packages/react-day-picker/src/useCalendar.test.tsx @@ -0,0 +1,78 @@ +import { act, renderHook } from "@/test/render"; + +import { defaultDateLib } from "./classes/DateLib"; +import { useCalendar } from "./useCalendar"; + +describe("useCalendar", () => { + describe("when navigating before the first navigable month", () => { + const startMonth = new Date(2024, 1, 1); + const endMonth = new Date(2024, 3, 1); + let displayedMonth: Date | undefined; + let handleMonthChange: jest.Mock; + + beforeEach(() => { + handleMonthChange = jest.fn(); + const { result } = renderHook(() => + useCalendar( + { + defaultMonth: startMonth, + startMonth, + endMonth, + onMonthChange: handleMonthChange, + }, + defaultDateLib, + ), + ); + + act(() => { + result.current.goToMonth(new Date(2024, 0, 15)); + }); + + displayedMonth = result.current.months[0].date; + }); + + test("calls onMonthChange with the first navigable month", () => { + expect(handleMonthChange).toHaveBeenCalledWith(startMonth); + }); + + test("displays the first navigable month", () => { + expect(displayedMonth).toEqual(startMonth); + }); + }); + + describe("when navigating after the last navigable month", () => { + const startMonth = new Date(2024, 1, 1); + const endMonth = new Date(2024, 3, 1); + let displayedMonth: Date | undefined; + let handleMonthChange: jest.Mock; + + beforeEach(() => { + handleMonthChange = jest.fn(); + const { result } = renderHook(() => + useCalendar( + { + defaultMonth: startMonth, + startMonth, + endMonth, + onMonthChange: handleMonthChange, + }, + defaultDateLib, + ), + ); + + act(() => { + result.current.goToMonth(new Date(2024, 4, 15)); + }); + + displayedMonth = result.current.months[0].date; + }); + + test("calls onMonthChange with the last navigable month", () => { + expect(handleMonthChange).toHaveBeenCalledWith(endMonth); + }); + + test("displays the last navigable month", () => { + expect(displayedMonth).toEqual(endMonth); + }); + }); +}); diff --git a/packages/react-day-picker/src/useCalendar.ts b/packages/react-day-picker/src/useCalendar.ts index 44a02c0f11..99250dc8cd 100644 --- a/packages/react-day-picker/src/useCalendar.ts +++ b/packages/react-day-picker/src/useCalendar.ts @@ -16,6 +16,7 @@ import { getNextMonth } from "./helpers/getNextMonth.js"; import { getPreviousMonth } from "./helpers/getPreviousMonth.js"; import { getWeeks } from "./helpers/getWeeks.js"; import { useControlledValue } from "./helpers/useControlledValue.js"; +import { createDateAdapter } from "./internal/dateAdapter.js"; import type { DayPickerProps } from "./types/props.js"; /** @@ -92,6 +93,7 @@ export function useCalendar( >, dateLib: DateLib, ): Calendar { + const dateAdapter = useMemo(() => createDateAdapter(dateLib), [dateLib]); const [navStart, navEnd] = getNavMonths(props, dateLib); const { startOfMonth, endOfMonth } = dateLib; @@ -116,6 +118,7 @@ export function useCalendar( navEnd, { numberOfMonths: props.numberOfMonths }, dateLib, + dateAdapter, ); const dates = getDates( @@ -161,12 +164,13 @@ export function useCalendar( }; }, [ dateLib, - firstMonth.getTime(), - navEnd?.getTime(), - navStart?.getTime(), + dateAdapter, + dateAdapter.timeKey(firstMonth), + navEnd ? dateAdapter.timeKey(navEnd) : undefined, + navStart ? dateAdapter.timeKey(navStart) : undefined, props.disableNavigation, props.broadcastCalendar, - props.endMonth?.getTime(), + props.endMonth ? dateAdapter.timeKey(props.endMonth) : undefined, props.fixedWeeks, props.ISOWeek, props.numberOfMonths, @@ -185,11 +189,11 @@ export function useCalendar( } let newMonth = startOfMonth(date); // if month is before start, use the first month instead - if (navStart && newMonth < startOfMonth(navStart)) { + if (navStart && dateAdapter.compare(newMonth, startOfMonth(navStart)) < 0) { newMonth = startOfMonth(navStart); } // if month is after endMonth, use the last month instead - if (navEnd && newMonth > startOfMonth(navEnd)) { + if (navEnd && dateAdapter.compare(newMonth, startOfMonth(navEnd)) > 0) { newMonth = startOfMonth(navEnd); } setFirstMonth(newMonth); diff --git a/packages/react-day-picker/src/utils/addToRange.ts b/packages/react-day-picker/src/utils/addToRange.ts index 62dd07cd3c..e68abe5ffc 100644 --- a/packages/react-day-picker/src/utils/addToRange.ts +++ b/packages/react-day-picker/src/utils/addToRange.ts @@ -1,4 +1,5 @@ import { type DateLib, defaultDateLib } from "../classes/DateLib.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; import type { DateRange } from "../types/index.js"; /** @@ -23,7 +24,7 @@ export function addToRange( dateLib: DateLib = defaultDateLib, ): DateRange | undefined { const { from, to } = initialRange || {}; - const { isSameDay, isAfter, isBefore } = dateLib; + const dateAdapter = createDateAdapter(dateLib); let range: DateRange | undefined; @@ -32,7 +33,7 @@ export function addToRange( range = { from: date, to: min > 0 ? undefined : date }; } else if (from && !to) { // adding date to an incomplete range - if (isSameDay(from, date)) { + if (dateAdapter.isSameDay(from, date)) { // adding a date equal to the start of the range if (min === 0) { range = { from, to: date }; @@ -41,7 +42,7 @@ export function addToRange( } else { range = undefined; } - } else if (isBefore(date, from)) { + } else if (dateAdapter.isBefore(date, from)) { // adding a date before the start of the range range = { from: date, to: from }; } else { @@ -50,26 +51,26 @@ export function addToRange( } } else if (from && to) { // adding date to a complete range - if (isSameDay(from, date) && isSameDay(to, date)) { + if (dateAdapter.isSameDay(from, date) && dateAdapter.isSameDay(to, date)) { // adding a date that is equal to both start and end of the range if (required) { range = { from, to }; } else { range = undefined; } - } else if (isSameDay(from, date)) { + } else if (dateAdapter.isSameDay(from, date)) { // adding a date equal to the the start of the range range = { from, to: min > 0 ? undefined : date }; - } else if (isSameDay(to, date)) { + } else if (dateAdapter.isSameDay(to, date)) { // adding a dare equal to the end of the range range = { from: date, to: min > 0 ? undefined : date }; - } else if (isBefore(date, from)) { + } else if (dateAdapter.isBefore(date, from)) { // adding a date before the start of the range range = { from: date, to: to }; - } else if (isAfter(date, from)) { + } else if (dateAdapter.isAfter(date, from)) { // adding a date after the start of the range range = { from, to: date }; - } else if (isAfter(date, to)) { + } else if (dateAdapter.isAfter(date, to)) { // adding a date after the end of the range range = { from, to: date }; } else { diff --git a/packages/react-day-picker/src/utils/dateMatchModifiers.test.ts b/packages/react-day-picker/src/utils/dateMatchModifiers.test.ts index 6b74f379bc..22333a26f0 100644 --- a/packages/react-day-picker/src/utils/dateMatchModifiers.test.ts +++ b/packages/react-day-picker/src/utils/dateMatchModifiers.test.ts @@ -1,3 +1,4 @@ +import { TZDate } from "@date-fns/tz"; import { addDays, subDays } from "date-fns"; import { defaultDateLib } from "../classes/DateLib"; @@ -61,6 +62,22 @@ describe("when matching the day of week", () => { }); }); +describe("when matching the day of week for a TZDate", () => { + const testDay = new TZDate(2024, 0, 7, "Pacific/Honolulu"); + const matcher: DayOfWeek = { + dayOfWeek: [testDay.getDay()], + }; + let result: boolean; + + beforeEach(() => { + result = dateMatchModifiers(testDay, [matcher], defaultDateLib); + }); + + test("should return true", () => { + expect(result).toBe(true); + }); +}); + describe("when matching date interval (closed)", () => { const matcher: DateInterval = { before: addDays(testDay, 5), diff --git a/packages/react-day-picker/src/utils/dateMatchModifiers.ts b/packages/react-day-picker/src/utils/dateMatchModifiers.ts index f249d05869..1739b3ca6f 100644 --- a/packages/react-day-picker/src/utils/dateMatchModifiers.ts +++ b/packages/react-day-picker/src/utils/dateMatchModifiers.ts @@ -1,4 +1,5 @@ import { type DateLib, defaultDateLib } from "../classes/DateLib.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; import type { Matcher } from "../types/index.js"; import { rangeIncludesDate } from "./rangeIncludesDate.js"; @@ -26,32 +27,39 @@ export function dateMatchModifiers( dateLib: DateLib = defaultDateLib, ): boolean { const matchersArr = !Array.isArray(matchers) ? [matchers] : matchers; - const { isSameDay, differenceInCalendarDays, isAfter } = dateLib; + const dateAdapter = createDateAdapter(dateLib); + const { differenceInCalendarDays } = dateLib; return matchersArr.some((matcher: Matcher) => { if (typeof matcher === "boolean") { return matcher; } - if (dateLib.isDate(matcher)) { - return isSameDay(date, matcher); + if (dateAdapter.isDate(matcher)) { + return dateAdapter.isSameDay(date, matcher); } if (isDatesArray(matcher, dateLib)) { - return matcher.some((matcherDate) => isSameDay(date, matcherDate)); + return matcher.some((matcherDate) => + dateAdapter.isSameDay(date, matcherDate), + ); } if (isDateRange(matcher)) { return rangeIncludesDate(matcher, date, false, dateLib); } if (isDayOfWeekType(matcher)) { + const dayOfWeek = dateAdapter.getDay(date); if (!Array.isArray(matcher.dayOfWeek)) { - return matcher.dayOfWeek === date.getDay(); + return matcher.dayOfWeek === dayOfWeek; } - return matcher.dayOfWeek.includes(date.getDay()); + return matcher.dayOfWeek.includes(dayOfWeek); } if (isDateInterval(matcher)) { const diffBefore = differenceInCalendarDays(matcher.before, date); const diffAfter = differenceInCalendarDays(matcher.after, date); const isDayBefore = diffBefore > 0; const isDayAfter = diffAfter < 0; - const isClosedInterval = isAfter(matcher.before, matcher.after); + const isClosedInterval = dateAdapter.isAfter( + matcher.before, + matcher.after, + ); if (isClosedInterval) { return isDayAfter && isDayBefore; } else { diff --git a/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.test.ts b/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.test.ts index 038500ec21..6d5f86ffb5 100644 --- a/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.test.ts +++ b/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.test.ts @@ -1,3 +1,5 @@ +import { TZDate } from "@date-fns/tz"; + import { defaultDateLib } from "../classes/DateLib"; import { rangeContainsDayOfWeek } from "./rangeContainsDayOfWeek"; @@ -46,3 +48,20 @@ describe("should return true", () => { }); } }); + +describe("when the range contains a TZDate", () => { + const sunday = new TZDate(2024, 8, 1, "Pacific/Honolulu"); + let result: boolean; + + beforeEach(() => { + result = rangeContainsDayOfWeek( + { from: sunday, to: sunday }, + sunday.getDay(), + defaultDateLib, + ); + }); + + test("should match the day of week", () => { + expect(result).toBe(true); + }); +}); diff --git a/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.ts b/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.ts index f761f21058..590bb2b16c 100644 --- a/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.ts +++ b/packages/react-day-picker/src/utils/rangeContainsDayOfWeek.ts @@ -1,4 +1,5 @@ import { type DateLib, defaultDateLib } from "../classes/DateLib.js"; +import { createDateAdapter } from "../internal/dateAdapter.js"; /** * Checks if a date range contains one or more specified days of the week. @@ -18,13 +19,14 @@ export function rangeContainsDayOfWeek( dateLib: DateLib = defaultDateLib, ) { const dayOfWeekArr = !Array.isArray(dayOfWeek) ? [dayOfWeek] : dayOfWeek; + const dateAdapter = createDateAdapter(dateLib); let date = range.from; const totalDays = dateLib.differenceInCalendarDays(range.to, range.from); // iterate at maximum one week or the total days if the range is shorter than one week const totalDaysLimit = Math.min(totalDays, 6); for (let i = 0; i <= totalDaysLimit; i++) { - if (dayOfWeekArr.includes(date.getDay())) { + if (dayOfWeekArr.includes(dateAdapter.getDay(date))) { return true; } date = dateLib.addDays(date, 1);