Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/visible-months-range-panels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-day-picker": minor
---

feat: add visible months navigation for independent range panels.
154 changes: 154 additions & 0 deletions packages/react-day-picker/src/DayPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DayPicker mode="range" visibleMonths={[january2024, december2025]} />,
);

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 (
<DayPicker
captionLayout="dropdown"
mode="range"
onVisibleMonthsChange={(months, context) => {
handleVisibleMonthsChange(months, context);
setVisibleMonths(months);
}}
visibleMonths={visibleMonths}
/>
);
}

render(<TestDayPicker />);

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(
<DayPicker
defaultVisibleMonths={[january2024, december2025]}
mode="range"
/>,
);

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(
<DayPicker
defaultVisibleMonths={[january2024, december2025]}
mode="range"
/>,
);

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(
<DayPicker
captionLayout="dropdown"
defaultVisibleMonths={[january2024, new Date(2024, 1, 1)]}
mode="range"
onVisibleMonthsChange={handleVisibleMonthsChange}
onSelect={handleSelect}
selected={{
from: new Date(2024, 0, 15),
to: new Date(2024, 1, 10),
}}
/>,
);

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(
<DayPicker
defaultVisibleMonths={[january2024, december2025]}
mode="range"
onVisibleMonthsChange={handleVisibleMonthsChange}
/>,
);

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(
<DayPicker
Expand Down
70 changes: 61 additions & 9 deletions packages/react-day-picker/src/DayPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export function DayPicker(initialProps: DayPickerProps) {
if (props.defaultMonth) {
props.defaultMonth = toTimeZone(props.defaultMonth, timeZone);
}
if (props.visibleMonths) {
props.visibleMonths = props.visibleMonths.map((month) =>
toTimeZone(month, timeZone),
);
}
if (props.defaultVisibleMonths) {
props.defaultVisibleMonths = props.defaultVisibleMonths.map((month) =>
toTimeZone(month, timeZone),
);
}
if (props.startMonth) {
props.startMonth = toTimeZone(props.startMonth, timeZone);
}
Expand Down Expand Up @@ -161,7 +171,6 @@ export function DayPicker(initialProps: DayPickerProps) {
captionLayout,
mode,
navLayout,
numberOfMonths = 1,
onDayBlur,
onDayClick,
onDayFocus,
Expand Down Expand Up @@ -194,6 +203,8 @@ export function DayPicker(initialProps: DayPickerProps) {
previousMonth,
nextMonth,
goToMonth,
goToVisibleMonth,
usesVisibleMonths,
} = calendar;

const getModifiers = createGetModifiers(
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -326,26 +368,34 @@ export function DayPicker(initialProps: DayPickerProps) {
);

const handleMonthChange = useCallback(
(date: Date, monthOffset: number) =>
(date: Date, monthOffset: number, displayIndex: number) =>
(e: ChangeEvent<HTMLSelectElement>) => {
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<HTMLSelectElement>) => {
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(
Expand Down Expand Up @@ -491,6 +541,7 @@ export function DayPicker(initialProps: DayPickerProps) {
onChange={handleMonthChange(
calendarMonth.date,
monthOffset,
displayIndex,
)}
options={getMonthOptions(
calendarMonth.date,
Expand Down Expand Up @@ -519,6 +570,7 @@ export function DayPicker(initialProps: DayPickerProps) {
onChange={handleYearChange(
calendarMonth.date,
monthOffset,
displayIndex,
)}
options={getYearOptions(
navStart,
Expand Down Expand Up @@ -583,7 +635,7 @@ export function DayPicker(initialProps: DayPickerProps) {
</components.MonthCaption>
{navLayout === "around" &&
!props.hideNavigation &&
displayIndex === numberOfMonths - 1 && (
displayIndex === months.length - 1 && (
<components.NextMonthButton
type="button"
className={classNames[UI.NextMonthButton]}
Expand All @@ -602,7 +654,7 @@ export function DayPicker(initialProps: DayPickerProps) {
/>
</components.NextMonthButton>
)}
{displayIndex === numberOfMonths - 1 &&
{displayIndex === months.length - 1 &&
navLayout === "after" &&
!props.hideNavigation && (
<components.Nav
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import type { DayPickerProps } from "../types/index.js";
export function getDataAttributes(
props: DayPickerProps,
): Record<string, unknown> {
const displayedMonths = props.visibleMonths ?? props.defaultVisibleMonths;
const dataAttributes: Record<string, unknown> = {
"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,
Expand Down
16 changes: 16 additions & 0 deletions packages/react-day-picker/src/helpers/getDates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/react-day-picker/src/helpers/getDates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export function getDates(
props: Pick<DayPickerProps, "ISOWeek" | "fixedWeeks" | "broadcastCalendar">,
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 {
Expand Down
Loading
Loading