@pushwoosh/dumb-components 2.2.0 → 2.3.0

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);
@@ -2,11 +2,11 @@ import { type ReactElement } from 'react';
2
2
  import type { TooltipProps } from './types';
3
3
  /**
4
4
  * Wraps an element and shows a contextual tooltip on hover/focus, with configurable
5
- * position and width. Positioning is handled by floating-ui, so the tooltip flips to a
6
- * fallback placement when the requested one would overflow the viewport. Use
5
+ * position, width and color. Positioning is handled by floating-ui, so the tooltip
6
+ * flips to a fallback placement when the requested one would overflow the viewport. Use
7
7
  * {@link TooltipTrigger} for a standalone help icon.
8
8
  *
9
9
  * @example
10
10
  * <Tooltip content="Copy to clipboard" position="top-middle"><IconButton /></Tooltip>
11
11
  */
12
- export declare function Tooltip({ content, position, offset, arrowOffset, maxWidth, zIndex, isDisabled, isVisible, reference, children, }: TooltipProps): ReactElement;
12
+ export declare function Tooltip({ content, color, position, offset, arrowOffset, maxWidth, zIndex, isDisabled, isVisible, reference, children, }: TooltipProps): ReactElement;
@@ -7,8 +7,8 @@ import { Arrow, TooltipBox } from './styles';
7
7
  import { useTooltipPosition } from './useTooltipPosition';
8
8
  /**
9
9
  * Wraps an element and shows a contextual tooltip on hover/focus, with configurable
10
- * position and width. Positioning is handled by floating-ui, so the tooltip flips to a
11
- * fallback placement when the requested one would overflow the viewport. Use
10
+ * position, width and color. Positioning is handled by floating-ui, so the tooltip
11
+ * flips to a fallback placement when the requested one would overflow the viewport. Use
12
12
  * {@link TooltipTrigger} for a standalone help icon.
13
13
  *
14
14
  * @example
@@ -16,6 +16,7 @@ import { useTooltipPosition } from './useTooltipPosition';
16
16
  */
17
17
  export function Tooltip({
18
18
  content,
19
+ color = 'main',
19
20
  position = 'top-start',
20
21
  offset,
21
22
  arrowOffset,
@@ -88,10 +89,12 @@ export function Tooltip({
88
89
  zIndex
89
90
  },
90
91
  "$maxWidth": maxWidth,
92
+ "$tooltipColor": color,
91
93
  "data-placement": placement,
92
94
  children: [content, _jsx(Arrow, {
93
95
  ref: arrowRef,
94
- style: arrowStyles
96
+ style: arrowStyles,
97
+ "$tooltipColor": color
95
98
  })]
96
99
  }), document.body)]
97
100
  });
@@ -1,3 +1,3 @@
1
1
  export { Tooltip } from './Tooltip';
2
2
  export { TooltipTrigger } from './TooltipTrigger';
3
- export type { TooltipPosition } from './types';
3
+ export type { TooltipPosition, TooltipColor } from './types';
@@ -1,8 +1,12 @@
1
1
  import { Color } from '@pushwoosh/kit-constants';
2
+ import type { TooltipColor } from './types';
2
3
  export declare const TooltipBox: import("styled-components").StyledComponent<"div", any, {
3
4
  $maxWidth?: number | "none";
5
+ $tooltipColor?: TooltipColor;
6
+ }, never>;
7
+ export declare const Arrow: import("styled-components").StyledComponent<"div", any, {
8
+ $tooltipColor?: TooltipColor;
4
9
  }, never>;
