@pushwoosh/dumb-components 1.1.233 → 1.1.235
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/CalendarSuperForm/components/CalendarTimezoneSelect.js +3 -3
- package/Calendar/helpers-tz.d.ts +0 -5
- package/Calendar/helpers-tz.js +0 -31
- package/Calendar/index.d.ts +0 -1
- package/Calendar/index.js +0 -1
- package/NativeRichMediaPreview/NativeRichMediaPreview.d.ts +33 -0
- package/NativeRichMediaPreview/NativeRichMediaPreview.js +31 -16
- package/NativeRichMediaPreview/__tests__/NativeRichMediaPreview.test.d.ts +1 -0
- package/NativeRichMediaPreview/__tests__/NativeRichMediaPreview.test.js +81 -0
- package/NativeRichMediaPreview/index.d.ts +1 -1
- package/NativeRichMediaPreview/types.d.ts +7 -1
- package/helpers/__tests__/timezones.test.d.ts +1 -0
- package/helpers/__tests__/timezones.test.js +125 -0
- package/helpers/index.d.ts +1 -0
- package/helpers/index.js +2 -1
- package/helpers/timezones.d.ts +23 -0
- package/helpers/timezones.js +82 -0
- package/index.d.ts +3 -2
- package/index.js +3 -2
- package/package.json +1 -1
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
3
|
import { Combobox } from '../../../Combobox';
|
|
4
|
-
import {
|
|
4
|
+
import { findTimezoneOption, listTimezones } from '../../../helpers/timezones';
|
|
5
5
|
export const CalendarTimezoneSelect = ({
|
|
6
6
|
timezone,
|
|
7
7
|
onChange,
|
|
8
8
|
isDisabled
|
|
9
9
|
}) => {
|
|
10
|
-
const timezonesOptions = useMemo(() =>
|
|
11
|
-
const value = useMemo(() => timezonesOptions
|
|
10
|
+
const timezonesOptions = useMemo(() => listTimezones(new Date(), [timezone]), [timezone]);
|
|
11
|
+
const value = useMemo(() => findTimezoneOption(timezonesOptions, timezone), [timezonesOptions, timezone]);
|
|
12
12
|
return _jsx(Combobox, {
|
|
13
13
|
select: true,
|
|
14
14
|
items: timezonesOptions,
|
package/Calendar/helpers-tz.d.ts
CHANGED
|
@@ -1,7 +1,2 @@
|
|
|
1
|
-
export type TimezoneOption = {
|
|
2
|
-
value: string;
|
|
3
|
-
label: string;
|
|
4
|
-
};
|
|
5
|
-
export declare function getSortedTimezones(): TimezoneOption[];
|
|
6
1
|
export declare function groupTimezonesByOffset(at?: Date): [number, string[]][];
|
|
7
2
|
export declare function formatTimezoneShort(timezone: string): string;
|
package/Calendar/helpers-tz.js
CHANGED
|
@@ -13,37 +13,6 @@ function formatTimezoneOffset(minutes) {
|
|
|
13
13
|
return `UTC${sign}${hours}:${formatTwoDigitNumber(mins)}`;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
-
export function getSortedTimezones() {
|
|
17
|
-
const timezones = getAllTimeZones();
|
|
18
|
-
const now = new Date();
|
|
19
|
-
return [...timezones, 'UTC'].map(tz => {
|
|
20
|
-
const offset = getTimezoneOffset(tz, now);
|
|
21
|
-
const offsetFormatted = formatTimezoneOffset(offset);
|
|
22
|
-
let displayName;
|
|
23
|
-
if (tz === 'UTC') {
|
|
24
|
-
displayName = 'Coordinated Universal Time';
|
|
25
|
-
} else if (tz.includes('/')) {
|
|
26
|
-
const parts = tz.split('/');
|
|
27
|
-
if (parts.length >= 2) {
|
|
28
|
-
const country = parts[0].replace(/_/g, ' ');
|
|
29
|
-
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
|
30
|
-
displayName = `${city} / ${country}`;
|
|
31
|
-
} else {
|
|
32
|
-
displayName = tz.replace(/_/g, ' ');
|
|
33
|
-
}
|
|
34
|
-
} else {
|
|
35
|
-
displayName = tz.replace(/_/g, ' ');
|
|
36
|
-
}
|
|
37
|
-
return {
|
|
38
|
-
value: tz,
|
|
39
|
-
label: `${offsetFormatted} – ${displayName}`
|
|
40
|
-
};
|
|
41
|
-
}).sort((a, b) => {
|
|
42
|
-
if (a.value === 'UTC') return -1;
|
|
43
|
-
if (b.value === 'UTC') return 1;
|
|
44
|
-
return a.label.localeCompare(b.label);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
16
|
export function groupTimezonesByOffset(at = new Date()) {
|
|
48
17
|
const zones = getAllTimeZones();
|
|
49
18
|
const groups = new Map();
|
package/Calendar/index.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export { CalendarSuperForm, type CalendarSuperFormProps } from './CalendarSuperForm';
|
|
2
|
-
export { getSortedTimezones, type TimezoneOption } from './helpers-tz';
|
|
3
2
|
export { CalendarDropdown, stringifyValue, type CalendarDropdownProps, type CalendarDropdownRangeDateProps, } from './CalendarDropdown';
|
|
4
3
|
export { dateToStruct, structToDate, dateToStructTz, structToDateTz, getTimezoneOffset, addPeriod, compareStructs, getPeriodEnd, getPeriodStart, pickDateStruct, } from './helpers';
|
|
5
4
|
export type { DateStruct, RangeValue } from './types';
|
package/Calendar/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
export { CalendarSuperForm } from './CalendarSuperForm';
|
|
2
|
-
export { getSortedTimezones } from './helpers-tz';
|
|
3
2
|
export { CalendarDropdown, stringifyValue } from './CalendarDropdown';
|
|
4
3
|
export { dateToStruct, structToDate, dateToStructTz, structToDateTz, getTimezoneOffset, addPeriod, compareStructs, getPeriodEnd, getPeriodStart, pickDateStruct } from './helpers';
|
|
@@ -1,3 +1,36 @@
|
|
|
1
1
|
import { type ReactElement } from 'react';
|
|
2
2
|
import type { NativeRichMediaPreviewProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Preview of a native rich media (modal, banner, stories, spinwheel, …)
|
|
5
|
+
* rendered from its JSON config; lays out with plain CSS in the real pixels
|
|
6
|
+
* of the given width/height box, with no fidelity to any phone's pt-metrics
|
|
7
|
+
* since this is a preview and devices vary. Content flows inside a scrollable
|
|
8
|
+
* layer above a static backdrop (the dim overlay or, for
|
|
9
|
+
* `fullscreen`/`video`/`stories`, the full-bleed background image or poster):
|
|
10
|
+
* content taller than the box scrolls, shorter content aligns per display
|
|
11
|
+
* type while the backdrop stays put. Pass `scale` to lay the content out on a
|
|
12
|
+
* larger (or smaller) reference viewport and scale the whole preview to fill
|
|
13
|
+
* the box — the way to cram a phone-sized layout into a small preview box
|
|
14
|
+
* while keeping its proportions. Shows an invalid-config placeholder when the
|
|
15
|
+
* config cannot be parsed or misses required contract fields.
|
|
16
|
+
* With `withThemeToggle` the preview carries a chip that flips it between the light
|
|
17
|
+
* values and the dark overlay merged over them; pass `theme` with `onThemeChange`
|
|
18
|
+
* to drive that from outside, when a surrounding editor has its own theme switch.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* <NativeRichMediaPreview
|
|
22
|
+
* width={304}
|
|
23
|
+
* height={659}
|
|
24
|
+
* content={{
|
|
25
|
+
* displayType: 'modal',
|
|
26
|
+
* modal: {
|
|
27
|
+
* showClose: true,
|
|
28
|
+
* dimBackground: true,
|
|
29
|
+
* background: '#FFFFFFFF',
|
|
30
|
+
* title: { text: 'Hi', color: '#4B5057FF' },
|
|
31
|
+
* buttons: [],
|
|
32
|
+
* },
|
|
33
|
+
* }}
|
|
34
|
+
* />
|
|
35
|
+
*/
|
|
3
36
|
export declare function NativeRichMediaPreview(props: NativeRichMediaPreviewProps): ReactElement;
|
|
@@ -152,6 +152,17 @@ function renderConfig(config, activeIndex) {
|
|
|
152
152
|
return _jsx(NativeRichMediaInvalidView, {});
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
function parseDarkVariant(content, withThemeToggle) {
|
|
156
|
+
if (!withThemeToggle) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const merged = mergeNativeDarkOverlay(content);
|
|
160
|
+
// The merge hands back `content` itself, untouched, whenever there is nothing to apply.
|
|
161
|
+
if (merged === content) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return parseNativeRichMediaConfig(merged);
|
|
165
|
+
}
|
|
155
166
|
/**
|
|
156
167
|
* Preview of a native rich media (modal, banner, stories, spinwheel, …)
|
|
157
168
|
* rendered from its JSON config; lays out with plain CSS in the real pixels
|
|
@@ -165,6 +176,9 @@ function renderConfig(config, activeIndex) {
|
|
|
165
176
|
* the box — the way to cram a phone-sized layout into a small preview box
|
|
166
177
|
* while keeping its proportions. Shows an invalid-config placeholder when the
|
|
167
178
|
* config cannot be parsed or misses required contract fields.
|
|
179
|
+
* With `withThemeToggle` the preview carries a chip that flips it between the light
|
|
180
|
+
* values and the dark overlay merged over them; pass `theme` with `onThemeChange`
|
|
181
|
+
* to drive that from outside, when a surrounding editor has its own theme switch.
|
|
168
182
|
*
|
|
169
183
|
* @example
|
|
170
184
|
* <NativeRichMediaPreview
|
|
@@ -182,17 +196,6 @@ function renderConfig(config, activeIndex) {
|
|
|
182
196
|
* }}
|
|
183
197
|
* />
|
|
184
198
|
*/
|
|
185
|
-
function parseDarkVariant(content, withThemeToggle) {
|
|
186
|
-
if (!withThemeToggle) {
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
const merged = mergeNativeDarkOverlay(content);
|
|
190
|
-
// The merge hands back `content` itself, untouched, whenever there is nothing to apply.
|
|
191
|
-
if (merged === content) {
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
return parseNativeRichMediaConfig(merged);
|
|
195
|
-
}
|
|
196
199
|
export function NativeRichMediaPreview(props) {
|
|
197
200
|
const {
|
|
198
201
|
content,
|
|
@@ -204,9 +207,20 @@ export function NativeRichMediaPreview(props) {
|
|
|
204
207
|
activeIndex = 0,
|
|
205
208
|
isLoading = false,
|
|
206
209
|
withBackground = true,
|
|
207
|
-
withThemeToggle = false
|
|
210
|
+
withThemeToggle = false,
|
|
211
|
+
theme: controlledTheme,
|
|
212
|
+
onThemeChange
|
|
208
213
|
} = props;
|
|
209
|
-
const [
|
|
214
|
+
const [ownTheme, setOwnTheme] = useState('light');
|
|
215
|
+
const isControlled = controlledTheme !== undefined;
|
|
216
|
+
const theme = controlledTheme ?? ownTheme;
|
|
217
|
+
const toggleTheme = () => {
|
|
218
|
+
const next = theme === 'dark' ? 'light' : 'dark';
|
|
219
|
+
if (!isControlled) {
|
|
220
|
+
setOwnTheme(next);
|
|
221
|
+
}
|
|
222
|
+
onThemeChange?.(next);
|
|
223
|
+
};
|
|
210
224
|
const {
|
|
211
225
|
config,
|
|
212
226
|
isDarkRendered,
|
|
@@ -222,6 +236,7 @@ export function NativeRichMediaPreview(props) {
|
|
|
222
236
|
canToggleDark: darkParsed !== null
|
|
223
237
|
};
|
|
224
238
|
}, [content, localization, language, theme, withThemeToggle]);
|
|
239
|
+
const renderedTheme = isDarkRendered ? 'dark' : 'light';
|
|
225
240
|
const shouldDim = !isLoading && dimsScreen(config);
|
|
226
241
|
return _jsxs(PreviewHost, {
|
|
227
242
|
...LIGHT_ISLAND_PROPS,
|
|
@@ -239,9 +254,9 @@ export function NativeRichMediaPreview(props) {
|
|
|
239
254
|
})
|
|
240
255
|
}, config.displayType), !isLoading && config && renderChrome(config)]
|
|
241
256
|
}), !isLoading && config && withThemeToggle && _jsx(NativeRichMediaThemeChip, {
|
|
242
|
-
theme:
|
|
243
|
-
disabled: !canToggleDark,
|
|
244
|
-
onToggle:
|
|
257
|
+
theme: isControlled ? theme : renderedTheme,
|
|
258
|
+
disabled: !isControlled && !canToggleDark,
|
|
259
|
+
onToggle: toggleTheme
|
|
245
260
|
}), isLoading && _jsx(SpinnerLayer, {
|
|
246
261
|
children: _jsx(Spinner, {
|
|
247
262
|
size: "medium",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { NativeRichMediaPreview } from '../NativeRichMediaPreview';
|
|
4
|
+
const lightContent = {
|
|
5
|
+
displayType: 'modal',
|
|
6
|
+
modal: {
|
|
7
|
+
showClose: true,
|
|
8
|
+
dimBackground: true,
|
|
9
|
+
background: '#FFFFFFFF',
|
|
10
|
+
title: {
|
|
11
|
+
text: 'Hi',
|
|
12
|
+
color: '#4B5057FF'
|
|
13
|
+
},
|
|
14
|
+
buttons: []
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const darkableContent = {
|
|
18
|
+
...lightContent,
|
|
19
|
+
modal: {
|
|
20
|
+
...lightContent.modal,
|
|
21
|
+
dark: {
|
|
22
|
+
background: '#14161AFF',
|
|
23
|
+
title: {
|
|
24
|
+
color: '#F2F4F7FF'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const themeChip = () => screen.getByLabelText('Switch light and dark');
|
|
30
|
+
const renderPreview = (props = {}) => render(_jsx(NativeRichMediaPreview, {
|
|
31
|
+
content: darkableContent,
|
|
32
|
+
width: 304,
|
|
33
|
+
height: 659,
|
|
34
|
+
withThemeToggle: true,
|
|
35
|
+
...props
|
|
36
|
+
}));
|
|
37
|
+
describe('NativeRichMediaPreview theme toggle', () => {
|
|
38
|
+
it('keeps its own theme when uncontrolled', () => {
|
|
39
|
+
renderPreview();
|
|
40
|
+
expect(themeChip()).toHaveAttribute('aria-pressed', 'false');
|
|
41
|
+
fireEvent.click(themeChip());
|
|
42
|
+
expect(themeChip()).toHaveAttribute('aria-pressed', 'true');
|
|
43
|
+
});
|
|
44
|
+
it('follows the theme prop instead of a click when controlled', () => {
|
|
45
|
+
const onThemeChange = jest.fn();
|
|
46
|
+
const {
|
|
47
|
+
rerender
|
|
48
|
+
} = renderPreview({
|
|
49
|
+
theme: 'dark',
|
|
50
|
+
onThemeChange
|
|
51
|
+
});
|
|
52
|
+
expect(themeChip()).toHaveAttribute('aria-pressed', 'true');
|
|
53
|
+
fireEvent.click(themeChip());
|
|
54
|
+
expect(onThemeChange).toHaveBeenCalledWith('light');
|
|
55
|
+
expect(themeChip()).toHaveAttribute('aria-pressed', 'true');
|
|
56
|
+
rerender(_jsx(NativeRichMediaPreview, {
|
|
57
|
+
content: darkableContent,
|
|
58
|
+
width: 304,
|
|
59
|
+
height: 659,
|
|
60
|
+
withThemeToggle: true,
|
|
61
|
+
theme: "light",
|
|
62
|
+
onThemeChange: onThemeChange
|
|
63
|
+
}));
|
|
64
|
+
expect(themeChip()).toHaveAttribute('aria-pressed', 'false');
|
|
65
|
+
});
|
|
66
|
+
it('reports the controlled theme even when the content has no dark overlay', () => {
|
|
67
|
+
renderPreview({
|
|
68
|
+
content: lightContent,
|
|
69
|
+
theme: 'dark',
|
|
70
|
+
onThemeChange: jest.fn()
|
|
71
|
+
});
|
|
72
|
+
expect(themeChip()).toHaveAttribute('aria-pressed', 'true');
|
|
73
|
+
expect(themeChip()).not.toBeDisabled();
|
|
74
|
+
});
|
|
75
|
+
it('disables the chip on content without a dark overlay when uncontrolled', () => {
|
|
76
|
+
renderPreview({
|
|
77
|
+
content: lightContent
|
|
78
|
+
});
|
|
79
|
+
expect(themeChip()).toBeDisabled();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { NativeRichMediaConfig, NativeRichMediaDisplayType, NativeRichMediaDarkOverlay, NativeRichMediaText, NativeRichMediaBorder, NativeRichMediaCover, NativeRichMediaAction, NativeRichMediaButton, NativeRichMediaReward, NativeRichMediaModalBlock, NativeRichMediaSheetBlock, NativeRichMediaCarouselBlock, NativeRichMediaStoriesBlock, NativeRichMediaBannerBlock, NativeRichMediaFullscreenBlock, NativeRichMediaVideoBlock, NativeRichMediaPipBlock, NativeRichMediaScratchcardBlock, NativeRichMediaSpinwheelBlock, NativeRichMediaPreviewProps, NativeRichMediaLocalization, } from './types';
|
|
1
|
+
export type { NativeRichMediaConfig, NativeRichMediaDisplayType, NativeRichMediaDarkOverlay, NativeRichMediaText, NativeRichMediaBorder, NativeRichMediaCover, NativeRichMediaAction, NativeRichMediaButton, NativeRichMediaReward, NativeRichMediaModalBlock, NativeRichMediaSheetBlock, NativeRichMediaCarouselBlock, NativeRichMediaStoriesBlock, NativeRichMediaBannerBlock, NativeRichMediaFullscreenBlock, NativeRichMediaVideoBlock, NativeRichMediaPipBlock, NativeRichMediaScratchcardBlock, NativeRichMediaSpinwheelBlock, NativeRichMediaPreviewProps, NativeRichMediaLocalization, NativeRichMediaTheme, } from './types';
|
|
2
2
|
export { NATIVE_RICH_MEDIA_DISPLAY_TYPES, EDITABLE_NATIVE_RICH_MEDIA_DISPLAY_TYPES } from './types';
|
|
3
3
|
export { parseNativeRichMediaConfig } from './parseNativeRichMediaConfig';
|
|
4
4
|
export { parseLocalizationParams } from './parseLocalizationParams';
|
|
@@ -319,6 +319,8 @@ export declare const EDITABLE_NATIVE_RICH_MEDIA_DISPLAY_TYPES: NativeRichMediaDi
|
|
|
319
319
|
* fallback. Text lives here rather than inline in `content`.
|
|
320
320
|
*/
|
|
321
321
|
export type NativeRichMediaLocalization = Record<string, Record<string, string>>;
|
|
322
|
+
/** Colour scheme the preview renders: the light values, or them merged with the dark overlay. */
|
|
323
|
+
export type NativeRichMediaTheme = 'light' | 'dark';
|
|
322
324
|
/** Props of {@link NativeRichMediaPreview}. */
|
|
323
325
|
export type NativeRichMediaPreviewProps = {
|
|
324
326
|
/** Rich media config, as an object or a JSON string. */
|
|
@@ -339,6 +341,10 @@ export type NativeRichMediaPreviewProps = {
|
|
|
339
341
|
isLoading?: boolean;
|
|
340
342
|
/** Render the container chrome — white backdrop and border (default `true`). Set `false` for a transparent preview over the consumer's own background. */
|
|
341
343
|
withBackground?: boolean;
|
|
342
|
-
/** Show a chip that switches the preview between the light and dark variants (default `false`).
|
|
344
|
+
/** Show a chip that switches the preview between the light and dark variants (default `false`). Uncontrolled, the preview opens light. */
|
|
343
345
|
withThemeToggle?: boolean;
|
|
346
|
+
/** Theme to render, making the chip controlled: the preview follows this value, the chip mirrors it (rather than the rendered result, since content without a dark overlay renders identically in both themes) and only reports clicks through `onThemeChange`. Omit to let the preview keep its own state. */
|
|
347
|
+
theme?: NativeRichMediaTheme;
|
|
348
|
+
/** Called with the other theme when the chip is clicked; the only way to react to the chip on a controlled preview. */
|
|
349
|
+
onThemeChange?: (theme: NativeRichMediaTheme) => void;
|
|
344
350
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { canonicalizeTimezone, findTimezoneOption, formatTimezoneLabel, formatTimezoneOffset, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption } from '../timezones';
|
|
2
|
+
const winter = new Date('2026-01-15T12:00:00Z');
|
|
3
|
+
const melbourneDst = new Date('2026-10-10T12:00:00Z');
|
|
4
|
+
describe('formatTimezoneOffset', () => {
|
|
5
|
+
it('renders sign, hours and minutes', () => {
|
|
6
|
+
expect(formatTimezoneOffset(300)).toBe('GMT+05:00');
|
|
7
|
+
expect(formatTimezoneOffset(330)).toBe('GMT+05:30');
|
|
8
|
+
expect(formatTimezoneOffset(-210)).toBe('GMT-03:30');
|
|
9
|
+
expect(formatTimezoneOffset(0)).toBe('GMT+00:00');
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
describe('formatTimezoneLabel', () => {
|
|
13
|
+
it('uses the current tzdata: Kazakhstan is UTC+5 since 2024', () => {
|
|
14
|
+
expect(formatTimezoneLabel('Asia/Almaty', winter)).toBe('(GMT+05:00) Asia/Almaty');
|
|
15
|
+
expect(formatTimezoneLabel('Asia/Qostanay', winter)).toBe('(GMT+05:00) Asia/Qostanay');
|
|
16
|
+
});
|
|
17
|
+
it('keeps half-hour zones east of UTC exact', () => {
|
|
18
|
+
expect(formatTimezoneLabel('Asia/Kolkata', winter)).toBe('(GMT+05:30) Asia/Kolkata');
|
|
19
|
+
expect(formatTimezoneLabel('Asia/Kathmandu', winter)).toBe('(GMT+05:45) Asia/Kathmandu');
|
|
20
|
+
expect(formatTimezoneLabel('America/St_Johns', winter)).toBe('(GMT-03:30) America/St_Johns');
|
|
21
|
+
});
|
|
22
|
+
it('resolves the offset at the given instant, not today', () => {
|
|
23
|
+
expect(getTimezoneOffsetSafe('Australia/Melbourne', winter)).toBe(660);
|
|
24
|
+
expect(getTimezoneOffsetSafe('Australia/Melbourne', new Date('2026-07-01T00:00:00Z'))).toBe(600);
|
|
25
|
+
expect(getTimezoneOffsetSafe('Australia/Melbourne', melbourneDst)).toBe(660);
|
|
26
|
+
});
|
|
27
|
+
it('labels Etc and POSIX names the backend accepts but the picker does not list', () => {
|
|
28
|
+
expect(formatTimezoneLabel('Etc/GMT+3', winter)).toBe('(GMT-03:00) Etc/GMT+3');
|
|
29
|
+
expect(formatTimezoneLabel('Etc/GMT-0', winter)).toBe('(GMT+00:00) Etc/GMT-0');
|
|
30
|
+
expect(formatTimezoneLabel('EST', winter)).toBe('(GMT-05:00) EST');
|
|
31
|
+
});
|
|
32
|
+
it('falls back to the bare name for a zone the engine does not know', () => {
|
|
33
|
+
expect(getTimezoneOffsetSafe('Not/A_Zone')).toBeNull();
|
|
34
|
+
expect(formatTimezoneLabel('Not/A_Zone')).toBe('Not/A_Zone');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('canonicalizeTimezone', () => {
|
|
38
|
+
it('maps aliases onto one engine name and rejects unknown zones', () => {
|
|
39
|
+
expect(canonicalizeTimezone('Europe/Kiev')).toBe(canonicalizeTimezone('Europe/Kyiv'));
|
|
40
|
+
expect(canonicalizeTimezone('Asia/Calcutta')).toBe(canonicalizeTimezone('Asia/Kolkata'));
|
|
41
|
+
expect(canonicalizeTimezone('Etc/UTC')).toBe('UTC');
|
|
42
|
+
expect(canonicalizeTimezone('Not/A_Zone')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('listTimezones', () => {
|
|
46
|
+
const options = listTimezones(winter);
|
|
47
|
+
it('contains UTC exactly once and no duplicates', () => {
|
|
48
|
+
expect(options.filter(option => option.value === 'UTC')).toHaveLength(1);
|
|
49
|
+
expect(new Set(options.map(option => option.value)).size).toBe(options.length);
|
|
50
|
+
});
|
|
51
|
+
it('covers every zone the engine reports', () => {
|
|
52
|
+
const supported = Intl.supportedValuesOf('timeZone');
|
|
53
|
+
expect(options.length).toBeGreaterThanOrEqual(supported.length);
|
|
54
|
+
supported.forEach(name => {
|
|
55
|
+
expect(options.some(option => option.value === name)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it('is sorted by offset, UTC first among the zero-offset zones, then by name', () => {
|
|
59
|
+
const offsets = options.map(option => getTimezoneOffsetSafe(option.value, winter) ?? Infinity);
|
|
60
|
+
expect([...offsets].sort((a, b) => a - b)).toEqual(offsets);
|
|
61
|
+
const zeroOffset = options.filter(option => getTimezoneOffsetSafe(option.value, winter) === 0);
|
|
62
|
+
expect(zeroOffset[0].value).toBe('UTC');
|
|
63
|
+
});
|
|
64
|
+
it('labels every option in the shared format', () => {
|
|
65
|
+
options.forEach(option => {
|
|
66
|
+
expect(option.label).toBe(formatTimezoneLabel(option.value, winter));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
it('keeps stored values selectable verbatim, skipping blanks', () => {
|
|
70
|
+
const withStored = listTimezones(winter, ['Asia/Calcutta', 'Etc/GMT-0', 'EST', 'Not/A_Zone', '', null, undefined]);
|
|
71
|
+
['Asia/Calcutta', 'Etc/GMT-0', 'EST', 'Not/A_Zone'].forEach(name => {
|
|
72
|
+
expect(withStored.filter(option => option.value === name)).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
expect(withStored.some(option => option.value === '')).toBe(false);
|
|
75
|
+
expect(withStored[withStored.length - 1].value).toBe('Not/A_Zone');
|
|
76
|
+
});
|
|
77
|
+
it('falls back to the browser zone and UTC when the engine has no zone list', () => {
|
|
78
|
+
const original = Intl.supportedValuesOf;
|
|
79
|
+
Object.defineProperty(Intl, 'supportedValuesOf', {
|
|
80
|
+
value: undefined,
|
|
81
|
+
configurable: true
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const fallback = listTimezones(winter).map(option => option.value);
|
|
85
|
+
expect(fallback).toContain('UTC');
|
|
86
|
+
expect(fallback).toContain(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
87
|
+
expect(fallback.length).toBeLessThanOrEqual(2);
|
|
88
|
+
} finally {
|
|
89
|
+
Object.defineProperty(Intl, 'supportedValuesOf', {
|
|
90
|
+
value: original,
|
|
91
|
+
configurable: true
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('findTimezoneOption', () => {
|
|
97
|
+
const options = listTimezones(winter, ['Asia/Calcutta']);
|
|
98
|
+
it('matches a stored name exactly, aliases included', () => {
|
|
99
|
+
expect(findTimezoneOption(options, 'Asia/Almaty', winter)?.value).toBe('Asia/Almaty');
|
|
100
|
+
expect(findTimezoneOption(options, 'Asia/Calcutta', winter)?.value).toBe('Asia/Calcutta');
|
|
101
|
+
expect(findTimezoneOption(options, 'Not/Stored', winter)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
it('maps a legacy numeric offset onto a zone with that offset at the instant', () => {
|
|
104
|
+
expect(getTimezoneOffsetSafe(findTimezoneOption(options, 330, winter).value, winter)).toBe(330);
|
|
105
|
+
expect(getTimezoneOffsetSafe(findTimezoneOption(options, '-210', winter).value, winter)).toBe(-210);
|
|
106
|
+
expect(findTimezoneOption(options, 7, winter)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
it('returns null for an empty value', () => {
|
|
109
|
+
expect(findTimezoneOption(options, '', winter)).toBeNull();
|
|
110
|
+
expect(findTimezoneOption(options, null, winter)).toBeNull();
|
|
111
|
+
expect(findTimezoneOption(options, undefined, winter)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('resolveTimezoneOption', () => {
|
|
115
|
+
it('never rewrites the stored value', () => {
|
|
116
|
+
expect(resolveTimezoneOption('Europe/Kiev', winter)).toEqual({
|
|
117
|
+
value: 'Europe/Kiev',
|
|
118
|
+
label: '(GMT+02:00) Europe/Kiev'
|
|
119
|
+
});
|
|
120
|
+
expect(resolveTimezoneOption('Not/A_Zone', winter)).toEqual({
|
|
121
|
+
value: 'Not/A_Zone',
|
|
122
|
+
label: 'Not/A_Zone'
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
package/helpers/index.d.ts
CHANGED
package/helpers/index.js
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A timezone picker option: `value` is the IANA name the backend receives, `label` is what the user reads. */
|
|
2
|
+
export type TimezoneOption = {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
/** IANA name of the zone the browser runs in. */
|
|
7
|
+
export declare function getBrowserTimezone(): string;
|
|
8
|
+
/** The engine's own primary name for `timezone` (an alias like `Europe/Kyiv` may map to `Europe/Kiev`), or `null` for a zone it doesn't know. */
|
|
9
|
+
export declare function canonicalizeTimezone(timezone: string): string | null;
|
|
10
|
+
/** Offset of `timezone` in minutes east of UTC at instant `at`, or `null` for a zone the engine doesn't know. */
|
|
11
|
+
export declare function getTimezoneOffsetSafe(timezone: string, at?: Date): number | null;
|
|
12
|
+
/** Minutes east of UTC rendered as `GMT+05:00` / `GMT-03:30`. */
|
|
13
|
+
export declare function formatTimezoneOffset(minutes: number): string;
|
|
14
|
+
/** The shared picker label, `(GMT+05:00) Asia/Almaty` at instant `at`; a zone the engine doesn't know is labelled by its bare name. */
|
|
15
|
+
export declare function formatTimezoneLabel(timezone: string, at?: Date): string;
|
|
16
|
+
/** Option for any stored zone name — aliases, `Etc/GMT±N`, POSIX abbreviations — kept verbatim as the value. */
|
|
17
|
+
export declare function resolveTimezoneOption(timezone: string, at?: Date): TimezoneOption;
|
|
18
|
+
/** 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. */
|
|
19
|
+
export declare function listTimezones(at?: Date, include?: Array<string | null | undefined>): TimezoneOption[];
|
|
20
|
+
/** 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`. */
|
|
21
|
+
export declare function findTimezoneOption(options: TimezoneOption[], value: string | number | null | undefined, at?: Date): TimezoneOption | null;
|
|
22
|
+
/** @deprecated Use `listTimezones`; kept for one release so consumers can migrate without a build break. */
|
|
23
|
+
export declare const getSortedTimezones: typeof listTimezones;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { getTimezoneOffset } from '../Calendar/helpers';
|
|
2
|
+
const UTC = 'UTC';
|
|
3
|
+
/** IANA name of the zone the browser runs in. */
|
|
4
|
+
export function getBrowserTimezone() {
|
|
5
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || UTC;
|
|
6
|
+
}
|
|
7
|
+
/** The engine's own primary name for `timezone` (an alias like `Europe/Kyiv` may map to `Europe/Kiev`), or `null` for a zone it doesn't know. */
|
|
8
|
+
export function canonicalizeTimezone(timezone) {
|
|
9
|
+
try {
|
|
10
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
11
|
+
timeZone: timezone
|
|
12
|
+
}).resolvedOptions().timeZone;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Offset of `timezone` in minutes east of UTC at instant `at`, or `null` for a zone the engine doesn't know. */
|
|
18
|
+
export function getTimezoneOffsetSafe(timezone, at = new Date()) {
|
|
19
|
+
try {
|
|
20
|
+
return getTimezoneOffset(timezone, at);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Minutes east of UTC rendered as `GMT+05:00` / `GMT-03:30`. */
|
|
26
|
+
export function formatTimezoneOffset(minutes) {
|
|
27
|
+
const sign = minutes < 0 ? '-' : '+';
|
|
28
|
+
const absolute = Math.abs(minutes);
|
|
29
|
+
const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
|
|
30
|
+
const mins = String(absolute % 60).padStart(2, '0');
|
|
31
|
+
return `GMT${sign}${hours}:${mins}`;
|
|
32
|
+
}
|
|
33
|
+
/** The shared picker label, `(GMT+05:00) Asia/Almaty` at instant `at`; a zone the engine doesn't know is labelled by its bare name. */
|
|
34
|
+
export function formatTimezoneLabel(timezone, at = new Date()) {
|
|
35
|
+
const offset = getTimezoneOffsetSafe(timezone, at);
|
|
36
|
+
return offset === null ? timezone : `(${formatTimezoneOffset(offset)}) ${timezone}`;
|
|
37
|
+
}
|
|
38
|
+
/** Option for any stored zone name — aliases, `Etc/GMT±N`, POSIX abbreviations — kept verbatim as the value. */
|
|
39
|
+
export function resolveTimezoneOption(timezone, at = new Date()) {
|
|
40
|
+
return {
|
|
41
|
+
value: timezone,
|
|
42
|
+
label: formatTimezoneLabel(timezone, at)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function supportedTimezones() {
|
|
46
|
+
const names = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : [];
|
|
47
|
+
return names.length > 0 ? names : [getBrowserTimezone()];
|
|
48
|
+
}
|
|
49
|
+
function compareZones(a, b) {
|
|
50
|
+
if (a.offset !== b.offset) {
|
|
51
|
+
if (a.offset === null) return 1;
|
|
52
|
+
if (b.offset === null) return -1;
|
|
53
|
+
return a.offset - b.offset;
|
|
54
|
+
}
|
|
55
|
+
if (a.name === UTC) return -1;
|
|
56
|
+
if (b.name === UTC) return 1;
|
|
57
|
+
return a.name.localeCompare(b.name);
|
|
58
|
+
}
|
|
59
|
+
/** 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. */
|
|
60
|
+
export function listTimezones(at = new Date(), include = []) {
|
|
61
|
+
const names = new Set([UTC, ...supportedTimezones()]);
|
|
62
|
+
include.forEach(name => {
|
|
63
|
+
if (name) names.add(name);
|
|
64
|
+
});
|
|
65
|
+
return [...names].map(name => ({
|
|
66
|
+
name,
|
|
67
|
+
offset: getTimezoneOffsetSafe(name, at)
|
|
68
|
+
})).sort(compareZones).map(({
|
|
69
|
+
name
|
|
70
|
+
}) => resolveTimezoneOption(name, at));
|
|
71
|
+
}
|
|
72
|
+
/** 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`. */
|
|
73
|
+
export function findTimezoneOption(options, value, at = new Date()) {
|
|
74
|
+
if (value === null || value === undefined || value === '') return null;
|
|
75
|
+
const byName = options.find(option => option.value === String(value));
|
|
76
|
+
if (byName) return byName;
|
|
77
|
+
const offset = Number(value);
|
|
78
|
+
if (!Number.isFinite(offset)) return null;
|
|
79
|
+
return options.find(option => getTimezoneOffsetSafe(option.value, at) === offset) ?? null;
|
|
80
|
+
}
|
|
81
|
+
/** @deprecated Use `listTimezones`; kept for one release so consumers can migrate without a build break. */
|
|
82
|
+
export const getSortedTimezones = listTimezones;
|
package/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { Checkbox } from './Checkbox';
|
|
|
7
7
|
export { CrashScreen } from './CrashScreen';
|
|
8
8
|
export { Divider } from './Divider';
|
|
9
9
|
export { Drawer, DrawerHeader, DrawerTitle, DrawerBody, DrawerDocs, useDocs, } from './Drawer';
|
|
10
|
-
export { CalendarSuperForm, CalendarDropdown, addPeriod, compareStructs, dateToStruct, dateToStructTz,
|
|
10
|
+
export { CalendarSuperForm, CalendarDropdown, addPeriod, compareStructs, dateToStruct, dateToStructTz, getTimezoneOffset, getPeriodEnd, getPeriodStart, structToDate, structToDateTz, stringifyValue, pickDateStruct, type CalendarDropdownProps, type CalendarDropdownRangeDateProps, type CalendarSuperFormProps, type DateStruct, type RangeValue, } from './Calendar';
|
|
11
11
|
export { DatePicker, DatePickerField, DateTimePicker, DateTimePickerField, } from './DateTimePicker';
|
|
12
12
|
export { DropdownMenu, DropdownMenuButton, DropdownMenuSection, DropdownMenuItem, DropdownMenuSubmenu, } from './DropdownMenu';
|
|
13
13
|
export { GetStartedPage } from './GetStartedPage';
|
|
@@ -50,7 +50,7 @@ export { Chip, type ChipProps, type ChipVariant } from './Chip';
|
|
|
50
50
|
export { RichmediaPreview, useRichmediaHtml } from './RichmediaPreview';
|
|
51
51
|
export { SandboxedHtmlFrame, type SandboxedHtmlFrameProps, type IsolatedSandboxedHtmlFrameProps, type InspectableSandboxedHtmlFrameProps, } from './SandboxedHtmlFrame';
|
|
52
52
|
export { NativeRichMediaPreview, parseNativeRichMediaConfig, parseLocalizationParams, NATIVE_RICH_MEDIA_DISPLAY_TYPES, EDITABLE_NATIVE_RICH_MEDIA_DISPLAY_TYPES, } from './NativeRichMediaPreview';
|
|
53
|
-
export type { NativeRichMediaConfig, NativeRichMediaDisplayType, NativeRichMediaText, NativeRichMediaBorder, NativeRichMediaCover, NativeRichMediaAction, NativeRichMediaButton, NativeRichMediaReward, NativeRichMediaDarkOverlay, NativeRichMediaModalBlock, NativeRichMediaSheetBlock, NativeRichMediaCarouselBlock, NativeRichMediaStoriesBlock, NativeRichMediaBannerBlock, NativeRichMediaFullscreenBlock, NativeRichMediaVideoBlock, NativeRichMediaPipBlock, NativeRichMediaScratchcardBlock, NativeRichMediaSpinwheelBlock, NativeRichMediaPreviewProps, NativeRichMediaLocalization, } from './NativeRichMediaPreview';
|
|
53
|
+
export type { NativeRichMediaConfig, NativeRichMediaDisplayType, NativeRichMediaText, NativeRichMediaBorder, NativeRichMediaCover, NativeRichMediaAction, NativeRichMediaButton, NativeRichMediaReward, NativeRichMediaDarkOverlay, NativeRichMediaModalBlock, NativeRichMediaSheetBlock, NativeRichMediaCarouselBlock, NativeRichMediaStoriesBlock, NativeRichMediaBannerBlock, NativeRichMediaFullscreenBlock, NativeRichMediaVideoBlock, NativeRichMediaPipBlock, NativeRichMediaScratchcardBlock, NativeRichMediaSpinwheelBlock, NativeRichMediaPreviewProps, NativeRichMediaLocalization, NativeRichMediaTheme, } from './NativeRichMediaPreview';
|
|
54
54
|
export { RichMessageEditor, RichMessageViewer, type DynamicContent, } from './RichMessageEditor';
|
|
55
55
|
export { FrequencyCappingEdit, FrequencyCappingReadonly, FrequencyCappingInApps, CappingCountingMode, CountingMode, type CountingModeProps, type LocalCapping, type CappingInAppBase, } from './FrequencyCapping';
|
|
56
56
|
export { SendRateEdit, SendRateReadonly, type SendRateBase, type SendRateEditProps, type SendRateReadonlyProps, } from './SendRate';
|
|
@@ -59,3 +59,4 @@ export { ApplicationPreloader, type ApplicationPreloaderProps, } from './Applica
|
|
|
59
59
|
export { NotificationInbox, NotificationBar, NotificationBell, type Notification, type NotificationInboxProps, type NotificationSeverity, type NotificationBarType, type NotificationBarProps, type NotificationBellProps, } from './NotificationInbox';
|
|
60
60
|
export { LeafletTheme } from './LeafletTheme';
|
|
61
61
|
export { buildBarcodeUrl, barcodePlaceholderSafeEscape, isBarcodePlaceholder, type BarcodeSymbology, type BarcodeUrlParams, } from './helpers/barcodeUrl';
|
|
62
|
+
export { canonicalizeTimezone, findTimezoneOption, formatTimezoneLabel, formatTimezoneOffset, getBrowserTimezone, getSortedTimezones, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption, type TimezoneOption, } from './helpers/timezones';
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export { Checkbox } from './Checkbox';
|
|
|
7
7
|
export { CrashScreen } from './CrashScreen';
|
|
8
8
|
export { Divider } from './Divider';
|
|
9
9
|
export { Drawer, DrawerHeader, DrawerTitle, DrawerBody, DrawerDocs, useDocs } from './Drawer';
|
|
10
|
-
export { CalendarSuperForm, CalendarDropdown, addPeriod, compareStructs, dateToStruct, dateToStructTz,
|
|
10
|
+
export { CalendarSuperForm, CalendarDropdown, addPeriod, compareStructs, dateToStruct, dateToStructTz, getTimezoneOffset, getPeriodEnd, getPeriodStart, structToDate, structToDateTz, stringifyValue, pickDateStruct } from './Calendar';
|
|
11
11
|
export { DatePicker, DatePickerField, DateTimePicker, DateTimePickerField } from './DateTimePicker';
|
|
12
12
|
export { DropdownMenu, DropdownMenuButton, DropdownMenuSection, DropdownMenuItem, DropdownMenuSubmenu } from './DropdownMenu';
|
|
13
13
|
export { GetStartedPage } from './GetStartedPage';
|
|
@@ -56,4 +56,5 @@ export { Alert } from './Alert';
|
|
|
56
56
|
export { ApplicationPreloader } from './ApplicationPreloader';
|
|
57
57
|
export { NotificationInbox, NotificationBar, NotificationBell } from './NotificationInbox';
|
|
58
58
|
export { LeafletTheme } from './LeafletTheme';
|
|
59
|
-
export { buildBarcodeUrl, barcodePlaceholderSafeEscape, isBarcodePlaceholder } from './helpers/barcodeUrl';
|
|
59
|
+
export { buildBarcodeUrl, barcodePlaceholderSafeEscape, isBarcodePlaceholder } from './helpers/barcodeUrl';
|
|
60
|
+
export { canonicalizeTimezone, findTimezoneOption, formatTimezoneLabel, formatTimezoneOffset, getBrowserTimezone, getSortedTimezones, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption } from './helpers/timezones';
|