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
4 changes: 3 additions & 1 deletion package/src/calendar-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ interface CalendarProviderPropsBase<F extends ValueFormat = ValueFormat> {
*/
locale?: string;
/**
* Custom Temporal namespace for environments without native Temporal support.
* The `Temporal` implementation to use. Not bundled — provide a `Temporal`
* namespace (e.g. from `temporal-polyfill` or `@js-temporal/polyfill`) unless
* the host exposes native `Temporal`. See `RootOwnProps.temporal` for links.
*/
temporal?: TemporalNamespace;
/**
Expand Down
2 changes: 0 additions & 2 deletions package/src/formats/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export { computeWeeksInWindow } from "../compute-weeks-in-window";

export { resolveFirstWeek, resolveFirstWeekSpec } from "../resolve-first-week";

export { Temporal } from "../temporal-polyfill";

export { useCalendarStable, useCalendarState } from "../calendar-context";

export { useMonthViewStable, useMonthViewState } from "../month-view-context";
Expand Down
2 changes: 0 additions & 2 deletions package/src/formats/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export { computeWeeksInWindow } from "../compute-weeks-in-window";

export { resolveFirstWeek, resolveFirstWeekSpec } from "../resolve-first-week";

export { Temporal } from "../temporal-polyfill";

export { useCalendarStable, useCalendarState } from "../calendar-context";

export { useMonthViewStable, useMonthViewState } from "../month-view-context";
Expand Down
2 changes: 0 additions & 2 deletions package/src/formats/plain-date-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export { computeWeeksInWindow } from "../compute-weeks-in-window";

export { resolveFirstWeek, resolveFirstWeekSpec } from "../resolve-first-week";

export { Temporal } from "../temporal-polyfill";

export { useCalendarStable, useCalendarState } from "../calendar-context";

export { useMonthViewStable, useMonthViewState } from "../month-view-context";
Expand Down
2 changes: 0 additions & 2 deletions package/src/formats/plain-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export { computeWeeksInWindow } from "../compute-weeks-in-window";

export { resolveFirstWeek, resolveFirstWeekSpec } from "../resolve-first-week";

export { Temporal } from "../temporal-polyfill";

export { useCalendarStable, useCalendarState } from "../calendar-context";

export { useMonthViewStable, useMonthViewState } from "../month-view-context";
Expand Down
2 changes: 0 additions & 2 deletions package/src/formats/plain-month-day.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export { computeWeeksInWindow } from "../compute-weeks-in-window";

export { resolveFirstWeek, resolveFirstWeekSpec } from "../resolve-first-week";

export { Temporal } from "../temporal-polyfill";

export { useCalendarStable, useCalendarState } from "../calendar-context";

export { useMonthViewStable, useMonthViewState } from "../month-view-context";
Expand Down
2 changes: 0 additions & 2 deletions package/src/formats/plain-year-month.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export { computeWeeksInWindow } from "../compute-weeks-in-window";

export { resolveFirstWeek, resolveFirstWeekSpec } from "../resolve-first-week";

export { Temporal } from "../temporal-polyfill";

export { useCalendarStable, useCalendarState } from "../calendar-context";

export { useMonthViewStable, useMonthViewState } from "../month-view-context";
Expand Down
2 changes: 0 additions & 2 deletions package/src/formats/zoned-date-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export { computeWeeksInWindow } from "../compute-weeks-in-window";

export { resolveFirstWeek, resolveFirstWeekSpec } from "../resolve-first-week";

export { Temporal } from "../temporal-polyfill";

export { useCalendarStable, useCalendarState } from "../calendar-context";

export { useMonthViewStable, useMonthViewState } from "../month-view-context";
Expand Down
1 change: 0 additions & 1 deletion package/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export {
export { computePreviewRange } from "./root-selection";
export { computeWeeksInWindow } from "./compute-weeks-in-window";
export { resolveFirstWeek, resolveFirstWeekSpec } from "./resolve-first-week";
export { Temporal } from "./temporal-polyfill";
export { useCalendarStable, useCalendarState } from "./calendar-context";
export { useMonthViewStable, useMonthViewState } from "./month-view-context";
export { useViewContext } from "./view-context";
Expand Down
8 changes: 6 additions & 2 deletions package/src/month-view-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
129 changes: 129 additions & 0 deletions package/src/month-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MonthView
{...thProps}
defaultValue={march15}
onMonthChange={onMonthChange}
>
<NextMonthButton data-testid="next" />
</MonthView>,
);

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(
<MonthView {...thProps} defaultValue={march15}>
<ViewingCapture
onCapture={(v) => {
viewing = v;
}}
/>
</MonthView>,
);

expect(viewing!.toString()).toBe("2026-03");

unmount();
});

it("NavButtonState.target is an ISO PlainYearMonth", () => {
let nextTarget: Temporal.PlainYearMonth | undefined;
const { unmount } = render(
<MonthView {...thProps} defaultValue={march15}>
<NextMonthButton
render={(props, state) => {
nextTarget = state.target;
return <button {...props} />;
}}
/>
</MonthView>,
);

// Next from ISO March 2026 → ISO April 2026, not Buddhist.
expect(nextTarget!.toString()).toBe("2026-04");

unmount();
});

it("still renders the month label in the locale calendar", () => {
const { container, unmount } = render(
<MonthView {...thProps} defaultValue={march15}>
<MonthYearString data-testid="label" />
</MonthView>,
);

// ISO 2026 → Buddhist Era 2569.
expect(
container.querySelector('[data-testid="label"]')!.textContent,
).toContain("2569");

unmount();
});

it("controlled round-trips without a century jump", () => {
function Harness() {
const [month, setMonth] = useState(
Temporal.PlainYearMonth.from("2026-03"),
);
return (
<MonthView {...thProps} month={month} onMonthChange={setMonth}>
<MonthYearString data-testid="label" />
<NextMonthButton data-testid="next" />
</MonthView>
);
}

const { container, unmount } = render(<Harness />);
const label = () =>
container.querySelector('[data-testid="label"]')!.textContent ?? "";
expect(label()).toContain("2569"); // March 2569 BE

act(() => {
container
.querySelector('[data-testid="next"]')!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

// Advanced one month, still Buddhist 2569 — not jumped ~543 years.
expect(label()).toContain("2569");
expect(label()).not.toContain("3112");

unmount();
});
});

describe("outsideDays", () => {
const march15 = Temporal.PlainDate.from("2026-03-15");

Expand Down
17 changes: 8 additions & 9 deletions package/src/month-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import type {
} from "./month-view-types";
import type { MonthData, RootState, ValueFormat } from "./types";
import {
calendarForLocale,
computeAdjacentMonth,
focusedDateForMonth,
getMonthWeeks,
Expand Down Expand Up @@ -105,21 +104,21 @@ 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).
// Built in ISO — `year`/`month` are ISO numbers, and the locale calendar
// only affects display (see MonthYearString). Passing `calendar` here would
// reinterpret the ISO numbers as locale-calendar fields (e.g. Buddhist),
// shifting the value by centuries.
const notifyMonth = useCallback(
(year: number, month: number) => {
onMonthChangeRef.current?.(
T.PlainYearMonth.from({ year, month, calendar: localeCalendar }),
);
onMonthChangeRef.current?.(T.PlainYearMonth.from({ year, month }));
},
[T, localeCalendar],
[T],
);

// Whether `{year, month}` falls within the currently displayed window.
Expand Down Expand Up @@ -343,14 +342,14 @@ function MonthViewRoot(props: MonthViewRootProps) {
);

// --- viewingYearMonth (for the render-prop rootState) ---
// ISO — the locale calendar only affects display, not this value.
const viewingYearMonth = useMemo(
() =>
T.PlainYearMonth.from({
year: currentMonth.year,
month: currentMonth.month,
calendar: localeCalendar,
}),
[currentMonth, T, localeCalendar],
[currentMonth, T],
);

// --- Fire onMonthChange when uncontrolled navigation moves the view (not on
Expand Down
32 changes: 15 additions & 17 deletions package/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
NextMonthButtonProps,
NavButtonState,
} from "./types";
import { selectedToZdt, zdtToNativeDate, calendarForLocale } from "./utils";
import { selectedToZdt, zdtToNativeDate } from "./utils";

const dateStringStateAttributesMapping = {
root: () => null,
Expand Down Expand Up @@ -166,7 +166,7 @@ function MonthYearStringFn(
...otherProps
} = props;
const monthIndex = monthIndexProp ?? 0;
const { locale } = useCalendarStable();
const { locale, temporal: T } = useCalendarStable();
const monthViewStable = useMonthViewStable();
const {
currentMonth: currentDateTime,
Expand All @@ -186,15 +186,19 @@ function MonthYearStringFn(
const displayYear = monthData?.year ?? currentDateTime.year;
const displayMonth = monthData?.month ?? currentDateTime.month;

const displayDate = new Date(displayYear, displayMonth - 1, 1);
const defaultOptions: Intl.DateTimeFormatOptions = options ?? {
month: "long",
year: "numeric",
};
const formatted = displayDate.toLocaleDateString(
locales ?? locale,
defaultOptions,
);
// Format through Temporal
// `displayYear`/`displayMonth` are ISO; formatting an ISO
// `PlainDate` (not `PlainYearMonth`, which throws on calendar mismatch)
// lets Intl render it in the locale's calendar (e.g. Buddhist for th-TH).
const formatted = T.PlainDate.from({
year: displayYear,
month: displayMonth,
day: 1,
}).toLocaleString(locales ?? locale, defaultOptions);

const state = useMemo<MonthYearStringState>(
() => ({
Expand Down Expand Up @@ -246,7 +250,6 @@ function useNavButton<F extends ValueFormat = ValueFormat>(
disabled: globalDisabled,
minValue,
maxValue,
locale,
temporal: T,
} = useCalendarStable();
const monthViewStable = useMonthViewStable();
Expand Down Expand Up @@ -304,16 +307,11 @@ function useNavButton<F extends ValueFormat = ValueFormat>(
);
}, [globalDisabled, destYear, destMonth, boundValue, direction]);

const localeCalendar = useMemo(() => calendarForLocale(locale), [locale]);

// ISO — `destYear`/`destMonth` are ISO numbers; the locale calendar only
// affects display, so it must not be injected into this value.
const target = useMemo(
() =>
T.PlainYearMonth.from({
year: destYear,
month: destMonth,
calendar: localeCalendar,
}),
[destYear, destMonth, T, localeCalendar],
() => T.PlainYearMonth.from({ year: destYear, month: destMonth }),
[destYear, destMonth, T],
);

const state = useMemo<NavButtonState<F>>(
Expand Down
Loading
Loading