@transferwise/components 0.0.0-experimental-47052c9 → 0.0.0-experimental-b2b287d

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 (149) hide show
  1. package/build/Snackbar/Snackbar.js +178 -102
  2. package/build/Snackbar/Snackbar.js.map +1 -1
  3. package/build/Snackbar/Snackbar.messages.js +14 -0
  4. package/build/Snackbar/Snackbar.messages.js.map +1 -0
  5. package/build/Snackbar/Snackbar.messages.mjs +10 -0
  6. package/build/Snackbar/Snackbar.messages.mjs.map +1 -0
  7. package/build/Snackbar/Snackbar.mjs +180 -101
  8. package/build/Snackbar/Snackbar.mjs.map +1 -1
  9. package/build/Snackbar/SnackbarContext.js.map +1 -1
  10. package/build/Snackbar/SnackbarContext.mjs.map +1 -1
  11. package/build/Snackbar/SnackbarPortal.js +12 -0
  12. package/build/Snackbar/SnackbarPortal.js.map +1 -0
  13. package/build/Snackbar/SnackbarPortal.mjs +8 -0
  14. package/build/Snackbar/SnackbarPortal.mjs.map +1 -0
  15. package/build/Snackbar/SnackbarProvider.js +24 -28
  16. package/build/Snackbar/SnackbarProvider.js.map +1 -1
  17. package/build/Snackbar/SnackbarProvider.mjs +25 -29
  18. package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
  19. package/build/Snackbar/constants.js +17 -0
  20. package/build/Snackbar/constants.js.map +1 -0
  21. package/build/Snackbar/constants.mjs +11 -0
  22. package/build/Snackbar/constants.mjs.map +1 -0
  23. package/build/Snackbar/useSnackbarAutoDismiss.js +66 -0
  24. package/build/Snackbar/useSnackbarAutoDismiss.js.map +1 -0
  25. package/build/Snackbar/useSnackbarAutoDismiss.mjs +62 -0
  26. package/build/Snackbar/useSnackbarAutoDismiss.mjs.map +1 -0
  27. package/build/Snackbar/useSnackbarManager.js +153 -0
  28. package/build/Snackbar/useSnackbarManager.js.map +1 -0
  29. package/build/Snackbar/useSnackbarManager.mjs +149 -0
  30. package/build/Snackbar/useSnackbarManager.mjs.map +1 -0
  31. package/build/Snackbar/useSnackbarSwipeDismiss.js +135 -0
  32. package/build/Snackbar/useSnackbarSwipeDismiss.js.map +1 -0
  33. package/build/Snackbar/useSnackbarSwipeDismiss.mjs +131 -0
  34. package/build/Snackbar/useSnackbarSwipeDismiss.mjs.map +1 -0
  35. package/build/i18n/en.json +1 -0
  36. package/build/i18n/en.json.js +1 -0
  37. package/build/i18n/en.json.js.map +1 -1
  38. package/build/i18n/en.json.mjs +1 -0
  39. package/build/i18n/en.json.mjs.map +1 -1
  40. package/build/index.js +0 -4
  41. package/build/index.js.map +1 -1
  42. package/build/index.mjs +0 -2
  43. package/build/index.mjs.map +1 -1
  44. package/build/main.css +116 -245
  45. package/build/styles/Snackbar/Snackbar.css +117 -54
  46. package/build/styles/main.css +116 -245
  47. package/build/types/Snackbar/Snackbar.d.ts +55 -9
  48. package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
  49. package/build/types/Snackbar/Snackbar.messages.d.ts +9 -0
  50. package/build/types/Snackbar/Snackbar.messages.d.ts.map +1 -0
  51. package/build/types/Snackbar/Snackbar.types.d.ts +12 -0
  52. package/build/types/Snackbar/Snackbar.types.d.ts.map +1 -0
  53. package/build/types/Snackbar/SnackbarContext.d.ts +2 -2
  54. package/build/types/Snackbar/SnackbarContext.d.ts.map +1 -1
  55. package/build/types/Snackbar/SnackbarPortal.d.ts +4 -0
  56. package/build/types/Snackbar/SnackbarPortal.d.ts.map +1 -0
  57. package/build/types/Snackbar/SnackbarProvider.d.ts +7 -0
  58. package/build/types/Snackbar/SnackbarProvider.d.ts.map +1 -1
  59. package/build/types/Snackbar/constants.d.ts +9 -0
  60. package/build/types/Snackbar/constants.d.ts.map +1 -0
  61. package/build/types/Snackbar/index.d.ts +1 -1
  62. package/build/types/Snackbar/index.d.ts.map +1 -1
  63. package/build/types/Snackbar/useSnackbar.d.ts +1 -1
  64. package/build/types/Snackbar/useSnackbar.d.ts.map +1 -1
  65. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts +13 -0
  66. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
  67. package/build/types/Snackbar/useSnackbarManager.d.ts +20 -0
  68. package/build/types/Snackbar/useSnackbarManager.d.ts.map +1 -0
  69. package/build/types/Snackbar/useSnackbarSwipeDismiss.d.ts +22 -0
  70. package/build/types/Snackbar/useSnackbarSwipeDismiss.d.ts.map +1 -0
  71. package/build/types/index.d.ts +1 -5
  72. package/build/types/index.d.ts.map +1 -1
  73. package/build/types/test-utils/index.d.ts +2 -0
  74. package/build/types/test-utils/index.d.ts.map +1 -1
  75. package/package.json +3 -3
  76. package/src/FlowNavigation/_stories/FlowNavigation.CriticalBanner.docs.mdx +66 -0
  77. package/src/Prompt/CriticalBanner/_stories/CriticalBanner.story.tsx +2 -0
  78. package/src/Snackbar/Snackbar.css +117 -54
  79. package/src/Snackbar/Snackbar.less +139 -60
  80. package/src/Snackbar/Snackbar.messages.ts +9 -0
  81. package/src/Snackbar/Snackbar.test.tsx +989 -119
  82. package/src/Snackbar/Snackbar.tsx +208 -93
  83. package/src/Snackbar/Snackbar.types.ts +17 -0
  84. package/src/Snackbar/SnackbarContext.ts +2 -2
  85. package/src/Snackbar/SnackbarPortal.tsx +8 -0
  86. package/src/Snackbar/SnackbarProvider.test.tsx +783 -0
  87. package/src/Snackbar/SnackbarProvider.tsx +27 -19
  88. package/src/Snackbar/_stories/Snackbar.accessibility.docs.mdx +58 -0
  89. package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +16 -8
  90. package/src/Snackbar/_stories/Snackbar.story.tsx +388 -61
  91. package/src/Snackbar/_stories/Snackbar.test.story.tsx +171 -47
  92. package/src/Snackbar/constants.ts +10 -0
  93. package/src/Snackbar/index.ts +1 -1
  94. package/src/Snackbar/useSnackbarAutoDismiss.ts +74 -0
  95. package/src/Snackbar/useSnackbarManager.ts +169 -0
  96. package/src/Snackbar/useSnackbarSwipeDismiss.ts +169 -0
  97. package/src/i18n/en.json +1 -0
  98. package/src/index.ts +1 -14
  99. package/src/main.css +116 -245
  100. package/src/main.less +0 -2
  101. package/build/CardContainer/CardContainer.js +0 -34
  102. package/build/CardContainer/CardContainer.js.map +0 -1
  103. package/build/CardContainer/CardContainer.mjs +0 -30
  104. package/build/CardContainer/CardContainer.mjs.map +0 -1
  105. package/build/CardWidget/CardWidget.js +0 -98
  106. package/build/CardWidget/CardWidget.js.map +0 -1
  107. package/build/CardWidget/CardWidget.mjs +0 -94
  108. package/build/CardWidget/CardWidget.mjs.map +0 -1
  109. package/build/CardWidget/CardWidgetAction.js +0 -73
  110. package/build/CardWidget/CardWidgetAction.js.map +0 -1
  111. package/build/CardWidget/CardWidgetAction.mjs +0 -68
  112. package/build/CardWidget/CardWidgetAction.mjs.map +0 -1
  113. package/build/styles/CardContainer/CardContainer.css +0 -108
  114. package/build/styles/CardWidget/CardWidget.css +0 -84
  115. package/build/types/CardContainer/CardContainer.d.ts +0 -8
  116. package/build/types/CardContainer/CardContainer.d.ts.map +0 -1
  117. package/build/types/CardContainer/CardContainer.types.d.ts +0 -53
  118. package/build/types/CardContainer/CardContainer.types.d.ts.map +0 -1
  119. package/build/types/CardContainer/_stories/helpers.d.ts +0 -3
  120. package/build/types/CardContainer/_stories/helpers.d.ts.map +0 -1
  121. package/build/types/CardContainer/index.d.ts +0 -3
  122. package/build/types/CardContainer/index.d.ts.map +0 -1
  123. package/build/types/CardWidget/CardWidget.d.ts +0 -10
  124. package/build/types/CardWidget/CardWidget.d.ts.map +0 -1
  125. package/build/types/CardWidget/CardWidget.types.d.ts +0 -55
  126. package/build/types/CardWidget/CardWidget.types.d.ts.map +0 -1
  127. package/build/types/CardWidget/CardWidgetAction.d.ts +0 -54
  128. package/build/types/CardWidget/CardWidgetAction.d.ts.map +0 -1
  129. package/build/types/CardWidget/index.d.ts +0 -4
  130. package/build/types/CardWidget/index.d.ts.map +0 -1
  131. package/src/CardContainer/CardContainer.css +0 -108
  132. package/src/CardContainer/CardContainer.less +0 -171
  133. package/src/CardContainer/CardContainer.test.tsx +0 -150
  134. package/src/CardContainer/CardContainer.tsx +0 -51
  135. package/src/CardContainer/CardContainer.types.ts +0 -60
  136. package/src/CardContainer/_stories/CardContainer.story.tsx +0 -167
  137. package/src/CardContainer/_stories/CardContainer.test.story.tsx +0 -103
  138. package/src/CardContainer/_stories/helpers.tsx +0 -50
  139. package/src/CardContainer/index.ts +0 -6
  140. package/src/CardWidget/CardWidget.css +0 -84
  141. package/src/CardWidget/CardWidget.less +0 -110
  142. package/src/CardWidget/CardWidget.test.tsx +0 -222
  143. package/src/CardWidget/CardWidget.tsx +0 -83
  144. package/src/CardWidget/CardWidget.types.ts +0 -63
  145. package/src/CardWidget/CardWidgetAction.tsx +0 -128
  146. package/src/CardWidget/_stories/CardWidget.story.tsx +0 -238
  147. package/src/CardWidget/_stories/CardWidget.test.story.tsx +0 -350
  148. package/src/CardWidget/index.ts +0 -3
  149. package/src/Prompt/CriticalBanner/_stories/CriticalBanner.docs.mdx +0 -122
