@transferwise/components 0.0.0-experimental-5598332 → 0.0.0-experimental-cbc00a7
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/ListItem/ListItem.js +2 -32
- package/build/ListItem/ListItem.js.map +1 -1
- package/build/ListItem/ListItem.mjs +3 -33
- package/build/ListItem/ListItem.mjs.map +1 -1
- package/build/Snackbar/Snackbar.js +107 -105
- package/build/Snackbar/Snackbar.js.map +1 -1
- package/build/Snackbar/Snackbar.mjs +109 -104
- 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 +23 -27
- package/build/Snackbar/SnackbarProvider.js.map +1 -1
- package/build/Snackbar/SnackbarProvider.mjs +24 -28
- package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
- package/build/Snackbar/constants.js +8 -0
- package/build/Snackbar/constants.js.map +1 -0
- package/build/Snackbar/constants.mjs +5 -0
- package/build/Snackbar/constants.mjs.map +1 -0
- package/build/Snackbar/useSnackbarManager.js +119 -0
- package/build/Snackbar/useSnackbarManager.js.map +1 -0
- package/build/Snackbar/useSnackbarManager.mjs +115 -0
- package/build/Snackbar/useSnackbarManager.mjs.map +1 -0
- package/build/i18n/th.json +1 -1
- package/build/i18n/th.json.js +1 -1
- package/build/i18n/th.json.mjs +1 -1
- package/build/i18n/zh-CN.json +0 -1
- package/build/i18n/zh-CN.json.js +0 -1
- package/build/i18n/zh-CN.json.js.map +1 -1
- package/build/i18n/zh-CN.json.mjs +0 -1
- package/build/i18n/zh-CN.json.mjs.map +1 -1
- package/build/main.css +113 -61
- package/build/styles/Snackbar/Snackbar.css +116 -64
- package/build/styles/main.css +113 -61
- package/build/types/Body/Body.d.ts +2 -2
- package/build/types/IconButton/IconButton.d.ts +1 -1
- package/build/types/ListItem/ListItem.d.ts +0 -2
- package/build/types/ListItem/ListItem.d.ts.map +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
- package/build/types/Snackbar/Snackbar.d.ts +10 -8
- package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
- package/build/types/Snackbar/Snackbar.types.d.ts +19 -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.map +1 -1
- package/build/types/Snackbar/constants.d.ts +3 -0
- package/build/types/Snackbar/constants.d.ts.map +1 -0
- package/build/types/Snackbar/useSnackbar.d.ts +1 -1
- package/build/types/Snackbar/useSnackbar.d.ts.map +1 -1
- package/build/types/Snackbar/useSnackbarManager.d.ts +16 -0
- package/build/types/Snackbar/useSnackbarManager.d.ts.map +1 -0
- 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/package.json +3 -3
- package/src/ListItem/ListItem.test.tsx +0 -42
- package/src/ListItem/ListItem.tsx +2 -23
- package/src/Snackbar/Snackbar.css +116 -64
- package/src/Snackbar/Snackbar.less +121 -60
- package/src/Snackbar/Snackbar.test.tsx +122 -133
- package/src/Snackbar/Snackbar.tsx +95 -96
- package/src/Snackbar/Snackbar.types.ts +21 -0
- package/src/Snackbar/SnackbarContext.ts +2 -2
- package/src/Snackbar/SnackbarPortal.tsx +8 -0
- package/src/Snackbar/SnackbarProvider.test.tsx +289 -0
- package/src/Snackbar/SnackbarProvider.tsx +19 -18
- package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +16 -6
- package/src/Snackbar/_stories/Snackbar.story.tsx +62 -1
- package/src/Snackbar/_stories/Snackbar.test.story.tsx +26 -5
- package/src/Snackbar/constants.ts +2 -0
- package/src/Snackbar/useSnackbarManager.ts +122 -0
- package/src/i18n/th.json +1 -1
- package/src/i18n/zh-CN.json +0 -1
- package/src/main.css +113 -61
- package/src/ListItem/ListItem.ssr.test.tsx +0 -115
|
@@ -1,25 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import Button from '../Button';
|
|
1
|
+
import { Card } from '@transferwise/icons';
|
|
3
2
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
3
|
+
import { act, 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 { SNACKBAR_EXIT_DURATION } from './constants';
|
|
8
|
+
import { Snackbar } from './Snackbar';
|
|
9
|
+
import type { SnackbarInternalProps } from './Snackbar.types';
|
|
10
10
|
|
|
11
|
+
const TIMEOUT = 4500;
|
|
11
12
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
|
|
12
13
|
|
|
14
|
+
mockMatchMedia();
|
|
15
|
+
|
|
13
16
|
const advanceTimers = (duration: number) => {
|
|
14
|
-
act(() =>
|
|
15
|
-
jest.advanceTimersByTime(duration);
|
|
16
|
-
});
|
|
17
|
+
act(() => jest.advanceTimersByTime(duration));
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
-
|
|
20
|
+
const enableLiveRegion = () => advanceTimers(WDS_LIVE_REGION_DELAY_MS);
|
|
21
|
+
|
|
22
|
+
const renderSnackbar = (props: Partial<SnackbarInternalProps> = {}) => {
|
|
23
|
+
const snackbarProps: SnackbarInternalProps = {
|
|
24
|
+
text: 'Changes saved',
|
|
25
|
+
timeout: TIMEOUT,
|
|
26
|
+
announcementKey: 1,
|
|
27
|
+
visible: true,
|
|
28
|
+
onDismissRequest: jest.fn(),
|
|
29
|
+
onExited: jest.fn(),
|
|
30
|
+
...props,
|
|
31
|
+
};
|
|
32
|
+
return { ...render(<Snackbar {...snackbarProps} />), snackbarProps };
|
|
33
|
+
};
|
|
21
34
|
|
|
22
35
|
beforeEach(() => {
|
|
36
|
+
resetLiveRegionAnnouncementQueue();
|
|
23
37
|
jest.useFakeTimers();
|
|
24
38
|
});
|
|
25
39
|
|
|
@@ -28,179 +42,154 @@ afterEach(() => {
|
|
|
28
42
|
jest.useRealTimers();
|
|
29
43
|
});
|
|
30
44
|
|
|
31
|
-
describe('Snackbar', () => {
|
|
32
|
-
it('renders
|
|
33
|
-
renderSnackbar();
|
|
45
|
+
describe('Snackbar controlled surface', () => {
|
|
46
|
+
it('renders no live region while not visible', () => {
|
|
47
|
+
renderSnackbar({ visible: false });
|
|
34
48
|
|
|
35
|
-
expect(screen.
|
|
36
|
-
expect(screen.getByRole('alert')).toHaveClass('snackbar');
|
|
49
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
37
50
|
});
|
|
38
51
|
|
|
39
|
-
it('
|
|
40
|
-
renderSnackbar(
|
|
41
|
-
|
|
42
|
-
expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('shows the notification once text arrives after mount', () => {
|
|
46
|
-
const { rerender } = renderSnackbar();
|
|
47
|
-
|
|
48
|
-
rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
|
|
52
|
+
it('renders the message when visible on mount', () => {
|
|
53
|
+
renderSnackbar();
|
|
49
54
|
|
|
50
55
|
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
51
56
|
});
|
|
52
57
|
|
|
53
58
|
it('renders an action and calls its onClick handler', async () => {
|
|
54
59
|
const onClick = jest.fn();
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
rerender(
|
|
58
|
-
<Snackbar
|
|
59
|
-
action={{ label: 'View', onClick }}
|
|
60
|
-
text="Transfer completed"
|
|
61
|
-
timeout={TIMEOUT}
|
|
62
|
-
timestamp={1}
|
|
63
|
-
/>,
|
|
64
|
-
);
|
|
60
|
+
renderSnackbar({ action: { label: 'View', onClick } });
|
|
61
|
+
|
|
65
62
|
await user.click(screen.getByRole('button', { name: 'View' }));
|
|
66
63
|
|
|
67
64
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
68
65
|
});
|
|
69
66
|
|
|
70
|
-
it('
|
|
71
|
-
|
|
67
|
+
it('renders a navigation action with a React-node label', () => {
|
|
68
|
+
renderSnackbar({
|
|
69
|
+
action: { label: <span>View transfer</span>, href: '/transfers/123', target: '_blank' },
|
|
70
|
+
});
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
const link = screen.getByRole('link', { name: 'View transfer' });
|
|
73
|
+
expect(link).toHaveAttribute('href', '/transfers/123');
|
|
74
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
75
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
76
|
+
});
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
it('calls a navigation action handler when activated with the keyboard', async () => {
|
|
79
|
+
const onClick: React.MouseEventHandler<HTMLAnchorElement> = jest.fn((event) => {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
});
|
|
82
|
+
renderSnackbar({ action: { label: 'View transfer', href: '/transfers/123', onClick } });
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
|
|
81
|
-
});
|
|
84
|
+
screen.getByRole('link', { name: 'View transfer' }).focus();
|
|
85
|
+
await user.keyboard('{Enter}');
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
it('requests dismissal after the timeout without changing its own visibility', () => {
|
|
91
|
+
const onDismissRequest = jest.fn();
|
|
92
|
+
renderSnackbar({ onDismissRequest });
|
|
88
93
|
|
|
89
|
-
advanceTimers(
|
|
90
|
-
expect(
|
|
94
|
+
advanceTimers(TIMEOUT - 1);
|
|
95
|
+
expect(onDismissRequest).not.toHaveBeenCalled();
|
|
91
96
|
|
|
92
97
|
advanceTimers(1);
|
|
93
|
-
expect(
|
|
94
|
-
expect(screen.
|
|
98
|
+
expect(onDismissRequest).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
95
100
|
});
|
|
96
101
|
|
|
97
|
-
it('
|
|
98
|
-
const
|
|
102
|
+
it('clears the dismissal timer when it begins exiting', () => {
|
|
103
|
+
const onDismissRequest = jest.fn();
|
|
104
|
+
const { rerender, snackbarProps } = renderSnackbar({ onDismissRequest });
|
|
99
105
|
|
|
100
|
-
rerender(<Snackbar
|
|
101
|
-
advanceTimers(TIMEOUT
|
|
106
|
+
rerender(<Snackbar {...snackbarProps} visible={false} />);
|
|
107
|
+
advanceTimers(TIMEOUT);
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
109
|
+
expect(onDismissRequest).not.toHaveBeenCalled();
|
|
105
110
|
});
|
|
106
111
|
|
|
107
|
-
it('
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
|
|
111
|
-
advanceTimers(TIMEOUT - 100);
|
|
112
|
-
|
|
113
|
-
rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
|
|
114
|
-
advanceTimers(CSS_TRANSITION_DURATION);
|
|
115
|
-
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
112
|
+
it('notifies the provider only after its exit transition finishes', () => {
|
|
113
|
+
const onExited = jest.fn();
|
|
114
|
+
const { rerender, snackbarProps } = renderSnackbar({ onExited });
|
|
116
115
|
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
rerender(<Snackbar {...snackbarProps} visible={false} />);
|
|
117
|
+
advanceTimers(SNACKBAR_EXIT_DURATION - 1);
|
|
118
|
+
expect(onExited).not.toHaveBeenCalled();
|
|
119
119
|
|
|
120
120
|
advanceTimers(1);
|
|
121
|
-
|
|
122
|
-
expect(screen.queryByText('
|
|
121
|
+
expect(onExited).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
|
|
123
123
|
});
|
|
124
|
+
});
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
|
|
130
|
-
// Shortened while the replacement is still waiting out the exit transition.
|
|
131
|
-
rerender(<Snackbar text="Second message" timeout={1000} timestamp={2} />);
|
|
132
|
-
|
|
133
|
-
advanceTimers(CSS_TRANSITION_DURATION);
|
|
134
|
-
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
126
|
+
describe('Snackbar live region', () => {
|
|
127
|
+
it('announces the notification as a polite status message', () => {
|
|
128
|
+
renderSnackbar();
|
|
129
|
+
const liveRegion = screen.getByRole('status');
|
|
135
130
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
expect(screen.
|
|
131
|
+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
|
|
132
|
+
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
|
|
133
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
139
134
|
});
|
|
140
135
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const { rerender } = renderSnackbar();
|
|
136
|
+
it('delays live-region exposure until assistive technology can observe the update', () => {
|
|
137
|
+
renderSnackbar();
|
|
138
|
+
const liveRegionContent = screen.getByRole('status').firstElementChild;
|
|
145
139
|
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
expect(liveRegionContent).toHaveAttribute('aria-hidden', 'true');
|
|
141
|
+
advanceTimers(WDS_LIVE_REGION_DELAY_MS - 1);
|
|
142
|
+
expect(liveRegionContent).toHaveAttribute('aria-hidden', 'true');
|
|
148
143
|
|
|
149
|
-
advanceTimers(
|
|
150
|
-
expect(
|
|
144
|
+
advanceTimers(1);
|
|
145
|
+
expect(liveRegionContent).not.toHaveAttribute('aria-hidden');
|
|
151
146
|
});
|
|
152
147
|
|
|
153
|
-
it('
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
|
|
157
|
-
rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={1} />);
|
|
148
|
+
it('keeps interactive controls outside the delayed announcement subtree', () => {
|
|
149
|
+
renderSnackbar({ action: { label: 'Undo' } });
|
|
158
150
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
151
|
+
const liveRegionContent = screen.getByRole('status').firstElementChild;
|
|
152
|
+
const action = screen.getByRole('button', { name: 'Undo' });
|
|
153
|
+
const dismiss = screen.getByRole('button', { name: 'Close' });
|
|
154
|
+
expect(liveRegionContent).toHaveAttribute('aria-hidden', 'true');
|
|
155
|
+
expect(action.closest('[aria-hidden="true"]')).toBeNull();
|
|
156
|
+
expect(dismiss.closest('[aria-hidden="true"]')).toBeNull();
|
|
162
157
|
});
|
|
158
|
+
});
|
|
163
159
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const pendingTimers = jest.getTimerCount();
|
|
169
|
-
unmount();
|
|
160
|
+
describe('Snackbar media', () => {
|
|
161
|
+
it('uses the shared PrimitivePrompt structure', () => {
|
|
162
|
+
renderSnackbar();
|
|
163
|
+
enableLiveRegion();
|
|
170
164
|
|
|
171
|
-
|
|
172
|
-
|
|
165
|
+
expect(screen.getByText('Changes saved').closest('.wds-prompt')).toHaveClass(
|
|
166
|
+
'wds-Snackbar-container',
|
|
167
|
+
);
|
|
168
|
+
expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('wds-prompt__dismiss');
|
|
173
169
|
});
|
|
174
|
-
});
|
|
175
170
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
render(
|
|
179
|
-
<SnackbarProvider timeout={TIMEOUT}>
|
|
180
|
-
<SnackbarConsumer>
|
|
181
|
-
{({ createSnackbar }) => (
|
|
182
|
-
<Button onClick={() => createSnackbar({ text: 'Changes saved' })}>Show</Button>
|
|
183
|
-
)}
|
|
184
|
-
</SnackbarConsumer>
|
|
185
|
-
</SnackbarProvider>,
|
|
186
|
-
);
|
|
171
|
+
it('renders custom media in the fixed-size media wrapper', () => {
|
|
172
|
+
renderSnackbar({ media: <Card title="Card" /> });
|
|
187
173
|
|
|
188
|
-
|
|
189
|
-
|
|
174
|
+
expect(screen.getByTestId('card-icon').parentElement).toHaveClass('wds-Snackbar__media-asset');
|
|
175
|
+
});
|
|
190
176
|
|
|
191
|
-
|
|
177
|
+
it('renders proposition media in the fixed-size media wrapper', () => {
|
|
178
|
+
renderSnackbar({ sentiment: 'proposition' });
|
|
179
|
+
const icon = screen
|
|
180
|
+
.getByText('Changes saved')
|
|
181
|
+
.closest('.wds-prompt')
|
|
182
|
+
?.querySelector('.tw-icon-tags');
|
|
192
183
|
|
|
193
|
-
expect(
|
|
184
|
+
expect(icon?.parentElement).toHaveClass('wds-Snackbar__media-asset');
|
|
194
185
|
});
|
|
195
186
|
|
|
196
|
-
it('
|
|
197
|
-
|
|
187
|
+
it('requests dismissal through the close button', async () => {
|
|
188
|
+
const onDismissRequest = jest.fn();
|
|
189
|
+
renderSnackbar({ onDismissRequest });
|
|
198
190
|
|
|
199
|
-
|
|
200
|
-
await user.click(trigger);
|
|
201
|
-
await user.click(trigger);
|
|
202
|
-
await user.click(trigger);
|
|
191
|
+
await user.click(screen.getByRole('button', { name: 'Close' }));
|
|
203
192
|
|
|
204
|
-
expect(
|
|
193
|
+
expect(onDismissRequest).toHaveBeenCalledTimes(1);
|
|
205
194
|
});
|
|
206
195
|
});
|
|
@@ -1,129 +1,128 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Tags } from '@transferwise/icons';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
2
3
|
import { CSSTransition } from 'react-transition-group';
|
|
3
4
|
|
|
4
5
|
import Button from '../Button';
|
|
5
6
|
import Body from '../Body';
|
|
7
|
+
import { LiveRegion } from '../common';
|
|
6
8
|
import type { ThemeDark, ThemeLight } from '../common';
|
|
7
|
-
import
|
|
9
|
+
import { PrimitivePrompt } from '../Prompt/PrimitivePrompt';
|
|
10
|
+
import type { Sentiment } from '../SentimentSurface';
|
|
11
|
+
import StatusIcon from '../StatusIcon';
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
import { SNACKBAR_ENTER_DURATION, SNACKBAR_EXIT_DURATION } from './constants';
|
|
14
|
+
import type { SnackbarAction, SnackbarInternalProps } from './Snackbar.types';
|
|
10
15
|
|
|
11
16
|
export interface SnackbarProps {
|
|
12
|
-
action
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
/** One optional action, supporting a callback or navigation through `href` and `target`. */
|
|
18
|
+
action?: SnackbarAction;
|
|
19
|
+
media?: React.ReactNode;
|
|
20
|
+
sentiment?: Sentiment;
|
|
16
21
|
text: React.ReactNode;
|
|
17
22
|
/** @deprecated Has no effect. Snackbar theming is based on ThemeProvider. */
|
|
18
23
|
theme?: ThemeLight | ThemeDark;
|
|
19
24
|
timeout: number;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
interface SnackbarState extends Pick<SnackbarProps, 'action' | 'text'> {
|
|
24
|
-
visible: boolean;
|
|
25
|
+
/** @deprecated Has no effect when using SnackbarProvider, which manages Snackbar identity. */
|
|
26
|
+
timestamp?: number;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export const Snackbar = (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
[],
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
// Runs after every render to mirror the componentDidUpdate lifecycle this component previously
|
|
48
|
-
// relied on, including the shouldComponentUpdate guard that ignored every update without text.
|
|
49
|
-
// Layout, not passive, so a notification is shown or replaced before the browser paints, as
|
|
50
|
-
// componentDidUpdate did. Both rules below are disabled on purpose: the dependency-less effect
|
|
51
|
-
// and its synchronous state updates are what keep this conversion behaviour-identical.
|
|
52
|
-
/* eslint-disable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */
|
|
53
|
-
useLayoutEffect(() => {
|
|
54
|
-
const previousProps = committedProps.current;
|
|
55
|
-
committedProps.current = props;
|
|
56
|
-
|
|
57
|
-
const { action, text, timestamp } = props;
|
|
58
|
-
|
|
59
|
-
// shouldComponentUpdate bailed out whenever the incoming text was empty. That also blocked the
|
|
60
|
-
// updates scheduled below, so a Snackbar whose text is cleared stays on screen until it is
|
|
61
|
-
// given text again. Preserved here deliberately to keep this refactor behaviour-identical.
|
|
62
|
-
const updateWhileTextIsPresent = (update: () => void) => {
|
|
63
|
-
if (committedProps.current.text) {
|
|
64
|
-
update();
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const setLeaveTimeout = () => {
|
|
69
|
-
leaveTimeout.current = window.setTimeout(() => {
|
|
70
|
-
updateWhileTextIsPresent(() => setState((current) => ({ ...current, visible: false })));
|
|
71
|
-
}, committedProps.current.timeout);
|
|
72
|
-
};
|
|
29
|
+
export const Snackbar = ({
|
|
30
|
+
action,
|
|
31
|
+
announcementKey,
|
|
32
|
+
media,
|
|
33
|
+
onDismissRequest,
|
|
34
|
+
onExited,
|
|
35
|
+
sentiment = 'neutral',
|
|
36
|
+
text,
|
|
37
|
+
timeout,
|
|
38
|
+
visible,
|
|
39
|
+
}: SnackbarInternalProps) => {
|
|
40
|
+
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!visible) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
73
46
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
};
|
|
47
|
+
const id = window.setTimeout(onDismissRequest, timeout);
|
|
48
|
+
return () => window.clearTimeout(id);
|
|
49
|
+
}, [announcementKey, onDismissRequest, timeout, visible]);
|
|
78
50
|
|
|
79
|
-
|
|
80
|
-
|
|
51
|
+
const renderMedia = () => {
|
|
52
|
+
if (media) {
|
|
53
|
+
return <span className="wds-Snackbar__media-asset">{media}</span>;
|
|
81
54
|
}
|
|
82
55
|
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
setState((current) => ({ ...current, visible: false }));
|
|
90
|
-
transitionTimeout.current = window.setTimeout(() => {
|
|
91
|
-
updateWhileTextIsPresent(show);
|
|
92
|
-
}, CSS_TRANSITION_DURATION);
|
|
93
|
-
} else {
|
|
94
|
-
show();
|
|
95
|
-
}
|
|
56
|
+
if (sentiment === 'proposition') {
|
|
57
|
+
return (
|
|
58
|
+
<span className="wds-Snackbar__media-asset">
|
|
59
|
+
<Tags aria-hidden size={24} />
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
96
62
|
}
|
|
97
|
-
});
|
|
98
|
-
/* eslint-enable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */
|
|
99
63
|
|
|
100
|
-
|
|
101
|
-
|
|
64
|
+
return <StatusIcon sentiment={sentiment} size={24} />;
|
|
65
|
+
};
|
|
102
66
|
|
|
103
67
|
return (
|
|
104
|
-
<div
|
|
68
|
+
<div
|
|
69
|
+
className="wds-Snackbar"
|
|
70
|
+
style={
|
|
71
|
+
{
|
|
72
|
+
'--Snackbar-enter-duration': `${SNACKBAR_ENTER_DURATION}ms`,
|
|
73
|
+
'--Snackbar-exit-duration': `${SNACKBAR_EXIT_DURATION}ms`,
|
|
74
|
+
} as React.CSSProperties
|
|
75
|
+
}
|
|
76
|
+
>
|
|
105
77
|
<CSSTransition
|
|
106
|
-
|
|
78
|
+
appear
|
|
79
|
+
nodeRef={surfaceRef}
|
|
107
80
|
in={visible}
|
|
108
|
-
classNames="
|
|
81
|
+
classNames="wds-Snackbar-container"
|
|
109
82
|
timeout={{
|
|
110
|
-
appear:
|
|
111
|
-
enter:
|
|
112
|
-
exit:
|
|
83
|
+
appear: SNACKBAR_ENTER_DURATION,
|
|
84
|
+
enter: SNACKBAR_ENTER_DURATION,
|
|
85
|
+
exit: SNACKBAR_EXIT_DURATION,
|
|
113
86
|
}}
|
|
114
87
|
unmountOnExit
|
|
88
|
+
onExited={onExited}
|
|
115
89
|
>
|
|
116
|
-
<
|
|
117
|
-
{
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
90
|
+
<PrimitivePrompt
|
|
91
|
+
ref={surfaceRef}
|
|
92
|
+
className="wds-Snackbar-container"
|
|
93
|
+
sentiment={sentiment}
|
|
94
|
+
emphasis="elevated"
|
|
95
|
+
media={renderMedia()}
|
|
96
|
+
onDismiss={onDismissRequest}
|
|
97
|
+
>
|
|
98
|
+
<div className="wds-Snackbar__content">
|
|
99
|
+
<LiveRegion
|
|
100
|
+
aria-live="polite"
|
|
101
|
+
announceOnChange={announcementKey}
|
|
102
|
+
className="wds-Snackbar__message"
|
|
103
|
+
>
|
|
104
|
+
<Body as="span" className="wds-Snackbar__body">
|
|
105
|
+
{text}
|
|
106
|
+
</Body>
|
|
107
|
+
</LiveRegion>
|
|
108
|
+
{action ? (
|
|
109
|
+
<Button
|
|
110
|
+
className="wds-Snackbar__action"
|
|
111
|
+
v2
|
|
112
|
+
size="sm"
|
|
113
|
+
priority="secondary"
|
|
114
|
+
href={action.href}
|
|
115
|
+
target={action.target}
|
|
116
|
+
onClick={action.onClick}
|
|
117
|
+
>
|
|
118
|
+
{action.label}
|
|
119
|
+
</Button>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
</PrimitivePrompt>
|
|
124
123
|
</CSSTransition>
|
|
125
124
|
</div>
|
|
126
125
|
);
|
|
127
126
|
};
|
|
128
127
|
|
|
129
|
-
export default
|
|
128
|
+
export default Snackbar;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ButtonProps, ButtonReferenceType } from '../Button/Button.types';
|
|
2
|
+
import type { SnackbarProps } from './Snackbar';
|
|
3
|
+
|
|
4
|
+
/** One optional action. Supports callbacks and navigation through the Button API. */
|
|
5
|
+
export type SnackbarAction = Pick<ButtonProps, 'href' | 'target'> & {
|
|
6
|
+
label: ButtonProps['children'];
|
|
7
|
+
onClick?: React.MouseEventHandler<ButtonReferenceType>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Props accepted by `createSnackbar` from `useSnackbar()`. */
|
|
11
|
+
export type SnackbarCreationProps = Omit<SnackbarProps, 'timeout'>;
|
|
12
|
+
|
|
13
|
+
/** Props the provider passes to control a Snackbar. Internal. */
|
|
14
|
+
export interface SnackbarInternalProps extends Omit<SnackbarProps, 'timeout' | 'timestamp'> {
|
|
15
|
+
/** Changes for each new Snackbar so LiveRegion re-announces it. */
|
|
16
|
+
announcementKey: number;
|
|
17
|
+
onDismissRequest: () => void;
|
|
18
|
+
onExited: () => void;
|
|
19
|
+
timeout: number;
|
|
20
|
+
visible: boolean;
|
|
21
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createContext } from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { SnackbarCreationProps } from './Snackbar.types';
|
|
4
4
|
|
|
5
5
|
export type SnackbarContextType = {
|
|
6
|
-
createSnackbar: (props:
|
|
6
|
+
createSnackbar: (props: SnackbarCreationProps) => void;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
export const SnackbarContext = createContext<SnackbarContextType>({ createSnackbar: () => {} });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import withNextPortal from '../WithNextPortal/WithNextPortal';
|
|
2
|
+
|
|
3
|
+
import { Snackbar } from './Snackbar';
|
|
4
|
+
|
|
5
|
+
/** Renders the Snackbar into `document.body` after mount, so it's safe for SSR. */
|
|
6
|
+
const SnackbarPortal = withNextPortal(Snackbar);
|
|
7
|
+
|
|
8
|
+
export default SnackbarPortal;
|