@shopgate/pwa-ui-material 7.31.5-beta.1 → 7.31.5-beta.2

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/SnackBar/index.js CHANGED
@@ -5,17 +5,10 @@ import { config } from 'react-spring';
5
5
  import { Spring } from 'react-spring/renderprops.cjs';
6
6
  import Ellipsis from '@shopgate/pwa-common/components/Ellipsis';
7
7
  import { i18n } from '@shopgate/engage/core/helpers';
8
- import { useLongPress } from '@shopgate/engage/core/hooks/events';
9
8
  import { themeColors, themeShadows } from '@shopgate/pwa-common/helpers/config';
10
9
  import { makeStyles } from '@shopgate/engage/styles';
11
10
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
11
  const defaultToast = {};
13
-
14
- // Approximate settle time of the slide-out (config.stiff). The current toast is removed from the
15
- // queue this long after it starts sliding out, so the exit animation is fully visible before the
16
- // next toast slides in. Timer-driven removal keeps queue advancement independent of the spring's
17
- // onRest callback, which fires unreliably during the toast-to-toast handoff.
18
- const EXIT_ANIMATION_MS = 500;
19
12
  const backgroundColor = themeColors.lightDark;
20
13
  const buttonColor = themeColors.accent;
21
14
  const buttonColorContrast = Color(buttonColor).contrast(Color(backgroundColor));
@@ -101,106 +94,48 @@ const SnackBar = ({
101
94
  cx
102
95
  } = useStyles();
103
96
  const toasts = useMemo(() => toastsProp || [], [toastsProp]);
104
- const current = toasts.length ? toasts[0] : null;
105
- const currentId = current ? current.id : null;
106
97
  const [visible, setVisible] = useState(true);
107
- const autoHideTimer = useRef(null);
108
- const exitTimer = useRef(null);
109
- const pressingRef = useRef(false);
110
-
111
- // Latest values reachable from the stable timer callbacks below without re-creating them.
112
- const durationRef = useRef();
113
- durationRef.current = current && current.duration || 2500;
114
- const removeToastRef = useRef(removeToast);
115
- removeToastRef.current = removeToast;
116
-
117
- // Show whenever there is a toast to display; hide when the queue drains. Reacting to the queue
118
- // length is what slides the next toast in after the current one has been removed.
98
+ const visibleRef = useRef(visible);
99
+ const timerRef = useRef(null);
100
+ useEffect(() => {
101
+ visibleRef.current = visible;
102
+ }, [visible]);
119
103
  useEffect(() => {
120
104
  setVisible(toasts.length > 0);
121
105
  }, [toasts.length]);
122
- useEffect(() => () => {
123
- clearTimeout(autoHideTimer.current);
124
- clearTimeout(exitTimer.current);
125
- }, []);
126
106
  const snack = useMemo(() => {
127
- const raw = current || defaultToast;
107
+ const raw = toasts.length ? toasts[0] : defaultToast;
128
108
  return {
129
109
  ...raw,
130
110
  message: i18n.text(raw.message || '', raw.messageParams || {}),
131
111
  actionLabel: i18n.text(raw.actionLabel || '')
132
112
  };
133
- }, [current]);
134
-
135
- // Slide the current toast out, then advance the queue once the exit animation has played. The
136
- // removal is timer-driven (not the spring's onRest), so it fires exactly once for the toast being
137
- // dismissed and can't be re-triggered by the spring re-settling as the next toast slides in.
113
+ }, [toasts]);
138
114
  const hide = useCallback(() => {
139
- clearTimeout(autoHideTimer.current);
140
- clearTimeout(exitTimer.current);
115
+ clearTimeout(timerRef.current);
141
116
  setVisible(false);
142
- exitTimer.current = setTimeout(() => removeToastRef.current(), EXIT_ANIMATION_MS);
143
117
  }, []);
