@pushwoosh/dumb-components 2.2.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Calendar/__tests__/CalendarMonthSlider.test.d.ts +1 -0
- package/Calendar/__tests__/CalendarMonthSlider.test.js +85 -0
- package/Calendar/__tests__/helpers.test.js +20 -1
- package/Calendar/helpers.js +10 -3
- package/helpers/__tests__/timezones.test.js +13 -0
- package/helpers/timezones.js +28 -8
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { CalendarMonthSlider } from '../CalendarMonthSlider/CalendarMonthSlider';
|
|
4
|
+
const LAST_DAY_START = {
|
|
5
|
+
year: 2026,
|
|
6
|
+
month: 1,
|
|
7
|
+
day: 31
|
|
8
|
+
};
|
|
9
|
+
function renderSlider(startMonth = LAST_DAY_START) {
|
|
10
|
+
return render(_jsx(CalendarMonthSlider, {
|
|
11
|
+
startMonth: startMonth,
|
|
12
|
+
onDaySelect: jest.fn()
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
function selectsOf(kind) {
|
|
16
|
+
const selects = screen.getAllByRole('combobox');
|
|
17
|
+
const isYear = select => /^\d+$/.test(select.options[0].text);
|
|
18
|
+
return selects.filter(select => kind === 'year' ? isYear(select) : !isYear(select));
|
|
19
|
+
}
|
|
20
|
+
function visibleMonths() {
|
|
21
|
+
const years = selectsOf('year');
|
|
22
|
+
return selectsOf('month').map((month, i) => `${years[i].value}-${month.value.padStart(2, '0')}`);
|
|
23
|
+
}
|
|
24
|
+
function step(direction) {
|
|
25
|
+
const [prev, next] = screen.getAllByRole('button');
|
|
26
|
+
fireEvent.click(direction === 'prev' ? prev : next);
|
|
27
|
+
act(() => {
|
|
28
|
+
jest.runAllTimers();
|
|
29
|
+
});
|
|
30
|
+
fireEvent.transitionEnd(selectsOf('month')[0]);
|
|
31
|
+
}
|
|
32
|
+
describe('CalendarMonthSlider', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
jest.useFakeTimers();
|
|
35
|
+
jest.setSystemTime(new Date(2026, 5, 15));
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
jest.useRealTimers();
|
|
39
|
+
});
|
|
40
|
+
it('renders the start month regardless of the day it was opened on', () => {
|
|
41
|
+
renderSlider();
|
|
42
|
+
expect(visibleMonths()).toEqual(['2026-01']);
|
|
43
|
+
});
|
|
44
|
+
it('steps to the adjacent month in both directions without skipping one', () => {
|
|
45
|
+
renderSlider();
|
|
46
|
+
step('next');
|
|
47
|
+
expect(visibleMonths()).toEqual(['2026-02']);
|
|
48
|
+
step('next');
|
|
49
|
+
expect(visibleMonths()).toEqual(['2026-03']);
|
|
50
|
+
step('prev');
|
|
51
|
+
expect(visibleMonths()).toEqual(['2026-02']);
|
|
52
|
+
});
|
|
53
|
+
it('crosses the year boundary in both directions', () => {
|
|
54
|
+
renderSlider({
|
|
55
|
+
year: 2026,
|
|
56
|
+
month: 12,
|
|
57
|
+
day: 31
|
|
58
|
+
});
|
|
59
|
+
step('next');
|
|
60
|
+
expect(visibleMonths()).toEqual(['2027-01']);
|
|
61
|
+
step('prev');
|
|
62
|
+
step('prev');
|
|
63
|
+
expect(visibleMonths()).toEqual(['2026-11']);
|
|
64
|
+
});
|
|
65
|
+
it('shows the adjacent month, not the one after, while the step animates', () => {
|
|
66
|
+
renderSlider();
|
|
67
|
+
fireEvent.click(screen.getAllByRole('button')[1]);
|
|
68
|
+
expect(visibleMonths()).toEqual(['2026-01', '2026-02']);
|
|
69
|
+
});
|
|
70
|
+
it('jumps to the month and year picked in the selects', () => {
|
|
71
|
+
renderSlider();
|
|
72
|
+
fireEvent.change(selectsOf('month')[0], {
|
|
73
|
+
target: {
|
|
74
|
+
value: '2'
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
expect(visibleMonths()).toEqual(['2026-02']);
|
|
78
|
+
fireEvent.change(selectsOf('year')[0], {
|
|
79
|
+
target: {
|
|
80
|
+
value: '2027'
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
expect(visibleMonths()).toEqual(['2027-02']);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addPeriod, setPeriodStruct } from '../helpers';
|
|
1
|
+
import { addPeriod, getTimezoneOffset, setPeriodStruct } from '../helpers';
|
|
2
2
|
describe('addPeriod', () => {
|
|
3
3
|
it('clamps the day when the target month is shorter', () => {
|
|
4
4
|
expect(addPeriod({
|
|
@@ -121,4 +121,23 @@ describe('setPeriodStruct', () => {
|
|
|
121
121
|
day: 28
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
|
+
});
|
|
125
|
+
describe('getTimezoneOffset', () => {
|
|
126
|
+
it('follows DST at the given instant', () => {
|
|
127
|
+
expect(getTimezoneOffset('Australia/Melbourne', new Date('2026-06-15T12:00:00Z'))).toBe(600);
|
|
128
|
+
expect(getTimezoneOffset('Australia/Melbourne', new Date('2026-10-10T12:00:00Z'))).toBe(660);
|
|
129
|
+
expect(getTimezoneOffset('Asia/Kolkata', new Date('2026-01-15T12:00:00Z'))).toBe(330);
|
|
130
|
+
});
|
|
131
|
+
it('builds one Intl.DateTimeFormat per zone, however many instants it is asked about', () => {
|
|
132
|
+
const construct = jest.spyOn(Intl, 'DateTimeFormat');
|
|
133
|
+
try {
|
|
134
|
+
getTimezoneOffset('Pacific/Chatham', new Date('2026-01-15T12:00:00Z'));
|
|
135
|
+
const afterFirst = construct.mock.calls.length;
|
|
136
|
+
getTimezoneOffset('Pacific/Chatham', new Date('2026-06-15T12:00:00Z'));
|
|
137
|
+
getTimezoneOffset('Pacific/Chatham', new Date('2026-10-10T12:00:00Z'));
|
|
138
|
+
expect(construct.mock.calls.length).toBe(afterFirst);
|
|
139
|
+
} finally {
|
|
140
|
+
construct.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
124
143
|
});
|
package/Calendar/helpers.js
CHANGED
|
@@ -163,8 +163,10 @@ export const structToDate = (ds, tzOffsetMinutes) => {
|
|
|
163
163
|
const utcTime = Date.UTC(ds.year, ds.month - 1, ds.day, hour, minute, second, millisecond);
|
|
164
164
|
return new Date(utcTime - tzOffsetMinutes * 60_000);
|
|
165
165
|
};
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
const offsetFormatters = new Map();
|
|
167
|
+
function offsetFormatter(timezoneName) {
|
|
168
|
+
const cached = offsetFormatters.get(timezoneName);
|
|
169
|
+
if (cached) return cached;
|
|
168
170
|
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
169
171
|
timeZone: timezoneName,
|
|
170
172
|
hour12: false,
|
|
@@ -175,7 +177,12 @@ export function getTimezoneOffset(timezoneName, at = new Date()) {
|
|
|
175
177
|
minute: '2-digit',
|
|
176
178
|
second: '2-digit'
|
|
177
179
|
});
|
|
178
|
-
|
|
180
|
+
offsetFormatters.set(timezoneName, dtf);
|
|
181
|
+
return dtf;
|
|
182
|
+
}
|
|
183
|
+
/** Returns the offset in minutes (east of UTC) for an IANA `timezoneName` at the given instant `at`. */
|
|
184
|
+
export function getTimezoneOffset(timezoneName, at = new Date()) {
|
|
185
|
+
const parts = offsetFormatter(timezoneName).formatToParts(at);
|
|
179
186
|
const y = Number(parts.find(p => p.type === 'year')?.value);
|
|
180
187
|
const mo = Number(parts.find(p => p.type === 'month')?.value) - 1;
|
|
181
188
|
const d = Number(parts.find(p => p.type === 'day')?.value);
|
|
@@ -124,6 +124,19 @@ describe('listTimezones', () => {
|
|
|
124
124
|
expect(withStored.some(option => option.value === '')).toBe(false);
|
|
125
125
|
expect(withStored[withStored.length - 1].value).toBe('Not/A_Zone');
|
|
126
126
|
});
|
|
127
|
+
it('answers an equal but fresh list when called again for the same minute', () => {
|
|
128
|
+
const again = listTimezones(winter);
|
|
129
|
+
expect(again).toEqual(options);
|
|
130
|
+
expect(again).not.toBe(options);
|
|
131
|
+
again.length = 0;
|
|
132
|
+
expect(listTimezones(winter)).toEqual(options);
|
|
133
|
+
});
|
|
134
|
+
it('re-resolves offsets when the instant moves across a DST boundary', () => {
|
|
135
|
+
const standard = listTimezones(melbourneStandard).find(option => option.value === 'Australia/Melbourne');
|
|
136
|
+
const dst = listTimezones(melbourneDst).find(option => option.value === 'Australia/Melbourne');
|
|
137
|
+
expect(standard?.label).toBe('(UTC+10:00) Australia/Melbourne');
|
|
138
|
+
expect(dst?.label).toBe('(UTC+11:00) Australia/Melbourne');
|
|
139
|
+
});
|
|
127
140
|
it('falls back to the browser zone and UTC when the engine has no zone list', () => {
|
|
128
141
|
const original = Intl.supportedValuesOf;
|
|
129
142
|
Object.defineProperty(Intl, 'supportedValuesOf', {
|
package/helpers/timezones.js
CHANGED
|
@@ -30,10 +30,12 @@ export function formatTimezoneOffset(minutes) {
|
|
|
30
30
|
const mins = String(absolute % 60).padStart(2, '0');
|
|
31
31
|
return `UTC${sign}${hours}:${mins}`;
|
|
32
32
|
}
|
|
33
|
+
function labelForOffset(timezone, offset) {
|
|
34
|
+
return offset === null ? timezone : `(${formatTimezoneOffset(offset)}) ${timezone}`;
|
|
35
|
+
}
|
|
33
36
|
/** The shared picker label, `(UTC+05:00) Asia/Almaty` at instant `at`; a zone the engine doesn't know is labelled by its bare name. */
|
|
34
37
|
export function formatTimezoneLabel(timezone, at = new Date()) {
|
|
35
|
-
|
|
36
|
-
return offset === null ? timezone : `(${formatTimezoneOffset(offset)}) ${timezone}`;
|
|
38
|
+
return labelForOffset(timezone, getTimezoneOffsetSafe(timezone, at));
|
|
37
39
|
}
|
|
38
40
|
/** Compact badge for a zone, `UTC+05:00`; a zone the engine doesn't know is shown by its bare name. */
|
|
39
41
|
export function formatTimezoneShort(timezone, at = new Date()) {
|
|
@@ -80,18 +82,36 @@ function compareZones(a, b) {
|
|
|
80
82
|
if (b.name === UTC) return 1;
|
|
81
83
|
return a.name.localeCompare(b.name);
|
|
82
84
|
}
|
|
85
|
+
function buildTimezoneList(names, at) {
|
|
86
|
+
return names.map(name => ({
|
|
87
|
+
name,
|
|
88
|
+
offset: getTimezoneOffsetSafe(name, at)
|
|
89
|
+
})).sort(compareZones).map(({
|
|
90
|
+
name,
|
|
91
|
+
offset
|
|
92
|
+
}) => ({
|
|
93
|
+
value: name,
|
|
94
|
+
label: labelForOffset(name, offset)
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
function listCacheKey(names, at) {
|
|
98
|
+
return `${Math.floor(at.getTime() / 60_000)}|${names.join(',')}`;
|
|
99
|
+
}
|
|
100
|
+
let cachedListKey = null;
|
|
101
|
+
let cachedList = [];
|
|
83
102
|
/** Zones the browser knows plus `UTC` and every non-empty `include` value, sorted by offset at `at` then by name. Pass the stored value as `include` so an old alias stays selectable and is re-saved unchanged. */
|
|
84
103
|
export function listTimezones(at = new Date(), include = []) {
|
|
85
104
|
const names = new Set([UTC, ...supportedTimezones()]);
|
|
86
105
|
include.forEach(name => {
|
|
87
106
|
if (name) names.add(name);
|
|
88
107
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
108
|
+
const zones = [...names];
|
|
109
|
+
const key = listCacheKey(zones, at);
|
|
110
|
+
if (key !== cachedListKey) {
|
|
111
|
+
cachedList = buildTimezoneList(zones, at);
|
|
112
|
+
cachedListKey = key;
|
|
113
|
+
}
|
|
114
|
+
return cachedList.slice();
|
|
95
115
|
}
|
|
96
116
|
/** Option matching a stored value: by zone name, or for a legacy numeric offset (minutes east of UTC) the first zone with that offset at `at`. */
|
|
97
117
|
export function findTimezoneOption(options, value, at = new Date()) {
|