5
- export declare const Arrow: import("styled-components").StyledComponent<"div", any, {}, never>;
6
10
  export declare const HelpIconWrapper: import("styled-components").StyledComponent<"div", any, import("@pushwoosh/kit-helpers/styled-helpers").CommonBoxProps & {
7
11
  $alignContent?: import("@pushwoosh/kit-helpers/styled-helpers").AlignContentValue;
8
12
  $alignItems?: import("@pushwoosh/kit-helpers/styled-helpers").ItemAlignmentValue;
package/Tooltip/styles.js CHANGED
@@ -1,17 +1,23 @@
1
- import styled from 'styled-components';
2
- import { Color, FontSize, ShapeRadius } from '@pushwoosh/kit-constants';
1
+ import styled, { css } from 'styled-components';
2
+ import { Color, FontSize, Shadow, ShapeRadius } from '@pushwoosh/kit-constants';
3
3
  import { Horizontal } from '@pushwoosh/kit-helpers';
4
4
  import { DEFAULT_MAX_WIDTH } from './constants';
5
+ const clearBox = css(["color:", ";background-color:", ";border:1px solid ", ";box-shadow:", ";"], Color.MAIN, Color.CLEAR, Color.FORM, Shadow.REGULAR);
5
6
  export const TooltipBox = styled.div.withConfig({
6
7
  displayName: "TooltipBox",
7
8
  componentId: "sc-1hariht-0"
8
- })(["padding:12px;color:", ";background-color:", ";border-radius:", ";font-size:", ";line-height:inherit;outline:0;pointer-events:none;max-width:", ";"], Color.CLEAR, Color.MAIN, ShapeRadius.DIALOG, FontSize.REGULAR, ({
9
+ })(["padding:12px;color:", ";background-color:", ";border-radius:", ";font-size:", ";line-height:inherit;outline:0;pointer-events:none;max-width:", ";", ""], Color.CLEAR, Color.MAIN, ShapeRadius.DIALOG, FontSize.REGULAR, ({
9
10
  $maxWidth
10
- }) => $maxWidth === 'none' ? 'min(var(--pw-page-max-width, 100vw), calc(100vw - 16px))' : `min(${$maxWidth ?? DEFAULT_MAX_WIDTH}px, var(--pw-page-max-width, 100vw), calc(100vw - 16px))`);
11
+ }) => $maxWidth === 'none' ? 'min(var(--pw-page-max-width, 100vw), calc(100vw - 16px))' : `min(${$maxWidth ?? DEFAULT_MAX_WIDTH}px, var(--pw-page-max-width, 100vw), calc(100vw - 16px))`, ({
12
+ $tooltipColor
13
+ }) => $tooltipColor === 'clear' && clearBox);
14
+ const clearArrowBorder = css(["[data-placement^='top'] &{border-right:1px solid ", ";border-bottom:1px solid ", ";}[data-placement^='bottom'] &{border-top:1px solid ", ";border-left:1px solid ", ";}[data-placement^='left'] &{border-top:1px solid ", ";border-right:1px solid ", ";}[data-placement^='right'] &{border-bottom:1px solid ", ";border-left:1px solid ", ";}"], Color.FORM, Color.FORM, Color.FORM, Color.FORM, Color.FORM, Color.FORM, Color.FORM, Color.FORM);
11
15
  export const Arrow = styled.div.withConfig({
12
16
  displayName: "Arrow",
13
17
  componentId: "sc-1hariht-1"
14
- })(["position:absolute;width:10px;height:10px;border-radius:2px;background-color:", ";transform:rotate(45deg);"], Color.MAIN);
18
+ })(["position:absolute;width:10px;height:10px;border-radius:2px;background-color:", ";transform:rotate(45deg);", ""], Color.MAIN, ({
19
+ $tooltipColor
20
+ }) => $tooltipColor === 'clear' && css(["background-color:", ";", ""], Color.CLEAR, clearArrowBorder));
15
21
  export const HelpIconWrapper = styled(Horizontal).withConfig({
16
22
  displayName: "HelpIconWrapper",
17
23
  componentId: "sc-1hariht-2"
@@ -1,10 +1,14 @@
1
1
  import type { RefObject, ReactElement, ReactNode } from 'react';
2
2
  /** Placement of a {@link Tooltip} relative to its anchor (side + alignment). */
3
3
  export type TooltipPosition = 'top-start' | 'top-middle' | 'top-end' | 'right-start' | 'right-middle' | 'right-end' | 'bottom-start' | 'bottom-middle' | 'bottom-end' | 'left-start' | 'left-middle' | 'left-end';
4
+ /** Color of a {@link Tooltip}, named after the background token it uses. */
5
+ export type TooltipColor = 'main' | 'clear';
4
6
  /** Props of {@link Tooltip}. */
5
7
  export type TooltipProps = {
6
8
  /** Tooltip content. */
7
9
  readonly content: ReactNode;
10
+ /** Background color (default `main`); `clear` is a card on the page background, with border and shadow. */
11
+ readonly color?: TooltipColor;
8
12
  /** Placement relative to the anchor (default `top-start`). */
9
13
  readonly position?: TooltipPosition;
10
14
  /** `[skidding, distance]` offset from the anchor; defaults per position. */
@@ -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', {
@@ -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
- 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()) {
@@ -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
- return [...names].map(name => ({
90
- name,
91
- offset: getTimezoneOffsetSafe(name, at)
92
- })).sort(compareZones).map(({
93
- name
94
- }) => 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();
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()) {
package/index.d.ts CHANGED
@@ -28,7 +28,7 @@ export { Combobox, type ComboboxProps, type ComboboxChangeSource, type ComboboxC
28
28
  export { ComboboxMulti, type ComboboxMultiProps } from './ComboboxMulti';
29
29
  export { Switch } from './Switch';
30
30
  export { TextSkeleton, RectangularSkeleton, CircularSkeleton } from './Skeleton';
31
- export { Tooltip, type TooltipPosition, TooltipTrigger } from './Tooltip';
31
+ export { Tooltip, type TooltipPosition, type TooltipColor, TooltipTrigger } from './Tooltip';
32
32
  export { Tab, TabBar, TabPanel, Tabs, type TabCode } from './Tabs';
33
33
  export { Link, type LinkPropsWithoutAs, type LinkPropsWithAsTag, type LinkPropsWithAsComponent, } from './Link';
34
34
  export { StatisticCard } from './StatisticCard';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/dumb-components",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",