144
-
145
- // (Re)starts the auto-hide countdown. Stable identity (reads the duration via a ref), so the
146
- // effect below only re-runs when the shown toast actually changes.
147
- const scheduleAutoHide = useCallback(() => {
148
- clearTimeout(autoHideTimer.current);
149
- autoHideTimer.current = setTimeout(hide, durationRef.current);
150
- }, [hide]);
151
-
152
- // Start the countdown once per shown toast, keyed on its id — NOT on the spring's onRest. With
153
- // `force`, onRest fires on every re-render, so scheduling the auto-hide there restarted the
154
- // countdown on each render and could keep a toast on screen far longer than its duration.
155
- useEffect(() => {
156
- if (!currentId) {
157
- return undefined;
158
- }
159
- scheduleAutoHide();
160
- return () => clearTimeout(autoHideTimer.current);
161
- }, [currentId, scheduleAutoHide]);
162
118
  const handleAction = useCallback(() => {
163
- current?.action?.();
164
- hide();
165
- }, [current, hide]);
166
- const handleLongPress = useCallback(() => {
167
- pressingRef.current = false;
168
- if (snack.onLongPress) {
169
- snack.onLongPress();
119
+ clearTimeout(timerRef.current);
120
+ if (toasts[0]) {
121
+ toasts[0].action();
170
122
  }
171
123
  hide();
172
- }, [snack, hide]);
173
-
174
- // While a long press is held, freeze the auto-hide countdown so the toast can't disappear
175
- // mid-press. A press released before the threshold resumes the countdown from the start.
176
- const handlePressStart = useCallback(() => {
177
- pressingRef.current = true;
178
- clearTimeout(autoHideTimer.current);
179
- }, []);
180
- const handlePressCancel = useCallback(() => {
181
- pressingRef.current = false;
182
- if (currentId) {
183
- scheduleAutoHide();
124
+ }, [toasts, hide]);
125
+ const handleRest = useCallback(() => {
126
+ if (visibleRef.current) {
127
+ const duration = toasts[0]?.duration || 2500;
128
+ timerRef.current = setTimeout(hide, duration);
129
+ } else {
130
+ removeToast();
184
131
  }
185
- }, [currentId, scheduleAutoHide]);
186
- const longPressHandlers = useLongPress(handleLongPress, {
187
- threshold: 4000,
188
- onStart: handlePressStart,
189
- onCancel: handlePressCancel
190
- });
132
+ }, [toasts, hide, removeToast]);
191
133
  const {
192
134
  action = null,
193
135
  actionLabel = null,
194
- message = null,
195
- onLongPress = null
136
+ message = null
196
137
  } = snack;
