Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/calm-calendars-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"react-day-picker": patch
"@daypicker/react": patch
---

fix: preserve focus when controlled month changes replace the focused day.
43 changes: 43 additions & 0 deletions examples/ControlledMonthFocus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from "react";

import { activeElement, dateButton, grid } from "@/test/elements";
import { act, render, screen } from "@/test/render";
import { user } from "@/test/user";
import { ControlledMonthFocus } from "./ControlledMonthFocus";

const march = new Date(2026, 2, 15);
const november = new Date(2026, 10, 15);

describe("when J changes the month while a day is focused", () => {
beforeEach(async () => {
render(<ControlledMonthFocus />);
act(() => dateButton(march).focus());
await user.keyboard("j");
});

test("displays the new month", () => {
expect(grid()).toHaveAccessibleName("November 2026");
});

test("focuses the selected day in the new month", () => {
expect(activeElement()).toBe(dateButton(november));
});
});

describe("when the external button changes the month", () => {
let jumpButton: HTMLButtonElement;

beforeEach(async () => {
render(<ControlledMonthFocus />);
jumpButton = screen.getByRole("button", { name: "Jump to November" });
await user.click(jumpButton);
});

test("displays the new month", () => {
expect(grid()).toHaveAccessibleName("November 2026");
});

test("keeps focus on the external button", () => {
expect(activeElement()).toBe(jumpButton);
});
});
53 changes: 53 additions & 0 deletions examples/ControlledMonthFocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { DayPicker } from "@daypicker/react";
import { format, isSameMonth } from "date-fns";
import React from "react";

const march = new Date(2026, 2, 15);
const november = new Date(2026, 10, 15);

/** Demonstrates focus preservation across controlled month changes. */
export function ControlledMonthFocus() {
const [month, setMonth] = React.useState(march);
const [selected, setSelected] = React.useState<Date | undefined>(march);
const [focusedDay, setFocusedDay] = React.useState<Date>();

const jumpToOtherMonth = () => {
const nextDay = isSameMonth(month, march) ? november : march;
setMonth(nextDay);
setSelected(nextDay);
};

return (
<fieldset
onKeyDown={(event) => {
if (event.key.toLowerCase() === "j") {
jumpToOtherMonth();
}
}}
>
<legend>Controlled month focus</legend>
<p>
Focus a day and press J to change the controlled month while preserving
focus. Use the button to change the month while focus is outside the day
grid.
</p>
<DayPicker
mode="single"
month={month}
onDayBlur={() => setFocusedDay(undefined)}
onDayFocus={setFocusedDay}
onMonthChange={setMonth}
onSelect={setSelected}
selected={selected}
footer={
focusedDay
? `Focused day: ${format(focusedDay, "PPPP")}`
: "No day has focus"
}
/>
<button type="button" onClick={jumpToOtherMonth}>
Jump to {isSameMonth(month, march) ? "November" : "March"}
</button>
</fieldset>
);
}
1 change: 1 addition & 0 deletions examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from "./Buddhist";
export * from "./BuddhistEn";
export * from "./ContainerAttributes";
export * from "./Controlled";
export * from "./ControlledMonthFocus";
export * from "./ControlledSelection";
export * from "./CssModules";
export * from "./CssVariables";
Expand Down
72 changes: 72 additions & 0 deletions packages/react-day-picker/src/DayPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,78 @@ describe("when the `month` is changed programmatically", () => {
expect(grid("February 2023")).toBeInTheDocument();
});

describe("when the controlled month removes the focused day", () => {
const initialDay = new Date(2026, 2, 15);
const newDay = new Date(2026, 10, 15);
const onSelect = jest.fn();

describe("when a day has focus", () => {
beforeEach(() => {
const { rerender } = render(
<DayPicker
month={initialDay}
mode="single"
onSelect={onSelect}
selected={initialDay}
/>,
);
act(() => dateButton(initialDay).focus());

rerender(
<DayPicker
month={newDay}
mode="single"
onSelect={onSelect}
selected={newDay}
/>,
);
});

test("moves focus to the selected day in the new month", () => {
expect(activeElement()).toBe(dateButton(newDay));
});
});

describe("when focus is outside the day grid", () => {
let outsideButton: HTMLButtonElement;

beforeEach(() => {
const { rerender } = render(
<>
<button type="button">Outside the calendar</button>
<DayPicker
month={initialDay}
mode="single"
onSelect={onSelect}
selected={initialDay}
/>
</>,
);
outsideButton = screen.getByRole("button", {
name: "Outside the calendar",
});
act(() => dateButton(initialDay).focus());
act(() => outsideButton.focus());

rerender(
<>
<button type="button">Outside the calendar</button>
<DayPicker
month={newDay}
mode="single"
onSelect={onSelect}
selected={newDay}
/>
</>,
);
});

test("does not move focus", () => {
expect(activeElement()).toBe(outsideButton);
});
});
});

describe("when the month prop is rerendered with non-first-of-month dates", () => {
const monthDates: unknown[] = [];
const components = {
Expand Down
13 changes: 12 additions & 1 deletion packages/react-day-picker/src/useFocus.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";

import type { CalendarDay, DateLib } from "./classes/index.js";
import { calculateFocusTarget } from "./helpers/calculateFocusTarget.js";
Expand Down Expand Up @@ -62,6 +62,17 @@ export function useFocus<T extends DayPickerProps>(
autoFocus ? focusTarget : undefined,
);

useEffect(() => {
if (!focusedDay || !focusTarget) return;

const isFocusedDayDisplayed = calendar.days.some((day) =>
day.isEqualTo(focusedDay),
);
if (!isFocusedDayDisplayed) {
setFocused(focusTarget);
}
}, [calendar.days, focusedDay, focusTarget]);

const blur = () => {
setLastFocused(focusedDay);
setFocused(undefined);
Expand Down
Loading