@transferwise/components 46.166.0 → 46.168.0
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/README.md +1 -1
- package/build/CardContainer/CardContainer.js +34 -0
- package/build/CardContainer/CardContainer.js.map +1 -0
- package/build/CardContainer/CardContainer.mjs +30 -0
- package/build/CardContainer/CardContainer.mjs.map +1 -0
- package/build/CardWidget/CardWidget.js +98 -0
- package/build/CardWidget/CardWidget.js.map +1 -0
- package/build/CardWidget/CardWidget.mjs +94 -0
- package/build/CardWidget/CardWidget.mjs.map +1 -0
- package/build/CardWidget/CardWidgetAction.js +73 -0
- package/build/CardWidget/CardWidgetAction.js.map +1 -0
- package/build/CardWidget/CardWidgetAction.mjs +68 -0
- package/build/CardWidget/CardWidgetAction.mjs.map +1 -0
- package/build/Link/Link.js.map +1 -1
- package/build/Link/Link.mjs.map +1 -1
- package/build/ListItem/ListItem.js +2 -0
- package/build/ListItem/ListItem.js.map +1 -1
- package/build/ListItem/ListItem.mjs +2 -0
- package/build/ListItem/ListItem.mjs.map +1 -1
- package/build/Loader/Loader.js.map +1 -1
- package/build/Loader/Loader.mjs.map +1 -1
- 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/Prompt/Snackbar/Snackbar.js +205 -0
- package/build/Prompt/Snackbar/Snackbar.js.map +1 -0
- package/build/Prompt/Snackbar/Snackbar.messages.js +14 -0
- package/build/Prompt/Snackbar/Snackbar.messages.js.map +1 -0
- package/build/Prompt/Snackbar/Snackbar.messages.mjs +10 -0
- package/build/Prompt/Snackbar/Snackbar.messages.mjs.map +1 -0
- package/build/Prompt/Snackbar/Snackbar.mjs +203 -0
- package/build/Prompt/Snackbar/Snackbar.mjs.map +1 -0
- package/build/Prompt/Snackbar/SnackbarContext.js.map +1 -0
- package/build/Prompt/Snackbar/SnackbarContext.mjs.map +1 -0
- package/build/Prompt/Snackbar/SnackbarPortal.js +12 -0
- package/build/Prompt/Snackbar/SnackbarPortal.js.map +1 -0
- package/build/Prompt/Snackbar/SnackbarPortal.mjs +8 -0
- package/build/Prompt/Snackbar/SnackbarPortal.mjs.map +1 -0
- package/build/Prompt/Snackbar/SnackbarProvider.js +42 -0
- package/build/Prompt/Snackbar/SnackbarProvider.js.map +1 -0
- package/build/Prompt/Snackbar/SnackbarProvider.mjs +38 -0
- package/build/Prompt/Snackbar/SnackbarProvider.mjs.map +1 -0
- package/build/Prompt/Snackbar/constants.js +17 -0
- package/build/Prompt/Snackbar/constants.js.map +1 -0
- package/build/Prompt/Snackbar/constants.mjs +11 -0
- package/build/Prompt/Snackbar/constants.mjs.map +1 -0
- package/build/Prompt/Snackbar/useSnackbar.js.map +1 -0
- package/build/Prompt/Snackbar/useSnackbar.mjs.map +1 -0
- package/build/Prompt/Snackbar/useSnackbarAutoDismiss.js +66 -0
- package/build/Prompt/Snackbar/useSnackbarAutoDismiss.js.map +1 -0
- package/build/Prompt/Snackbar/useSnackbarAutoDismiss.mjs +62 -0
- package/build/Prompt/Snackbar/useSnackbarAutoDismiss.mjs.map +1 -0
- package/build/Prompt/Snackbar/useSnackbarManager.js +153 -0
- package/build/Prompt/Snackbar/useSnackbarManager.js.map +1 -0
- package/build/Prompt/Snackbar/useSnackbarManager.mjs +149 -0
- package/build/Prompt/Snackbar/useSnackbarManager.mjs.map +1 -0
- package/build/Prompt/Snackbar/useSnackbarSwipeDismiss.js +135 -0
- package/build/Prompt/Snackbar/useSnackbarSwipeDismiss.js.map +1 -0
- package/build/Prompt/Snackbar/useSnackbarSwipeDismiss.mjs +131 -0
- package/build/Prompt/Snackbar/useSnackbarSwipeDismiss.mjs.map +1 -0
- package/build/Section/Section.js.map +1 -1
- package/build/Section/Section.mjs.map +1 -1
- package/build/_deprecated/Select/Select.js +1 -1
- package/build/_deprecated/Select/Select.js.map +1 -1
- package/build/_deprecated/Select/Select.mjs +1 -1
- package/build/_deprecated/Select/Select.mjs.map +1 -1
- package/build/common/BottomSheet/BottomSheet.js +1 -3
- package/build/common/BottomSheet/BottomSheet.js.map +1 -1
- package/build/common/BottomSheet/BottomSheet.mjs +1 -3
- package/build/common/BottomSheet/BottomSheet.mjs.map +1 -1
- 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/index.js +7 -3
- package/build/index.js.map +1 -1
- package/build/index.mjs +5 -3
- package/build/index.mjs.map +1 -1
- package/build/main.css +331 -75
- package/build/styles/CardContainer/CardContainer.css +108 -0
- package/build/styles/CardWidget/CardWidget.css +78 -0
- package/build/styles/IconButton/IconButton.css +3 -4
- package/build/styles/ListItem/ListItem.css +8 -0
- package/build/styles/Prompt/Snackbar/Snackbar.css +134 -0
- package/build/styles/main.css +331 -75
- package/build/types/Body/Body.d.ts +2 -2
- package/build/types/CardContainer/CardContainer.d.ts +8 -0
- package/build/types/CardContainer/CardContainer.d.ts.map +1 -0
- package/build/types/CardContainer/CardContainer.types.d.ts +55 -0
- package/build/types/CardContainer/CardContainer.types.d.ts.map +1 -0
- package/build/types/CardContainer/_stories/helpers.d.ts +3 -0
- package/build/types/CardContainer/_stories/helpers.d.ts.map +1 -0
- package/build/types/CardContainer/index.d.ts +3 -0
- package/build/types/CardContainer/index.d.ts.map +1 -0
- package/build/types/CardWidget/CardWidget.d.ts +10 -0
- package/build/types/CardWidget/CardWidget.d.ts.map +1 -0
- package/build/types/CardWidget/CardWidget.types.d.ts +55 -0
- package/build/types/CardWidget/CardWidget.types.d.ts.map +1 -0
- package/build/types/CardWidget/CardWidgetAction.d.ts +54 -0
- package/build/types/CardWidget/CardWidgetAction.d.ts.map +1 -0
- package/build/types/CardWidget/index.d.ts +4 -0
- package/build/types/CardWidget/index.d.ts.map +1 -0
- package/build/types/IconButton/IconButton.d.ts +1 -1
- package/build/types/Link/Link.d.ts +1 -1
- package/build/types/ListItem/ListItem.d.ts +1 -1
- package/build/types/ListItem/ListItem.d.ts.map +1 -1
- package/build/types/Loader/Loader.d.ts +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
- package/build/types/Prompt/PrimitivePrompt/PrimitivePrompt.d.ts +11 -0
- package/build/types/Prompt/PrimitivePrompt/PrimitivePrompt.d.ts.map +1 -1
- package/build/types/Prompt/Snackbar/Snackbar.d.ts +62 -0
- package/build/types/Prompt/Snackbar/Snackbar.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/Snackbar.messages.d.ts +9 -0
- package/build/types/Prompt/Snackbar/Snackbar.messages.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/Snackbar.types.d.ts +12 -0
- package/build/types/Prompt/Snackbar/Snackbar.types.d.ts.map +1 -0
- package/build/types/{Snackbar → Prompt/Snackbar}/SnackbarContext.d.ts +2 -2
- package/build/types/Prompt/Snackbar/SnackbarContext.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/SnackbarPortal.d.ts +4 -0
- package/build/types/Prompt/Snackbar/SnackbarPortal.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/SnackbarProvider.d.ts +13 -0
- package/build/types/Prompt/Snackbar/SnackbarProvider.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/constants.d.ts +9 -0
- package/build/types/Prompt/Snackbar/constants.d.ts.map +1 -0
- package/build/types/{Snackbar → Prompt/Snackbar}/index.d.ts +1 -1
- package/build/types/Prompt/Snackbar/index.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/useSnackbar.d.ts +3 -0
- package/build/types/Prompt/Snackbar/useSnackbar.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/useSnackbarAutoDismiss.d.ts +13 -0
- package/build/types/Prompt/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/useSnackbarManager.d.ts +20 -0
- package/build/types/Prompt/Snackbar/useSnackbarManager.d.ts.map +1 -0
- package/build/types/Prompt/Snackbar/useSnackbarSwipeDismiss.d.ts +22 -0
- package/build/types/Prompt/Snackbar/useSnackbarSwipeDismiss.d.ts.map +1 -0
- package/build/types/Section/Section.d.ts +1 -1
- package/build/types/Title/Title.d.ts +2 -2
- package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
- package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
- package/build/types/_deprecated/Select/Select.d.ts +1 -1
- package/build/types/common/BottomSheet/BottomSheet.d.ts +1 -3
- package/build/types/common/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/build/types/index.d.ts +9 -5
- 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 +4 -4
- package/src/CardContainer/CardContainer.css +108 -0
- package/src/CardContainer/CardContainer.less +171 -0
- package/src/CardContainer/CardContainer.test.tsx +150 -0
- package/src/CardContainer/CardContainer.tsx +51 -0
- package/src/CardContainer/CardContainer.types.ts +62 -0
- package/src/CardContainer/_stories/CardContainer.story.tsx +167 -0
- package/src/CardContainer/_stories/CardContainer.test.story.tsx +103 -0
- package/src/CardContainer/_stories/helpers.tsx +50 -0
- package/src/CardContainer/index.ts +6 -0
- package/src/CardWidget/CardWidget.css +78 -0
- package/src/CardWidget/CardWidget.less +107 -0
- package/src/CardWidget/CardWidget.test.tsx +221 -0
- package/src/CardWidget/CardWidget.tsx +78 -0
- package/src/CardWidget/CardWidget.types.ts +63 -0
- package/src/CardWidget/CardWidgetAction.tsx +128 -0
- package/src/CardWidget/_stories/CardWidget.story.tsx +238 -0
- package/src/CardWidget/_stories/CardWidget.test.story.tsx +350 -0
- package/src/CardWidget/index.ts +3 -0
- package/src/IconButton/IconButton.css +3 -4
- package/src/IconButton/IconButton.less +14 -1
- package/src/Link/Link.tsx +1 -1
- package/src/ListItem/AdditionalInfo/_stories/ListItemAdditionalInfo.story.tsx +1 -1
- package/src/ListItem/AvatarView/_stories/ListItemAvatarView.story.tsx +3 -3
- package/src/ListItem/ListItem.css +8 -0
- package/src/ListItem/ListItem.less +8 -0
- package/src/ListItem/ListItem.test.tsx +20 -0
- package/src/ListItem/ListItem.tsx +3 -1
- package/src/ListItem/_stories/ListItem.disabled.story.tsx +1 -1
- package/src/ListItem/_stories/ListItem.layout.test.story.tsx +1 -1
- package/src/ListItem/_stories/ListItem.scenarios.story.tsx +2 -2
- package/src/ListItem/_stories/ListItem.story.tsx +1 -1
- package/src/Loader/Loader.tsx +1 -1
- package/src/Prompt/CriticalBanner/_stories/CriticalBanner.docs.mdx +122 -0
- package/src/Prompt/CriticalBanner/_stories/CriticalBanner.story.tsx +0 -2
- package/src/Prompt/PrimitivePrompt/PrimitivePrompt.test.tsx +24 -1
- package/src/Prompt/PrimitivePrompt/PrimitivePrompt.tsx +8 -2
- package/src/Prompt/Snackbar/Snackbar.css +134 -0
- package/src/Prompt/Snackbar/Snackbar.less +159 -0
- package/src/Prompt/Snackbar/Snackbar.messages.ts +9 -0
- package/src/Prompt/Snackbar/Snackbar.test.tsx +1079 -0
- package/src/Prompt/Snackbar/Snackbar.tsx +245 -0
- package/src/Prompt/Snackbar/Snackbar.types.ts +17 -0
- package/src/{Snackbar → Prompt/Snackbar}/SnackbarContext.ts +2 -2
- package/src/Prompt/Snackbar/SnackbarPortal.tsx +8 -0
- package/src/Prompt/Snackbar/SnackbarProvider.test.tsx +783 -0
- package/src/Prompt/Snackbar/SnackbarProvider.tsx +41 -0
- package/src/Prompt/Snackbar/_stories/Snackbar.accessibility.docs.mdx +58 -0
- package/src/{Snackbar → Prompt/Snackbar}/_stories/Snackbar.rtl.test.story.tsx +17 -9
- package/src/Prompt/Snackbar/_stories/Snackbar.story.tsx +416 -0
- package/src/Prompt/Snackbar/_stories/Snackbar.test.story.tsx +263 -0
- package/src/Prompt/Snackbar/constants.ts +10 -0
- package/src/{Snackbar → Prompt/Snackbar}/index.ts +1 -1
- package/src/Prompt/Snackbar/useSnackbarAutoDismiss.ts +74 -0
- package/src/Prompt/Snackbar/useSnackbarManager.ts +169 -0
- package/src/Prompt/Snackbar/useSnackbarSwipeDismiss.ts +169 -0
- package/src/Section/Section.tsx +1 -1
- package/src/_deprecated/Select/Select.tsx +1 -1
- package/src/_deprecated/Select/_stories/Select.story.tsx +1 -1
- package/src/common/BottomSheet/BottomSheet.tsx +1 -3
- package/src/i18n/en.json +1 -0
- package/src/index.ts +18 -5
- package/src/main.css +331 -75
- package/src/main.less +3 -1
- package/build/Snackbar/Snackbar.js +0 -128
- package/build/Snackbar/Snackbar.js.map +0 -1
- package/build/Snackbar/Snackbar.mjs +0 -123
- package/build/Snackbar/Snackbar.mjs.map +0 -1
- package/build/Snackbar/SnackbarContext.js.map +0 -1
- package/build/Snackbar/SnackbarContext.mjs.map +0 -1
- package/build/Snackbar/SnackbarProvider.js +0 -46
- package/build/Snackbar/SnackbarProvider.js.map +0 -1
- package/build/Snackbar/SnackbarProvider.mjs +0 -42
- package/build/Snackbar/SnackbarProvider.mjs.map +0 -1
- package/build/Snackbar/useSnackbar.js.map +0 -1
- package/build/Snackbar/useSnackbar.mjs.map +0 -1
- package/build/styles/Snackbar/Snackbar.css +0 -71
- package/build/types/Snackbar/Snackbar.d.ts +0 -16
- package/build/types/Snackbar/Snackbar.d.ts.map +0 -1
- package/build/types/Snackbar/SnackbarContext.d.ts.map +0 -1
- package/build/types/Snackbar/SnackbarProvider.d.ts +0 -6
- package/build/types/Snackbar/SnackbarProvider.d.ts.map +0 -1
- package/build/types/Snackbar/index.d.ts.map +0 -1
- package/build/types/Snackbar/useSnackbar.d.ts +0 -3
- package/build/types/Snackbar/useSnackbar.d.ts.map +0 -1
- package/src/FlowNavigation/_stories/FlowNavigation.CriticalBanner.docs.mdx +0 -66
- package/src/Snackbar/Snackbar.css +0 -71
- package/src/Snackbar/Snackbar.less +0 -80
- package/src/Snackbar/Snackbar.test.tsx +0 -206
- package/src/Snackbar/Snackbar.tsx +0 -129
- package/src/Snackbar/SnackbarProvider.tsx +0 -33
- package/src/Snackbar/_stories/Snackbar.story.tsx +0 -89
- package/src/Snackbar/_stories/Snackbar.test.story.tsx +0 -139
- /package/build/{Snackbar → Prompt/Snackbar}/SnackbarContext.js +0 -0
- /package/build/{Snackbar → Prompt/Snackbar}/SnackbarContext.mjs +0 -0
- /package/build/{Snackbar → Prompt/Snackbar}/useSnackbar.js +0 -0
- /package/build/{Snackbar → Prompt/Snackbar}/useSnackbar.mjs +0 -0
- /package/src/{Snackbar → Prompt/Snackbar}/useSnackbar.ts +0 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
import { Card } from '@transferwise/icons';
|
|
2
|
+
|
|
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';
|
|
6
|
+
|
|
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';
|
|
15
|
+
|
|
16
|
+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
|
|
17
|
+
|
|
18
|
+
mockMatchMedia();
|
|
19
|
+
|
|
20
|
+
const advanceTimers = (duration: number) => {
|
|
21
|
+
act(() => jest.advanceTimersByTime(duration));
|
|
22
|
+
};
|
|
23
|
+
|
|
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
|
+
};
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
resetLiveRegionAnnouncementQueue();
|
|
47
|
+
jest.useFakeTimers();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
jest.clearAllTimers();
|
|
52
|
+
jest.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
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', () => {
|
|
63
|
+
renderSnackbar();
|
|
64
|
+
|
|
65
|
+
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders an action and calls its onClick handler', async () => {
|
|
69
|
+
const onClick = jest.fn();
|
|
70
|
+
const { snackbarProps } = renderSnackbar({ action: { label: 'View', onClick } });
|
|
71
|
+
|
|
72
|
+
await user.click(screen.getByRole('button', { name: 'View' }));
|
|
73
|
+
|
|
74
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
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 });
|
|
82
|
+
|
|
83
|
+
await user.click(screen.getByRole('button', { name: 'Undo' }));
|
|
84
|
+
|
|
85
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(onDismissRequest).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
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}');
|
|
141
|
+
|
|
142
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('requests dismissal after the timeout without changing its own visibility', () => {
|
|
146
|
+
const onDismissRequest = jest.fn();
|
|
147
|
+
renderEnteredSnackbar({ onDismissRequest });
|
|
148
|
+
|
|
149
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
150
|
+
expect(onDismissRequest).not.toHaveBeenCalled();
|
|
151
|
+
|
|
152
|
+
advanceTimers(1);
|
|
153
|
+
expect(onDismissRequest).toHaveBeenCalledTimes(1);
|
|
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();
|
|
174
|
+
|
|
175
|
+
advanceTimers(1);
|
|
176
|
+
expect(onExited).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
|
|
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
|
+
}
|
|
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();
|
|
272
|
+
advanceTimers(1);
|
|
273
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('cancels a running timer when the duration becomes indefinite', () => {
|
|
277
|
+
const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
278
|
+
advanceTimers(2000);
|
|
279
|
+
|
|
280
|
+
rerender(<Snackbar {...snackbarProps} duration="indefinite" />);
|
|
281
|
+
advanceTimers(60_000);
|
|
282
|
+
|
|
283
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
284
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
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
|
+
});
|
|
299
|
+
|
|
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();
|
|
306
|
+
|
|
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
|
+
});
|
|
317
|
+
|
|
318
|
+
it('starts the full reading duration only after the entry transition finishes', () => {
|
|
319
|
+
const { snackbarProps } = renderSnackbar({ duration: 'default' });
|
|
320
|
+
|
|
321
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
322
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
323
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
324
|
+
advanceTimers(1);
|
|
325
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
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
|
+
});
|
|
340
|
+
|
|
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());
|
|
345
|
+
|
|
346
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
347
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION * 2);
|
|
348
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
349
|
+
|
|
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);
|
|
355
|
+
});
|
|
356
|
+
|
|
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());
|
|
362
|
+
|
|
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
|
+
});
|
|
369
|
+
|
|
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);
|
|
377
|
+
});
|
|
378
|
+
|
|
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
|
+
});
|
|
390
|
+
|
|
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);
|
|
520
|
+
});
|
|
521
|
+
|
|
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);
|
|
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
|
+
}
|
|
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);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
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',
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('renders custom media in the fixed-size media wrapper', () => {
|
|
685
|
+
renderSnackbar({ media: <Card title="Card" /> });
|
|
686
|
+
|
|
687
|
+
expect(screen.getByTestId('card-icon').parentElement).toHaveClass('wds-Snackbar__media-asset');
|
|
688
|
+
});
|
|
689
|
+
|
|
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)' });
|
|
749
|
+
});
|
|
750
|
+
|
|
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);
|
|
906
|
+
|
|
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
|
+
});
|
|
1066
|
+
|
|
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');
|
|
1078
|
+
});
|
|
1079
|
+
});
|