197
-
198
- // A toast is either long-pressable or has a whole-box click action — never both. Attaching both
199
- // would let a single long press fire onLongPress (at the threshold) and then handleAction (on the
200
- // release click). Long-press takes precedence.
201
- const boxProps = onLongPress ? {
202
- ...longPressHandlers
203
- } : {
138
+ const boxProps = {
204
139
  ...(action && !actionLabel && {
205
140
  onClick: handleAction
206
141
  })
@@ -222,6 +157,7 @@ const SnackBar = ({
222
157
  config: config.stiff,
223
158
  reverse: !visible,
224
159
  force: true,
160
+ onRest: handleRest,
225
161
  children: springProps =>
226
162
  /*#__PURE__*/
227
163
  // eslint-disable-next-line max-len
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@shopgate/pwa-ui-material",
3
- "version": "7.31.5-beta.1",
3
+ "version": "7.31.5-beta.2",
4
4
  "description": "Shopgate's material design UI components.",
5
5
  "main": "index.js",
6
6
  "license": "Apache-2.0",
7
7
  "devDependencies": {
8
- "@shopgate/pwa-common": "7.31.5-beta.1",
8
+ "@shopgate/pwa-common": "7.31.5-beta.2",
9
9
  "@types/color": "^4.2.0",
10
10
  "@types/react-transition-group": "^4.4.12",
11
11
  "react": "^17.0.2",
package/SnackBar/spec.js DELETED
@@ -1,293 +0,0 @@
1
- import React, { useState, useCallback, useEffect } from 'react';
2
- import { render, screen, fireEvent } from '@testing-library/react';
3
- import { act } from 'react-dom/test-utils';
4
- import SnackBar from "./index";
5
-
6
- // Use the real long-press hook (the barrel would otherwise drag in appEvents/pwa-core).
7
- import { jsx as _jsx } from "react/jsx-runtime";
8
- jest.mock('@shopgate/engage/core/hooks/events', () => ({
9
- __esModule: true,
10
- useLongPress: jest.requireActual('@shopgate/engage/core/hooks/events/useLongPress').default
11
- }));
12
- jest.mock('@shopgate/engage/core/helpers', () => ({
13
- i18n: {
14
- text: input => input || ''
15
- }
16
- }));
17
- jest.mock('@shopgate/pwa-common/helpers/config', () => ({
18
- themeColors: {
19
- light: '#ffffff',
20
- lightDark: '#333333',
21
- accent: '#00aaff'
22
- },
23
- themeShadows: {
24
- toast: 'none'
25
- }
26
- }));
27
- jest.mock('@shopgate/pwa-common/components/Ellipsis', () => ({
28
- __esModule: true,
29
- default: ({
30
- children
31
- }) => children
32
- }));
33
- jest.mock('@shopgate/engage/styles', () => ({
34
- makeStyles: () => () => () => ({
35
- classes: new Proxy({}, {
36
- get: (_, prop) => prop
37
- }),
38
- cx: (...args) => args.filter(Boolean).join(' ')
39
- })
40
- }));
41
-
42
- // Render the Spring render-prop child immediately without animating. onRest is fired on a 0ms
43
- // timer to mirror react-spring calling it after the commit — this drives the auto-hide countdown.
44
- jest.mock('react-spring', () => ({
45
- config: {
46
- stiff: {}
47
- }
48
- }));
49
- jest.mock('react-spring/renderprops.cjs', () => {
50
- // eslint-disable-next-line global-require
51
- const {
52
- useEffect: useSpringEffect
53
- } = require('react');
54
- return {
55
- Spring: ({
56
- children,
57
- onRest
58
- }) => {
59
- useSpringEffect(() => {
60
- const id = setTimeout(() => onRest && onRest(), 0);
61
- return () => clearTimeout(id);
62
- });
63
- return children({});
64
- }
65
- };
66
- });
67
- describe('<SnackBar />', () => {
68
- beforeEach(() => {
69
- jest.useFakeTimers();
70
- });
71
- afterEach(() => {
72
- jest.clearAllTimers();
73
- jest.useRealTimers();
74
- });
75
- it('should invoke onLongPress after a long press on a toast that provides one', () => {
76
- const onLongPress = jest.fn();
77
- const toasts = [{
78
- id: 'pipeline.error',
79
- message: 'error.general',
80
- onLongPress,
81
- duration: 5000
82
- }];
83
- render(/*#__PURE__*/_jsx(SnackBar, {
84
- removeToast: jest.fn(),
85
- toasts: toasts
86
- }));
87
- const message = screen.getByText('error.general');
88
- fireEvent.mouseDown(message);
89
- act(() => {
90
- jest.advanceTimersByTime(4000);
91
- });
92
- fireEvent.mouseUp(message);
93
- expect(onLongPress).toHaveBeenCalledTimes(1);
94
- });
95
- it('should keep the toast open while it is being long-pressed instead of auto-hiding', () => {
96
- const onLongPress = jest.fn();
97
- const removeToast = jest.fn();
98
- const toasts = [{
99
- id: 'pipeline.error',
100
- message: 'error.general',
101
- onLongPress,
102
- duration: 2500
103
- }];
104
- render(/*#__PURE__*/_jsx(SnackBar, {
105
- removeToast: removeToast,
106
- toasts: toasts
107
- }));
108
- const message = screen.getByText('error.general');
109
- fireEvent.mouseDown(message);
110
-
111
- // Hold well past the toast's 2500ms lifetime: the auto-hide countdown must be frozen, so the
112
- // toast neither hides nor gets removed while the press is ongoing.
113
- act(() => {
114
- jest.advanceTimersByTime(3000);
115
- });
116
- expect(onLongPress).not.toHaveBeenCalled();
117
- expect(removeToast).not.toHaveBeenCalled();
118
-
119
- // The long press still completes at its 4000ms threshold.
120
- act(() => {
121
- jest.advanceTimersByTime(1000);
122
- });
123
- expect(onLongPress).toHaveBeenCalledTimes(1);
124
- });
125
- it('should invoke the pressed toast\'s handler even if the toast is mutated in place mid-press', () => {
126
- // ToastProvider updates a same-id toast by mutating it in place, so the long-press must fire
127
- // the handler captured when the press started, not whatever toasts[0].onLongPress became.
128
- const pressedHandler = jest.fn();
129
- const replacementHandler = jest.fn();
130
- const toasts = [{
131
- id: 'pipeline.error',
132
- message: 'error.general',
133
- onLongPress: pressedHandler,
134
- duration: 5000
135
- }];
136
- render(/*#__PURE__*/_jsx(SnackBar, {
137
- removeToast: jest.fn(),
138
- toasts: toasts
139
- }));
140
- const message = screen.getByText('error.general');
141
- fireEvent.mouseDown(message);
142
- // A second pipeline error arrives during the press and overwrites the toast's handler in place.
143
- toasts[0].onLongPress = replacementHandler;
144
- act(() => {
145
- jest.advanceTimersByTime(4000);
146
- });
147
- fireEvent.mouseUp(message);
148
- expect(pressedHandler).toHaveBeenCalledTimes(1);
149
- expect(replacementHandler).not.toHaveBeenCalled();
150
- });
151
- it('should not also fire the box click action on a long press when onLongPress is present', () => {
152
- const onLongPress = jest.fn();
153
- const action = jest.fn();
154
- const toasts = [{
155
- id: 'pipeline.error',
156
- message: 'error.general',
157
- onLongPress,
158
- action,
159
- // no actionLabel → would otherwise become a whole-box onClick
160
- duration: 5000
161
- }];
162
- render(/*#__PURE__*/_jsx(SnackBar, {
163
- removeToast: jest.fn(),
164
- toasts: toasts
165
- }));
166
- const message = screen.getByText('error.general');
167
- fireEvent.mouseDown(message);
168
- act(() => {
169
- jest.advanceTimersByTime(4000);
170
- });
171
- fireEvent.mouseUp(message);
172
- // The pointer release also produces a click, which must not run the action too.
173
- fireEvent.click(message);
174
- expect(onLongPress).toHaveBeenCalledTimes(1);
175
- expect(action).not.toHaveBeenCalled();
176
- });
177
- it('should not invoke onLongPress when the press is released before the threshold', () => {
178
- const onLongPress = jest.fn();
179
- const toasts = [{
180
- id: 'pipeline.error',
181
- message: 'error.general',
182
- onLongPress,
183
- duration: 5000
184
- }];
185
- render(/*#__PURE__*/_jsx(SnackBar, {
186
- removeToast: jest.fn(),
187
- toasts: toasts
188
- }));
189
- const message = screen.getByText('error.general');
190
- fireEvent.mouseDown(message);
191
- act(() => {
192
- jest.advanceTimersByTime(300);
193
- });
194
- fireEvent.mouseUp(message);
195
- act(() => {
196
- jest.advanceTimersByTime(500);
197
- });
198
- expect(onLongPress).not.toHaveBeenCalled();
199
- });
200
- it('should not attach long-press handlers when the toast has no onLongPress', () => {
201
- const toasts = [{
202
- id: 'pipeline.error',
203
- message: 'error.general',
204
- duration: 5000
205
- }];
206
- render(/*#__PURE__*/_jsx(SnackBar, {
207
- removeToast: jest.fn(),
208
- toasts: toasts
209
- }));
210
-
211
- // A long press must be a no-op (and must not throw) when no handler is provided.
212
- const message = screen.getByText('error.general');
213
- fireEvent.mouseDown(message);
214
- expect(() => act(() => {
215
- jest.advanceTimersByTime(500);
216
- })).not.toThrow();
217
- });
218
- it('should play each queued toast in turn — the second is not skipped or instantly removed', () => {
219
- // Mirrors ToastProvider: a FIFO queue whose head is dropped (immutably) on removeToast.
220
- const Harness = () => {
221
- const [toasts, setToasts] = useState([{
222
- id: 'a',
223
- message: 'first.toast',
224
- duration: 2500
225
- }, {
226
- id: 'b',
227
- message: 'second.toast',
228
- duration: 2500
229
- }]);
230
- const removeToast = useCallback(() => setToasts(t => t.slice(1)), []);
231
- if (!toasts.length) return null;
232
- return /*#__PURE__*/_jsx(SnackBar, {
233
- removeToast: removeToast,
234
- toasts: toasts
235
- });
236
- };
237
- render(/*#__PURE__*/_jsx(Harness, {}));
238
-
239
- // First toast is shown; the second is still queued, not yet rendered.
240
- expect(screen.getByText('first.toast')).toBeTruthy();
241
- expect(screen.queryByText('second.toast')).toBe(null);
242
-
243
- // First toast dwells (2500ms), then slides out and is removed after the exit animation.
244
- act(() => {
245
- jest.advanceTimersByTime(2500 + 600);
246
- });
247
-
248
- // The second toast is now on screen — it got its turn rather than being dropped instantly.
249
- expect(screen.getByText('second.toast')).toBeTruthy();
250
- expect(screen.queryByText('first.toast')).toBe(null);
251
-
252
- // It in turn dwells and is removed, draining the queue.
253
- act(() => {
254
- jest.advanceTimersByTime(2500 + 600);
255
- });
256
- expect(screen.queryByText('second.toast')).toBe(null);
257
- });
258
- it('auto-hides within its duration even when the parent re-renders repeatedly', () => {
259
- // The spring re-settles (and fires onRest) on every render because of `force`. The auto-hide
260
- // countdown must therefore NOT be tied to onRest, or frequent parent re-renders would keep
261
- // resetting it and the toast would outlive its duration.
262
- const removeToast = jest.fn();
263
- const Harness = () => {
264
- const [, tick] = useState(0);
265
- useEffect(() => {
266
- const id = setInterval(() => tick(n => n + 1), 500); // re-render every 500ms (< duration)
267
- return () => clearInterval(id);
268
- }, []);
269
- const toasts = [{
270
- id: 'a',
271
- message: 'error.general',
272
- duration: 2500
273
- }];
274
- return /*#__PURE__*/_jsx(SnackBar, {
275
- removeToast: removeToast,
276
- toasts: toasts
277
- });
278
- };
279
- render(/*#__PURE__*/_jsx(Harness, {}));
280
-
281
- // Not yet gone before its duration elapses.
282
- act(() => {
283
- jest.advanceTimersByTime(2000);
284
- });
285
- expect(removeToast).not.toHaveBeenCalled();
286
-
287
- // Removed shortly after its duration (dwell + exit animation), despite the periodic re-renders.
288
- act(() => {
289
- jest.advanceTimersByTime(500 + 600);
290
- });
291
- expect(removeToast).toHaveBeenCalledTimes(1);
292
- });
293
- });