@transferwise/components 0.0.0-experimental-d72e9b7 → 0.0.0-experimental-f4ca4fa
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/build/Prompt/PrimitivePrompt/PrimitivePrompt.js +2 -1
- package/build/Prompt/PrimitivePrompt/PrimitivePrompt.js.map +1 -1
- package/build/Prompt/PrimitivePrompt/PrimitivePrompt.mjs +2 -1
- package/build/Prompt/PrimitivePrompt/PrimitivePrompt.mjs.map +1 -1
- package/build/Provider/Provider.js +24 -1
- package/build/Provider/Provider.js.map +1 -1
- package/build/Provider/Provider.mjs +24 -1
- package/build/Provider/Provider.mjs.map +1 -1
- package/build/Snackbar/Snackbar.js +179 -102
- package/build/Snackbar/Snackbar.js.map +1 -1
- package/build/Snackbar/Snackbar.messages.js +14 -0
- package/build/Snackbar/Snackbar.messages.js.map +1 -0
- package/build/Snackbar/Snackbar.messages.mjs +10 -0
- package/build/Snackbar/Snackbar.messages.mjs.map +1 -0
- package/build/Snackbar/Snackbar.mjs +181 -101
- package/build/Snackbar/Snackbar.mjs.map +1 -1
- package/build/Snackbar/SnackbarContext.js.map +1 -1
- package/build/Snackbar/SnackbarContext.mjs.map +1 -1
- package/build/Snackbar/SnackbarPortal.js +12 -0
- package/build/Snackbar/SnackbarPortal.js.map +1 -0
- package/build/Snackbar/SnackbarPortal.mjs +8 -0
- package/build/Snackbar/SnackbarPortal.mjs.map +1 -0
- package/build/Snackbar/SnackbarProvider.js +24 -28
- package/build/Snackbar/SnackbarProvider.js.map +1 -1
- package/build/Snackbar/SnackbarProvider.mjs +25 -29
- package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
- package/build/Snackbar/constants.js +17 -0
- package/build/Snackbar/constants.js.map +1 -0
- package/build/Snackbar/constants.mjs +11 -0
- package/build/Snackbar/constants.mjs.map +1 -0
- package/build/Snackbar/useSnackbarAutoDismiss.js +66 -0
- package/build/Snackbar/useSnackbarAutoDismiss.js.map +1 -0
- package/build/Snackbar/useSnackbarAutoDismiss.mjs +62 -0
- package/build/Snackbar/useSnackbarAutoDismiss.mjs.map +1 -0
- package/build/Snackbar/useSnackbarManager.js +153 -0
- package/build/Snackbar/useSnackbarManager.js.map +1 -0
- package/build/Snackbar/useSnackbarManager.mjs +149 -0
- package/build/Snackbar/useSnackbarManager.mjs.map +1 -0
- package/build/Snackbar/useSnackbarSwipeDismiss.js +135 -0
- package/build/Snackbar/useSnackbarSwipeDismiss.js.map +1 -0
- package/build/Snackbar/useSnackbarSwipeDismiss.mjs +131 -0
- package/build/Snackbar/useSnackbarSwipeDismiss.mjs.map +1 -0
- package/build/i18n/en.json +1 -0
- package/build/i18n/en.json.js +1 -0
- package/build/i18n/en.json.js.map +1 -1
- package/build/i18n/en.json.mjs +1 -0
- package/build/i18n/en.json.mjs.map +1 -1
- package/build/main.css +119 -57
- package/build/styles/IconButton/IconButton.css +3 -4
- package/build/styles/Snackbar/Snackbar.css +117 -54
- package/build/styles/main.css +119 -57
- package/build/types/Prompt/PrimitivePrompt/PrimitivePrompt.d.ts +11 -0
- package/build/types/Prompt/PrimitivePrompt/PrimitivePrompt.d.ts.map +1 -1
- package/build/types/Snackbar/Snackbar.d.ts +55 -9
- package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
- package/build/types/Snackbar/Snackbar.messages.d.ts +9 -0
- package/build/types/Snackbar/Snackbar.messages.d.ts.map +1 -0
- package/build/types/Snackbar/Snackbar.types.d.ts +12 -0
- package/build/types/Snackbar/Snackbar.types.d.ts.map +1 -0
- package/build/types/Snackbar/SnackbarContext.d.ts +2 -2
- package/build/types/Snackbar/SnackbarContext.d.ts.map +1 -1
- package/build/types/Snackbar/SnackbarPortal.d.ts +4 -0
- package/build/types/Snackbar/SnackbarPortal.d.ts.map +1 -0
- package/build/types/Snackbar/SnackbarProvider.d.ts +7 -0
- package/build/types/Snackbar/SnackbarProvider.d.ts.map +1 -1
- package/build/types/Snackbar/constants.d.ts +9 -0
- package/build/types/Snackbar/constants.d.ts.map +1 -0
- package/build/types/Snackbar/index.d.ts +1 -1
- package/build/types/Snackbar/index.d.ts.map +1 -1
- package/build/types/Snackbar/useSnackbar.d.ts +1 -1
- package/build/types/Snackbar/useSnackbar.d.ts.map +1 -1
- package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts +13 -0
- package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
- package/build/types/Snackbar/useSnackbarManager.d.ts +20 -0
- package/build/types/Snackbar/useSnackbarManager.d.ts.map +1 -0
- package/build/types/Snackbar/useSnackbarSwipeDismiss.d.ts +22 -0
- package/build/types/Snackbar/useSnackbarSwipeDismiss.d.ts.map +1 -0
- package/build/types/index.d.ts +1 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/test-utils/index.d.ts +2 -0
- package/build/types/test-utils/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/IconButton/IconButton.css +3 -4
- package/src/IconButton/IconButton.less +14 -1
- package/src/Prompt/PrimitivePrompt/PrimitivePrompt.test.tsx +24 -1
- package/src/Prompt/PrimitivePrompt/PrimitivePrompt.tsx +8 -2
- package/src/Provider/Provider.tsx +1 -1
- package/src/Snackbar/Snackbar.css +117 -54
- package/src/Snackbar/Snackbar.less +139 -60
- package/src/Snackbar/Snackbar.messages.ts +9 -0
- package/src/Snackbar/Snackbar.test.tsx +992 -119
- package/src/Snackbar/Snackbar.tsx +209 -93
- package/src/Snackbar/Snackbar.types.ts +17 -0
- package/src/Snackbar/SnackbarContext.ts +2 -2
- package/src/Snackbar/SnackbarPortal.tsx +8 -0
- package/src/Snackbar/SnackbarProvider.test.tsx +783 -0
- package/src/Snackbar/SnackbarProvider.tsx +27 -19
- package/src/Snackbar/_stories/Snackbar.accessibility.docs.mdx +58 -0
- package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +16 -8
- package/src/Snackbar/_stories/Snackbar.story.tsx +388 -61
- package/src/Snackbar/_stories/Snackbar.test.story.tsx +171 -47
- package/src/Snackbar/constants.ts +10 -0
- package/src/Snackbar/index.ts +1 -1
- package/src/Snackbar/useSnackbarAutoDismiss.ts +74 -0
- package/src/Snackbar/useSnackbarManager.ts +169 -0
- package/src/Snackbar/useSnackbarSwipeDismiss.ts +169 -0
- package/src/i18n/en.json +1 -0
- package/src/index.ts +1 -1
- package/src/main.css +119 -57
|
@@ -1,25 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import Button from '../Button';
|
|
1
|
+
import { Card } from '@transferwise/icons';
|
|
3
2
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
20
|
-
|
|
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,1028 @@ afterEach(() => {
|
|
|
28
52
|
jest.useRealTimers();
|
|
29
53
|
});
|
|
30
54
|
|
|
31
|
-
describe('Snackbar', () => {
|
|
32
|
-
it('renders
|
|
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.
|
|
36
|
-
expect(screen.getByRole('alert')).toHaveClass('snackbar');
|
|
65
|
+
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
37
66
|
});
|
|
38
67
|
|
|
39
|
-
it('
|
|
40
|
-
|
|
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
|
-
|
|
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('
|
|
46
|
-
const
|
|
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
|
-
|
|
83
|
+
await user.click(screen.getByRole('button', { name: 'Undo' }));
|
|
49
84
|
|
|
50
|
-
expect(
|
|
85
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(onDismissRequest).toHaveBeenCalledTimes(1);
|
|
51
87
|
});
|
|
52
88
|
|
|
53
|
-
it('
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
65
|
-
|
|
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('
|
|
71
|
-
const
|
|
145
|
+
it('requests dismissal after the timeout without changing its own visibility', () => {
|
|
146
|
+
const onDismissRequest = jest.fn();
|
|
147
|
+
renderEnteredSnackbar({ onDismissRequest });
|
|
72
148
|
|
|
73
|
-
|
|
149
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
150
|
+
expect(onDismissRequest).not.toHaveBeenCalled();
|
|
74
151
|
|
|
75
|
-
advanceTimers(
|
|
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
|
-
|
|
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());
|
|
82
201
|
|
|
83
|
-
|
|
84
|
-
|
|
202
|
+
await user.keyboard('{Escape}');
|
|
203
|
+
|
|
204
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
85
206
|
|
|
86
|
-
|
|
87
|
-
|
|
207
|
+
it('ignores other keys', async () => {
|
|
208
|
+
const { snackbarProps } = renderSnackbar();
|
|
88
209
|
|
|
89
|
-
|
|
90
|
-
expect(screen.queryByText('Second message')).not.toBeInTheDocument();
|
|
210
|
+
await user.keyboard('{Enter}');
|
|
91
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
|
+
}
|
|
240
|
+
|
|
241
|
+
await user.keyboard('{Escape}');
|
|
242
|
+
|
|
243
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('includes the translated keyboard hint in the polite announcement, not the close button', () => {
|
|
247
|
+
renderSnackbar();
|
|
248
|
+
enableLiveRegion();
|
|
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(
|
|
94
|
-
expect(screen.queryByText('First message')).not.toBeInTheDocument();
|
|
273
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
95
274
|
});
|
|
96
275
|
|
|
97
|
-
it('
|
|
98
|
-
const { rerender } =
|
|
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
|
|
101
|
-
advanceTimers(
|
|
280
|
+
rerender(<Snackbar {...snackbarProps} duration="indefinite" />);
|
|
281
|
+
advanceTimers(60_000);
|
|
102
282
|
|
|
103
|
-
|
|
104
|
-
expect(
|
|
283
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
284
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
105
285
|
});
|
|
106
286
|
|
|
107
|
-
it('
|
|
108
|
-
const { rerender } =
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
114
|
-
advanceTimers(
|
|
115
|
-
expect(
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
expect(screen.queryByText('Second message')).not.toBeInTheDocument();
|
|
325
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
123
326
|
});
|
|
124
327
|
|
|
125
|
-
it('
|
|
126
|
-
const {
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
134
|
-
|
|
346
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
347
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION * 2);
|
|
348
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
135
349
|
|
|
136
|
-
|
|
137
|
-
advanceTimers(
|
|
138
|
-
expect(
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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(
|
|
154
|
-
|
|
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 });
|
|
384
|
+
|
|
385
|
+
advanceTimers(milliseconds - 1);
|
|
386
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
387
|
+
advanceTimers(1);
|
|
388
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
389
|
+
});
|
|
155
390
|
|
|
156
|
-
|
|
157
|
-
|
|
391
|
+
it.each([1000, 4500, 5000, 5001, 9000])(
|
|
392
|
+
'preserves an explicit legacy timeout of %sms',
|
|
393
|
+
(timeout) => {
|
|
394
|
+
const { snackbarProps } = renderEnteredSnackbar({ timeout });
|
|
158
395
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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);
|
|
162
418
|
});
|
|
163
419
|
|
|
164
|
-
it('
|
|
165
|
-
const {
|
|
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);
|
|
520
|
+
});
|
|
166
521
|
|
|
167
|
-
|
|
168
|
-
const
|
|
522
|
+
it('cleans up a resumed timer when unmounted', async () => {
|
|
523
|
+
const { unmount, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
524
|
+
const message = screen.getByText('Changes saved');
|
|
525
|
+
|
|
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
|
+
});
|
|
170
535
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|
|
546
|
+
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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',
|
|
677
|
+
);
|
|
678
|
+
expect(screen.getByRole('button', { name: 'Close' })).toHaveClass(
|
|
679
|
+
'wds-prompt__dismiss',
|
|
680
|
+
'np-icon-button-minimal-default',
|
|
186
681
|
);
|
|
682
|
+
});
|
|
187
683
|
|
|
188
|
-
it('
|
|
189
|
-
|
|
684
|
+
it('renders custom media in the fixed-size media wrapper', () => {
|
|
685
|
+
renderSnackbar({ media: <Card title="Card" /> });
|
|
190
686
|
|
|
191
|
-
|
|
687
|
+
expect(screen.getByTestId('card-icon').parentElement).toHaveClass('wds-Snackbar__media-asset');
|
|
688
|
+
});
|
|
192
689
|
|
|
193
|
-
|
|
690
|
+
it('renders proposition media in the fixed-size media wrapper', () => {
|
|
691
|
+
renderSnackbar({ sentiment: 'proposition' });
|
|
692
|
+
const icon = screen
|
|
693
|
+
.getByText('Changes saved')
|
|
694
|
+
.closest('.wds-prompt')
|
|
695
|
+
?.querySelector('.tw-icon-tags');
|
|
696
|
+
|
|
697
|
+
expect(icon?.parentElement).toHaveClass('wds-Snackbar__media-asset');
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('requests dismissal through the close button', async () => {
|
|
701
|
+
const onDismissRequest = jest.fn();
|
|
702
|
+
renderSnackbar({ onDismissRequest });
|
|
703
|
+
|
|
704
|
+
await user.click(screen.getByRole('button', { name: 'Close' }));
|
|
705
|
+
|
|
706
|
+
expect(onDismissRequest).toHaveBeenCalledTimes(1);
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe('Snackbar swipe dismissal', () => {
|
|
711
|
+
const setupSwipe = (props: Partial<SnackbarProps> = {}) => {
|
|
712
|
+
const view = renderEnteredSnackbar({ duration: 'indefinite', ...props });
|
|
713
|
+
const surface = screen.getByText('Changes saved').closest('.wds-Snackbar-container');
|
|
714
|
+
const container = surface?.closest('.wds-Snackbar');
|
|
715
|
+
if (!surface || !container) {
|
|
716
|
+
throw new Error('Expected the Snackbar surface and wrapper');
|
|
717
|
+
}
|
|
718
|
+
Object.defineProperty(surface, 'offsetHeight', { configurable: true, value: 100 });
|
|
719
|
+
Object.defineProperty(container, 'offsetHeight', { configurable: true, value: 140 });
|
|
720
|
+
return { ...view, surface, container };
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const mockPointerCapture = (element: Element) => {
|
|
724
|
+
const captured = new Set<number>();
|
|
725
|
+
const setPointerCapture = jest.fn((id: number) => captured.add(id));
|
|
726
|
+
const releasePointerCapture = jest.fn((id: number) => captured.delete(id));
|
|
727
|
+
Object.assign(element, {
|
|
728
|
+
setPointerCapture,
|
|
729
|
+
releasePointerCapture,
|
|
730
|
+
hasPointerCapture: (id: number) => captured.has(id),
|
|
731
|
+
});
|
|
732
|
+
return { setPointerCapture, releasePointerCapture };
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
it.each([-1, 0, 1])('uses the half-height threshold with an offset of %spx', async (offset) => {
|
|
736
|
+
const { surface, container, snackbarProps } = setupSwipe();
|
|
737
|
+
const distance = 100 * SNACKBAR_SWIPE_DISMISS_THRESHOLD + offset;
|
|
738
|
+
await user.pointer([
|
|
739
|
+
{ keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
740
|
+
{ coords: { clientX: 0, clientY: distance } },
|
|
741
|
+
'[/MouseLeft]',
|
|
742
|
+
]);
|
|
743
|
+
|
|
744
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(offset < 0 ? 0 : 1);
|
|
745
|
+
expect(container).toHaveClass(
|
|
746
|
+
offset < 0 ? 'wds-Snackbar--swipe-settle' : 'wds-Snackbar--swipe-dismiss',
|
|
747
|
+
);
|
|
748
|
+
expect(container).toHaveStyle({ transform: offset < 0 ? '' : 'translateY(140px)' });
|
|
194
749
|
});
|
|
195
750
|
|
|
196
|
-
it('
|
|
197
|
-
|
|
751
|
+
it('dismisses at the threshold with a touch pointer', async () => {
|
|
752
|
+
const { surface, snackbarProps } = setupSwipe();
|
|
753
|
+
await user.pointer([
|
|
754
|
+
{ keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
755
|
+
{ pointerName: 'TouchA', coords: { clientX: 0, clientY: 50 } },
|
|
756
|
+
'[/TouchA]',
|
|
757
|
+
]);
|
|
758
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('captures and releases a pointer whose release coordinates are outside the surface', async () => {
|
|
762
|
+
const { surface, container, snackbarProps } = setupSwipe();
|
|
763
|
+
const capture = mockPointerCapture(surface);
|
|
764
|
+
await user.pointer([
|
|
765
|
+
{ keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
766
|
+
{ coords: { clientX: 0, clientY: 30 } },
|
|
767
|
+
]);
|
|
768
|
+
expect(capture.setPointerCapture).toHaveBeenCalledWith(1);
|
|
769
|
+
expect(container).toHaveStyle({ transform: 'translateY(30px)' });
|
|
770
|
+
|
|
771
|
+
await user.pointer({ keys: '[/MouseLeft]', coords: { clientX: 0, clientY: 200 } });
|
|
772
|
+
|
|
773
|
+
expect(capture.releasePointerCapture).toHaveBeenCalledWith(1);
|
|
774
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
775
|
+
expect(container).toHaveStyle({ transform: 'translateY(200px)' });
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('does not trigger an action when a committed swipe starts on it', async () => {
|
|
779
|
+
const onClick = jest.fn();
|
|
780
|
+
const { snackbarProps } = setupSwipe({ action: { label: 'Undo', onClick } });
|
|
781
|
+
const action = screen.getByRole('button', { name: 'Undo' });
|
|
782
|
+
const capture = mockPointerCapture(action);
|
|
783
|
+
await user.pointer([
|
|
784
|
+
{ keys: '[MouseLeft>]', target: action, coords: { clientX: 0, clientY: 0 } },
|
|
785
|
+
{ coords: { clientX: 0, clientY: 50 } },
|
|
786
|
+
'[/MouseLeft]',
|
|
787
|
+
]);
|
|
788
|
+
|
|
789
|
+
expect(capture.setPointerCapture).toHaveBeenCalledWith(1);
|
|
790
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
791
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('keeps normal touch activation of the action working', async () => {
|
|
795
|
+
const onClick = jest.fn();
|
|
796
|
+
const { snackbarProps } = setupSwipe({ action: { label: 'Undo', onClick } });
|
|
797
|
+
await user.pointer([
|
|
798
|
+
{ keys: '[TouchA>]', target: screen.getByRole('button', { name: 'Undo' }) },
|
|
799
|
+
'[/TouchA]',
|
|
800
|
+
]);
|
|
801
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
802
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('prevents native link dragging without blocking a navigation action swipe', async () => {
|
|
806
|
+
const onClick = jest.fn();
|
|
807
|
+
const { snackbarProps } = setupSwipe({
|
|
808
|
+
action: { label: 'View', href: '#transfer', onClick },
|
|
809
|
+
});
|
|
810
|
+
const action = screen.getByRole('link', { name: 'View' });
|
|
811
|
+
mockPointerCapture(action);
|
|
812
|
+
await user.pointer({
|
|
813
|
+
keys: '[MouseLeft>]',
|
|
814
|
+
target: action,
|
|
815
|
+
coords: { clientX: 0, clientY: 0 },
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
expect(fireEvent.dragStart(action)).toBe(false);
|
|
819
|
+
|
|
820
|
+
await user.pointer([{ coords: { clientX: 0, clientY: 50 } }, '[/MouseLeft]']);
|
|
821
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
822
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it.each([
|
|
826
|
+
['button', undefined],
|
|
827
|
+
['link', '#transfer'],
|
|
828
|
+
] as const)('cancels off-target drag clicks on an action %s', async (role, href) => {
|
|
829
|
+
const onClick = jest.fn();
|
|
830
|
+
const { snackbarProps } = setupSwipe({ action: { label: 'Undo', href, onClick } });
|
|
831
|
+
const action = screen.getByRole(role, { name: 'Undo' });
|
|
832
|
+
mockPointerCapture(action);
|
|
833
|
+
jest.spyOn(action, 'getBoundingClientRect').mockReturnValue({
|
|
834
|
+
left: 0,
|
|
835
|
+
right: 100,
|
|
836
|
+
top: 0,
|
|
837
|
+
bottom: 40,
|
|
838
|
+
} as DOMRect);
|
|
839
|
+
|
|
840
|
+
for (const coords of [
|
|
841
|
+
{ clientX: 20, clientY: -60 },
|
|
842
|
+
{ clientX: 150, clientY: 20 },
|
|
843
|
+
]) {
|
|
844
|
+
await user.pointer([
|
|
845
|
+
{ keys: '[MouseLeft>]', target: action, coords: { clientX: 20, clientY: 20 } },
|
|
846
|
+
{ coords },
|
|
847
|
+
'[/MouseLeft]',
|
|
848
|
+
]);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
852
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
853
|
+
|
|
854
|
+
await user.pointer([
|
|
855
|
+
{ keys: '[MouseLeft>]', target: action, coords: { clientX: 20, clientY: 20 } },
|
|
856
|
+
'[/MouseLeft]',
|
|
857
|
+
]);
|
|
858
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
859
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it.each(['{Enter}', ' '])(
|
|
863
|
+
'keeps keyboard activation with %s after an off-target release',
|
|
864
|
+
async (key) => {
|
|
865
|
+
const onClick = jest.fn();
|
|
866
|
+
setupSwipe({ action: { label: 'Undo', onClick } });
|
|
867
|
+
const action = screen.getByRole('button', { name: 'Undo' });
|
|
868
|
+
await user.pointer({ keys: '[MouseLeft>]', target: action });
|
|
869
|
+
// A cancelled press need not produce a click before the next keyboard activation.
|
|
870
|
+
const release = new MouseEvent('pointerup', { bubbles: true, clientX: 0, clientY: -60 });
|
|
871
|
+
Object.defineProperty(release, 'pointerId', { value: 1 });
|
|
872
|
+
fireEvent(action, release);
|
|
873
|
+
await user.keyboard(key);
|
|
874
|
+
|
|
875
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
876
|
+
await user.pointer('[/MouseLeft]');
|
|
877
|
+
},
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
it.each([
|
|
881
|
+
[0, -60],
|
|
882
|
+
[80, 60],
|
|
883
|
+
[60, 60],
|
|
884
|
+
])('ignores non-downward/mostly vertical movement (%s, %s)', async (clientX, clientY) => {
|
|
885
|
+
const { surface, container, snackbarProps } = setupSwipe();
|
|
886
|
+
await user.pointer([
|
|
887
|
+
{ keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
888
|
+
{ coords: { clientX, clientY } },
|
|
889
|
+
'[/MouseLeft]',
|
|
890
|
+
]);
|
|
891
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
892
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it.each(['pointercancel', 'lostpointercapture'])('restores the position on %s', async (type) => {
|
|
896
|
+
const { surface, container, snackbarProps } = setupSwipe();
|
|
897
|
+
mockPointerCapture(surface);
|
|
898
|
+
await user.pointer([
|
|
899
|
+
{ keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
900
|
+
{ coords: { clientX: 0, clientY: 80 } },
|
|
901
|
+
]);
|
|
902
|
+
expect(container).toHaveStyle({ transform: 'translateY(80px)' });
|
|
903
|
+
const event = new Event(type, { bubbles: true });
|
|
904
|
+
Object.defineProperty(event, 'pointerId', { value: 1 });
|
|
905
|
+
fireEvent(surface, event);
|
|
198
906
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
await user.
|
|
907
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
908
|
+
expect(container).toHaveClass('wds-Snackbar--swipe-settle');
|
|
909
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
910
|
+
await user.pointer('[/MouseLeft]');
|
|
911
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('recovers when a captured child is removed and capture loss fires on its document', async () => {
|
|
915
|
+
const { surface, container, rerender, snackbarProps } = setupSwipe({
|
|
916
|
+
media: <span>Drag me</span>,
|
|
917
|
+
});
|
|
918
|
+
const child = screen.getByText('Drag me');
|
|
919
|
+
mockPointerCapture(child);
|
|
920
|
+
await user.pointer([
|
|
921
|
+
{ keys: '[MouseLeft>]', target: child, coords: { clientX: 0, clientY: 0 } },
|
|
922
|
+
{ coords: { clientX: 0, clientY: 30 } },
|
|
923
|
+
]);
|
|
924
|
+
expect(container).toHaveStyle({ transform: 'translateY(30px)' });
|
|
925
|
+
|
|
926
|
+
// An unrelated pointer losing capture must not cancel the active gesture.
|
|
927
|
+
const unrelatedLoss = new Event('lostpointercapture', { bubbles: true });
|
|
928
|
+
Object.defineProperty(unrelatedLoss, 'pointerId', { value: 2 });
|
|
929
|
+
fireEvent(surface.ownerDocument, unrelatedLoss);
|
|
930
|
+
expect(container).toHaveStyle({ transform: 'translateY(30px)' });
|
|
931
|
+
|
|
932
|
+
rerender(<Snackbar {...snackbarProps} media={undefined} />);
|
|
933
|
+
expect(child).not.toBeInTheDocument();
|
|
934
|
+
const captureLoss = new Event('lostpointercapture', { bubbles: true });
|
|
935
|
+
Object.defineProperty(captureLoss, 'pointerId', { value: 1 });
|
|
936
|
+
fireEvent(surface.ownerDocument, captureLoss);
|
|
937
|
+
|
|
938
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
939
|
+
expect(container).toHaveClass('wds-Snackbar--swipe-settle');
|
|
940
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
941
|
+
await user.pointer({ keys: '[/MouseLeft]', target: document.body });
|
|
942
|
+
await user.pointer([
|
|
943
|
+
{ keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
944
|
+
{ coords: { clientX: 0, clientY: 50 } },
|
|
945
|
+
'[/MouseLeft]',
|
|
946
|
+
]);
|
|
947
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('removes its document capture-loss listener on unmount', () => {
|
|
951
|
+
const addListener = jest.spyOn(document, 'addEventListener');
|
|
952
|
+
const removeListener = jest.spyOn(document, 'removeEventListener');
|
|
953
|
+
try {
|
|
954
|
+
const { unmount } = setupSwipe();
|
|
955
|
+
const listener = addListener.mock.calls.find(([type]) => type === 'lostpointercapture')?.[1];
|
|
956
|
+
expect(listener).toEqual(expect.any(Function));
|
|
957
|
+
|
|
958
|
+
unmount();
|
|
959
|
+
|
|
960
|
+
expect(removeListener).toHaveBeenCalledWith('lostpointercapture', listener);
|
|
961
|
+
} finally {
|
|
962
|
+
addListener.mockRestore();
|
|
963
|
+
removeListener.mockRestore();
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it('ignores secondary pointers while retaining the primary gesture', async () => {
|
|
968
|
+
const { surface, container, snackbarProps } = setupSwipe();
|
|
969
|
+
const capture = mockPointerCapture(surface);
|
|
970
|
+
await user.pointer([
|
|
971
|
+
{ keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
972
|
+
{ keys: '[TouchB>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
973
|
+
{ pointerName: 'TouchB', coords: { clientX: 0, clientY: 100 } },
|
|
974
|
+
'[/TouchB]',
|
|
975
|
+
]);
|
|
976
|
+
expect(capture.setPointerCapture).toHaveBeenCalledTimes(1);
|
|
977
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
978
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
979
|
+
|
|
980
|
+
await user.pointer([
|
|
981
|
+
{ pointerName: 'TouchA', coords: { clientX: 0, clientY: 50 } },
|
|
982
|
+
'[/TouchA]',
|
|
983
|
+
]);
|
|
984
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('ignores non-primary mouse buttons', async () => {
|
|
988
|
+
const { surface, container, snackbarProps } = setupSwipe();
|
|
989
|
+
await user.pointer([
|
|
990
|
+
{ keys: '[MouseRight>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
991
|
+
{ coords: { clientX: 0, clientY: 100 } },
|
|
992
|
+
'[/MouseRight]',
|
|
993
|
+
]);
|
|
994
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
995
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('clears the drag state after the exit finishes', async () => {
|
|
999
|
+
const { surface, container, rerender, snackbarProps } = setupSwipe();
|
|
1000
|
+
await user.pointer([
|
|
1001
|
+
{ keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
1002
|
+
{ coords: { clientX: 0, clientY: 50 } },
|
|
1003
|
+
'[/MouseLeft]',
|
|
1004
|
+
]);
|
|
1005
|
+
rerender(<Snackbar {...snackbarProps} visible={false} />);
|
|
1006
|
+
advanceTimers(SNACKBAR_EXIT_DURATION);
|
|
1007
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
1008
|
+
expect(container).not.toHaveClass('wds-Snackbar--swipe-dismiss');
|
|
1009
|
+
expect(snackbarProps.onExited).toHaveBeenCalledTimes(1);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('keeps dragging across a render and uses the latest dismissal callback', async () => {
|
|
1013
|
+
const { surface, container, rerender, snackbarProps } = setupSwipe();
|
|
1014
|
+
await user.pointer([
|
|
1015
|
+
{ keys: '[MouseLeft>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
1016
|
+
{ coords: { clientX: 0, clientY: 30 } },
|
|
1017
|
+
]);
|
|
1018
|
+
const onDismissRequest = jest.fn();
|
|
1019
|
+
rerender(<Snackbar {...snackbarProps} onDismissRequest={onDismissRequest} />);
|
|
1020
|
+
expect(container).toHaveStyle({ transform: 'translateY(30px)' });
|
|
1021
|
+
|
|
1022
|
+
await user.pointer([{ coords: { clientX: 0, clientY: 50 } }, '[/MouseLeft]']);
|
|
1023
|
+
expect(onDismissRequest).toHaveBeenCalledTimes(1);
|
|
1024
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it('ends an active gesture without dismissing again when an exit starts mid-drag', async () => {
|
|
1028
|
+
const { surface, container, rerender, snackbarProps } = setupSwipe();
|
|
1029
|
+
const capture = mockPointerCapture(surface);
|
|
1030
|
+
await user.pointer([
|
|
1031
|
+
{ keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
1032
|
+
{ pointerName: 'TouchA', coords: { clientX: 0, clientY: 30 } },
|
|
1033
|
+
]);
|
|
1034
|
+
rerender(<Snackbar {...snackbarProps} visible={false} />);
|
|
1035
|
+
|
|
1036
|
+
expect(capture.releasePointerCapture).toHaveBeenCalledTimes(1);
|
|
1037
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
1038
|
+
|
|
1039
|
+
await user.pointer([
|
|
1040
|
+
{ pointerName: 'TouchA', coords: { clientX: 0, clientY: 80 } },
|
|
1041
|
+
'[/TouchA]',
|
|
1042
|
+
]);
|
|
1043
|
+
expect(container).toHaveStyle({ transform: '' });
|
|
1044
|
+
expect(container).not.toHaveClass('wds-Snackbar--swipe-dismiss');
|
|
1045
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('pauses auto-dismiss during a touch gesture and resumes with the remaining time', async () => {
|
|
1049
|
+
const { surface, snackbarProps } = setupSwipe({ duration: 'default' });
|
|
1050
|
+
advanceTimers(1000);
|
|
1051
|
+
await user.pointer([
|
|
1052
|
+
{ keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
1053
|
+
{ pointerName: 'TouchA', coords: { clientX: 0, clientY: 20 } },
|
|
1054
|
+
]);
|
|
1055
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION);
|
|
1056
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
1057
|
+
|
|
1058
|
+
await user.pointer('[/TouchA]');
|
|
1059
|
+
// Touch emulates mouse hover; leave so only the swipe pause is under test.
|
|
1060
|
+
await user.unhover(surface);
|
|
1061
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1000 - 1);
|
|
1062
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
1063
|
+
advanceTimers(1);
|
|
1064
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
1065
|
+
});
|
|
203
1066
|
|
|
204
|
-
|
|
1067
|
+
it('stays off-screen when the parent honours the swipe dismissal', async () => {
|
|
1068
|
+
const { surface, container, rerender, snackbarProps } = setupSwipe();
|
|
1069
|
+
await user.pointer([
|
|
1070
|
+
{ keys: '[TouchA>]', target: surface, coords: { clientX: 0, clientY: 0 } },
|
|
1071
|
+
{ pointerName: 'TouchA', coords: { clientX: 0, clientY: 50 } },
|
|
1072
|
+
'[/TouchA]',
|
|
1073
|
+
]);
|
|
1074
|
+
rerender(<Snackbar {...snackbarProps} visible={false} />);
|
|
1075
|
+
advanceTimers(SNACKBAR_EXIT_DURATION - 1);
|
|
1076
|
+
expect(container).toHaveStyle({ transform: 'translateY(140px)' });
|
|
1077
|
+
expect(container).toHaveClass('wds-Snackbar--swipe-dismiss');
|
|
205
1078
|
});
|
|
206
1079
|
});
|