@shopgate/pwa-ui-material 7.31.4 → 7.31.5-beta.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/SnackBar/index.js CHANGED
@@ -5,10 +5,17 @@ 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';
8
9
  import { themeColors, themeShadows } from '@shopgate/pwa-common/helpers/config';
9
10
  import { makeStyles } from '@shopgate/engage/styles';
10
11
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
12
  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;
12
19
  const backgroundColor = themeColors.lightDark;
13
20
  const buttonColor = themeColors.accent;
14
21
  const buttonColorContrast = Color(buttonColor).contrast(Color(backgroundColor));
@@ -94,48 +101,106 @@ const SnackBar = ({
94
101
  cx
95
102
  } = useStyles();
96
103
  const toasts = useMemo(() => toastsProp || [], [toastsProp]);
104
+ const current = toasts.length ? toasts[0] : null;
105
+ const currentId = current ? current.id : null;
97
106
  const [visible, setVisible] = useState(true);
98
- const visibleRef = useRef(visible);
99
- const timerRef = useRef(null);
100
- useEffect(() => {
101
- visibleRef.current = visible;
102
- }, [visible]);
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.
103
119
  useEffect(() => {
104
120
  setVisible(toasts.length > 0);
105
121
  }, [toasts.length]);
122
+ useEffect(() => () => {
123
+ clearTimeout(autoHideTimer.current);
124
+ clearTimeout(exitTimer.current);
125
+ }, []);
106
126
  const snack = useMemo(() => {
107
- const raw = toasts.length ? toasts[0] : defaultToast;
127
+ const raw = current || defaultToast;
108
128
  return {
109
129
  ...raw,
110
130
  message: i18n.text(raw.message || '', raw.messageParams || {}),
111
131
  actionLabel: i18n.text(raw.actionLabel || '')
112
132
  };
113
- }, [toasts]);
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.
114
138
  const hide = useCallback(() => {
115
- clearTimeout(timerRef.current);
139
+ clearTimeout(autoHideTimer.current);
140
+ clearTimeout(exitTimer.current);
116
141
  setVisible(false);
142
+ exitTimer.current = setTimeout(() => removeToastRef.current(), EXIT_ANIMATION_MS);
117
143
  }, []);
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]);
118
162
  const handleAction = useCallback(() => {
119
- clearTimeout(timerRef.current);
120
- if (toasts[0]) {
121
- toasts[0].action();
163
+ current?.action?.();
164
+ hide();
165
+ }, [current, hide]);
166
+ const handleLongPress = useCallback(() => {
167
+ pressingRef.current = false;
168
+ if (snack.onLongPress) {
169
+ snack.onLongPress();
122
170
  }
123
171
  hide();
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();
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();
131
184
  }
132
- }, [toasts, hide, removeToast]);
185
+ }, [currentId, scheduleAutoHide]);
186
+ const longPressHandlers = useLongPress(handleLongPress, {
187
+ threshold: 4000,
188
+ onStart: handlePressStart,
189
+ onCancel: handlePressCancel
190
+ });
133
191
  const {
134
192
  action = null,
135
193
  actionLabel = null,
136
- message = null
194
+ message = null,
195
+ onLongPress = null
137
196
  } = snack;
138
- const boxProps = {
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
+ } : {
139
204
  ...(action && !actionLabel && {
140
205
  onClick: handleAction
141
206
  })
@@ -157,7 +222,6 @@ const SnackBar = ({
157
222
  config: config.stiff,
158
223
  reverse: !visible,
159
224
  force: true,
160
- onRest: handleRest,
161
225
  children: springProps =>
162
226
  /*#__PURE__*/
163
227
  // eslint-disable-next-line max-len
@@ -0,0 +1,293 @@
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
+ });
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@shopgate/pwa-ui-material",
3
- "version": "7.31.4",
3
+ "version": "7.31.5-beta.1",
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.4",
8
+ "@shopgate/pwa-common": "7.31.5-beta.1",
9
9
  "@types/color": "^4.2.0",
10
10
  "@types/react-transition-group": "^4.4.12",
11
11
  "react": "^17.0.2",