@transferwise/components 0.0.0-experimental-bf3b43b → 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.
Files changed (73) hide show
  1. package/build/Snackbar/Snackbar.js +107 -105
  2. package/build/Snackbar/Snackbar.js.map +1 -1
  3. package/build/Snackbar/Snackbar.mjs +109 -104
  4. package/build/Snackbar/Snackbar.mjs.map +1 -1
  5. package/build/Snackbar/SnackbarContext.js.map +1 -1
  6. package/build/Snackbar/SnackbarContext.mjs.map +1 -1
  7. package/build/Snackbar/SnackbarPortal.js +12 -0
  8. package/build/Snackbar/SnackbarPortal.js.map +1 -0
  9. package/build/Snackbar/SnackbarPortal.mjs +8 -0
  10. package/build/Snackbar/SnackbarPortal.mjs.map +1 -0
  11. package/build/Snackbar/SnackbarProvider.js +23 -27
  12. package/build/Snackbar/SnackbarProvider.js.map +1 -1
  13. package/build/Snackbar/SnackbarProvider.mjs +24 -28
  14. package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
  15. package/build/Snackbar/constants.js +8 -0
  16. package/build/Snackbar/constants.js.map +1 -0
  17. package/build/Snackbar/constants.mjs +5 -0
  18. package/build/Snackbar/constants.mjs.map +1 -0
  19. package/build/Snackbar/useSnackbarManager.js +119 -0
  20. package/build/Snackbar/useSnackbarManager.js.map +1 -0
  21. package/build/Snackbar/useSnackbarManager.mjs +115 -0
  22. package/build/Snackbar/useSnackbarManager.mjs.map +1 -0
  23. package/build/i18n/th.json +1 -1
  24. package/build/i18n/th.json.js +1 -1
  25. package/build/i18n/th.json.mjs +1 -1
  26. package/build/i18n/zh-CN.json +0 -1
  27. package/build/i18n/zh-CN.json.js +0 -1
  28. package/build/i18n/zh-CN.json.js.map +1 -1
  29. package/build/i18n/zh-CN.json.mjs +0 -1
  30. package/build/i18n/zh-CN.json.mjs.map +1 -1
  31. package/build/main.css +113 -61
  32. package/build/styles/Snackbar/Snackbar.css +116 -64
  33. package/build/styles/main.css +113 -61
  34. package/build/types/Body/Body.d.ts +2 -2
  35. package/build/types/IconButton/IconButton.d.ts +1 -1
  36. package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
  37. package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
  38. package/build/types/Snackbar/Snackbar.d.ts +10 -8
  39. package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
  40. package/build/types/Snackbar/Snackbar.types.d.ts +19 -0
  41. package/build/types/Snackbar/Snackbar.types.d.ts.map +1 -0
  42. package/build/types/Snackbar/SnackbarContext.d.ts +2 -2
  43. package/build/types/Snackbar/SnackbarContext.d.ts.map +1 -1
  44. package/build/types/Snackbar/SnackbarPortal.d.ts +4 -0
  45. package/build/types/Snackbar/SnackbarPortal.d.ts.map +1 -0
  46. package/build/types/Snackbar/SnackbarProvider.d.ts.map +1 -1
  47. package/build/types/Snackbar/constants.d.ts +3 -0
  48. package/build/types/Snackbar/constants.d.ts.map +1 -0
  49. package/build/types/Snackbar/useSnackbar.d.ts +1 -1
  50. package/build/types/Snackbar/useSnackbar.d.ts.map +1 -1
  51. package/build/types/Snackbar/useSnackbarManager.d.ts +16 -0
  52. package/build/types/Snackbar/useSnackbarManager.d.ts.map +1 -0
  53. package/build/types/Title/Title.d.ts +2 -2
  54. package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
  55. package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
  56. package/package.json +3 -3
  57. package/src/Snackbar/Snackbar.css +116 -64
  58. package/src/Snackbar/Snackbar.less +121 -60
  59. package/src/Snackbar/Snackbar.test.tsx +122 -133
  60. package/src/Snackbar/Snackbar.tsx +95 -96
  61. package/src/Snackbar/Snackbar.types.ts +21 -0
  62. package/src/Snackbar/SnackbarContext.ts +2 -2
  63. package/src/Snackbar/SnackbarPortal.tsx +8 -0
  64. package/src/Snackbar/SnackbarProvider.test.tsx +289 -0
  65. package/src/Snackbar/SnackbarProvider.tsx +19 -18
  66. package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +16 -6
  67. package/src/Snackbar/_stories/Snackbar.story.tsx +62 -1
  68. package/src/Snackbar/_stories/Snackbar.test.story.tsx +26 -5
  69. package/src/Snackbar/constants.ts +2 -0
  70. package/src/Snackbar/useSnackbarManager.ts +122 -0
  71. package/src/i18n/th.json +1 -1
  72. package/src/i18n/zh-CN.json +0 -1
  73. package/src/main.css +113 -61
