@transferwise/components 0.0.0-experimental-85015e0 → 0.0.0-experimental-7990d38

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.
Files changed (37) hide show
  1. package/build/Snackbar/Snackbar.js +22 -9
  2. package/build/Snackbar/Snackbar.js.map +1 -1
  3. package/build/Snackbar/Snackbar.mjs +22 -9
  4. package/build/Snackbar/Snackbar.mjs.map +1 -1
  5. package/build/Snackbar/SnackbarProvider.js +1 -1
  6. package/build/Snackbar/SnackbarProvider.js.map +1 -1
  7. package/build/Snackbar/SnackbarProvider.mjs +1 -1
  8. package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
  9. package/build/Snackbar/constants.js +6 -0
  10. package/build/Snackbar/constants.js.map +1 -1
  11. package/build/Snackbar/constants.mjs +5 -1
  12. package/build/Snackbar/constants.mjs.map +1 -1
  13. package/build/Snackbar/useSnackbarAutoDismiss.js +143 -0
  14. package/build/Snackbar/useSnackbarAutoDismiss.js.map +1 -0
  15. package/build/Snackbar/useSnackbarAutoDismiss.mjs +139 -0
  16. package/build/Snackbar/useSnackbarAutoDismiss.mjs.map +1 -0
  17. package/build/types/Snackbar/Snackbar.d.ts +16 -4
  18. package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
  19. package/build/types/Snackbar/Snackbar.types.d.ts +2 -0
  20. package/build/types/Snackbar/Snackbar.types.d.ts.map +1 -1
  21. package/build/types/Snackbar/SnackbarProvider.d.ts +7 -0
  22. package/build/types/Snackbar/SnackbarProvider.d.ts.map +1 -1
  23. package/build/types/Snackbar/constants.d.ts +4 -0
  24. package/build/types/Snackbar/constants.d.ts.map +1 -1
  25. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts +19 -0
  26. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
  27. package/package.json +1 -1
  28. package/src/Snackbar/Snackbar.test.tsx +293 -4
  29. package/src/Snackbar/Snackbar.tsx +30 -14
  30. package/src/Snackbar/Snackbar.types.ts +3 -0
  31. package/src/Snackbar/SnackbarProvider.test.tsx +64 -4
  32. package/src/Snackbar/SnackbarProvider.tsx +8 -1
  33. package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +2 -4
  34. package/src/Snackbar/_stories/Snackbar.story.tsx +37 -0
  35. package/src/Snackbar/_stories/Snackbar.test.story.tsx +3 -5
  36. package/src/Snackbar/constants.ts +5 -0
  37. package/src/Snackbar/useSnackbarAutoDismiss.ts +152 -0
@@ -3,13 +3,18 @@ import { renderHook } from '@testing-library/react';
3
3
  import { act, mockMatchMedia, render, screen, userEvent } from '../test-utils';
4
4
  import { resetLiveRegionAnnouncementQueue } from '../common/LiveRegion/LiveRegion';
5
5
 
6
- import { SNACKBAR_ENTER_DURATION, SNACKBAR_EXIT_DURATION } from './constants';
6
+ import {
7
+ SNACKBAR_DEFAULT_DURATION,
8
+ SNACKBAR_ENTER_DURATION,
9
+ SNACKBAR_EXIT_DURATION,
10
+ SNACKBAR_EXTENDED_DURATION,
11
+ } from './constants';
7
12
  import type { SnackbarCreationProps } from './Snackbar.types';
8
13
  import SnackbarProvider from './SnackbarProvider';
9
14
  import useSnackbar from './useSnackbar';
10
15
  import useSnackbarManager from './useSnackbarManager';
11
16
 
12
- const TIMEOUT = 4500;
17
+ const TIMEOUT = SNACKBAR_DEFAULT_DURATION;
13
18
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
14
19
 
15
20
  mockMatchMedia();
@@ -114,10 +119,11 @@ describe('SnackbarProvider orchestration', () => {
114
119
  );
115
120
  });
116
121
 