@@ -1,25 +1,49 @@
1
- import { act, render, screen, userEvent } from '../test-utils';
2
- import Button from '../Button';
1
+ import { Card } from '@transferwise/icons';
3
2
 
4
- import { Snackbar, type SnackbarProps } from './Snackbar';
5
- import { SnackbarConsumer } from './SnackbarContext';
6
- import SnackbarProvider from './SnackbarProvider';
3
+ import { act, fireEvent, mockMatchMedia, render, screen, userEvent } from '../test-utils';
4
+ import { WDS_LIVE_REGION_DELAY_MS } from '../common/constants';
5
+ import { resetLiveRegionAnnouncementQueue } from '../common/LiveRegion/LiveRegion';
7
6
 
8
- const CSS_TRANSITION_DURATION = 400;
9
- const TIMEOUT = 4000;
7
+ import {
8
+ SNACKBAR_DEFAULT_DURATION,
9
+ SNACKBAR_ENTER_DURATION,
10
+ SNACKBAR_EXIT_DURATION,
11
+ SNACKBAR_EXTENDED_DURATION,
12
+ SNACKBAR_SWIPE_DISMISS_THRESHOLD,
13
+ } from './constants';
14
+ import { Snackbar, type SnackbarProps } from './Snackbar';
10
15
 
11
16
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
12
17
 
18
+ mockMatchMedia();
19
+
13
20
  const advanceTimers = (duration: number) => {
14
- act(() => {
15
- jest.advanceTimersByTime(duration);
16
- });
21
+ act(() => jest.advanceTimersByTime(duration));
17
22
  };
18
23
 
19
- const renderSnackbar = (props: Partial<SnackbarProps> = {}) =>
20
- render(<Snackbar text="" timeout={TIMEOUT} timestamp={0} {...props} />);
24
+ const enableLiveRegion = () => advanceTimers(WDS_LIVE_REGION_DELAY_MS);
25
+
26
+ const renderSnackbar = (props: Partial<SnackbarProps> = {}) => {
27
+ const snackbarProps: SnackbarProps = {
28
+ text: 'Changes saved',
29
+ timeout: SNACKBAR_DEFAULT_DURATION,
30
+ announcementKey: 1,
31
+ visible: true,
32
+ onDismissRequest: jest.fn(),
33
+ onExited: jest.fn(),
34
+ ...props,
35
+ };
36
+ return { ...render(<Snackbar {...snackbarProps} />), snackbarProps };
37
+ };
38
+
39
+ const renderEnteredSnackbar = (props: Partial<SnackbarProps> = {}) => {
40
+ const view = renderSnackbar(props);
41
+ advanceTimers(SNACKBAR_ENTER_DURATION);
42
+ return view;
43
+ };
21
44
 
22
45
  beforeEach(() => {
46
+ resetLiveRegionAnnouncementQueue();
23
47
  jest.useFakeTimers();
24
48
  });
25
49
 
@@ -28,179 +52,1025 @@ afterEach(() => {
28
52
  jest.useRealTimers();
29
53
  });
30
54
 