@@ -1,25 +1,39 @@
1
- import { act, render, screen, userEvent } from '../test-utils';
2
- import Button from '../Button';
1
+ import { Card } from '@transferwise/icons';
3
2
 
4
- import { Snackbar, type SnackbarProps } from './Snackbar';
5
- import { SnackbarConsumer } from './SnackbarContext';
6
- import SnackbarProvider from './SnackbarProvider';
3
+ import { act, mockMatchMedia, render, screen, userEvent } from '../test-utils';
4
+ import { WDS_LIVE_REGION_DELAY_MS } from '../common/constants';
5
+ import { resetLiveRegionAnnouncementQueue } from '../common/LiveRegion/LiveRegion';
7
6
 
8
- const CSS_TRANSITION_DURATION = 400;
9
- const TIMEOUT = 4000;
7
+ import { 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 renderSnackbar = (props: Partial<SnackbarProps> = {}) =>
20
- render(<Snackbar text="" timeout={TIMEOUT} timestamp={0} {...props} />);
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 the live region wrapper even while no notification is shown', () => {
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.getByRole('alert')).toHaveAttribute('aria-live', 'assertive');
36
- expect(screen.getByRole('alert')).toHaveClass('snackbar');
49
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
37
50
  });
38
51
 
39
- it('does not show a notification that is already present on mount', () => {
40
- renderSnackbar({ text: 'Changes saved', timestamp: 1 });
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
- const { rerender } = renderSnackbar();
56
-
57
- rerender(
58
- <Snackbar
59
- action={{ label: 'View', onClick }}
60
- text="Transfer completed"
61
- timeout={TIMEOUT}
62
- timestamp={1}
63
- />,
64
- );
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('dismisses the notification once the timeout elapses', () => {
71
- const { rerender } = renderSnackbar();
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
- rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
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
- advanceTimers(TIMEOUT - 1);
76
- expect(screen.getByText('Changes saved')).toBeInTheDocument();
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
- advanceTimers(1);
79
- advanceTimers(CSS_TRANSITION_DURATION);
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
- it('exits the current notification before entering the next one', () => {
84
- const { rerender } = renderSnackbar();
87
+ expect(onClick).toHaveBeenCalledTimes(1);
88
+ });
85
89
 
86
- rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
87
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
90
+ it('requests dismissal after the timeout without changing its own visibility', () => {
91
+ const onDismissRequest = jest.fn();
92
+ renderSnackbar({ onDismissRequest });
88
93
 
89
- advanceTimers(CSS_TRANSITION_DURATION - 1);
90
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
94
+ advanceTimers(TIMEOUT - 1);
95
+ expect(onDismissRequest).not.toHaveBeenCalled();
91
96
 
92
97
  advanceTimers(1);
93
- expect(screen.getByText('Second message')).toBeInTheDocument();
94
- expect(screen.queryByText('First message')).not.toBeInTheDocument();
98
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
99
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
95
100
  });
96
101
 
97
- it('shows the next notification immediately when the previous one has already been dismissed', () => {
98
- const { rerender } = renderSnackbar();
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 text="First message" timeout={TIMEOUT} timestamp={1} />);
101
- advanceTimers(TIMEOUT + CSS_TRANSITION_DURATION);
106
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
107
+ advanceTimers(TIMEOUT);
102
108
 
103
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
104
- expect(screen.getByText('Second message')).toBeInTheDocument();
109
+ expect(onDismissRequest).not.toHaveBeenCalled();
105
110
  });
106
111
 
107
- it('restarts the timeout for a replacing notification', () => {
108
- const { rerender } = renderSnackbar();
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
- advanceTimers(TIMEOUT - 1);
118
- expect(screen.getByText('Second message')).toBeInTheDocument();
116
+ rerender(<Snackbar {...snackbarProps} visible={false} />);
117
+ advanceTimers(SNACKBAR_EXIT_DURATION - 1);
118
+ expect(onExited).not.toHaveBeenCalled();
119
119
 
120
120
  advanceTimers(1);
121
- advanceTimers(CSS_TRANSITION_DURATION);
122
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
121
+ expect(onExited).toHaveBeenCalledTimes(1);
122
+ expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
123
123
  });
124
+ });
124
125
 
125
- it('dismisses a delayed replacement on the timeout current when it appears', () => {
126
- const { rerender } = renderSnackbar();
127
-
128
- rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
129
- rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
130
- // Shortened while the replacement is still waiting out the exit transition.
131
- rerender(<Snackbar text="Second message" timeout={1000} timestamp={2} />);
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
- advanceTimers(1000);
137
- advanceTimers(CSS_TRANSITION_DURATION);
138
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
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
- // Documents existing behaviour rather than endorsing it: clearing the text leaves the notification
142
- // on screen indefinitely, because the previous shouldComponentUpdate blocked the dismissal too.
143
- it('keeps the notification visible for as long as text is empty', () => {
144
- const { rerender } = renderSnackbar();
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
- rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
147
- rerender(<Snackbar text="" timeout={TIMEOUT} timestamp={2} />);
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(TIMEOUT + CSS_TRANSITION_DURATION);
150
- expect(screen.getByText('Changes saved')).toBeInTheDocument();
144
+ advanceTimers(1);
145
+ expect(liveRegionContent).not.toHaveAttribute('aria-hidden');
151
146
  });
152
147
 
153
- it('ignores a repeated timestamp', () => {
154
- const { rerender } = renderSnackbar();
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
- advanceTimers(CSS_TRANSITION_DURATION);
160
- expect(screen.getByText('First message')).toBeInTheDocument();
161
- expect(screen.queryByText('Second message')).not.toBeInTheDocument();
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
- it('clears its pending dismissal timer on unmount', () => {
165
- const { rerender, unmount } = renderSnackbar();
166
-
167
- rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
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
- // The timer left behind belongs to CSSTransition's enter timeout, not to Snackbar.
172
- expect(jest.getTimerCount()).toBe(pendingTimers - 1);
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
- describe('Snackbar created through SnackbarProvider', () => {
177
- const renderProvider = () =>
178
- render(
179
- <SnackbarProvider timeout={TIMEOUT}>
180
- <SnackbarConsumer>
181
- {({ createSnackbar }) => (
182
- <Button onClick={() => createSnackbar({ text: 'Changes saved' })}>Show</Button>
183
- )}
184
- </SnackbarConsumer>
185
- </SnackbarProvider>,
186
- );
171
+ it('renders custom media in the fixed-size media wrapper', () => {
172
+ renderSnackbar({ media: <Card title="Card" /> });
187
173
 
188
- it('shows a notification created through the context', async () => {
189
- renderProvider();
174
+ expect(screen.getByTestId('card-icon').parentElement).toHaveClass('wds-Snackbar__media-asset');
175
+ });
190
176
 
191
- await user.click(screen.getByRole('button', { name: 'Show' }));
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(screen.getByText('Changes saved')).toBeInTheDocument();
184
+ expect(icon?.parentElement).toHaveClass('wds-Snackbar__media-asset');
194
185
  });
195
186
 
196
- it('shows only one notification when the trigger is clicked repeatedly', async () => {
197
- renderProvider();
187
+ it('requests dismissal through the close button', async () => {
188
+ const onDismissRequest = jest.fn();
189
+ renderSnackbar({ onDismissRequest });
198
190
 
199
- const trigger = screen.getByRole('button', { name: 'Show' });
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(screen.getAllByRole('alert')).toHaveLength(1);
193
+ expect(onDismissRequest).toHaveBeenCalledTimes(1);
205
194
  });
206
195
  });
@@ -1,129 +1,128 @@
1
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
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 withNextPortal from '../WithNextPortal/WithNextPortal';
9
+ import { PrimitivePrompt } from '../Prompt/PrimitivePrompt';
10
+ import type { Sentiment } from '../SentimentSurface';
11
+ import StatusIcon from '../StatusIcon';
8
12
 
9
- const CSS_TRANSITION_DURATION = 400;
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
- label: string;
14
- onClick?: React.MouseEventHandler<HTMLButtonElement>;
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
- timestamp: number;
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 = (props: SnackbarProps) => {
28
- const bodyRef = useRef<HTMLSpanElement>(null);
29
- const leaveTimeout = useRef(0);
30
- const transitionTimeout = useRef(0);
31
- /** The props of the last committed render, as `this.props` was in the class component. */
32
- const committedProps = useRef(props);
33
-
34
- const [state, setState] = useState<SnackbarState>({
35
- visible: false,
36
- text: '',
37
- });
38
-
39
- useEffect(
40
- () => () => {
41
- window.clearTimeout(leaveTimeout.current);
42
- window.clearTimeout(transitionTimeout.current);
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 show = () => {
75
- setState((current) => ({ ...current, visible: true, action, text }));
76
- setLeaveTimeout();
77
- };
47
+ const id = window.setTimeout(onDismissRequest, timeout);
48
+ return () => window.clearTimeout(id);
49
+ }, [announcementKey, onDismissRequest, timeout, visible]);
78
50
 
79
- if (!text) {
80
- return;
51
+ const renderMedia = () => {
52
+ if (media) {
53
+ return <span className="wds-Snackbar__media-asset">{media}</span>;
81
54
  }
82
55
 
83
- if (!previousProps.text) {
84
- show();
85
- } else if (previousProps.timestamp !== timestamp) {
86
- window.clearTimeout(leaveTimeout.current);
87
-
88
- if (state.visible) {
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
- const { action, text, visible } = state;
101
- const { timeout } = props;
64
+ return <StatusIcon sentiment={sentiment} size={24} />;
65
+ };
102
66
 
103
67
  return (
104
- <div className="snackbar" aria-live="assertive" role="alert">
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
- nodeRef={bodyRef}
78
+ appear
79
+ nodeRef={surfaceRef}
107
80
  in={visible}
108
- classNames="snackbar__text-container"
81
+ classNames="wds-Snackbar-container"
109
82
  timeout={{
110
- appear: 0,
111
- enter: timeout,
112
- exit: CSS_TRANSITION_DURATION,
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
- <Body ref={bodyRef} as="span" className="snackbar__text">
117
- {text}
118
- {action ? (
119
- <Button className="snackbar__text__action" v2 size="sm" onClick={action.onClick}>
120
- {action.label}
121
- </Button>
122
- ) : null}
123
- </Body>
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 withNextPortal(Snackbar);
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 { SnackbarProps } from './Snackbar';
3
+ import type { SnackbarCreationProps } from './Snackbar.types';
4
4
 
5
5
  export type SnackbarContextType = {
6
- createSnackbar: (props: Omit<SnackbarProps, 'timeout' | 'timestamp'>) => void;
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;