diff --git a/.changeset/visible-months-range-panels.md b/.changeset/visible-months-range-panels.md new file mode 100644 index 0000000000..3e0f76550c --- /dev/null +++ b/.changeset/visible-months-range-panels.md @@ -0,0 +1,5 @@ +--- +"react-day-picker": minor +--- + +feat: add visible months navigation for independent range panels. diff --git a/packages/react-day-picker/src/DayPicker.test.tsx b/packages/react-day-picker/src/DayPicker.test.tsx index bc0359837b..069896daca 100644 --- a/packages/react-day-picker/src/DayPicker.test.tsx +++ b/packages/react-day-picker/src/DayPicker.test.tsx @@ -658,6 +658,160 @@ describe("when using reversed dropdowns with numberOfMonths > 1 (issue #2741)", }); }); +describe("when using visibleMonths in range mode", () => { + const january2024 = new Date(2024, 0, 1); + const december2025 = new Date(2025, 11, 1); + + test("renders the visible months", () => { + render( + , + ); + + expect(grid("January 2024")).toBeInTheDocument(); + expect(grid("December 2025")).toBeInTheDocument(); + }); + + test("changes only the selected panel from its month dropdown", async () => { + const handleVisibleMonthsChange = jest.fn(); + + function TestDayPicker() { + const [visibleMonths, setVisibleMonths] = React.useState([ + january2024, + december2025, + ]); + + return ( + { + handleVisibleMonthsChange(months, context); + setVisibleMonths(months); + }} + visibleMonths={visibleMonths} + /> + ); + } + + render(); + + const secondMonthDropdown = screen.getAllByRole("combobox", { + name: labelMonthDropdown(), + })[1]; + + await user.selectOptions(secondMonthDropdown, "5"); + + const grids = screen.getAllByRole("grid"); + expect(grids[0]).toHaveAccessibleName("January 2024"); + expect(grids[1]).toHaveAccessibleName("June 2025"); + expect(handleVisibleMonthsChange).toHaveBeenCalledWith( + [january2024, new Date(2025, 5, 1)], + { + changedIndex: 1, + month: new Date(2025, 5, 1), + source: "dropdown", + }, + ); + }); + + test("updates only the first visible month when clicking previous", async () => { + render( + , + ); + + await user.click(previousButton()); + + const grids = screen.getAllByRole("grid"); + expect(grids[0]).toHaveAccessibleName("December 2023"); + expect(grids[1]).toHaveAccessibleName("December 2025"); + }); + + test("updates only the last visible month when clicking next", async () => { + render( + , + ); + + await user.click(nextButton()); + + const grids = screen.getAllByRole("grid"); + expect(grids[0]).toHaveAccessibleName("January 2024"); + expect(grids[1]).toHaveAccessibleName("January 2026"); + }); + + test("allows dropdown navigation to make visible months non-chronological", async () => { + const handleVisibleMonthsChange = jest.fn(); + const handleSelect = jest.fn(); + + render( + , + ); + + const firstMonthDropdown = screen.getAllByRole("combobox", { + name: labelMonthDropdown(), + })[0]; + + await user.selectOptions(firstMonthDropdown, "2"); + + const grids = screen.getAllByRole("grid"); + expect(grids[0]).toHaveAccessibleName("March 2024"); + expect(grids[1]).toHaveAccessibleName("February 2024"); + expect(dateButton(new Date(2024, 2, 15))).toBeInTheDocument(); + expect(dateButton(new Date(2024, 1, 15))).toBeInTheDocument(); + expect(handleVisibleMonthsChange).toHaveBeenCalledWith( + [new Date(2024, 2, 1), new Date(2024, 1, 1)], + { + changedIndex: 0, + month: new Date(2024, 2, 1), + source: "dropdown", + }, + ); + expect(handleSelect).not.toHaveBeenCalled(); + }); + + test("changes the focused panel from keyboard navigation", async () => { + const handleVisibleMonthsChange = jest.fn(); + + render( + , + ); + + await user.click(dateButton(new Date(2024, 0, 15))); + await user.keyboard("{PageDown}"); + + const grids = screen.getAllByRole("grid"); + expect(grids[0]).toHaveAccessibleName("February 2024"); + expect(grids[1]).toHaveAccessibleName("December 2025"); + expect(handleVisibleMonthsChange).toHaveBeenCalledWith( + [new Date(2024, 1, 1), december2025], + { + changedIndex: 0, + month: new Date(2024, 1, 1), + source: "keyboard", + }, + ); + }); +}); + test("should render the custom components", () => { render( + toTimeZone(month, timeZone), + ); + } + if (props.defaultVisibleMonths) { + props.defaultVisibleMonths = props.defaultVisibleMonths.map((month) => + toTimeZone(month, timeZone), + ); + } if (props.startMonth) { props.startMonth = toTimeZone(props.startMonth, timeZone); } @@ -161,7 +171,6 @@ export function DayPicker(initialProps: DayPickerProps) { captionLayout, mode, navLayout, - numberOfMonths = 1, onDayBlur, onDayClick, onDayFocus, @@ -194,6 +203,8 @@ export function DayPicker(initialProps: DayPickerProps) { previousMonth, nextMonth, goToMonth, + goToVisibleMonth, + usesVisibleMonths, } = calendar; const getModifiers = createGetModifiers( @@ -242,15 +253,46 @@ export function DayPicker(initialProps: DayPickerProps) { const handlePreviousClick = useCallback(() => { if (!previousMonth) return; + if (usesVisibleMonths) { + const changedMonth = goToVisibleMonth(0, previousMonth, "navigation"); + if (changedMonth) { + onPrevClick?.(changedMonth); + } + return; + } goToMonth(previousMonth); onPrevClick?.(previousMonth); - }, [previousMonth, goToMonth, onPrevClick]); + }, [ + previousMonth, + usesVisibleMonths, + goToVisibleMonth, + goToMonth, + onPrevClick, + ]); const handleNextClick = useCallback(() => { if (!nextMonth) return; + if (usesVisibleMonths) { + const changedMonth = goToVisibleMonth( + months.length - 1, + nextMonth, + "navigation", + ); + if (changedMonth) { + onNextClick?.(changedMonth); + } + return; + } goToMonth(nextMonth); onNextClick?.(nextMonth); - }, [goToMonth, nextMonth, onNextClick]); + }, [ + goToMonth, + goToVisibleMonth, + months.length, + nextMonth, + onNextClick, + usesVisibleMonths, + ]); const handleDayClick = useCallback( (day: CalendarDay, m: Modifiers) => (e: MouseEvent) => { @@ -326,26 +368,34 @@ export function DayPicker(initialProps: DayPickerProps) { ); const handleMonthChange = useCallback( - (date: Date, monthOffset: number) => + (date: Date, monthOffset: number, displayIndex: number) => (e: ChangeEvent) => { const selectedMonth = Number(e.target.value); const month = dateLib.setMonth( dateLib.startOfMonth(date), selectedMonth, ); + if (usesVisibleMonths) { + goToVisibleMonth(displayIndex, month, "dropdown"); + return; + } goToMonth(dateLib.addMonths(month, -monthOffset)); }, - [dateLib, goToMonth], + [dateLib, goToMonth, goToVisibleMonth, usesVisibleMonths], ); const handleYearChange = useCallback( - (date: Date, monthOffset: number) => + (date: Date, monthOffset: number, displayIndex: number) => (e: ChangeEvent) => { const selectedYear = Number(e.target.value); const month = dateLib.setYear(dateLib.startOfMonth(date), selectedYear); + if (usesVisibleMonths) { + goToVisibleMonth(displayIndex, month, "dropdown"); + return; + } goToMonth(dateLib.addMonths(month, -monthOffset)); }, - [dateLib, goToMonth], + [dateLib, goToMonth, goToVisibleMonth, usesVisibleMonths], ); const { className, style } = useMemo( @@ -491,6 +541,7 @@ export function DayPicker(initialProps: DayPickerProps) { onChange={handleMonthChange( calendarMonth.date, monthOffset, + displayIndex, )} options={getMonthOptions( calendarMonth.date, @@ -519,6 +570,7 @@ export function DayPicker(initialProps: DayPickerProps) { onChange={handleYearChange( calendarMonth.date, monthOffset, + displayIndex, )} options={getYearOptions( navStart, @@ -583,7 +635,7 @@ export function DayPicker(initialProps: DayPickerProps) { {navLayout === "around" && !props.hideNavigation && - displayIndex === numberOfMonths - 1 && ( + displayIndex === months.length - 1 && ( )} - {displayIndex === numberOfMonths - 1 && + {displayIndex === months.length - 1 && navLayout === "after" && !props.hideNavigation && ( { + const displayedMonths = props.visibleMonths ?? props.defaultVisibleMonths; const dataAttributes: Record = { "data-mode": props.mode ?? undefined, "data-required": "required" in props ? props.required : undefined, "data-multiple-months": - (props.numberOfMonths && props.numberOfMonths > 1) || undefined, + (displayedMonths + ? displayedMonths.length > 1 + : props.numberOfMonths && props.numberOfMonths > 1) || undefined, "data-week-numbers": props.showWeekNumber || undefined, "data-broadcast-calendar": props.broadcastCalendar || undefined, "data-nav-layout": props.navLayout || undefined, diff --git a/packages/react-day-picker/src/helpers/getDates.test.ts b/packages/react-day-picker/src/helpers/getDates.test.ts index 5a15c161e6..9c4f94bfa2 100644 --- a/packages/react-day-picker/src/helpers/getDates.test.ts +++ b/packages/react-day-picker/src/helpers/getDates.test.ts @@ -203,6 +203,22 @@ describe("when the first month and the last month are different", () => { expect(dates[dates.length - 1]).toEqual(new Date(2024, 0, 6)); }); }); + describe("when the display months are not chronological", () => { + const firstMonth = new Date(2026, 0, 1); + const lastMonth = new Date(2025, 10, 1); + + test("should return dates between the earliest and latest months", () => { + const dates = getDates( + [firstMonth, lastMonth], + undefined, + { fixedWeeks: false }, + defaultDateLib, + ); + + expect(dates[0]).toEqual(new Date(2025, 9, 26)); + expect(dates[dates.length - 1]).toEqual(new Date(2026, 0, 31)); + }); + }); describe("when using a max date", () => { const firstMonth = new Date(2023, 4, 1); const lastMonth = new Date(2023, 11, 1); diff --git a/packages/react-day-picker/src/helpers/getDates.ts b/packages/react-day-picker/src/helpers/getDates.ts index cdb1e91a38..cb4ce43f6c 100644 --- a/packages/react-day-picker/src/helpers/getDates.ts +++ b/packages/react-day-picker/src/helpers/getDates.ts @@ -19,8 +19,8 @@ export function getDates( props: Pick, dateLib: DateLib, ): Date[] { - const firstMonth = displayMonths[0]; - const lastMonth = displayMonths[displayMonths.length - 1]; + const firstMonth = dateLib.min(displayMonths); + const lastMonth = dateLib.max(displayMonths); const { ISOWeek, fixedWeeks, broadcastCalendar } = props ?? {}; const { diff --git a/packages/react-day-picker/src/types/props.test.tsx b/packages/react-day-picker/src/types/props.test.tsx index 0133f147d2..7e12c8bc6d 100644 --- a/packages/react-day-picker/src/types/props.test.tsx +++ b/packages/react-day-picker/src/types/props.test.tsx @@ -50,6 +50,8 @@ const dateShapedProps: DayPickerProps = { today: date, month, defaultMonth: month, + visibleMonths: [month, endMonth], + defaultVisibleMonths: [month, endMonth], startMonth: month, endMonth, disabled: dateMatchers, @@ -69,6 +71,16 @@ const dateShapedProps: DayPickerProps = { onMonthChange: (changedMonth) => { void (changedMonth satisfies Date); }, + onVisibleMonthsChange: (visibleMonths, context) => { + void (visibleMonths satisfies Date[]); + void (context.changedIndex satisfies number | undefined); + void (context.month satisfies Date | undefined); + void (context.source satisfies + | "navigation" + | "dropdown" + | "keyboard" + | undefined); + }, onNextClick: (nextMonth) => { void (nextMonth satisfies Date); }, @@ -179,6 +191,10 @@ const Test = () => { {/* @ts-expect-error `defaultMonth` must be a Date */} + {/* @ts-expect-error `visibleMonths` must contain Date values */} + + {/* @ts-expect-error `defaultVisibleMonths` must contain Date values */} + {/* @ts-expect-error `startMonth` must be a Date */} {/* @ts-expect-error `endMonth` must be a Date */} diff --git a/packages/react-day-picker/src/types/props.ts b/packages/react-day-picker/src/types/props.ts index 75e201819a..38f7029022 100644 --- a/packages/react-day-picker/src/types/props.ts +++ b/packages/react-day-picker/src/types/props.ts @@ -16,6 +16,7 @@ import type { MonthChangeEventHandler, Numerals, Styles, + VisibleMonthsChangeEventHandler, } from "./shared.js"; /** @@ -108,6 +109,18 @@ export interface PropsBase { * @see https://daypicker.dev/docs/navigation */ month?: Date; + /** + * The months displayed in the calendar. + * + * Use this prop with {@link onVisibleMonthsChange} to control each displayed + * month independently. + */ + visibleMonths?: Date[]; + /** + * The initial months to show in the calendar when using independently + * controlled visible months. + */ + defaultVisibleMonths?: Date[]; /** * The number of displayed months. * @@ -456,6 +469,11 @@ export interface PropsBase { */ onMonthChange?: MonthChangeEventHandler; + /** + * Event fired when the visible months change. + */ + onVisibleMonthsChange?: VisibleMonthsChangeEventHandler; + /** * Event handler when the next month button is clicked. * diff --git a/packages/react-day-picker/src/types/shared.ts b/packages/react-day-picker/src/types/shared.ts index 0da93d6d8b..c360722922 100644 --- a/packages/react-day-picker/src/types/shared.ts +++ b/packages/react-day-picker/src/types/shared.ts @@ -238,6 +238,28 @@ export type DayEventHandler = ( */ export type MonthChangeEventHandler = (month: Date) => void; +/** + * The source of a visible months change. + */ +export type VisibleMonthsChangeSource = "navigation" | "dropdown" | "keyboard"; + +/** + * Additional information about a visible months change. + */ +export type VisibleMonthsChangeContext = { + changedIndex?: number; + month?: Date; + source?: VisibleMonthsChangeSource; +}; + +/** + * The event handler when the visible months are changed in the calendar. + */ +export type VisibleMonthsChangeEventHandler = ( + months: Date[], + context: VisibleMonthsChangeContext, +) => void; + /** * The CSS classnames to use for the {@link UI} elements, the * {@link SelectionState} and the {@link DayFlag}. diff --git a/packages/react-day-picker/src/useCalendar.ts b/packages/react-day-picker/src/useCalendar.ts index 44a02c0f11..02bf5b2b69 100644 --- a/packages/react-day-picker/src/useCalendar.ts +++ b/packages/react-day-picker/src/useCalendar.ts @@ -17,6 +17,7 @@ import { getPreviousMonth } from "./helpers/getPreviousMonth.js"; import { getWeeks } from "./helpers/getWeeks.js"; import { useControlledValue } from "./helpers/useControlledValue.js"; import type { DayPickerProps } from "./types/props.js"; +import type { VisibleMonthsChangeSource } from "./types/shared.js"; /** * Returns the calendar object used by DayPicker custom components. @@ -53,13 +54,24 @@ export interface Calendar { /** Navigate to the specified month. Will fire the `onMonthChange` callback. */ goToMonth: (month: Date) => void; + /** + * Navigate one displayed month by index. Will fire the + * `onVisibleMonthsChange` callback. + */ + goToVisibleMonth: ( + index: number, + month: Date, + source: VisibleMonthsChangeSource, + ) => Date | undefined; /** * Navigate to the month containing the specified day when it falls outside * the currently displayed calendar. * * @param day - The date to navigate to. */ - goToDay: (day: CalendarDay) => void; + goToDay: (day: CalendarDay, refDay?: CalendarDay) => void; + /** Whether the calendar is using the `visibleMonths` display model. */ + usesVisibleMonths: boolean; } /** @@ -85,10 +97,14 @@ export function useCalendar( | "reverseMonths" | "disableNavigation" | "onMonthChange" + | "onVisibleMonthsChange" | "month" | "defaultMonth" + | "mode" | "timeZone" | "broadcastCalendar" + | "visibleMonths" + | "defaultVisibleMonths" >, dateLib: DateLib, ): Calendar { @@ -101,22 +117,49 @@ export function useCalendar( // initialMonth is always computed from props.month if provided props.month ? initialMonth : undefined, ); + const usesVisibleMonths = + props.visibleMonths !== undefined || + props.defaultVisibleMonths !== undefined; + const normalizeVisibleMonths = ( + months: Date[] | undefined, + fallbackMonth: Date, + ) => { + const sourceMonths = months && months.length > 0 ? months : [fallbackMonth]; + return sourceMonths.map((month) => startOfMonth(month)); + }; + const getInitialVisibleMonths = (fallbackMonth: Date) => { + const initialVisibleMonths = + props.defaultVisibleMonths ?? props.visibleMonths; + return normalizeVisibleMonths(initialVisibleMonths, fallbackMonth); + }; + const controlledVisibleMonths = props.visibleMonths + ? normalizeVisibleMonths(props.visibleMonths, initialMonth) + : undefined; + const [visibleMonths, setVisibleMonths] = useControlledValue( + getInitialVisibleMonths(initialMonth), + controlledVisibleMonths, + ); // biome-ignore lint/correctness/useExhaustiveDependencies: change the initial month when the time zone changes. useEffect(() => { const newInitialMonth = getInitialMonth(props, navStart, navEnd, dateLib); setFirstMonth(newInitialMonth); + if (usesVisibleMonths) { + setVisibleMonths(getInitialVisibleMonths(newInitialMonth)); + } }, [props.timeZone]); /** The months displayed in the calendar. */ // biome-ignore lint/correctness/useExhaustiveDependencies: We want to recompute only when specific props change. const { months, weeks, days, previousMonth, nextMonth } = useMemo(() => { - const displayMonths = getDisplayMonths( - firstMonth, - navEnd, - { numberOfMonths: props.numberOfMonths }, - dateLib, - ); + const displayMonths = usesVisibleMonths + ? visibleMonths + : getDisplayMonths( + firstMonth, + navEnd, + { numberOfMonths: props.numberOfMonths }, + dateLib, + ); const dates = getDates( displayMonths, @@ -136,7 +179,7 @@ export function useCalendar( broadcastCalendar: props.broadcastCalendar, fixedWeeks: props.fixedWeeks, ISOWeek: props.ISOWeek, - reverseMonths: props.reverseMonths, + reverseMonths: usesVisibleMonths ? false : props.reverseMonths, }, dateLib, ); @@ -145,12 +188,18 @@ export function useCalendar( const days = getDays(months); const previousMonth = getPreviousMonth( - firstMonth, + displayMonths[0], navStart, - props, + usesVisibleMonths ? { ...props, numberOfMonths: 1 } : props, + dateLib, + ); + const lastDisplayMonth = displayMonths[displayMonths.length - 1]; + const nextMonth = getNextMonth( + usesVisibleMonths ? lastDisplayMonth : firstMonth, + navEnd, + usesVisibleMonths ? { ...props, numberOfMonths: 1 } : props, dateLib, ); - const nextMonth = getNextMonth(firstMonth, navEnd, props, dateLib); return { months, @@ -162,6 +211,7 @@ export function useCalendar( }, [ dateLib, firstMonth.getTime(), + visibleMonths.map((month) => month.getTime()).join("-"), navEnd?.getTime(), navStart?.getTime(), props.disableNavigation, @@ -172,9 +222,10 @@ export function useCalendar( props.numberOfMonths, props.pagedNavigation, props.reverseMonths, + usesVisibleMonths, ]); - const { disableNavigation, onMonthChange } = props; + const { disableNavigation, onMonthChange, onVisibleMonthsChange } = props; const isDayInCalendar = (day: CalendarDay) => weeks.some((week: CalendarWeek) => week.days.some((d) => d.isEqualTo(day))); @@ -196,11 +247,51 @@ export function useCalendar( onMonthChange?.(newMonth); }; - const goToDay = (day: CalendarDay) => { + const goToVisibleMonth = ( + index: number, + date: Date, + source: VisibleMonthsChangeSource, + ) => { + if (disableNavigation || !usesVisibleMonths) { + return undefined; + } + let newMonth = startOfMonth(date); + if (navStart && newMonth < startOfMonth(navStart)) { + newMonth = startOfMonth(navStart); + } + if (navEnd && newMonth > startOfMonth(navEnd)) { + newMonth = startOfMonth(navEnd); + } + const newVisibleMonths = visibleMonths.map((month, monthIndex) => + monthIndex === index ? newMonth : month, + ); + setVisibleMonths(newVisibleMonths); + onVisibleMonthsChange?.(newVisibleMonths, { + changedIndex: index, + month: newMonth, + source, + }); + return newMonth; + }; + + const goToDay = (day: CalendarDay, refDay?: CalendarDay) => { // is this check necessary? if (isDayInCalendar(day)) { return; } + if (usesVisibleMonths) { + const visibleMonthIndex = months.findIndex( + (month) => + startOfMonth(month.date).getTime() === + startOfMonth(refDay?.displayMonth ?? day.displayMonth).getTime(), + ); + goToVisibleMonth( + visibleMonthIndex === -1 ? 0 : visibleMonthIndex, + day.date, + "keyboard", + ); + return; + } goToMonth(day.date); }; @@ -216,7 +307,9 @@ export function useCalendar( nextMonth, goToMonth, + goToVisibleMonth, goToDay, + usesVisibleMonths, }; return calendar; diff --git a/packages/react-day-picker/src/useFocus.ts b/packages/react-day-picker/src/useFocus.ts index 05e66862d1..56f310f7f8 100644 --- a/packages/react-day-picker/src/useFocus.ts +++ b/packages/react-day-picker/src/useFocus.ts @@ -89,7 +89,7 @@ export function useFocus( } } - calendar.goToDay(nextFocus); + calendar.goToDay(nextFocus, focusedDay); setFocused(nextFocus); };