31
- describe('Snackbar', () => {
32
- it('renders the live region wrapper even while no notification is shown', () => {
55
+ describe('Snackbar controlled surface', () => {
56
+ it('renders no live region while not visible', () => {
57
+ renderSnackbar({ visible: false });
58
+
59
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
60
+ });
61
+
62
+ it('renders the message when visible on mount', () => {
33
63
  renderSnackbar();
34
64
 
35
- expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'assertive');
36
- expect(screen.getByRole('alert')).toHaveClass('snackbar');
65
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
37
66
  });
38
67
 
39
- it('does not show a notification that is already present on mount', () => {
40
- renderSnackbar({ text: 'Changes saved', timestamp: 1 });
68
+ it('renders an action and calls its onClick handler', async () => {
69
+ const onClick = jest.fn();
70
+ const { snackbarProps } = renderSnackbar({ action: { label: 'View', onClick } });
41
71
 
42
- expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
72
+ await user.click(screen.getByRole('button', { name: 'View' }));
73
+
74
+ expect(onClick).toHaveBeenCalledTimes(1);
75
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
43
76
  });
44
77
 
45
- it('shows the notification once text arrives after mount', () => {
46
- const { rerender } = renderSnackbar();
78
+ it('runs the action before requesting dismissal', async () => {
79
+ const onDismissRequest = jest.fn();
80
+ const onClick = jest.fn(() => expect(onDismissRequest).not.toHaveBeenCalled());
81
+ renderSnackbar({ action: { label: 'Undo', onClick }, onDismissRequest });
47
82
 
48
- rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
83
+ await user.click(screen.getByRole('button', { name: 'Undo' }));
49
84
 
50
- expect(screen.getByText('Changes saved')).toBeInTheDocument();
85
+ expect(onClick).toHaveBeenCalledTimes(1);
86
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
51
87
  });
52
88
 
53
- it('renders an action and calls its onClick handler', async () => {
54
- const onClick = jest.fn();
55
- const { rerender } = renderSnackbar();
56
-
57
- rerender(
58
- <Snackbar
59
- action={{ label: 'View', onClick }}
60
- text="Transfer completed"
61
- timeout={TIMEOUT}
62
- timestamp={1}
63
- />,
64
- );
65
- await user.click(screen.getByRole('button', { name: 'View' }));
89
+ it('requests dismissal even when the action throws, without swallowing its error', async () => {
90
+ const error = new Error('Action failed');
91
+ const handleError = jest.fn((event: ErrorEvent) => event.preventDefault());
92
+ const { snackbarProps } = renderSnackbar({
93
+ action: {
94
+ label: 'Undo',
95
+ onClick: () => {
96
+ throw error;
97
+ },
98
+ },
99
+ });
100
+ window.addEventListener('error', handleError);
101
+ try {
102
+ await user.click(screen.getByRole('button', { name: 'Undo' }));
103
+
104
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
105
+ expect(handleError).toHaveBeenCalled();
106
+ expect(handleError.mock.calls[0][0].error).toBe(error);
107
+ } finally {
108
+ window.removeEventListener('error', handleError);
109
+ }
110
+ });
111
+
112
+ it('requests dismissal for a navigation action without a callback', async () => {
113
+ const { snackbarProps } = renderSnackbar({
114
+ action: { label: 'View transfer', href: '/transfers/123', target: '_blank' },
115
+ });
116
+
117
+ await user.click(screen.getByRole('link', { name: 'View transfer' }));
118
+
119
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it('renders a navigation action with a React-node label', () => {
123
+ renderSnackbar({
124
+ action: { label: <span>View transfer</span>, href: '/transfers/123', target: '_blank' },
125
+ });
126
+
127
+ const link = screen.getByRole('link', { name: 'View transfer' });
128
+ expect(link).toHaveAttribute('href', '/transfers/123');
129
+ expect(link).toHaveAttribute('target', '_blank');
130
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
131
+ });
132
+
133
+ it('calls a navigation action handler when activated with the keyboard', async () => {
134
+ const onClick: React.MouseEventHandler<HTMLAnchorElement> = jest.fn((event) => {
135
+ event.preventDefault();
136
+ });
137
+ renderSnackbar({ action: { label: 'View transfer', href: '/transfers/123', onClick } });
138
+
139
+ act(() => screen.getByRole('link', { name: 'View transfer' }).focus());
140
+ await user.keyboard('{Enter}');
66
141
 
67
142
  expect(onClick).toHaveBeenCalledTimes(1);
68
143
  });
69
144
 
70
- it('dismisses the notification once the timeout elapses', () => {
71
- const { rerender } = renderSnackbar();
145
+ it('requests dismissal after the timeout without changing its own visibility', () => {
146
+ const onDismissRequest = jest.fn();
147
+ renderEnteredSnackbar({ onDismissRequest });
72
148
 
73
- rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
149
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
150
+ expect(onDismissRequest).not.toHaveBeenCalled();
74
151
 
75
- advanceTimers(TIMEOUT - 1);
152
+ advanceTimers(1);
153
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
76
154
  expect(screen.getByText('Changes saved')).toBeInTheDocument();
155
+ });
156
+
157
+ it('clears the dismissal timer when it begins exiting', () => {
158
+ const onDismissRequest = jest.fn();
159
+ const { rerender, snackbarProps } = renderEnteredSnackbar({ onDismissRequest });
160
+
161
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
162
+ advanceTimers(SNACKBAR_DEFAULT_DURATION);
163
+
164
+ expect(onDismissRequest).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('notifies the provider only after its exit transition finishes', () => {
168
+ const onExited = jest.fn();
169
+ const { rerender, snackbarProps } = renderSnackbar({ onExited });
170
+
171
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
172
+ advanceTimers(SNACKBAR_EXIT_DURATION - 1);
173
+ expect(onExited).not.toHaveBeenCalled();
77
174
 
78
175
  advanceTimers(1);
79
- advanceTimers(CSS_TRANSITION_DURATION);
176
+ expect(onExited).toHaveBeenCalledTimes(1);
80
177
  expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
81
178
  });
179
+ });
180
+
181
+ describe('Snackbar keyboard dismissal', () => {
182
+ it('requests dismissal with Escape without moving focus', async () => {
183
+ const { snackbarProps } = renderSnackbar({ action: { label: 'Undo' } });
184
+ const action = screen.getByRole('button', { name: 'Undo' });
185
+ act(() => action.focus());
186
+
187
+ await user.keyboard('{Escape}');
188
+
189
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
190
+ expect(action).toHaveFocus();
191
+ });
192
+
193
+ it('respects Escape already handled by the focused control', async () => {
194
+ const { snackbarProps } = renderSnackbar();
195
+ render(
196
+ <button type="button" onKeyDown={(event) => event.preventDefault()}>
197
+ Other control
198
+ </button>,
199
+ );
200
+ act(() => screen.getByRole('button', { name: 'Other control' }).focus());
201
+
202
+ await user.keyboard('{Escape}');
203
+
204
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it('ignores other keys', async () => {
208
+ const { snackbarProps } = renderSnackbar();
209
+
210
+ await user.keyboard('{Enter}');
211
+
212
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('ignores Escape during IME composition but handles it afterwards', async () => {
216
+ const { snackbarProps } = renderSnackbar();
217
+ render(<input aria-label="Recipient" />);
218
+ const input = screen.getByRole('textbox', { name: 'Recipient' });
219
+ act(() => input.focus());
220
+
221
+ // eslint-disable-next-line testing-library/prefer-user-event -- userEvent cannot set KeyboardEvent.isComposing.
222
+ fireEvent.keyDown(input, { key: 'Escape', isComposing: true });
223
+
224
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
225
+ expect(input).toHaveFocus();
226
+
227
+ await user.keyboard('{Escape}');
228
+
229
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
230
+ expect(input).toHaveFocus();
231
+ });
232
+
233
+ it.each(['exit', 'unmount'] as const)('removes its Escape listener on %s', async (cleanup) => {
234
+ const { rerender, unmount, snackbarProps } = renderSnackbar();
235
+ if (cleanup === 'exit') {
236
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
237
+ } else {
238
+ unmount();
239
+ }
82
240
 
83
- it('exits the current notification before entering the next one', () => {
84
- const { rerender } = renderSnackbar();
241
+ await user.keyboard('{Escape}');
85
242
 
86
- rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
87
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
243
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
244
+ });
88
245
 
89
- advanceTimers(CSS_TRANSITION_DURATION - 1);
90
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
246
+ it('includes the translated keyboard hint in the polite announcement, not the close button', () => {
247
+ renderSnackbar();
248
+ enableLiveRegion();
91
249
 
250
+ const hint = screen.getByText('Press Escape to dismiss.');
251
+ expect(hint).toHaveClass('sr-only');
252
+ expect(screen.getByRole('status')).toContainElement(hint);
253
+ expect(screen.getByRole('status')).toHaveTextContent('Changes saved Press Escape to dismiss.');
254
+ expect(screen.getByRole('button', { name: 'Close' })).not.toContainElement(hint);
255
+ expect(screen.queryByText('neptune.Snackbar.escapeDismissHint')).not.toBeInTheDocument();
256
+ });
257
+ });
258
+
259
+ describe('Snackbar duration', () => {
260
+ it.each([
261
+ ['default', 'extended', SNACKBAR_EXTENDED_DURATION],
262
+ ['extended', 'default', SNACKBAR_DEFAULT_DURATION],
263
+ ['indefinite', 'default', SNACKBAR_DEFAULT_DURATION],
264
+ ] as const)('starts a fresh duration when %s changes to %s', (initial, next, milliseconds) => {
265
+ const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: initial });
266
+ advanceTimers(2000);
267
+
268
+ rerender(<Snackbar {...snackbarProps} duration={next} />);
269
+
270
+ advanceTimers(milliseconds - 1);
271
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
92
272
  advanceTimers(1);
93
- expect(screen.getByText('Second message')).toBeInTheDocument();
94
- expect(screen.queryByText('First message')).not.toBeInTheDocument();
273
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
95
274
  });
96
275
 
97
- it('shows the next notification immediately when the previous one has already been dismissed', () => {
98
- const { rerender } = renderSnackbar();
276
+ it('cancels a running timer when the duration becomes indefinite', () => {
277
+ const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
278
+ advanceTimers(2000);
99
279
 
100
- rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
101
- advanceTimers(TIMEOUT + CSS_TRANSITION_DURATION);
280
+ rerender(<Snackbar {...snackbarProps} duration="indefinite" />);
281
+ advanceTimers(60_000);
102
282
 
103
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
104
- expect(screen.getByText('Second message')).toBeInTheDocument();
283
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
284
+ expect(jest.getTimerCount()).toBe(0);
105
285
  });
106
286
 
107
- it('restarts the timeout for a replacing notification', () => {
108
- const { rerender } = renderSnackbar();
287
+ it('preserves remaining time when the dismissal callback changes while running', () => {
288
+ const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
289
+ advanceTimers(SNACKBAR_DEFAULT_DURATION / 2);
290
+ const onDismissRequest = jest.fn();
291
+
292
+ rerender(<Snackbar {...snackbarProps} onDismissRequest={onDismissRequest} />);
293
+ advanceTimers(SNACKBAR_DEFAULT_DURATION / 2 - 1);
294
+ expect(onDismissRequest).not.toHaveBeenCalled();
295
+ advanceTimers(1);
296
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
297
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
298
+ });
109
299
 
110
- rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
111
- advanceTimers(TIMEOUT - 100);
300
+ it('preserves remaining time when the dismissal callback changes while paused', async () => {
301
+ const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
302
+ const message = screen.getByText('Changes saved');
303
+ advanceTimers(SNACKBAR_DEFAULT_DURATION / 2);
304
+ await user.hover(message);
305
+ const onDismissRequest = jest.fn();
112
306
 
113
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
114
- advanceTimers(CSS_TRANSITION_DURATION);
115
- expect(screen.getByText('Second message')).toBeInTheDocument();
307
+ rerender(<Snackbar {...snackbarProps} onDismissRequest={onDismissRequest} />);
308
+ advanceTimers(SNACKBAR_DEFAULT_DURATION * 2);
309
+ expect(onDismissRequest).not.toHaveBeenCalled();
310
+ await user.unhover(message);
311
+ advanceTimers(SNACKBAR_DEFAULT_DURATION / 2 - 1);
312
+ expect(onDismissRequest).not.toHaveBeenCalled();
313
+ advanceTimers(1);
314
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
315
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
316
+ });
116
317
 
117
- advanceTimers(TIMEOUT - 1);
118
- expect(screen.getByText('Second message')).toBeInTheDocument();
318
+ it('starts the full reading duration only after the entry transition finishes', () => {
319
+ const { snackbarProps } = renderSnackbar({ duration: 'default' });
119
320
 
321
+ advanceTimers(SNACKBAR_ENTER_DURATION);
322
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
323
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
120
324
  advanceTimers(1);
121
- advanceTimers(CSS_TRANSITION_DURATION);
122
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
325
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
123
326
  });
124
327
 
125
- it('dismisses a delayed replacement on the timeout current when it appears', () => {
126
- const { rerender } = renderSnackbar();
328
+ it('does not start the timer when hover ends before entry finishes', async () => {
329
+ const { snackbarProps } = renderSnackbar({ duration: 'default' });
330
+ const message = screen.getByText('Changes saved');
331
+ await user.hover(message);
332
+ await user.unhover(message);
333
+
334
+ advanceTimers(SNACKBAR_ENTER_DURATION);
335
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
336
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
337
+ advanceTimers(1);
338
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
339
+ });
127
340
 
128
- rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
129
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
130
- // Shortened while the replacement is still waiting out the exit transition.
131
- rerender(<Snackbar text="Second message" timeout={1000} timestamp={2} />);
341
+ it('starts paused when keyboard focus enters during the entry transition', () => {
342
+ const { snackbarProps } = renderSnackbar({ duration: 'default', action: { label: 'Undo' } });
343
+ const action = screen.getByRole('button', { name: 'Undo' });
344
+ act(() => action.focus());
132
345
 
133
- advanceTimers(CSS_TRANSITION_DURATION);
134
- expect(screen.getByText('Second message')).toBeInTheDocument();
346
+ advanceTimers(SNACKBAR_ENTER_DURATION);
347
+ advanceTimers(SNACKBAR_DEFAULT_DURATION * 2);
348
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
135
349
 
136
- advanceTimers(1000);
137
- advanceTimers(CSS_TRANSITION_DURATION);
138
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
350
+ act(() => action.blur());
351
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
352
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
353
+ advanceTimers(1);
354
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
139
355
  });
140
356
 
141
- // Documents existing behaviour rather than endorsing it: clearing the text leaves the notification
142
- // on screen indefinitely, because the previous shouldComponentUpdate blocked the dismissal too.
143
- it('keeps the notification visible for as long as text is empty', () => {
144
- const { rerender } = renderSnackbar();
357
+ it('does not start the timer when focus leaves before entry finishes', () => {
358
+ const { snackbarProps } = renderSnackbar({ duration: 'default', action: { label: 'Undo' } });
359
+ const action = screen.getByRole('button', { name: 'Undo' });
360
+ act(() => action.focus());
361
+ act(() => action.blur());
145
362
 
146
- rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
147
- rerender(<Snackbar text="" timeout={TIMEOUT} timestamp={2} />);
363
+ advanceTimers(SNACKBAR_ENTER_DURATION);
364
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
365
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
366
+ advanceTimers(1);
367
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
368
+ });
148
369
 
149
- advanceTimers(TIMEOUT + CSS_TRANSITION_DURATION);
150
- expect(screen.getByText('Changes saved')).toBeInTheDocument();
370
+ it('uses four seconds when no duration or legacy timeout is supplied', () => {
371
+ const { snackbarProps } = renderEnteredSnackbar({ timeout: undefined });
372
+
373
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
374
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
375
+ advanceTimers(1);
376
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
151
377
  });
152
378
 
153
- it('ignores a repeated timestamp', () => {
154
- const { rerender } = renderSnackbar();
379
+ it.each([
380
+ ['default', SNACKBAR_DEFAULT_DURATION],
381
+ ['extended', SNACKBAR_EXTENDED_DURATION],
382
+ ] as const)('uses the %s duration instead of the legacy timeout', (duration, milliseconds) => {
383
+ const { snackbarProps } = renderEnteredSnackbar({ duration, timeout: 1 });
155
384
 
156
- rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
157
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={1} />);
385
+ advanceTimers(milliseconds - 1);
386
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
387
+ advanceTimers(1);
388
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
389
+ });
158
390
 
159
- advanceTimers(CSS_TRANSITION_DURATION);
160
- expect(screen.getByText('First message')).toBeInTheDocument();
161
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
391
+ it.each([1000, 4500, 5000, 5001, 9000])(
392
+ 'preserves an explicit legacy timeout of %sms',
393
+ (timeout) => {
394
+ const { snackbarProps } = renderEnteredSnackbar({ timeout });
395
+
396
+ advanceTimers(timeout - 1);
397
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
398
+ advanceTimers(1);
399
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
400
+ },
401
+ );
402
+
403
+ it('preserves a zero legacy timeout rather than using the default duration', () => {
404
+ const { snackbarProps } = renderEnteredSnackbar({ timeout: 0 });
405
+
406
+ advanceTimers(0);
407
+
408
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
409
+ });
410
+
411
+ it('does not auto-dismiss an indefinite Snackbar, even with a legacy timeout', () => {
412
+ const { snackbarProps } = renderEnteredSnackbar({ duration: 'indefinite', timeout: 1 });
413
+
414
+ advanceTimers(60_000);
415
+
416
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
417
+ expect(jest.getTimerCount()).toBe(0);
418
+ });
419
+
420
+ it('pauses on hover and resumes with only the remaining time', async () => {
421
+ const { snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
422
+ const message = screen.getByText('Changes saved');
423
+
424
+ advanceTimers(2000);
425
+ await user.hover(message);
426
+ advanceTimers(10_000);
427
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
428
+
429
+ await user.unhover(message);
430
+ advanceTimers(1999);
431
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
432
+ advanceTimers(1);
433
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
434
+ });
435
+
436
+ it('pauses on keyboard focus and resumes when focus leaves', () => {
437
+ const { snackbarProps } = renderEnteredSnackbar({
438
+ duration: 'default',
439
+ action: { label: 'Undo' },
440
+ });
441
+
442
+ advanceTimers(2000);
443
+ act(() => screen.getByRole('button', { name: 'Undo' }).focus());
444
+ advanceTimers(10_000);
445
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
446
+
447
+ act(() => screen.getByRole('button', { name: 'Undo' }).blur());
448
+ advanceTimers(1999);
449
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
450
+ advanceTimers(1);
451
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
452
+ });
453
+
454
+ it('stays paused when focus moves between Snackbar controls', () => {
455
+ const { snackbarProps } = renderEnteredSnackbar({
456
+ duration: 'default',
457
+ action: { label: 'Undo' },
458
+ });
459
+
460
+ advanceTimers(2000);
461
+ act(() => screen.getByRole('button', { name: 'Undo' }).focus());
462
+ act(() => screen.getByRole('button', { name: 'Close' }).focus());
463
+ advanceTimers(10_000);
464
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
465
+
466
+ act(() => screen.getByRole('button', { name: 'Close' }).blur());
467
+ advanceTimers(2000);
468
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
469
+ });
470
+
471
+ it.each(['hover', 'focus'] as const)(
472
+ 'resumes only after both hover and focus end, with %s ending first',
473
+ async (firstToEnd) => {
474
+ const { snackbarProps } = renderEnteredSnackbar({
475
+ duration: 'default',
476
+ action: { label: 'Undo' },
477
+ });
478
+ const message = screen.getByText('Changes saved');
479
+ const action = screen.getByRole('button', { name: 'Undo' });
480
+
481
+ advanceTimers(2000);
482
+ await user.hover(message);
483
+ act(() => action.focus());
484
+ if (firstToEnd === 'hover') {
485
+ await user.unhover(message);
486
+ } else {
487
+ act(() => action.blur());
488
+ }
489
+ advanceTimers(10_000);
490
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
491
+
492
+ if (firstToEnd === 'hover') {
493
+ act(() => action.blur());
494
+ } else {
495
+ await user.unhover(message);
496
+ }
497
+ advanceTimers(1999);
498
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
499
+ advanceTimers(1);
500
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
501
+ },
502
+ );
503
+
504
+ it('keeps the remaining time across repeated pauses', async () => {
505
+ const { snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
506
+ const message = screen.getByText('Changes saved');
507
+
508
+ advanceTimers(1000);
509
+ await user.hover(message);
510
+ advanceTimers(10_000);
511
+ await user.unhover(message);
512
+ advanceTimers(1000);
513
+ await user.hover(message);
514
+ advanceTimers(10_000);
515
+ await user.unhover(message);
516
+ advanceTimers(1999);
517
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
518
+ advanceTimers(1);
519
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
162
520
  });
163
521
 
164
- it('clears its pending dismissal timer on unmount', () => {
165
- const { rerender, unmount } = renderSnackbar();
522
+ it('cleans up a resumed timer when unmounted', async () => {
523
+ const { unmount, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
524
+ const message = screen.getByText('Changes saved');
166
525
 
167
- rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
168
- const pendingTimers = jest.getTimerCount();
526
+ advanceTimers(1000);
527
+ await user.hover(message);
528
+ await user.unhover(message);
169
529
  unmount();
530
+ advanceTimers(10_000);
531
+
532
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
533
+ expect(jest.getTimerCount()).toBe(0);
534
+ });
535
+
536
+ it('starts paused if the surface is already hovered when shown', async () => {
537
+ const matches = jest
538
+ .spyOn(HTMLElement.prototype, 'matches')
539
+ .mockImplementation((selector) => selector === ':hover');
540
+ let view: ReturnType<typeof renderSnackbar>;
541
+ try {
542
+ view = renderEnteredSnackbar({ duration: 'default' });
543
+ } finally {
544
+ matches.mockRestore();
545
+ }
170
546
 
171
- // The timer left behind belongs to CSSTransition's enter timeout, not to Snackbar.
172
- expect(jest.getTimerCount()).toBe(pendingTimers - 1);
547
+ advanceTimers(10_000);
548
+ expect(view.snackbarProps.onDismissRequest).not.toHaveBeenCalled();
549
+ const message = screen.getByText('Changes saved');
550
+ await user.hover(message);
551
+ await user.unhover(message);
552
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
553
+ expect(view.snackbarProps.onDismissRequest).not.toHaveBeenCalled();
554
+ advanceTimers(1);
555
+ expect(view.snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
556
+ });
557
+
558
+ it('does not restart a paused timer while the Snackbar is exiting', async () => {
559
+ const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
560
+ const message = screen.getByText('Changes saved');
561
+ advanceTimers(1000);
562
+ await user.hover(message);
563
+
564
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
565
+ await user.unhover(message);
566
+ advanceTimers(10_000);
567
+
568
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
569
+ expect(jest.getTimerCount()).toBe(0);
173
570
  });
174
571
  });
175
572
 
176
- describe('Snackbar created through SnackbarProvider', () => {
177
- const renderProvider = () =>
178
- render(
179
- <SnackbarProvider timeout={TIMEOUT}>
180
- <SnackbarConsumer>
181
- {({ createSnackbar }) => (
182
- <Button onClick={() => createSnackbar({ text: 'Changes saved' })}>Show</Button>
183
- )}
184
- </SnackbarConsumer>
185
- </SnackbarProvider>,
573
+ describe('Snackbar loading', () => {
574
+ it.each(['neutral', 'success', 'warning', 'negative', 'proposition'] as const)(
575
+ 'replaces custom media and %s sentiment with the neutral loading indicator',
576
+ (sentiment) => {
577
+ renderSnackbar({ loading: true, sentiment, media: <Card title="Card" /> });
578
+
579
+ expect(screen.getByTestId('Snackbar_ProcessIndicator')).toHaveClass(
580
+ 'process-xs',
581
+ 'wds-Snackbar__process-indicator',
582
+ );
583
+ expect(screen.queryByTestId('card-icon')).not.toBeInTheDocument();
584
+ expect(screen.getByRole('status').closest('.wds-prompt')).toHaveClass(
585
+ 'wds-sentiment-neutral-light-elevated',
586
+ );
587
+ },
588
+ );
589
+
590
+ it.each([undefined, 'default', 'extended', 'indefinite'] as const)(
591
+ 'does not auto-dismiss while loading with duration %s, even with a zero timeout',
592
+ (duration) => {
593
+ const { snackbarProps } = renderEnteredSnackbar({ loading: true, duration, timeout: 0 });
594
+
595
+ advanceTimers(SNACKBAR_EXTENDED_DURATION * 2);
596
+
597
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
598
+ },
599
+ );
600
+
601
+ it('cancels a running timer when loading begins', () => {
602
+ const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
603
+ advanceTimers(SNACKBAR_DEFAULT_DURATION / 2);
604
+
605
+ rerender(<Snackbar {...snackbarProps} loading />);
606
+ advanceTimers(SNACKBAR_EXTENDED_DURATION * 2);
607
+
608
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
609
+ });
610
+
611
+ it('starts a fresh duration after loading ends, respecting hover', async () => {
612
+ const { rerender, snackbarProps } = renderEnteredSnackbar({
613
+ loading: true,
614
+ duration: 'default',
615
+ sentiment: 'success',
616
+ media: <Card title="Card" />,
617
+ });
618
+ const message = screen.getByText('Changes saved');
619
+ await user.hover(message);
620
+
621
+ rerender(<Snackbar {...snackbarProps} loading={false} />);
622
+ advanceTimers(SNACKBAR_EXTENDED_DURATION * 2);
623
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
624
+ expect(screen.queryByTestId('Snackbar_ProcessIndicator')).not.toBeInTheDocument();
625
+ expect(screen.getByTestId('card-icon')).toBeInTheDocument();
626
+ expect(message.closest('.wds-prompt')).toHaveClass('wds-sentiment-success-light-elevated');
627
+
628
+ await user.unhover(message);
629
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
630
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
631
+ advanceTimers(1);
632
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
633
+ });
634
+ });
635
+
636
+ describe('Snackbar live region', () => {
637
+ it('announces the notification as a polite status message', () => {
638
+ renderSnackbar();
639
+ const liveRegion = screen.getByRole('status');
640
+
641
+ expect(liveRegion).toHaveAttribute('aria-live', 'polite');
642
+ expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
643
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
644
+ });
645
+
646
+ it('delays live-region exposure until assistive technology can observe the update', () => {
647
+ renderSnackbar();
648
+ const liveRegionContent = screen.getByRole('status').firstElementChild;
649
+
650
+ expect(liveRegionContent).toHaveAttribute('aria-hidden', 'true');
651
+ advanceTimers(WDS_LIVE_REGION_DELAY_MS - 1);
652
+ expect(liveRegionContent).toHaveAttribute('aria-hidden', 'true');
653
+
654
+ advanceTimers(1);
655
+ expect(liveRegionContent).not.toHaveAttribute('aria-hidden');
656
+ });
657
+
658
+ it('keeps interactive controls outside the delayed announcement subtree', () => {
659
+ renderSnackbar({ action: { label: 'Undo' } });
660
+
661
+ const liveRegionContent = screen.getByRole('status').firstElementChild;
662
+ const action = screen.getByRole('button', { name: 'Undo' });
663
+ const dismiss = screen.getByRole('button', { name: 'Close' });
664
+ expect(liveRegionContent).toHaveAttribute('aria-hidden', 'true');
665
+ expect(action.closest('[aria-hidden="true"]')).toBeNull();
666
+ expect(dismiss.closest('[aria-hidden="true"]')).toBeNull();
667
+ });
668
+ });
669
+
670
+ describe('Snackbar media', () => {
671
+ it('uses the shared PrimitivePrompt structure', () => {
672
+ renderSnackbar();
673
+ enableLiveRegion();
674
+
675
+ expect(screen.getByText('Changes saved').closest('.wds-prompt')).toHaveClass(
676
+ 'wds-Snackbar-container',
186
677
  );
678
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('wds-prompt__dismiss');
679
+ });
187
680
 
188
- it('shows a notification created through the context', async () => {
189
- renderProvider();
681
+ it('renders custom media in the fixed-size media wrapper', () => {
682
+ renderSnackbar({ media: <Card title="Card" /> });
190
683
 
191
- await user.click(screen.getByRole('button', { name: 'Show' }));
684
+ expect(screen.getByTestId('card-icon').parentElement).toHaveClass('wds-Snackbar__media-asset');
685
+ });
192
686
 
193
- expect(screen.getByText('Changes saved')).toBeInTheDocument();
687
+ it('renders proposition media in the fixed-size media wrapper', () => {
688
+ renderSnackbar({ sentiment: 'proposition' });
689
+ const icon = screen
690
+ .getByText('Changes saved')
691
+ .closest('.wds-prompt')
692
+ ?.querySelector('.tw-icon-tags');
693
+
694
+ expect(icon?.parentElement).toHaveClass('wds-Snackbar__media-asset');
695
+ });
696
+
697
+ it('requests dismissal through the close button', async () => {
698
+ const onDismissRequest = jest.fn();
699
+ renderSnackbar({ onDismissRequest });
700
+
701
+ await user.click(screen.getByRole('button', { name: 'Close' }));
702
+
703
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
704
+ });
705
+ });
706
+
707
+ describe('Snackbar swipe dismissal', () => {
708
+ const setupSwipe = (props: Partial<SnackbarProps> = {}) => {
709
+ const view = renderEnteredSnackbar({ duration: 'indefinite', ...props });
710
+ const surface = screen.getByText('Changes saved').closest('.wds-Snackbar-container');
711
+ const container = surface?.closest('.wds-Snackbar');
712
+ if (!surface || !container) {
713
+ throw new Error('Expected the Snackbar surface and wrapper');
714
+ }
715
+ Object.defineProperty(surface, 'offsetHeight', { configurable: true, value: 100 });
716
+ Object.defineProperty(container, 'offsetHeight', { configurable: true, value: 140 });
717
+ return { ...view, surface, container };
718
+ };
719
+
720
+ const mockPointerCapture = (element: Element) => {
721
+ const captured = new Set<number>();
722
+ const setPointerCapture = jest.fn((id: number) => captured.add(id));
723
+ const releasePointerCapture = jest.fn((id: number) => captured.delete(id));
724
+ Object.assign(element, {
725
+ setPointerCapture,
726
+ releasePointerCapture,
727
+ hasPointerCapture: (id: number) => captured.has(id),
728
+ });
729
+ return { setPointerCapture, releasePointerCapture };
730
+ };
731
+
732
+ it.each([-1, 0, 1])('uses the half-height threshold with an offset of %spx', async (offset) => {
733
+ const { surface, container, snackbarProps } = setupSwipe();
734
+ const distance = 100 * SNACKBAR_SWIPE_DISMISS_THRESHOLD + offset;
735
+ await user.pointer([
736
+ { keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
737
+ { coords: { clientX: 0, clientY: distance } },
738
+ '[/MouseLeft]',
739
+ ]);
740
+
741
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(offset < 0 ? 0 : 1);
742
+ expect(container).toHaveClass(
743
+ offset < 0 ? 'wds-Snackbar--swipe-settle' : 'wds-Snackbar--swipe-dismiss',
744
+ );
745
+ expect(container).toHaveStyle({ transform: offset < 0 ? '' : 'translateY(140px)' });
194
746
  });
195
747
 
196
- it('shows only one notification when the trigger is clicked repeatedly', async () => {
197
- renderProvider();
748
+ it('dismisses at the threshold with a touch pointer', async () => {
749
+ const { surface, snackbarProps } = setupSwipe();
750
+ await user.pointer([
751
+ { keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
752
+ { pointerName: 'TouchA', coords: { clientX: 0, clientY: 50 } },
753
+ '[/TouchA]',
754
+ ]);
755
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
756
+ });
757
+
758
+ it('captures and releases a pointer whose release coordinates are outside the surface', async () => {
759
+ const { surface, container, snackbarProps } = setupSwipe();
760
+ const capture = mockPointerCapture(surface);
761
+ await user.pointer([
762
+ { keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
763
+ { coords: { clientX: 0, clientY: 30 } },
764
+ ]);
765
+ expect(capture.setPointerCapture).toHaveBeenCalledWith(1);
766
+ expect(container).toHaveStyle({ transform: 'translateY(30px)' });
767
+
768
+ await user.pointer({ keys: '[/MouseLeft]', coords: { clientX: 0, clientY: 200 } });
769
+
770
+ expect(capture.releasePointerCapture).toHaveBeenCalledWith(1);
771
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
772
+ expect(container).toHaveStyle({ transform: 'translateY(200px)' });
773
+ });
774
+
775
+ it('does not trigger an action when a committed swipe starts on it', async () => {
776
+ const onClick = jest.fn();
777
+ const { snackbarProps } = setupSwipe({ action: { label: 'Undo', onClick } });
778
+ const action = screen.getByRole('button', { name: 'Undo' });
779
+ const capture = mockPointerCapture(action);
780
+ await user.pointer([
781
+ { keys: '[MouseLeft>]', target: action, coords: { clientX: 0, clientY: 0 } },
782
+ { coords: { clientX: 0, clientY: 50 } },
783
+ '[/MouseLeft]',
784
+ ]);
785
+
786
+ expect(capture.setPointerCapture).toHaveBeenCalledWith(1);
787
+ expect(onClick).not.toHaveBeenCalled();
788
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
789
+ });
790
+
791
+ it('keeps normal touch activation of the action working', async () => {
792
+ const onClick = jest.fn();
793
+ const { snackbarProps } = setupSwipe({ action: { label: 'Undo', onClick } });
794
+ await user.pointer([
795
+ { keys: '[TouchA>]', target: screen.getByRole('button', { name: 'Undo' }) },
796
+ '[/TouchA]',
797
+ ]);
798
+ expect(onClick).toHaveBeenCalledTimes(1);
799
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
800
+ });
801
+
802
+ it('prevents native link dragging without blocking a navigation action swipe', async () => {
803
+ const onClick = jest.fn();
804
+ const { snackbarProps } = setupSwipe({
805
+ action: { label: 'View', href: '#transfer', onClick },
806
+ });
807
+ const action = screen.getByRole('link', { name: 'View' });
808
+ mockPointerCapture(action);
809
+ await user.pointer({
810
+ keys: '[MouseLeft>]',
811
+ target: action,
812
+ coords: { clientX: 0, clientY: 0 },
813
+ });
814
+
815
+ expect(fireEvent.dragStart(action)).toBe(false);
816
+
817
+ await user.pointer([{ coords: { clientX: 0, clientY: 50 } }, '[/MouseLeft]']);
818
+ expect(onClick).not.toHaveBeenCalled();
819
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
820
+ });
821
+
822
+ it.each([
823
+ ['button', undefined],
824
+ ['link', '#transfer'],
825
+ ] as const)('cancels off-target drag clicks on an action %s', async (role, href) => {
826
+ const onClick = jest.fn();
827
+ const { snackbarProps } = setupSwipe({ action: { label: 'Undo', href, onClick } });
828
+ const action = screen.getByRole(role, { name: 'Undo' });
829
+ mockPointerCapture(action);
830
+ jest.spyOn(action, 'getBoundingClientRect').mockReturnValue({
831
+ left: 0,
832
+ right: 100,
833
+ top: 0,
834
+ bottom: 40,
835
+ } as DOMRect);
836
+
837
+ for (const coords of [
838
+ { clientX: 20, clientY: -60 },
839
+ { clientX: 150, clientY: 20 },
840
+ ]) {
841
+ await user.pointer([
842
+ { keys: '[MouseLeft>]', target: action, coords: { clientX: 20, clientY: 20 } },
843
+ { coords },
844
+ '[/MouseLeft]',
845
+ ]);
846
+ }
847
+
848
+ expect(onClick).not.toHaveBeenCalled();
849
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
850
+
851
+ await user.pointer([
852
+ { keys: '[MouseLeft>]', target: action, coords: { clientX: 20, clientY: 20 } },
853
+ '[/MouseLeft]',
854
+ ]);
855
+ expect(onClick).toHaveBeenCalledTimes(1);
856
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
857
+ });
858
+
859
+ it.each(['{Enter}', ' '])(
860
+ 'keeps keyboard activation with %s after an off-target release',
861
+ async (key) => {
862
+ const onClick = jest.fn();
863
+ setupSwipe({ action: { label: 'Undo', onClick } });
864
+ const action = screen.getByRole('button', { name: 'Undo' });
865
+ await user.pointer({ keys: '[MouseLeft>]', target: action });
866
+ // A cancelled press need not produce a click before the next keyboard activation.
867
+ const release = new MouseEvent('pointerup', { bubbles: true, clientX: 0, clientY: -60 });
868
+ Object.defineProperty(release, 'pointerId', { value: 1 });
869
+ fireEvent(action, release);
870
+ await user.keyboard(key);
871
+
872
+ expect(onClick).toHaveBeenCalledTimes(1);
873
+ await user.pointer('[/MouseLeft]');
874
+ },
875
+ );
876
+
877
+ it.each([
878
+ [0, -60],
879
+ [80, 60],
880
+ [60, 60],
881
+ ])('ignores non-downward/mostly vertical movement (%s, %s)', async (clientX, clientY) => {
882
+ const { surface, container, snackbarProps } = setupSwipe();
883
+ await user.pointer([
884
+ { keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
885
+ { coords: { clientX, clientY } },
886
+ '[/MouseLeft]',
887
+ ]);
888
+ expect(container).toHaveStyle({ transform: '' });
889
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
890
+ });
891
+
892
+ it.each(['pointercancel', 'lostpointercapture'])('restores the position on %s', async (type) => {
893
+ const { surface, container, snackbarProps } = setupSwipe();
894
+ mockPointerCapture(surface);
895
+ await user.pointer([
896
+ { keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
897
+ { coords: { clientX: 0, clientY: 80 } },
898
+ ]);
899
+ expect(container).toHaveStyle({ transform: 'translateY(80px)' });
900
+ const event = new Event(type, { bubbles: true });
901
+ Object.defineProperty(event, 'pointerId', { value: 1 });
902
+ fireEvent(surface, event);
198
903
 
199
- const trigger = screen.getByRole('button', { name: 'Show' });
200
- await user.click(trigger);
201
- await user.click(trigger);
202
- await user.click(trigger);
904
+ expect(container).toHaveStyle({ transform: '' });
905
+ expect(container).toHaveClass('wds-Snackbar--swipe-settle');
906
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
907
+ await user.pointer('[/MouseLeft]');
908
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
909
+ });
910
+
911
+ it('recovers when a captured child is removed and capture loss fires on its document', async () => {
912
+ const { surface, container, rerender, snackbarProps } = setupSwipe({
913
+ media: <span>Drag me</span>,
914
+ });
915
+ const child = screen.getByText('Drag me');
916
+ mockPointerCapture(child);
917
+ await user.pointer([
918
+ { keys: '[MouseLeft>]', target: child, coords: { clientX: 0, clientY: 0 } },
919
+ { coords: { clientX: 0, clientY: 30 } },
920
+ ]);
921
+ expect(container).toHaveStyle({ transform: 'translateY(30px)' });
922
+
923
+ // An unrelated pointer losing capture must not cancel the active gesture.
924
+ const unrelatedLoss = new Event('lostpointercapture', { bubbles: true });
925
+ Object.defineProperty(unrelatedLoss, 'pointerId', { value: 2 });
926
+ fireEvent(surface.ownerDocument, unrelatedLoss);
927
+ expect(container).toHaveStyle({ transform: 'translateY(30px)' });
928
+
929
+ rerender(<Snackbar {...snackbarProps} media={undefined} />);
930
+ expect(child).not.toBeInTheDocument();
931
+ const captureLoss = new Event('lostpointercapture', { bubbles: true });
932
+ Object.defineProperty(captureLoss, 'pointerId', { value: 1 });
933
+ fireEvent(surface.ownerDocument, captureLoss);
934
+
935
+ expect(container).toHaveStyle({ transform: '' });
936
+ expect(container).toHaveClass('wds-Snackbar--swipe-settle');
937
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
938
+ await user.pointer({ keys: '[/MouseLeft]', target: document.body });
939
+ await user.pointer([
940
+ { keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
941
+ { coords: { clientX: 0, clientY: 50 } },
942
+ '[/MouseLeft]',
943
+ ]);
944
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
945
+ });
946
+
947
+ it('removes its document capture-loss listener on unmount', () => {
948
+ const addListener = jest.spyOn(document, 'addEventListener');
949
+ const removeListener = jest.spyOn(document, 'removeEventListener');
950
+ try {
951
+ const { unmount } = setupSwipe();
952
+ const listener = addListener.mock.calls.find(([type]) => type === 'lostpointercapture')?.[1];
953
+ expect(listener).toEqual(expect.any(Function));
954
+
955
+ unmount();
956
+
957
+ expect(removeListener).toHaveBeenCalledWith('lostpointercapture', listener);
958
+ } finally {
959
+ addListener.mockRestore();
960
+ removeListener.mockRestore();
961
+ }
962
+ });
963
+
964
+ it('ignores secondary pointers while retaining the primary gesture', async () => {
965
+ const { surface, container, snackbarProps } = setupSwipe();
966
+ const capture = mockPointerCapture(surface);
967
+ await user.pointer([
968
+ { keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
969
+ { keys: '[TouchB>]', target: surface, coords: { clientX: 0, clientY: 0 } },
970
+ { pointerName: 'TouchB', coords: { clientX: 0, clientY: 100 } },
971
+ '[/TouchB]',
972
+ ]);
973
+ expect(capture.setPointerCapture).toHaveBeenCalledTimes(1);
974
+ expect(container).toHaveStyle({ transform: '' });
975
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
976
+
977
+ await user.pointer([
978
+ { pointerName: 'TouchA', coords: { clientX: 0, clientY: 50 } },
979
+ '[/TouchA]',
980
+ ]);
981
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
982
+ });
983
+
984
+ it('ignores non-primary mouse buttons', async () => {
985
+ const { surface, container, snackbarProps } = setupSwipe();
986
+ await user.pointer([
987
+ { keys: '[MouseRight>]', target: surface, coords: { clientX: 0, clientY: 0 } },
988
+ { coords: { clientX: 0, clientY: 100 } },
989
+ '[/MouseRight]',
990
+ ]);
991
+ expect(container).toHaveStyle({ transform: '' });
992
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
993
+ });
994
+
995
+ it('clears the drag state after the exit finishes', async () => {
996
+ const { surface, container, rerender, snackbarProps } = setupSwipe();
997
+ await user.pointer([
998
+ { keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
999
+ { coords: { clientX: 0, clientY: 50 } },
1000
+ '[/MouseLeft]',
1001
+ ]);
1002
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
1003
+ advanceTimers(SNACKBAR_EXIT_DURATION);
1004
+ expect(container).toHaveStyle({ transform: '' });
1005
+ expect(container).not.toHaveClass('wds-Snackbar--swipe-dismiss');
1006
+ expect(snackbarProps.onExited).toHaveBeenCalledTimes(1);
1007
+ });
1008
+
1009
+ it('keeps dragging across a render and uses the latest dismissal callback', async () => {
1010
+ const { surface, container, rerender, snackbarProps } = setupSwipe();
1011
+ await user.pointer([
1012
+ { keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
1013
+ { coords: { clientX: 0, clientY: 30 } },
1014
+ ]);
1015
+ const onDismissRequest = jest.fn();
1016
+ rerender(<Snackbar {...snackbarProps} onDismissRequest={onDismissRequest} />);
1017
+ expect(container).toHaveStyle({ transform: 'translateY(30px)' });
1018
+
1019
+ await user.pointer([{ coords: { clientX: 0, clientY: 50 } }, '[/MouseLeft]']);
1020
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
1021
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
1022
+ });
1023
+
1024
+ it('ends an active gesture without dismissing again when an exit starts mid-drag', async () => {
1025
+ const { surface, container, rerender, snackbarProps } = setupSwipe();
1026
+ const capture = mockPointerCapture(surface);
1027
+ await user.pointer([
1028
+ { keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
1029
+ { pointerName: 'TouchA', coords: { clientX: 0, clientY: 30 } },
1030
+ ]);
1031
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
1032
+
1033
+ expect(capture.releasePointerCapture).toHaveBeenCalledTimes(1);
1034
+ expect(container).toHaveStyle({ transform: '' });
1035
+
1036
+ await user.pointer([
1037
+ { pointerName: 'TouchA', coords: { clientX: 0, clientY: 80 } },
1038
+ '[/TouchA]',
1039
+ ]);
1040
+ expect(container).toHaveStyle({ transform: '' });
1041
+ expect(container).not.toHaveClass('wds-Snackbar--swipe-dismiss');
1042
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
1043
+ });
1044
+
1045
+ it('pauses auto-dismiss during a touch gesture and resumes with the remaining time', async () => {
1046
+ const { surface, snackbarProps } = setupSwipe({ duration: 'default' });
1047
+ advanceTimers(1000);
1048
+ await user.pointer([
1049
+ { keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
1050
+ { pointerName: 'TouchA', coords: { clientX: 0, clientY: 20 } },
1051
+ ]);
1052
+ advanceTimers(SNACKBAR_DEFAULT_DURATION);
1053
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
1054
+
1055
+ await user.pointer('[/TouchA]');
1056
+ // Touch emulates mouse hover; leave so only the swipe pause is under test.
1057
+ await user.unhover(surface);
1058
+ advanceTimers(SNACKBAR_DEFAULT_DURATION - 1000 - 1);
1059
+ expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
1060
+ advanceTimers(1);
1061
+ expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
1062
+ });
203
1063
 
204
- expect(screen.getAllByRole('alert')).toHaveLength(1);
1064
+ it('stays off-screen when the parent honours the swipe dismissal', async () => {
1065
+ const { surface, container, rerender, snackbarProps } = setupSwipe();
1066
+ await user.pointer([
1067
+ { keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
1068
+ { pointerName: 'TouchA', coords: { clientX: 0, clientY: 50 } },
1069
+ '[/TouchA]',
1070
+ ]);
1071
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
1072
+ advanceTimers(SNACKBAR_EXIT_DURATION - 1);
1073
+ expect(container).toHaveStyle({ transform: 'translateY(140px)' });
1074
+ expect(container).toHaveClass('wds-Snackbar--swipe-dismiss');
205
1075
  });
206
1076
  });