@pushwoosh/dumb-components 2.1.2 → 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.
@@ -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
  });
@@ -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
- /** Returns the offset in minutes (east of UTC) for an IANA `timezoneName` at the given instant `at`. */
167
- export function getTimezoneOffset(timezoneName, at = new Date()) {
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
- const parts = dtf.formatToParts(at);
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);
@@ -1,4 +1,4 @@
1
- import { canonicalizeTimezone, findTimezoneOption, formatTimezoneLabel, formatTimezoneOffset, formatTimezoneShort, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption } from '../timezones';
1
+ import { canonicalizeTimezone, findTimezoneOption, formatTimezoneLabel, formatTimezoneOffset, formatTimezoneAbbreviation, formatTimezoneBadge, formatTimezoneShort, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption } from '../timezones';
2
2
  const winter = new Date('2026-01-15T12:00:00Z');
3
3
  const melbourneDst = new Date('2026-10-10T12:00:00Z');
4
4
  const melbourneStandard = new Date('2026-06-15T12:00:00Z');
@@ -24,6 +24,41 @@ describe('formatTimezoneShort', () => {
24
24
  expect(formatTimezoneShort('Not/AZone', winter)).toBe('Not/AZone');
25
25
  });
26
26
  });
27
+ describe('formatTimezoneAbbreviation', () => {
28
+ it('returns the letter abbreviation where the engine has one', () => {
29
+ expect(formatTimezoneAbbreviation('America/New_York', winter)).toBe('EST');
30
+ expect(formatTimezoneAbbreviation('America/Los_Angeles', winter)).toBe('PST');
31
+ });
32
+ it('returns null where the engine only offers a GMT-style name', () => {
33
+ expect(formatTimezoneAbbreviation('Asia/Karachi', winter)).toBeNull();
34
+ expect(formatTimezoneAbbreviation('Asia/Kolkata', winter)).toBeNull();
35
+ expect(formatTimezoneAbbreviation('Europe/Moscow', winter)).toBeNull();
36
+ });
37
+ it('returns null for UTC and for a zone it does not know', () => {
38
+ expect(formatTimezoneAbbreviation('UTC', winter)).toBeNull();
39
+ expect(formatTimezoneAbbreviation('Not/AZone', winter)).toBeNull();
40
+ });
41
+ it('follows DST at the given instant', () => {
42
+ expect(formatTimezoneAbbreviation('America/New_York', new Date('2026-07-01T12:00:00Z'))).toBe('EDT');
43
+ });
44
+ });
45
+ describe('formatTimezoneBadge', () => {
46
+ it('pairs the abbreviation with the offset', () => {
47
+ expect(formatTimezoneBadge('America/New_York', winter)).toBe('EST (UTC-05:00)');
48
+ });
49
+ it('falls back to the bare offset for a zone without an abbreviation', () => {
50
+ expect(formatTimezoneBadge('Asia/Karachi', winter)).toBe('UTC+05:00');
51
+ expect(formatTimezoneBadge('Asia/Kolkata', winter)).toBe('UTC+05:30');
52
+ expect(formatTimezoneBadge('UTC', winter)).toBe('UTC+00:00');
53
+ });
54
+ it('follows DST at the given instant', () => {
55
+ expect(formatTimezoneBadge('Australia/Melbourne', melbourneStandard)).toBe('UTC+10:00');
56
+ expect(formatTimezoneBadge('Australia/Melbourne', melbourneDst)).toBe('UTC+11:00');
57
+ });
58
+ it('keeps an unknown zone readable', () => {
59
+ expect(formatTimezoneBadge('Not/AZone', winter)).toBe('Not/AZone');
60
+ });
61
+ });
27
62
  describe('formatTimezoneLabel', () => {
28
63
  it('uses the current tzdata: Kazakhstan is UTC+5 since 2024', () => {
29
64
  expect(formatTimezoneLabel('Asia/Almaty', winter)).toBe('(UTC+05:00) Asia/Almaty');
@@ -89,6 +124,19 @@ describe('listTimezones', () => {
89
124
  expect(withStored.some(option => option.value === '')).toBe(false);
90
125
  expect(withStored[withStored.length - 1].value).toBe('Not/A_Zone');
91
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
+ });
92
140
  it('falls back to the browser zone and UTC when the engine has no zone list', () => {
93
141
  const original = Intl.supportedValuesOf;
94
142
  Object.defineProperty(Intl, 'supportedValuesOf', {
@@ -15,6 +15,10 @@ export declare function formatTimezoneOffset(minutes: number): string;
15
15
  export declare function formatTimezoneLabel(timezone: string, at?: Date): string;
16
16
  /** Compact badge for a zone, `UTC+05:00`; a zone the engine doesn't know is shown by its bare name. */
17
17
  export declare function formatTimezoneShort(timezone: string, at?: Date): string;
18
+ /** Letter abbreviation of the zone at `at` (`EST`, `PKT`), or `null` when the engine only offers a `GMT+5`-style name or does not know the zone. */
19
+ export declare function formatTimezoneAbbreviation(timezone: string, at?: Date): string | null;
20
+ /** Badge naming the zone a time is shown in: `PKT (UTC+05:00)`, or the bare offset when the zone has no letter abbreviation. */
21
+ export declare function formatTimezoneBadge(timezone: string, at?: Date): string;
18
22
  /** Option for any stored zone name — aliases, `Etc/GMT±N`, POSIX abbreviations — kept verbatim as the value. */
19
23
  export declare function resolveTimezoneOption(timezone: string, at?: Date): TimezoneOption;
20
24
  /** 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. */
@@ -30,16 +30,37 @@ 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
- const offset = getTimezoneOffsetSafe(timezone, at);
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()) {
40
42
  const offset = getTimezoneOffsetSafe(timezone, at);
41
43
  return offset === null ? timezone : formatTimezoneOffset(offset);
42
44
  }
45
+ /** Letter abbreviation of the zone at `at` (`EST`, `PKT`), or `null` when the engine only offers a `GMT+5`-style name or does not know the zone. */
46
+ export function formatTimezoneAbbreviation(timezone, at = new Date()) {
47
+ try {
48
+ const parts = new Intl.DateTimeFormat('en-US', {
49
+ timeZone: timezone,
50
+ timeZoneName: 'short'
51
+ }).formatToParts(at);
52
+ const name = parts.find(part => part.type === 'timeZoneName')?.value ?? '';
53
+ return /^[A-Z]{2,5}$/.test(name) && name !== 'UTC' ? name : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Badge naming the zone a time is shown in: `PKT (UTC+05:00)`, or the bare offset when the zone has no letter abbreviation. */
59
+ export function formatTimezoneBadge(timezone, at = new Date()) {
60
+ const short = formatTimezoneShort(timezone, at);
61
+ const abbreviation = formatTimezoneAbbreviation(timezone, at);
62
+ return abbreviation ? `${abbreviation} (${short})` : short;
63
+ }
43
64
  /** Option for any stored zone name — aliases, `Etc/GMT±N`, POSIX abbreviations — kept verbatim as the value. */
44
65
  export function resolveTimezoneOption(timezone, at = new Date()) {
45
66
  return {
@@ -61,18 +82,36 @@ function compareZones(a, b) {
61
82
  if (b.name === UTC) return 1;
62
83
  return a.name.localeCompare(b.name);
63
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 = [];
64
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. */
65
103
  export function listTimezones(at = new Date(), include = []) {
66
104
  const names = new Set([UTC, ...supportedTimezones()]);
67
105
  include.forEach(name => {
68
106
  if (name) names.add(name);
69
107
  });
70
- return [...names].map(name => ({
71
- name,
72
- offset: getTimezoneOffsetSafe(name, at)
73
- })).sort(compareZones).map(({
74
- name
75
- }) => resolveTimezoneOption(name, at));
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();
76
115
  }
77
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`. */
78
117
  export function findTimezoneOption(options, value, at = new Date()) {
package/index.d.ts CHANGED
@@ -59,4 +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, formatTimezoneShort, getBrowserTimezone, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption, type TimezoneOption, } from './helpers/timezones';
62
+ export { canonicalizeTimezone, findTimezoneOption, formatTimezoneAbbreviation, formatTimezoneBadge, formatTimezoneLabel, formatTimezoneOffset, formatTimezoneShort, getBrowserTimezone, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption, type TimezoneOption, } from './helpers/timezones';
package/index.js CHANGED
@@ -57,4 +57,4 @@ export { ApplicationPreloader } from './ApplicationPreloader';
57
57
  export { NotificationInbox, NotificationBar, NotificationBell } from './NotificationInbox';
58
58
  export { LeafletTheme } from './LeafletTheme';
59
59
  export { buildBarcodeUrl, barcodePlaceholderSafeEscape, isBarcodePlaceholder } from './helpers/barcodeUrl';
60
- export { canonicalizeTimezone, findTimezoneOption, formatTimezoneLabel, formatTimezoneOffset, formatTimezoneShort, getBrowserTimezone, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption } from './helpers/timezones';
60
+ export { canonicalizeTimezone, findTimezoneOption, formatTimezoneAbbreviation, formatTimezoneBadge, formatTimezoneLabel, formatTimezoneOffset, formatTimezoneShort, getBrowserTimezone, getTimezoneOffsetSafe, listTimezones, resolveTimezoneOption } from './helpers/timezones';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/dumb-components",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",