117
- it('keeps the default timeout until semantic durations are introduced', async () => {
122
+ it('dismisses after the default four-second duration', async () => {
118
123
  renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }]);
119
124
  await user.click(screen.getByRole('button', { name: 'Show' }));
120
125
 
126
+ advanceTimers(SNACKBAR_ENTER_DURATION);
121
127
  advanceTimers(TIMEOUT - 1);
122
128
  expect(screen.getByText('Changes saved')).toBeInTheDocument();
123
129
 
@@ -131,6 +137,7 @@ describe('SnackbarProvider orchestration', () => {
131
137
  renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }], TIMEOUT * 2);
132
138
  await user.click(screen.getByRole('button', { name: 'Show' }));
133
139
 
140
+ advanceTimers(SNACKBAR_ENTER_DURATION);
134
141
  advanceTimers(TIMEOUT * 2 - 1);
135
142
  expect(screen.getByText('Changes saved')).toBeInTheDocument();
136
143
 
@@ -139,6 +146,58 @@ describe('SnackbarProvider orchestration', () => {
139
146
  expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
140
147
  });
141
148
 
149
+ it('uses an explicit semantic duration instead of the legacy Provider timeout', async () => {
150
+ renderProvider(
151
+ [{ label: 'Show', snackbar: { text: 'Changes saved', duration: 'extended' } }],
152
+ 1,
153
+ );
154
+ await user.click(screen.getByRole('button', { name: 'Show' }));
155
+
156
+ advanceTimers(SNACKBAR_ENTER_DURATION);
157
+ advanceTimers(SNACKBAR_EXTENDED_DURATION - 1);
158
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
159
+ advanceTimers(1);
160
+ advanceTimers(SNACKBAR_EXIT_DURATION);
161
+ expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
162
+ });
163
+
164
+ it('pauses the provider timer while keyboard focus moves within the Snackbar', async () => {
165
+ renderProvider([
166
+ { label: 'Show', snackbar: { text: 'Changes saved', action: { label: 'Undo' } } },
167
+ ]);
168
+ await user.click(screen.getByRole('button', { name: 'Show' }));
169
+ advanceTimers(SNACKBAR_ENTER_DURATION);
170
+ advanceTimers(TIMEOUT / 2);
171
+
172
+ await user.tab();
173
+ expect(screen.getByRole('button', { name: 'Undo' })).toHaveFocus();
174
+ advanceTimers(TIMEOUT * 2);
175
+ await user.tab();
176
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
177
+ advanceTimers(TIMEOUT * 2);
178
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
179
+
180
+ await user.tab();
181
+ advanceTimers(TIMEOUT / 2 - 1);
182
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
183
+ advanceTimers(1);
184
+ advanceTimers(SNACKBAR_EXIT_DURATION);
185
+ expect(document.querySelector('.wds-Snackbar')).toBeNull();
186
+ });
187
+
188
+ it('keeps indefinite notifications until manually dismissed', async () => {
189
+ renderProvider([
190
+ { label: 'Show', snackbar: { text: 'Changes saved', duration: 'indefinite' } },
191
+ ]);
192
+ await user.click(screen.getByRole('button', { name: 'Show' }));
193
+
194
+ advanceTimers(60_000);
195
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
196
+ await user.click(screen.getByRole('button', { name: 'Close' }));
197
+ advanceTimers(SNACKBAR_EXIT_DURATION);
198
+ expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
199
+ });
200
+
142
201
  it('exits the active Snackbar before showing the latest replacement', async () => {
143
202
  renderProvider([
144
203
  { label: 'Show first', snackbar: { text: 'First message' } },
@@ -203,6 +262,7 @@ describe('SnackbarProvider orchestration', () => {
203
262
  await user.click(screen.getByRole('button', { name: 'Replace' }));
204
263
  advanceTimers(SNACKBAR_EXIT_DURATION);
205
264
 
265
+ advanceTimers(SNACKBAR_ENTER_DURATION);
206
266
  advanceTimers(TIMEOUT - 1);
207
267
  expect(screen.getByText('Replacement')).toBeInTheDocument();
208
268
  advanceTimers(1);
@@ -292,7 +352,7 @@ describe('SnackbarProvider orchestration', () => {
292
352
  <SnackbarTriggers triggers={triggers} />
293
353
  </SnackbarProvider>,
294
354
  );
295
- advanceTimers(TIMEOUT - SNACKBAR_ENTER_DURATION);
355
+ advanceTimers(TIMEOUT);
296
356
  advanceTimers(SNACKBAR_EXIT_DURATION);
297
357
 
298
358
  expect(document.querySelector('.wds-Snackbar')).toBeNull();
@@ -5,11 +5,18 @@ import { SnackbarContext } from './SnackbarContext';
5
5
  import useSnackbarManager from './useSnackbarManager';
6
6
 
7
7
  export interface SnackbarProviderProps {
8
+ /**
9
+ * @deprecated Use per-Snackbar `duration` instead. Explicit millisecond values remain supported
10
+ * and are not automatically remapped when no `duration` is supplied.
11
+ * When neither option is supplied, the default is 4000ms (previously 4500ms), after entry finishes.
12
+ * To migrate, use `default` for 0–5000ms, `extended` for values above 5000ms, and `indefinite`
13
+ * for intentionally persistent notifications.
14
+ */
8
15
  timeout?: number;
9
16
  children?: React.ReactNode;
10
17
  }
11
18
 
12
- export default function SnackbarProvider({ timeout = 4500, children }: SnackbarProviderProps) {
19
+ export default function SnackbarProvider({ timeout, children }: SnackbarProviderProps) {
13
20
  const { active, announcementKey, createSnackbar, dismissSnackbar, handleExited, visible } =
14
21
  useSnackbarManager();
15
22
  const contextValue = useMemo(() => ({ createSnackbar }), [createSnackbar]);
@@ -15,8 +15,6 @@ export default {
15
15
 
16
16
  type Story = StoryObj<typeof Snackbar>;
17
17
 
18
- const TIMEOUT = 10_000_000;
19
-
20
18
  function ShowOnMount(props: SnackbarCreationProps) {
21
19
  const createSnackbar = useSnackbar();
22
20
 
@@ -31,8 +29,8 @@ function ShowOnMount(props: SnackbarCreationProps) {
31
29
 
32
30
  function SnackbarStory({ text, action }: { text: string; action?: { label: string } }) {
33
31
  return (
34
- <SnackbarProvider timeout={TIMEOUT}>
35
- <ShowOnMount text={<span>{text}</span>} action={action} />
32
+ <SnackbarProvider>
33
+ <ShowOnMount text={<span>{text}</span>} action={action} duration="indefinite" />
36
34
  </SnackbarProvider>
37
35
  );
38
36
  }
@@ -149,3 +149,40 @@ export const Media: StoryObj = {
149
149
  />
150
150
  ),
151
151
  };
152
+
153
+ /**
154
+ * `duration` uses four-second, six-second or indefinite presets, counted after entry finishes.
155
+ * Timed Snackbars pause while hovered or focused and resume when both interactions end.
156
+ */
157
+ export const Durations: StoryObj = {
158
+ parameters: {
159
+ docs: {
160
+ description: {
161
+ story:
162
+ 'The default duration is now **4000ms**, previously **4500ms**, when neither `duration` nor a legacy Provider `timeout` is supplied. ' +
163
+ 'Timed durations begin after the entry transition finishes, pause while hovered or focused, and resume with the remaining time only after both interactions end.\n\n' +
164
+ '`timeout` is deprecated but explicit millisecond values remain supported without automatic remapping. An explicit `duration` takes precedence. ' +
165
+ 'When migrating, choose `default` for 0–5000ms, `extended` for values above 5000ms, and `indefinite` for intentionally persistent notifications.',
166
+ },
167
+ },
168
+ },
169
+ render: () => (
170
+ <SnackbarExamples
171
+ examples={[
172
+ { label: 'Default (4s)', props: { text: 'Changes saved', duration: 'default' } },
173
+ {
174
+ label: 'Extended (6s)',
175
+ props: {
176
+ text: 'Transfer deleted',
177
+ duration: 'extended',
178
+ action: { label: 'Undo', onClick: fn() },
179
+ },
180
+ },
181
+ {
182
+ label: 'Indefinite',
183
+ props: { text: 'Waiting for confirmation', duration: 'indefinite' },
184
+ },
185
+ ]}
186
+ />
187
+ ),
188
+ };
@@ -24,21 +24,18 @@ const launchSnackbar = async (canvasElement: HTMLElement) => {
24
24
 
25
25
  type Args = Partial<SnackbarCreationProps> & {
26
26
  extraContext: React.ReactNode;
27
- timeout: number;
28
27
  };
29
28
 
30
- const TIMEOUT = 10000000;
31
-
32
29
  const Default = {
33
30
  args: {
34
- timeout: TIMEOUT,
31
+ duration: 'indefinite',
35
32
  } as Args,
36
33
  play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
37
34
  await launchSnackbar(canvasElement);
38
35
  },
39
36
  render: (args: Args) => {
40
37
  return (
41
- <SnackbarProvider timeout={args.timeout}>
38
+ <SnackbarProvider>
42
39
  <SnackbarConsumer>
43
40
  {({ createSnackbar }) => (
44
41
  <>
@@ -47,6 +44,7 @@ const Default = {
47
44
  block
48
45
  onClick={() => {
49
46
  createSnackbar({
47
+ duration: args.duration,
50
48
  action: args.action ?? {
51
49
  label: 'View',
52
50
  onClick: () => action('View button clicked.'),
@@ -1,2 +1,7 @@
1
1
  export const SNACKBAR_ENTER_DURATION = 300;
2
2
  export const SNACKBAR_EXIT_DURATION = 150;
3
+
4
+ /** Default auto-dismiss duration for short feedback, in milliseconds. */
5
+ export const SNACKBAR_DEFAULT_DURATION = 4000;
6
+ /** Extended auto-dismiss duration for messages needing more reading time, in milliseconds. */
7
+ export const SNACKBAR_EXTENDED_DURATION = 6000;
@@ -0,0 +1,152 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ import { SNACKBAR_DEFAULT_DURATION, SNACKBAR_EXTENDED_DURATION } from './constants';
4
+ import type { SnackbarDuration } from './Snackbar.types';
5
+
6
+ type UseSnackbarAutoDismissOptions = {
7
+ duration?: SnackbarDuration;
8
+ onDismissRequest: () => void;
9
+ resetKey: number;
10
+ timeout?: number;
11
+ visible: boolean;
12
+ };
13
+
14
+ const getAutoDismissDuration = ({
15
+ duration,
16
+ timeout,
17
+ }: Pick<UseSnackbarAutoDismissOptions, 'duration' | 'timeout'>) => {
18
+ if (duration === 'indefinite') {
19
+ return null;
20
+ }
21
+
22
+ if (duration === 'extended') {
23
+ return SNACKBAR_EXTENDED_DURATION;
24
+ }
25
+
26
+ if (duration === 'default') {
27
+ return SNACKBAR_DEFAULT_DURATION;
28
+ }
29
+
30
+ return timeout ?? SNACKBAR_DEFAULT_DURATION;
31
+ };
32
+
33
+ /** Starts after entry finishes and pauses on hover or focus, preserving the remaining time. */
34
+ const useSnackbarAutoDismiss = ({
35
+ duration,
36
+ onDismissRequest,
37
+ resetKey,
38
+ timeout,
39
+ visible,
40
+ }: UseSnackbarAutoDismissOptions) => {
41
+ const surfaceRef = useRef<HTMLDivElement>(null);
42
+ const hasEnteredRef = useRef(false);
43
+ const timeoutRef = useRef(0);
44
+ const remainingDurationRef = useRef<number | null>(null);
45
+ const timerStartedAtRef = useRef(0);
46
+ const interactionStateRef = useRef({ focusWithin: false, hovered: false });
47
+
48
+ const isInterrupted = useCallback(() => {
49
+ const { focusWithin, hovered } = interactionStateRef.current;
50
+ return focusWithin || hovered;
51
+ }, []);
52
+
53
+ const clearTimer = useCallback(() => {
54
+ window.clearTimeout(timeoutRef.current);
55
+ timeoutRef.current = 0;
56
+ }, []);
57
+
58
+ const startTimer = useCallback(
59
+ (remainingDuration = getAutoDismissDuration({ duration, timeout })) => {
60
+ clearTimer();
61
+ remainingDurationRef.current = remainingDuration;
62
+
63
+ if (!visible || !hasEnteredRef.current || remainingDuration === null || isInterrupted()) {
64
+ return;
65
+ }
66
+
67
+ timerStartedAtRef.current = Date.now();
68
+ timeoutRef.current = window.setTimeout(() => {
69
+ timeoutRef.current = 0;
70
+ remainingDurationRef.current = 0;
71
+ onDismissRequest();
72
+ }, remainingDuration);
73
+ },
74
+ [clearTimer, duration, isInterrupted, onDismissRequest, timeout, visible],
75
+ );
76
+
77
+ const pauseTimer = useCallback(() => {
78
+ if (!timeoutRef.current || remainingDurationRef.current === null) {
79
+ return;
80
+ }
81
+
82
+ clearTimer();
83
+ // Count only unpaused time; resuming must not grant another full duration.
84
+ remainingDurationRef.current = Math.max(
85
+ 0,
86
+ remainingDurationRef.current - (Date.now() - timerStartedAtRef.current),
87
+ );
88
+ }, [clearTimer]);
89
+
90
+ const resumeTimer = useCallback(() => {
91
+ if (isInterrupted() || remainingDurationRef.current === null) {
92
+ return;
93
+ }
94
+
95
+ startTimer(remainingDurationRef.current);
96
+ }, [isInterrupted, startTimer]);
97
+
98
+ const handleMouseEnter = useCallback(() => {
99
+ interactionStateRef.current = { ...interactionStateRef.current, hovered: true };
100
+ pauseTimer();
101
+ }, [pauseTimer]);
102
+
103
+ const handleMouseLeave = useCallback(() => {
104
+ interactionStateRef.current = { ...interactionStateRef.current, hovered: false };
105
+ resumeTimer();
106
+ }, [resumeTimer]);
107
+
108
+ const handleFocus = useCallback(() => {
109
+ interactionStateRef.current = { ...interactionStateRef.current, focusWithin: true };
110
+ pauseTimer();
111
+ }, [pauseTimer]);
112
+
113
+ const handleBlur = useCallback(
114
+ (event: React.FocusEvent<HTMLDivElement>) => {
115
+ if (event.currentTarget.contains(event.relatedTarget)) {
116
+ return;
117
+ }
118
+
119
+ interactionStateRef.current = { ...interactionStateRef.current, focusWithin: false };
120
+ resumeTimer();
121
+ },
122
+ [resumeTimer],
123
+ );
124
+
125
+ const handleEntered = useCallback(() => {
126
+ hasEnteredRef.current = true;
127
+ const snackbar = surfaceRef.current;
128
+ // Entry can finish beneath the pointer or with keyboard focus already inside the surface.
129
+ interactionStateRef.current = {
130
+ focusWithin: Boolean(snackbar?.contains(document.activeElement)),
131
+ hovered: snackbar?.matches(':hover') ?? false,
132
+ };
133
+ startTimer();
134
+ }, [startTimer]);
135
+
136
+ useEffect(() => {
137
+ if (!visible) {
138
+ hasEnteredRef.current = false;
139
+ clearTimer();
140
+ return undefined;
141
+ }
142
+
143
+ // Before onEntered this only prepares the duration; it cannot start the countdown.
144
+ startTimer();
145
+
146
+ return clearTimer;
147
+ }, [clearTimer, resetKey, startTimer, visible]);
148
+
149
+ return { handleBlur, handleEntered, handleFocus, handleMouseEnter, handleMouseLeave, surfaceRef };
150
+ };
151
+
152
+ export default useSnackbarAutoDismiss;