@shopgate/pwa-ui-material 7.32.0-beta.4 → 7.32.0-beta.6

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
@@ -4,9 +4,16 @@ import { config } from 'react-spring';
4
4
  import { Spring } from 'react-spring/renderprops.cjs';
5
5
  import Ellipsis from '@shopgate/pwa-common/components/Ellipsis';
6
6
  import { i18n } from '@shopgate/engage/core/helpers';
7
+ import { useLongPress } from '@shopgate/engage/core/hooks/events';
7
8
  import { makeStyles } from '@shopgate/engage/styles';
8
9
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
10
  const defaultToast = {};
11
+
12
+ // Approximate settle time of the slide-out (config.stiff). The current toast is removed from the
13
+ // queue this long after it starts sliding out, so the exit animation is fully visible before the
14
+ // next toast slides in. Timer-driven removal keeps queue advancement independent of the spring's
15
+ // onRest callback, which fires unreliably during the toast-to-toast handoff.
16
+ const EXIT_ANIMATION_MS = 500;
10
17
  const useStyles = makeStyles()(theme => ({
11
18
  container: {
12
19
  position: 'fixed',
@@ -88,48 +95,106 @@ const SnackBar = ({
88
95
  cx
89
96
  } = useStyles();
90
97
  const toasts = useMemo(() => toastsProp || [], [toastsProp]);
98
+ const current = toasts.length ? toasts[0] : null;
99
+ const currentId = current ? current.id : null;
91
100
  const [visible, setVisible] = useState(true);
92
- const visibleRef = useRef(visible);
93
- const timerRef = useRef(null);
94
- useEffect(() => {
95
- visibleRef.current = visible;
96
- }, [visible]);
101
+ const autoHideTimer = useRef(null);
102
+ const exitTimer = useRef(null);
103
+ const pressingRef = useRef(false);
104
+
105
+ // Latest values reachable from the stable timer callbacks below without re-creating them.
106
+ const durationRef = useRef();
107
+ durationRef.current = current && current.duration || 2500;
108
+ const removeToastRef = useRef(removeToast);
109
+ removeToastRef.current = removeToast;
110
+
111
+ // Show whenever there is a toast to display; hide when the queue drains. Reacting to the queue
112
+ // length is what slides the next toast in after the current one has been removed.
97
113
  useEffect(() => {
98
114
  setVisible(toasts.length > 0);
99
115
  }, [toasts.length]);
116
+ useEffect(() => () => {
117
+ clearTimeout(autoHideTimer.current);
118
+ clearTimeout(exitTimer.current);
119
+ }, []);
100
120
  const snack = useMemo(() => {
101
- const raw = toasts.length ? toasts[0] : defaultToast;
121
+ const raw = current || defaultToast;
102
122
  return {
103
123
  ...raw,
104
124
  message: i18n.text(raw.message || '', raw.messageParams || {}),
105
125
  actionLabel: i18n.text(raw.actionLabel || '')
106
126
  };
107
- }, [toasts]);
127
+ }, [current]);
128
+
129
+ // Slide the current toast out, then advance the queue once the exit animation has played. The
130
+ // removal is timer-driven (not the spring's onRest), so it fires exactly once for the toast being
131
+ // dismissed and can't be re-triggered by the spring re-settling as the next toast slides in.
108
132
  const hide = useCallback(() => {
109
- clearTimeout(timerRef.current);
133
+ clearTimeout(autoHideTimer.current);
134
+ clearTimeout(exitTimer.current);
110
135
  setVisible(false);
136
+ exitTimer.current = setTimeout(() => removeToastRef.current(), EXIT_ANIMATION_MS);
111
137
  }, []);
138
+
139
+ // (Re)starts the auto-hide countdown. Stable identity (reads the duration via a ref), so the
140
+ // effect below only re-runs when the shown toast actually changes.
141
+ const scheduleAutoHide = useCallback(() => {
142
+ clearTimeout(autoHideTimer.current);
143
+ autoHideTimer.current = setTimeout(hide, durationRef.current);
144
+ }, [hide]);
145
+
146
+ // Start the countdown once per shown toast, keyed on its id — NOT on the spring's onRest. With
147
+ // `force`, onRest fires on every re-render, so scheduling the auto-hide there restarted the
148
+ // countdown on each render and could keep a toast on screen far longer than its duration.
149
+ useEffect(() => {
150
+ if (!currentId) {
151
+ return undefined;
152
+ }
153
+ scheduleAutoHide();
154
+ return () => clearTimeout(autoHideTimer.current);
155
+ }, [currentId, scheduleAutoHide]);
112
156
  const handleAction = useCallback(() => {
113
- clearTimeout(timerRef.current);
114
- if (toasts[0]) {
115
- toasts[0].action();
157
+ current?.action?.();
158
+ hide();
159
+ }, [current, hide]);
160
+ const handleLongPress = useCallback(() => {
161
+ pressingRef.current = false;
162
+ if (snack.onLongPress) {
163
+ snack.onLongPress();
116
164
  }
117
165
  hide();
118
- }, [toasts, hide]);
119
- const handleRest = useCallback(() => {
120
- if (visibleRef.current) {
121
- const duration = toasts[0]?.duration || 2500;
122
- timerRef.current = setTimeout(hide, duration);
123
- } else {
124
- removeToast();
166
+ }, [snack, hide]);
167
+
168
+ // While a long press is held, freeze the auto-hide countdown so the toast can't disappear
169
+ // mid-press. A press released before the threshold resumes the countdown from the start.
170
+ const handlePressStart = useCallback(() => {
171
+ pressingRef.current = true;
172
+ clearTimeout(autoHideTimer.current);
173
+ }, []);
174
+ const handlePressCancel = useCallback(() => {
175
+ pressingRef.current = false;
176
+ if (currentId) {
177
+ scheduleAutoHide();
125
178
  }
126
- }, [toasts, hide, removeToast]);
179
+ }, [currentId, scheduleAutoHide]);
180
+ const longPressHandlers = useLongPress(handleLongPress, {
181
+ threshold: 4000,
182
+ onStart: handlePressStart,
183
+ onCancel: handlePressCancel
184
+ });
127
185
  const {
128
186
  action = null,
129
187
  actionLabel = null,
130
- message = null
188
+ message = null,
189
+ onLongPress = null
131
190
  } = snack;
132
- const boxProps = {
191
+
192
+ // A toast is either long-pressable or has a whole-box click action — never both. Attaching both
193
+ // would let a single long press fire onLongPress (at the threshold) and then handleAction (on the
194
+ // release click). Long-press takes precedence.
195
+ const boxProps = onLongPress ? {
196
+ ...longPressHandlers
197
+ } : {
133
198
  ...(action && !actionLabel && {
134
199
  onClick: handleAction
135
200
  })
@@ -151,7 +216,6 @@ const SnackBar = ({
151
216
  config: config.stiff,
152
217
  reverse: !visible,
153
218
  force: true,
154
- onRest: handleRest,
155
219
  children: springProps =>
156
220
  /*#__PURE__*/
157
221
  // 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.32.0-beta.4",
3
+ "version": "7.32.0-beta.6",
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.32.0-beta.4",
8
+ "@shopgate/pwa-common": "7.32.0-beta.6",
9
9
  "@types/color": "^4.2.0",
10
10
  "@types/react-transition-group": "^4.4.12",
11
11
  "react": "^17.0.2",