Skip to content

Commit 818581d

Browse files
authored
fix: ensure modifiers honor the timeZone prop (#2849)
* Add test case for #2833 Signed-off-by: gpbl <io@gpbl.dev> * Add test for timezones Signed-off-by: gpbl <io@gpbl.dev> * Add utilities to convert into timezone Signed-off-by: gpbl <io@gpbl.dev> * Lint files Signed-off-by: gpbl <io@gpbl.dev> * Lint file Signed-off-by: gpbl <io@gpbl.dev> * Update timezones Signed-off-by: gpbl <io@gpbl.dev> * Add testPathIgnorePatterns, remove log Signed-off-by: gpbl <io@gpbl.dev> * Add test for current date to be different Signed-off-by: gpbl <io@gpbl.dev> * Add comment Signed-off-by: gpbl <io@gpbl.dev> * Cleanup code Signed-off-by: gpbl <io@gpbl.dev> * Revert "Cleanup code”, update test to use proper attribute This reverts commit 469df6e. Signed-off-by: gpbl <io@gpbl.dev> --------- Signed-off-by: gpbl <io@gpbl.dev>
1 parent 5c1be86 commit 818581d

File tree

11 files changed

+399
-15
lines changed

11 files changed

+399
-15
lines changed

.github/workflows/package.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ jobs:
5454
cache: pnpm
5555
- run: pnpm install --frozen-lockfile
5656
- run: pnpm test
57+
- run: pnpm test:tz
5758

5859
build:
5960
runs-on: ubuntu-latest

examples/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export * from "./TestCase2585";
9696
export * from "./TestCase2835";
9797
export * from "./Testcase1567";
9898
export * from "./TimeZone";
99+
export * from "./timezone/TestCase2833";
99100
export * from "./Utc";
100101
export * from "./WeekIso";
101102
export * from "./Weeknumber";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from "react";
2+
3+
import { render, screen } from "@/test/render";
4+
import { TestCase2833 } from "./TestCase2833";
5+
6+
test("test should run in Australia/Adelaide timezone", () => {
7+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(
8+
"Australia/Adelaide",
9+
);
10+
});
11+
12+
test("current date should be different than the date in Etc/GMT+12 timezone", () => {
13+
// This test ensures that the test is running in a timezone with a shifted date
14+
const today = new Date();
15+
const todayInTimezone = new Date(
16+
today.toLocaleString("en-US", { timeZone: "Etc/GMT+12" }),
17+
);
18+
expect(todayInTimezone.getDate()).not.toBe(today.getDate());
19+
});
20+
21+
test("today's date should not be disabled", () => {
22+
const timeZone = "Etc/GMT+12";
23+
const today = new Date().toISOString().slice(0, 10); // yyyy-MM-dd in current zone
24+
const { container } = render(<TestCase2833 />);
25+
const day = container.querySelector(`[data-day="${today}"]`);
26+
expect(screen.getByTestId("now")).toHaveTextContent(
27+
`Australian Central Daylight Time`,
28+
);
29+
expect(screen.getByTestId("timezone")).toHaveTextContent(timeZone);
30+
expect(day).not.toHaveAttribute("data-disabled", "true");
31+
});

examples/timezone/TestCase2833.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from "react";
2+
3+
import { DayPicker, TZDate } from "react-day-picker";
4+
5+
const now = new Date();
6+
7+
export function TestCase2833() {
8+
const timeZone = "Etc/GMT+12";
9+
return (
10+
<div>
11+
<p data-testid="now">now: {now.toString()}</p>
12+
<p data-testid="timezone">time-zone: {timeZone}</p>
13+
<p>disabled before: {TZDate.tz(timeZone).toString()}</p>
14+
<DayPicker
15+
mode="single"
16+
timeZone={timeZone}
17+
disabled={{ before: now }}
18+
selected={now}
19+
/>
20+
</div>
21+
);
22+
}

jest.config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
process.env.TZ = "UTC";
1+
process.env.TZ = process.env.TEST_TZ ?? "UTC";
2+
3+
console.log(`Running tests in ${process.env.TZ} timezone`);
24

35
import type { Config } from "@jest/types";
46

@@ -31,6 +33,7 @@ const config: Config.InitialOptions = {
3133
...sharedConfig,
3234
displayName: "examples",
3335
roots: ["<rootDir>/examples"],
36+
testPathIgnorePatterns: ["<rootDir>/examples/timezone/"],
3437
moduleNameMapper: {
3538
"@/test/(.*)": ["<rootDir>/test/$1"],
3639
"react-day-picker/buddhist": ["<rootDir>/src/buddhist/index.tsx"],
@@ -41,10 +44,23 @@ const config: Config.InitialOptions = {
4144
"^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057
4245
},
4346
},
47+
{
48+
...sharedConfig,
49+
setupFilesAfterEnv: ["<rootDir>/test/setup.ts"],
50+
displayName: "examples/timezone",
51+
roots: ["<rootDir>/examples/timezone"],
52+
fakeTimers: { enableGlobally: false }, // disable fake timers for timezone tests because they interfere with Intl API
53+
moduleNameMapper: {
54+
"@/test/(.*)": ["<rootDir>/test/$1"],
55+
"react-day-picker": ["<rootDir>/src/index.ts"],
56+
"^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057
57+
},
58+
},
4459
{
4560
...sharedConfig,
4661
displayName: "examples/built",
4762
roots: ["<rootDir>/examples"],
63+
testPathIgnorePatterns: ["<rootDir>/examples/timezone/"],
4864
moduleNameMapper: {
4965
"@/test/(.*)": ["<rootDir>/test/$1"],
5066
"react-day-picker/buddhist": ["<rootDir>/dist/cjs/buddhist/index.js"],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@
198198
"format": "prettier -w \"**/*.{md,mdx,ts,tsx}\"",
199199
"lint": "biome check",
200200
"test": "jest --selectProjects examples --selectProjects src",
201+
"test:tz": "TEST_TZ=Australia/Adelaide jest --selectProjects examples/timezone",
201202
"test:build": "jest --selectProjects examples/built",
202203
"test-watch": "jest --watch",
203204
"typecheck": "tsc --project ./tsconfig.json --noEmit",

src/DayPicker.tsx

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { TZDate } from "@date-fns/tz";
21
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from "react";
32
import React, { useCallback, useMemo, useRef } from "react";
43
import type { CalendarDay } from "./classes/CalendarDay.js";
@@ -28,7 +27,9 @@ import { useCalendar } from "./useCalendar.js";
2827
import { type DayPickerContext, dayPickerContext } from "./useDayPicker.js";
2928
import { useFocus } from "./useFocus.js";
3029
import { useSelection } from "./useSelection.js";
30+
import { convertMatchersToTimeZone } from "./utils/convertMatchersToTimeZone.js";
3131
import { rangeIncludesDate } from "./utils/rangeIncludesDate.js";
32+
import { toTimeZone } from "./utils/toTimeZone.js";
3233
import { isDateRange } from "./utils/typeguards.js";
3334

3435
/**
@@ -41,42 +42,60 @@ import { isDateRange } from "./utils/typeguards.js";
4142
*/
4243
export function DayPicker(initialProps: DayPickerProps) {
4344
let props = initialProps;
45+
const timeZone = props.timeZone;
4446

45-
if (props.timeZone) {
47+
if (timeZone) {
4648
props = {
4749
...initialProps,
50+
timeZone,
4851
};
4952
if (props.today) {
50-
props.today = new TZDate(props.today, props.timeZone);
53+
props.today = toTimeZone(props.today, timeZone);
5154
}
5255
if (props.month) {
53-
props.month = new TZDate(props.month, props.timeZone);
56+
props.month = toTimeZone(props.month, timeZone);
5457
}
5558
if (props.defaultMonth) {
56-
props.defaultMonth = new TZDate(props.defaultMonth, props.timeZone);
59+
props.defaultMonth = toTimeZone(props.defaultMonth, timeZone);
5760
}
5861
if (props.startMonth) {
59-
props.startMonth = new TZDate(props.startMonth, props.timeZone);
62+
props.startMonth = toTimeZone(props.startMonth, timeZone);
6063
}
6164
if (props.endMonth) {
62-
props.endMonth = new TZDate(props.endMonth, props.timeZone);
65+
props.endMonth = toTimeZone(props.endMonth, timeZone);
6366
}
6467
if (props.mode === "single" && props.selected) {
65-
props.selected = new TZDate(props.selected, props.timeZone);
68+
props.selected = toTimeZone(props.selected, timeZone);
6669
} else if (props.mode === "multiple" && props.selected) {
67-
props.selected = props.selected?.map(
68-
(date) => new TZDate(date, props.timeZone),
70+
props.selected = props.selected?.map((date) =>
71+
toTimeZone(date, timeZone),
6972
);
7073
} else if (props.mode === "range" && props.selected) {
7174
props.selected = {
7275
from: props.selected.from
73-
? new TZDate(props.selected.from, props.timeZone)
74-
: undefined,
76+
? toTimeZone(props.selected.from, timeZone)
77+
: props.selected.from,
7578
to: props.selected.to
76-
? new TZDate(props.selected.to, props.timeZone)
77-
: undefined,
79+
? toTimeZone(props.selected.to, timeZone)
80+
: props.selected.to,
7881
};
7982
}
83+
if (props.disabled !== undefined) {
84+
props.disabled = convertMatchersToTimeZone(props.disabled, timeZone);
85+
}
86+
if (props.hidden !== undefined) {
87+
props.hidden = convertMatchersToTimeZone(props.hidden, timeZone);
88+
}
89+
if (props.modifiers) {
90+
const nextModifiers: NonNullable<typeof props.modifiers> = {};
91+
Object.keys(props.modifiers).forEach((key) => {
92+
nextModifiers[key] = convertMatchersToTimeZone(
93+
props.modifiers?.[key],
94+
timeZone,
95+
);
96+
});
97+
props.modifiers = nextModifiers;
98+
}
8099
}
81100
const { components, formatters, labels, dateLib, locale, classNames } =
82101
useMemo(() => {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { TZDate } from "@date-fns/tz";
2+
3+
import type { DateRange, Matcher } from "../types/index.js";
4+
5+
import { convertMatchersToTimeZone } from "./convertMatchersToTimeZone.js";
6+
7+
const timeZone = "Pacific/Honolulu";
8+
9+
describe("convertMatchersToTimeZone", () => {
10+
describe("when argument is a date", () => {
11+
const date = new Date("2024-09-26T00:00:00.000Z");
12+
let convertedDate: Date;
13+
14+
beforeEach(() => {
15+
convertedDate = convertMatchersToTimeZone(date, timeZone) as Date;
16+
});
17+
18+
test("returns a TZDate instance", () => {
19+
expect(convertedDate).toBeInstanceOf(TZDate);
20+
});
21+
22+
test("uses the provided time zone offset", () => {
23+
expect(convertedDate.toISOString()).toEqual(
24+
new TZDate(date, timeZone).toISOString(),
25+
);
26+
});
27+
});
28+
29+
describe("when argument is a date range", () => {
30+
const range: DateRange = {
31+
from: new Date("2024-09-24T00:00:00.000Z"),
32+
to: new Date("2024-09-26T00:00:00.000Z"),
33+
};
34+
let convertedRange: DateRange;
35+
36+
beforeEach(() => {
37+
convertedRange = convertMatchersToTimeZone(range, timeZone) as DateRange;
38+
});
39+
40+
test("converts the from date", () => {
41+
expect(convertedRange.from).toBeInstanceOf(TZDate);
42+
});
43+
44+
test("converts the to date", () => {
45+
expect(convertedRange.to).toBeInstanceOf(TZDate);
46+
});
47+
});
48+
49+
describe("when argument is a date interval", () => {
50+
const interval = {
51+
before: new Date("2024-09-20T00:00:00.000Z"),
52+
after: new Date("2024-09-10T00:00:00.000Z"),
53+
};
54+
let convertedInterval: typeof interval;
55+
56+
beforeEach(() => {
57+
convertedInterval = convertMatchersToTimeZone(
58+
interval,
59+
timeZone,
60+
) as typeof interval;
61+
});
62+
63+
test("converts the before date", () => {
64+
expect(convertedInterval.before).toBeInstanceOf(TZDate);
65+
});
66+
67+
test("converts the after date", () => {
68+
expect(convertedInterval.after).toBeInstanceOf(TZDate);
69+
});
70+
});
71+
72+
describe("when argument is a before matcher", () => {
73+
const before = { before: new Date("2024-08-31T00:00:00.000Z") };
74+
let convertedBefore: typeof before;
75+
76+
beforeEach(() => {
77+
convertedBefore = convertMatchersToTimeZone(
78+
before,
79+
timeZone,
80+
) as typeof before;
81+
});
82+
83+
test("converts the before value", () => {
84+
expect(convertedBefore.before).toBeInstanceOf(TZDate);
85+
});
86+
});
87+
88+
describe("when argument is an after matcher", () => {
89+
const after = { after: new Date("2024-10-01T00:00:00.000Z") };
90+
let convertedAfter: typeof after;
91+
92+
beforeEach(() => {
93+
convertedAfter = convertMatchersToTimeZone(
94+
after,
95+
timeZone,
96+
) as typeof after;
97+
});
98+
99+
test("converts the after value", () => {
100+
expect(convertedAfter.after).toBeInstanceOf(TZDate);
101+
});
102+
});
103+
104+
describe("when argument is an array of matchers", () => {
105+
const range: DateRange = {
106+
from: new Date("2024-09-24T00:00:00.000Z"),
107+
to: new Date("2024-09-26T00:00:00.000Z"),
108+
};
109+
const after = { after: new Date("2024-10-01T00:00:00.000Z") };
110+
let convertedRange: Matcher;
111+
let convertedAfter: Matcher;
112+
113+
beforeEach(() => {
114+
[convertedRange, convertedAfter] = convertMatchersToTimeZone(
115+
[range, after],
116+
timeZone,
117+
) as Matcher[];
118+
});
119+
120+
test("converts each range entry", () => {
121+
expect((convertedRange as DateRange).from).toBeInstanceOf(TZDate);
122+
});
123+
124+
test("converts each after entry", () => {
125+
expect((convertedAfter as typeof after).after).toBeInstanceOf(TZDate);
126+
});
127+
});
128+
129+
describe("when argument is undefined", () => {
130+
test("returns undefined", () => {
131+
expect(convertMatchersToTimeZone(undefined, timeZone)).toBeUndefined();
132+
});
133+
});
134+
135+
describe("when argument is a boolean", () => {
136+
test("returns the same boolean value", () => {
137+
expect(convertMatchersToTimeZone(true, timeZone)).toBe(true);
138+
});
139+
});
140+
141+
describe("when argument is a matcher function", () => {
142+
const matcher = (date: Date) => date.getDay() === 0;
143+
144+
test("returns the same function reference", () => {
145+
expect(convertMatchersToTimeZone(matcher, timeZone)).toBe(matcher);
146+
});
147+
});
148+
});

0 commit comments

Comments
 (0)