Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: incorrect year in showMonthAndYearPickers with locale #3331

Open
wants to merge 8 commits into
base: canary
Choose a base branch
from
8 changes: 8 additions & 0 deletions .changeset/purple-singers-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@nextui-org/calendar": patch
"@nextui-org/date-input": patch
"@nextui-org/system": patch
"@nextui-org/shared-utils": patch
---

Fixed incorrect year in `showMonthAndYearPickers` with different locales
35 changes: 35 additions & 0 deletions packages/components/calendar/__tests__/calendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {render, act, fireEvent} from "@testing-library/react";
import {CalendarDate, isWeekend} from "@internationalized/date";
import {triggerPress, keyCodes} from "@nextui-org/test-utils";
import {useLocale} from "@react-aria/i18n";
import {NextUIProvider} from "@nextui-org/system";

import {Calendar as CalendarBase, CalendarProps} from "../src";

Expand All @@ -16,6 +17,20 @@ const Calendar = React.forwardRef((props: CalendarProps, ref: React.Ref<HTMLDivE

Calendar.displayName = "Calendar";

const CalendarWithLocale = React.forwardRef(
(props: CalendarProps & {locale: string}, ref: React.Ref<HTMLDivElement>) => {
const {locale, ...otherProps} = props;

return (
<NextUIProvider locale={locale}>
<CalendarBase {...otherProps} ref={ref} disableAnimation />
</NextUIProvider>
);
},
);

CalendarWithLocale.displayName = "CalendarWithLocale";

describe("Calendar", () => {
beforeAll(() => {
jest.useFakeTimers();
Expand Down Expand Up @@ -418,5 +433,25 @@ describe("Calendar", () => {

expect(description).toBe("Selected date unavailable.");
});

it("should display the correct year and month in showMonthAndYearPickers with locale", () => {
const {getByRole} = render(
<CalendarWithLocale
showMonthAndYearPickers
defaultValue={new CalendarDate(2024, 6, 26)}
locale="th-TH-u-ca-buddhist"
/>,
);

const header = document.querySelector<HTMLButtonElement>(`button[data-slot="header"]`)!;

triggerPress(header);

const month = getByRole("button", {name: "มิถุนายน"});
const year = getByRole("button", {name: "พ.ศ. 2567"});

expect(month).toHaveAttribute("data-value", "6");
expect(year).toHaveAttribute("data-value", "2567");
});
});
});
21 changes: 15 additions & 6 deletions packages/components/calendar/src/use-calendar-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import type {SupportedCalendars} from "@nextui-org/system";
import type {CalendarState, RangeCalendarState} from "@react-stately/calendar";
import type {RefObject, ReactNode} from "react";

import {Calendar, CalendarDate} from "@internationalized/date";
import {createCalendar, Calendar, CalendarDate, DateFormatter} from "@internationalized/date";
import {mapPropsVariants, useProviderContext} from "@nextui-org/system";
import {useCallback, useMemo} from "react";
import {calendar} from "@nextui-org/theme";
import {useControlledState} from "@react-stately/utils";
import {ReactRef, useDOMRef} from "@nextui-org/react-utils";
import {useLocale} from "@react-aria/i18n";
import {clamp, dataAttr, objectToDeps} from "@nextui-org/shared-utils";
import {clamp, dataAttr, objectToDeps, getGregorianYearOffset} from "@nextui-org/shared-utils";
import {mergeProps} from "@react-aria/utils";

type NextUIBaseProps = Omit<HTMLNextUIProps<"div">, keyof AriaCalendarPropsBase | "onChange">;
Expand Down Expand Up @@ -183,6 +183,15 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) {

const globalContext = useProviderContext();

const {locale} = useLocale();

const calendarProp = createCalendar(new DateFormatter(locale).resolvedOptions().calendar);

// by default, we are using gregorian calendar with possible years in [1900, 2099]
// however, some locales such as `th-TH-u-ca-buddhist` using different calendar making the years out of bound
// hence, add the corresponding offset to make sure the year is within the bound
const gregorianYearOffset = getGregorianYearOffset(calendarProp.identifier);

const {
ref,
as,
Expand All @@ -198,9 +207,11 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) {
isHeaderExpanded: isHeaderExpandedProp,
isHeaderDefaultExpanded,
onHeaderExpandedChange = () => {},
minValue = globalContext?.defaultDates?.minDate ?? new CalendarDate(1900, 1, 1),
maxValue = globalContext?.defaultDates?.maxDate ?? new CalendarDate(2099, 12, 31),
createCalendar: createCalendarProp = globalContext?.createCalendar ?? null,
minValue = globalContext?.defaultDates?.minDate ??
new CalendarDate(calendarProp, 1900 + gregorianYearOffset, 1, 1),
maxValue = globalContext?.defaultDates?.maxDate ??
new CalendarDate(calendarProp, 2099 + gregorianYearOffset, 12, 31),
prevButtonProps: prevButtonPropsProp,
nextButtonProps: nextButtonPropsProp,
errorMessage,
Expand Down Expand Up @@ -239,8 +250,6 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) {
const hasMultipleMonths = visibleMonths > 1;
const shouldFilterDOMProps = typeof Component === "string";

const {locale} = useLocale();

const slots = useMemo(
() =>
calendar({
Expand Down
22 changes: 15 additions & 7 deletions packages/components/date-input/src/use-date-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import type {DOMAttributes, GroupDOMAttributes} from "@react-types/shared";
import type {DateInputGroupProps} from "./date-input-group";

import {useLocale} from "@react-aria/i18n";
import {CalendarDate} from "@internationalized/date";
import {createCalendar, CalendarDate, DateFormatter} from "@internationalized/date";
import {mergeProps} from "@react-aria/utils";
import {PropGetter, useProviderContext} from "@nextui-org/system";
import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system";
import {useDOMRef} from "@nextui-org/react-utils";
import {useDateField as useAriaDateField} from "@react-aria/datepicker";
import {useDateFieldState} from "@react-stately/datepicker";
import {createCalendar} from "@internationalized/date";
import {objectToDeps, clsx, dataAttr} from "@nextui-org/shared-utils";
import {objectToDeps, clsx, dataAttr, getGregorianYearOffset} from "@nextui-org/shared-utils";
import {dateInput} from "@nextui-org/theme";
import {useMemo} from "react";

Expand Down Expand Up @@ -116,6 +115,15 @@ export function useDateInput<T extends DateValue>(originalProps: UseDateInputPro

const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys);

const {locale} = useLocale();

const calendarProp = createCalendar(new DateFormatter(locale).resolvedOptions().calendar);

// by default, we are using gregorian calendar with possible years in [1900, 2099]
// however, some locales such as `th-TH-u-ca-buddhist` using different calendar making the years out of bound
// hence, add the corresponding offset to make sure the year is within the bound
const gregorianYearOffset = getGregorianYearOffset(calendarProp.identifier);

const {
ref,
as,
Expand All @@ -134,8 +142,10 @@ export function useDateInput<T extends DateValue>(originalProps: UseDateInputPro
descriptionProps: descriptionPropsProp,
validationBehavior = globalContext?.validationBehavior ?? "aria",
shouldForceLeadingZeros = true,
minValue = globalContext?.defaultDates?.minDate ?? new CalendarDate(1900, 1, 1),
maxValue = globalContext?.defaultDates?.maxDate ?? new CalendarDate(2099, 12, 31),
minValue = globalContext?.defaultDates?.minDate ??
new CalendarDate(calendarProp, 1900 + gregorianYearOffset, 1, 1),
maxValue = globalContext?.defaultDates?.maxDate ??
new CalendarDate(calendarProp, 2099 + gregorianYearOffset, 12, 31),
createCalendar: createCalendarProp = globalContext?.createCalendar ?? null,
isInvalid: isInvalidProp = validationState ? validationState === "invalid" : false,
errorMessage,
Expand All @@ -146,8 +156,6 @@ export function useDateInput<T extends DateValue>(originalProps: UseDateInputPro

const disableAnimation = originalProps.disableAnimation ?? globalContext?.disableAnimation;

const {locale} = useLocale();

const state = useDateFieldState({
...originalProps,
label,
Expand Down
49 changes: 49 additions & 0 deletions packages/components/date-picker/__tests__/date-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {render, act, fireEvent, waitFor} from "@testing-library/react";
import {pointerMap, triggerPress} from "@nextui-org/test-utils";
import userEvent from "@testing-library/user-event";
import {CalendarDate, CalendarDateTime} from "@internationalized/date";
import {NextUIProvider} from "@nextui-org/system";

import {DatePicker as DatePickerBase, DatePickerProps} from "../src";

Expand All @@ -24,6 +25,26 @@ const DatePicker = React.forwardRef((props: DatePickerProps, ref: React.Ref<HTML

DatePicker.displayName = "DatePicker";

const DatePickerWithLocale = React.forwardRef(
(props: DatePickerProps & {locale: string}, ref: React.Ref<HTMLDivElement>) => {
const {locale, ...otherProps} = props;

return (
<NextUIProvider locale={locale}>
<DatePickerBase
{...otherProps}
ref={ref}
disableAnimation
labelPlacement="outside"
shouldForceLeadingZeros={false}
/>
</NextUIProvider>
);
},
);

DatePickerWithLocale.displayName = "DatePickerWithLocale";

function getTextValue(el: any) {
if (
el.className?.includes?.("DatePicker-placeholder") &&
Expand Down Expand Up @@ -626,5 +647,33 @@ describe("DatePicker", () => {
// assert that the second datepicker dialog is open
expect(dialog).toBeVisible();
});

it("should display the correct year and month in showMonthAndYearPickers with locale", () => {
const {getByRole} = render(
<DatePickerWithLocale
showMonthAndYearPickers
defaultValue={new CalendarDate(2024, 6, 26)}
label="Date"
locale="th-TH-u-ca-buddhist"
/>,
);

const button = getByRole("button");

triggerPress(button);

const dialog = getByRole("dialog");
const header = document.querySelector<HTMLButtonElement>(`button[data-slot="header"]`)!;

expect(dialog).toBeVisible();

triggerPress(header);

const month = getByRole("button", {name: "มิถุนายน"});
const year = getByRole("button", {name: "พ.ศ. 2567"});

expect(month).toHaveAttribute("data-value", "6");
expect(year).toHaveAttribute("data-value", "2567");
});
});
});
8 changes: 3 additions & 5 deletions packages/core/system/src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {I18nProvider, I18nProviderProps} from "@react-aria/i18n";
import {RouterProvider} from "@react-aria/utils";
import {OverlayProvider} from "@react-aria/overlays";
import {useMemo} from "react";
import {CalendarDate} from "@internationalized/date";
import {MotionGlobalConfig} from "framer-motion";

import {ProviderContext} from "./provider-context";
Expand Down Expand Up @@ -42,10 +41,9 @@ export const NextUIProvider: React.FC<NextUIProviderProps> = ({
skipFramerMotionAnimations = disableAnimation,
validationBehavior = "aria",
locale = "en-US",
defaultDates = {
minDate: new CalendarDate(1900, 1, 1),
maxDate: new CalendarDate(2099, 12, 31),
},
// if minDate / maxDate are not specified in `defaultDates`
// then they will be set in `use-date-input.ts` or `use-calendar-base.ts`
defaultDates,
createCalendar,
...otherProps
}) => {
Expand Down
26 changes: 26 additions & 0 deletions packages/utilities/shared-utils/src/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export function getGregorianYearOffset(identifier: string): number {
switch (identifier) {
case "buddhist":
return 543;
case "ethiopic":
case "ethioaa":
return -8;
case "coptic":
return -284;
case "hebrew":
return 3760;
case "indian":
return -78;
case "islamic-civil":
case "islamic-tbla":
case "islamic-umalqura":
return -579;
case "persian":
return 622;
case "roc":
case "japanese":
case "gregory":
default:
return 0;
Comment on lines +20 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove Redundant Case Clauses

The cases for 'roc', 'japanese', and 'gregory' are redundant since they fall into the default case which returns 0. Removing these will simplify the switch statement without affecting functionality.

-    case "roc":
-    case "japanese":
-    case "gregory":
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "roc":
case "japanese":
case "gregory":
default:
return 0;
default:
return 0;
Tools
Biome

[error] 20-20: Useless case clause.

because the default clause is present:

Unsafe fix: Remove the useless case.

(lint/complexity/noUselessSwitchCase)


[error] 21-21: Useless case clause.

because the default clause is present:

Unsafe fix: Remove the useless case.

(lint/complexity/noUselessSwitchCase)


[error] 22-22: Useless case clause.

because the default clause is present:

Unsafe fix: Remove the useless case.

(lint/complexity/noUselessSwitchCase)

}
}
1 change: 1 addition & 0 deletions packages/utilities/shared-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from "./functions";
export * from "./numbers";
export * from "./console";
export * from "./types";
export * from "./dates";