From 0c0d7e90f534d3bcb275b8b48d795ecfa18a2d7a Mon Sep 17 00:00:00 2001 From: Klink <85062+dogmar@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:54:53 -0700 Subject: [PATCH 1/6] fix: expose ISO PlainYearMonth values for non-Gregorian locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onMonthChange, rootState.viewing, and NavButtonState.target were built with `PlainYearMonth.from({ year, month, calendar: localeCalendar })`, feeding ISO year/month numbers in as locale-calendar fields. With the full @js-temporal/polyfill (and native Temporal) this reinterpreted the numbers: for locale="th-TH" (Buddhist), viewing ISO June 2026 produced 1483-06-10[u-ca=buddhist] — off by ~543 years. (The mini shim is ISO-only and silently ignored the field, so the bug only surfaced with a real calendar implementation.) These values are now built in ISO. The locale calendar only ever affected display, which goes through `new Date(...).toLocaleDateString` independently (MonthYearString) and is unchanged — Thai still renders "มิถุนายน 2569". Making the exposed values ISO also lets the controlled `month`/onMonthChange pair round-trip under the identity function. `calendarForLocale` existed only to feed this buggy pattern and is now removed (not a public export). month/defaultMonth/onMonthChange docs now state the ISO contract. Adds th-TH coverage: onMonthChange and rootState.viewing emit ISO, NavButtonState.target is ISO, the label still localizes to the Buddhist era, and a controlled round-trip does not jump centuries. Co-Authored-By: Claude Opus 4.8 --- package/src/month-view-types.ts | 8 +- package/src/month-view.test.tsx | 129 ++++++++++++++++++++++++++++++++ package/src/month-view.tsx | 17 ++--- package/src/navigation.tsx | 16 ++-- package/src/utils.test.ts | 13 ---- package/src/utils.ts | 10 --- 6 files changed, 148 insertions(+), 45 deletions(-) diff --git a/package/src/month-view-types.ts b/package/src/month-view-types.ts index 25a2543..5954506 100644 --- a/package/src/month-view-types.ts +++ b/package/src/month-view-types.ts @@ -33,15 +33,19 @@ export interface MonthViewRootProps { overflowBehavior?: MonthOverflowBehavior | undefined; /** * The controlled visible month. When provided, the component is controlled. + * + * Interpreted in the ISO calendar (the year/month are read as ISO values). + * The `locale` only affects how the month is displayed, so this value + * round-trips directly with {@link onMonthChange}. */ month?: Temporal.PlainYearMonth | undefined; /** - * The initial visible month (uncontrolled). + * The initial visible month (uncontrolled). Interpreted in the ISO calendar. */ defaultMonth?: Temporal.PlainYearMonth | undefined; /** * Called when the visible month changes via navigation or focus movement. - * Not called on initial mount. + * Not called on initial mount. The argument is an ISO `PlainYearMonth`. */ onMonthChange?: ((month: Temporal.PlainYearMonth) => void) | undefined; /** React children. */ diff --git a/package/src/month-view.test.tsx b/package/src/month-view.test.tsx index 1bdb1ac..1dcabea 100644 --- a/package/src/month-view.test.tsx +++ b/package/src/month-view.test.tsx @@ -1504,6 +1504,135 @@ describe("numberOfMonths", () => { }); }); +describe("non-Gregorian locale (th-TH)", () => { + // th-TH defaults to the Buddhist calendar (ISO year + 543). All + // PlainYearMonth values the component exposes must be ISO; the locale + // calendar only affects display. + const thProps = { ...defaultProps, locale: "th-TH" } as const; + const march15 = Temporal.PlainDate.from("2026-03-15"); + + /** Captures rootState.viewing from MonthView context. */ + function ViewingCapture({ + onCapture, + }: { + onCapture: (viewing: Temporal.PlainYearMonth) => void; + }) { + const { rootState } = useMonthViewState(); + onCapture(rootState.viewing as Temporal.PlainYearMonth); + return null; + } + + it("onMonthChange emits an ISO PlainYearMonth, not the locale calendar", () => { + 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]; + // ISO April 2026 — NOT Buddhist-2026 (which is ISO 1483-06). + expect(arg.toString()).toBe("2026-04"); + expect(arg.year).toBe(2026); + expect(arg.month).toBe(4); + + unmount(); + }); + + it("rootState.viewing is an ISO PlainYearMonth", () => { + let viewing: Temporal.PlainYearMonth | undefined; + const { unmount } = render( + + { + viewing = v; + }} + /> + , + ); + + expect(viewing!.toString()).toBe("2026-03"); + + unmount(); + }); + + it("NavButtonState.target is an ISO PlainYearMonth", () => { + let nextTarget: Temporal.PlainYearMonth | undefined; + const { unmount } = render( + + { + nextTarget = state.target; + return