diff --git a/package/src/month-view.test.tsx b/package/src/month-view.test.tsx index dd18df9..f3e30de 100644 --- a/package/src/month-view.test.tsx +++ b/package/src/month-view.test.tsx @@ -1,5 +1,6 @@ import { Temporal } from "@js-temporal/polyfill"; import { render, act } from "@testing-library/react"; +import { useState } from "react"; // @vitest-environment jsdom import { describe, it, expect, vi } from "vitest"; import { useCalendarStable } from "./calendar-context"; @@ -1076,6 +1077,231 @@ describe("numberOfMonths", () => { }); }); + describe("controlled month", () => { + const march = Temporal.PlainYearMonth.from("2026-03"); + + it("next button fires onMonthChange and keeps the view until the parent updates", () => { + const onMonthChange = vi.fn(); + const { container, unmount } = render( + + + + , + ); + + act(() => { + container + .querySelector('[data-testid="next"]')! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onMonthChange).toHaveBeenCalledTimes(1); + const arg = onMonthChange.mock.calls[0]![0]; + expect(arg.year).toBe(2026); + expect(arg.month).toBe(4); + // Controlled: the view must not move on its own + expect( + container.querySelector('[data-testid="label"]')!.textContent, + ).toContain("March"); + + unmount(); + }); + + it("next button advances by one month with numberOfMonths > 1", () => { + // Regression: the adjacent month (April) is already visible in a + // [March, April] window, so notification must come from the button + // itself, not the focus-sync effect (which suppresses visible months). + const onMonthChange = vi.fn(); + const { container, unmount } = render( + + + , + ); + + act(() => { + container + .querySelector('[data-testid="next"]')! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onMonthChange).toHaveBeenCalledTimes(1); + const arg = onMonthChange.mock.calls[0]![0]; + expect(arg.year).toBe(2026); + expect(arg.month).toBe(4); + + unmount(); + }); + + it("still fires a later keyboard crossing after a no-op button click", () => { + // Regression for a value-keyed skip: focus already sits in the adjacent + // (visible) month and the parent ignores the click, so the button's + // focus move is a no-op. A subsequent keyboard crossing must NOT be + // wrongly suppressed. + const onMonthChange = vi.fn(); + let selectFn: (date: Temporal.PlainDate) => void = () => {}; + + const { container, unmount } = render( + + { + selectFn = fn; + }} + /> + + , + ); + + // Click next: target April is already the focused/visible month and the + // parent (vi.fn) ignores it → focus move is a no-op. + act(() => { + container + .querySelector('[data-testid="next"]')! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onMonthChange).toHaveBeenCalledTimes(1); // April, from the button + + // Keyboard crossing into a non-visible month must still notify. + act(() => { + selectFn(Temporal.PlainDate.from("2026-05-15")); + }); + expect(onMonthChange).toHaveBeenCalledTimes(2); + const arg = onMonthChange.mock.calls[1]![0]; + expect(arg.year).toBe(2026); + expect(arg.month).toBe(5); + + unmount(); + }); + + it("prev button fires onMonthChange with the preceding month", () => { + const onMonthChange = vi.fn(); + const { container, unmount } = render( + + + , + ); + + act(() => { + container + .querySelector('[data-testid="prev"]')! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onMonthChange).toHaveBeenCalledTimes(1); + const arg = onMonthChange.mock.calls[0]![0]; + expect(arg.year).toBe(2026); + expect(arg.month).toBe(2); + + unmount(); + }); + + it("round-trips with a stateful parent", () => { + function Harness() { + const [month, setMonth] = useState(march); + return ( + + + + + ); + } + + const { container, unmount } = render(); + expect( + container.querySelector('[data-testid="label"]')!.textContent, + ).toContain("March"); + + act(() => { + container + .querySelector('[data-testid="next"]')! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect( + container.querySelector('[data-testid="label"]')!.textContent, + ).toContain("April"); + + unmount(); + }); + + it("fires onMonthChange when focus moves into a non-visible month", () => { + const onMonthChange = vi.fn(); + let selectFn: (date: Temporal.PlainDate) => void = () => {}; + + const { unmount } = render( + + { + selectFn = fn; + }} + /> + , + ); + + act(() => { + selectFn(april15); + }); + + expect(onMonthChange).toHaveBeenCalledTimes(1); + const arg = onMonthChange.mock.calls[0]![0]; + expect(arg.year).toBe(2026); + expect(arg.month).toBe(4); + + unmount(); + }); + + it("does not fire when the focused month is already visible", () => { + const onMonthChange = vi.fn(); + let selectFn: (date: Temporal.PlainDate) => void = () => {}; + + const { unmount } = render( + + { + selectFn = fn; + }} + /> + , + ); + + // April is the second visible month — no notification needed + act(() => { + selectFn(april15); + }); + + expect(onMonthChange).not.toHaveBeenCalled(); + + unmount(); + }); + }); + describe("focusedDate across months", () => { it("preserves focusedDate when numberOfMonths increases", () => { let captured: { focusedDate: string } | undefined; diff --git a/package/src/month-view.tsx b/package/src/month-view.tsx index 7fb0aee..0543096 100644 --- a/package/src/month-view.tsx +++ b/package/src/month-view.tsx @@ -104,6 +104,38 @@ function MonthViewRoot(props: MonthViewRootProps) { [], ); + const localeCalendar = useMemo(() => calendarForLocale(locale), [locale]); + + // Always-current ref so callbacks/effects don't re-subscribe on every + // onMonthChange identity change. + const onMonthChangeRef = useRef(onMonthChange); + onMonthChangeRef.current = onMonthChange; + + // Request the parent move the view to `{year, month}` (controlled mode). + const notifyMonth = useCallback( + (year: number, month: number) => { + onMonthChangeRef.current?.( + T.PlainYearMonth.from({ year, month, calendar: localeCalendar }), + ); + }, + [T, localeCalendar], + ); + + // Whether `{year, month}` falls within the currently displayed window. + const isMonthVisible = useCallback( + (targetYear: number, targetMonth: number) => { + for (let i = 0; i < numberOfMonths; i++) { + const totalMonths = + currentMonth.year * 12 + (currentMonth.month - 1) + i; + const y = Math.floor(totalMonths / 12); + const m = (totalMonths % 12) + 1; + if (targetYear === y && targetMonth === m) return true; + } + return false; + }, + [currentMonth.year, currentMonth.month, numberOfMonths], + ); + // --- navigateToMonth: only shift if target not already visible --- const navigateToMonth = useCallback( (targetYear: number, targetMonth: number) => { @@ -123,24 +155,63 @@ function MonthViewRoot(props: MonthViewRootProps) { [isMonthControlled, numberOfMonths], ); - // Sync month when focused date crosses a month boundary + // Button navigation in controlled mode notifies the parent directly, then + // moves focus into the target month. This ref holds that focus value so the + // focus-sync effect skips re-notifying for it. Keyed by the value (not a + // one-shot flag) so it can't strand and suppress a later keyboard crossing + // if the button's focus move is a no-op or the parent ignores the change. + const skipFocusSyncForRef = useRef(null); + const prevFocusedRef = useRef(focusedDate); + + // Clear a stale skip target whenever the parent actually moves the view. + useEffect(() => { + skipFocusSyncForRef.current = null; + }, [monthProp?.year, monthProp?.month]); + + // Sync month when the focused date crosses a month boundary. Uncontrolled: + // shift internal state. Controlled: ask the parent to move the view, but + // only when focus actually moved out of the visible window (keyboard nav) — + // never as an echo of an external `month` update or a button click. useEffect(() => { + const prevFocused = prevFocusedRef.current; + prevFocusedRef.current = focusedDate; + if (isMonthControlled) { + if (skipFocusSyncForRef.current === focusedDate) { + skipFocusSyncForRef.current = null; + return; + } + if (prevFocused === focusedDate) return; + if (isMonthVisible(focusedDate.year, focusedDate.month)) return; + notifyMonth(focusedDate.year, focusedDate.month); + return; + } navigateToMonth(focusedDate.year, focusedDate.month); - }, [focusedDate, navigateToMonth]); + }, [ + focusedDate, + navigateToMonth, + isMonthControlled, + isMonthVisible, + notifyMonth, + ]); // --- Month navigation callbacks --- const goNextMonth = useCallback(() => { if (isMonthControlled) { - // Fire onMonthChange with the next month const { year, month, firstDay } = computeAdjacentMonth( { year: monthProp!.year, month: monthProp!.month }, "next", T, ); - setFocusedDate((prev) => - focusedDateForMonth(prev, { year, month }, firstDay), + // Notify the parent directly; the follow-on focus move is suppressed in + // the focus-sync effect (keyed on the value) so onMonthChange fires once. + const nextFocus = focusedDateForMonth( + focusedDate, + { year, month }, + firstDay, ); - // The effect on viewingYearMonth will fire onMonthChange + skipFocusSyncForRef.current = nextFocus; + notifyMonth(year, month); + setFocusedDate(nextFocus); return; } setInternalMonth((m) => { @@ -150,7 +221,7 @@ function MonthViewRoot(props: MonthViewRootProps) { ); return { year, month }; }); - }, [T, isMonthControlled, monthProp]); + }, [T, isMonthControlled, monthProp, notifyMonth, focusedDate]); const goPrevMonth = useCallback(() => { if (isMonthControlled) { @@ -159,9 +230,14 @@ function MonthViewRoot(props: MonthViewRootProps) { "prev", T, ); - setFocusedDate((prev) => - focusedDateForMonth(prev, { year, month }, firstDay), + const nextFocus = focusedDateForMonth( + focusedDate, + { year, month }, + firstDay, ); + skipFocusSyncForRef.current = nextFocus; + notifyMonth(year, month); + setFocusedDate(nextFocus); return; } setInternalMonth((m) => { @@ -171,7 +247,7 @@ function MonthViewRoot(props: MonthViewRootProps) { ); return { year, month }; }); - }, [T, isMonthControlled, monthProp]); + }, [T, isMonthControlled, monthProp, notifyMonth, focusedDate]); // --- Grid computation --- const allMonths = useMemo(() => { @@ -254,9 +330,7 @@ function MonthViewRoot(props: MonthViewRootProps) { ], ); - // --- viewingYearMonth (for onMonthChange callback) --- - const localeCalendar = useMemo(() => calendarForLocale(locale), [locale]); - + // --- viewingYearMonth (for the render-prop rootState) --- const viewingYearMonth = useMemo( () => T.PlainYearMonth.from({ @@ -267,9 +341,10 @@ function MonthViewRoot(props: MonthViewRootProps) { [currentMonth, T, localeCalendar], ); - // --- Fire onMonthChange (not on mount) --- - const onMonthChangeRef = useRef(onMonthChange); - onMonthChangeRef.current = onMonthChange; + // --- Fire onMonthChange when uncontrolled navigation moves the view (not on + // mount). In controlled mode the parent owns `month`, so notification flows + // from button clicks (goNext/goPrevMonth) and the focus-sync effect instead; + // firing here would just echo the parent's own update back to it. --- const mountedRef = useRef(false); useEffect(() => { @@ -277,8 +352,9 @@ function MonthViewRoot(props: MonthViewRootProps) { mountedRef.current = true; return; } + if (isMonthControlled) return; onMonthChangeRef.current?.(viewingYearMonth); - }, [viewingYearMonth]); + }, [viewingYearMonth, isMonthControlled]); // --- rootState (for render functions) --- const { selected, selectedDates, rangeStart, rangeEnd } = calState;