@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
@@ -0,0 +1,289 @@
1
+ import { renderHook } from '@testing-library/react';
2
+
3
+ import { act, mockMatchMedia, render, screen, userEvent } from '../test-utils';
4
+ import { resetLiveRegionAnnouncementQueue } from '../common/LiveRegion/LiveRegion';
5
+
6
+ import { SNACKBAR_ENTER_DURATION, SNACKBAR_EXIT_DURATION } from './constants';
7
+ import type { SnackbarCreationProps } from './Snackbar.types';
8
+ import SnackbarProvider from './SnackbarProvider';
9
+ import useSnackbar from './useSnackbar';
10
+ import useSnackbarManager from './useSnackbarManager';
11
+
12
+ const TIMEOUT = 4500;
13
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
14
+
15
+ mockMatchMedia();
16
+
17
+ const advanceTimers = (duration: number) => {
18
+ act(() => jest.advanceTimersByTime(duration));
19
+ };
20
+
21
+ type SnackbarTrigger = { label: string; snackbar: SnackbarCreationProps };
22
+
23
+ const SnackbarTriggers = ({ triggers }: { triggers: SnackbarTrigger[] }) => {
24
+ const showSnackbar = useSnackbar();
25
+
26
+ return (
27
+ <>
28
+ {triggers.map(({ label, snackbar }) => (
29
+ <button key={label} type="button" onClick={() => showSnackbar(snackbar)}>
30
+ {label}
31
+ </button>
32
+ ))}
33
+ </>
34
+ );
35
+ };
36
+
37
+ const BatchedSnackbarTrigger = () => {
38
+ const showSnackbar = useSnackbar();
39
+
40
+ return (
41
+ <button
42
+ type="button"
43
+ onClick={() => {
44
+ showSnackbar({ text: 'First message' });
45
+ showSnackbar({ text: 'Latest message' });
46
+ }}
47
+ >
48
+ Show latest
49
+ </button>
50
+ );
51
+ };
52
+
53
+ const renderProvider = (triggers: SnackbarTrigger[], timeout?: number) =>
54
+ render(
55
+ <SnackbarProvider timeout={timeout}>
56
+ <SnackbarTriggers triggers={triggers} />
57
+ </SnackbarProvider>,
58
+ );
59
+
60
+ beforeEach(() => {
61
+ jest.useFakeTimers();
62
+ resetLiveRegionAnnouncementQueue();
63
+ });
64
+
65
+ afterEach(() => {
66
+ jest.clearAllTimers();
67
+ jest.useRealTimers();
68
+ });
69
+
70
+ /* eslint-disable testing-library/no-node-access -- Portal placement, teardown and transition classes require structural DOM assertions. */
71
+ describe('SnackbarProvider orchestration', () => {
72
+ it('does not mount a portal or live region while idle', () => {
73
+ const { baseElement } = renderProvider([]);
74
+
75
+ expect(baseElement.querySelector('.wds-Snackbar')).toBeNull();
76
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
77
+ });
78
+
79
+ it('creates a Snackbar programmatically in document.body', async () => {
80
+ const { container } = renderProvider([
81
+ { label: 'Show', snackbar: { text: 'Changes saved', sentiment: 'success' } },
82
+ ]);
83
+
84
+ await user.click(screen.getByRole('button', { name: 'Show' }));
85
+
86
+ expect(screen.getByRole('status')).toHaveTextContent('Changes saved');
87
+ expect(container).not.toContainElement(screen.getByRole('status'));
88
+ expect(screen.getByText('Changes saved').closest('.wds-Snackbar')?.parentElement).toBe(
89
+ document.body,
90
+ );
91
+ });
92
+
93
+ it('keeps the default timeout until semantic durations are introduced', async () => {
94
+ renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }]);
95
+ await user.click(screen.getByRole('button', { name: 'Show' }));
96
+
97
+ advanceTimers(TIMEOUT - 1);
98
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
99
+
100
+ advanceTimers(1);
101
+ advanceTimers(SNACKBAR_EXIT_DURATION);
102
+ expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
103
+ expect(document.querySelector('.wds-Snackbar')).toBeNull();
104
+ });
105
+
106
+ it('retains an explicitly configured Provider timeout', async () => {
107
+ renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }], TIMEOUT * 2);
108
+ await user.click(screen.getByRole('button', { name: 'Show' }));
109
+
110
+ advanceTimers(TIMEOUT * 2 - 1);
111
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
112
+
113
+ advanceTimers(1);
114
+ advanceTimers(SNACKBAR_EXIT_DURATION);
115
+ expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
116
+ });
117
+
118
+ it('exits the active Snackbar before showing the latest replacement', async () => {
119
+ renderProvider([
120
+ { label: 'Show first', snackbar: { text: 'First message' } },
121
+ { label: 'Show second', snackbar: { text: 'Second message' } },
122
+ { label: 'Show latest', snackbar: { text: 'Latest message' } },
123
+ ]);
124
+
125
+ await user.click(screen.getByRole('button', { name: 'Show first' }));
126
+ advanceTimers(SNACKBAR_ENTER_DURATION);
127
+ await user.click(screen.getByRole('button', { name: 'Show second' }));
128
+ await user.click(screen.getByRole('button', { name: 'Show latest' }));
129
+
130
+ advanceTimers(SNACKBAR_EXIT_DURATION - 1);
131
+ expect(screen.getByText('First message')).toBeInTheDocument();
132
+ expect(screen.queryByText('Second message')).not.toBeInTheDocument();
133
+ expect(screen.queryByText('Latest message')).not.toBeInTheDocument();
134
+
135
+ advanceTimers(1);
136
+ expect(screen.queryByText('First message')).not.toBeInTheDocument();
137
+ expect(screen.queryByText('Second message')).not.toBeInTheDocument();
138
+ expect(screen.getByText('Latest message')).toBeInTheDocument();
139
+ expect(screen.getAllByRole('status')).toHaveLength(1);
140
+ expect(screen.getByText('Latest message').closest('.wds-Snackbar-container')).toHaveClass(
141
+ 'wds-Snackbar-container-appear-active',
142
+ );
143
+ });
144
+
145
+ it('shows the latest Snackbar when creations are batched before the first render', async () => {
146
+ render(
147
+ <SnackbarProvider>
148
+ <BatchedSnackbarTrigger />
149
+ </SnackbarProvider>,
150
+ );
151
+
152
+ await user.click(screen.getByRole('button', { name: 'Show latest' }));
153
+
154
+ expect(screen.queryByText('First message')).not.toBeInTheDocument();
155
+ expect(screen.getByText('Latest message')).toBeInTheDocument();
156
+ });
157
+
158
+ it('replaces Snackbars even when their timestamps and the clock are unchanged', async () => {
159
+ renderProvider([
160
+ { label: 'Show first', snackbar: { text: 'First message', timestamp: 1 } },
161
+ { label: 'Show second', snackbar: { text: 'Second message', timestamp: 1 } },
162
+ ]);
163
+
164
+ await user.click(screen.getByRole('button', { name: 'Show first' }));
165
+ await user.click(screen.getByRole('button', { name: 'Show second' }));
166
+ advanceTimers(SNACKBAR_EXIT_DURATION);
167
+
168
+ expect(screen.queryByText('First message')).not.toBeInTheDocument();
169
+ expect(screen.getByText('Second message')).toBeInTheDocument();
170
+ });
171
+
172
+ it('gives a replacing Snackbar a fresh timeout after the active one exits', async () => {
173
+ renderProvider([
174
+ { label: 'Show first', snackbar: { text: 'First message' } },
175
+ { label: 'Replace', snackbar: { text: 'Replacement' } },
176
+ ]);
177
+ await user.click(screen.getByRole('button', { name: 'Show first' }));
178
+ advanceTimers(TIMEOUT - SNACKBAR_EXIT_DURATION);
179
+ await user.click(screen.getByRole('button', { name: 'Replace' }));
180
+ advanceTimers(SNACKBAR_EXIT_DURATION);
181
+
182
+ advanceTimers(TIMEOUT - 1);
183
+ expect(screen.getByText('Replacement')).toBeInTheDocument();
184
+ advanceTimers(1);
185
+ advanceTimers(SNACKBAR_EXIT_DURATION);
186
+ expect(screen.queryByText('Replacement')).not.toBeInTheDocument();
187
+ });
188
+
189
+ it('does not freeze the active Snackbar when a replacement has empty text', async () => {
190
+ renderProvider([
191
+ { label: 'Show first', snackbar: { text: 'First message' } },
192
+ { label: 'Clear', snackbar: { text: '' } },
193
+ ]);
194
+ await user.click(screen.getByRole('button', { name: 'Show first' }));
195
+ advanceTimers(SNACKBAR_ENTER_DURATION);
196
+ await user.click(screen.getByRole('button', { name: 'Clear' }));
197
+ advanceTimers(SNACKBAR_EXIT_DURATION);
198
+
199
+ expect(screen.queryByText('First message')).not.toBeInTheDocument();
200
+ advanceTimers(TIMEOUT);
201
+ advanceTimers(SNACKBAR_EXIT_DURATION);
202
+ expect(document.querySelector('.wds-Snackbar')).toBeNull();
203
+ });
204
+
205
+ it('cleans up the active Snackbar timer when the Provider unmounts', async () => {
206
+ const view = renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }]);
207
+ await user.click(screen.getByRole('button', { name: 'Show' }));
208
+ advanceTimers(SNACKBAR_ENTER_DURATION);
209
+ expect(jest.getTimerCount()).toBe(1);
210
+
211
+ view.unmount();
212
+ expect(jest.getTimerCount()).toBe(0);
213
+ });
214
+
215
+ it('removes the portal after the close button finishes dismissing it', async () => {
216
+ renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }]);
217
+ await user.click(screen.getByRole('button', { name: 'Show' }));
218
+ await user.click(screen.getByRole('button', { name: 'Close' }));
219
+
220
+ advanceTimers(SNACKBAR_EXIT_DURATION - 1);
221
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
222
+ advanceTimers(1);
223
+ expect(document.querySelector('.wds-Snackbar')).toBeNull();
224
+ });
225
+
226
+ it('does not restart the timer when its parent rerenders', async () => {
227
+ const triggers = [{ label: 'Show', snackbar: { text: 'Changes saved' } }];
228
+ const view = renderProvider(triggers);
229
+ await user.click(screen.getByRole('button', { name: 'Show' }));
230
+ advanceTimers(SNACKBAR_ENTER_DURATION);
231
+
232
+ view.rerender(
233
+ <SnackbarProvider>
234
+ <SnackbarTriggers triggers={triggers} />
235
+ </SnackbarProvider>,
236
+ );
237
+ advanceTimers(TIMEOUT - SNACKBAR_ENTER_DURATION);
238
+ advanceTimers(SNACKBAR_EXIT_DURATION);
239
+
240
+ expect(document.querySelector('.wds-Snackbar')).toBeNull();
241
+ });
242
+
243
+ it('forwards navigation actions unchanged', async () => {
244
+ renderProvider([
245
+ {
246
+ label: 'Show',
247
+ snackbar: {
248
+ text: 'Transfer completed',
249
+ action: { label: <span>View transfer</span>, href: '/transfers/123', target: '_blank' },
250
+ },
251
+ },
252
+ ]);
253
+ await user.click(screen.getByRole('button', { name: 'Show' }));
254
+
255
+ const link = screen.getByRole('link', { name: 'View transfer' });
256
+ expect(link).toHaveAttribute('href', '/transfers/123');
257
+ expect(link).toHaveAttribute('target', '_blank');
258
+ });
259
+ });
260
+
261
+ /* eslint-enable testing-library/no-node-access */
262
+
263
+ describe('Snackbar manager instance guards', () => {
264
+ it('ignores an exit callback from an already replaced instance', () => {
265
+ const { result } = renderHook(() => useSnackbarManager());
266
+ act(() => result.current.showSnackbar({ text: 'First message' }));
267
+ const firstExited = result.current.handleExited;
268
+ act(() => result.current.showSnackbar({ text: 'Replacement' }));
269
+ act(() => firstExited());
270
+ const replacementKey = result.current.announcementKey;
271
+
272
+ act(() => firstExited());
273
+
274
+ expect(result.current.active?.props.text).toBe('Replacement');
275
+ expect(result.current.announcementKey).toBe(replacementKey);
276
+ expect(result.current.visible).toBe(true);
277
+ });
278
+
279
+ it('can dismiss a batched instance before it has rendered', () => {
280
+ const { result } = renderHook(() => useSnackbarManager());
281
+ act(() => {
282
+ result.current.showSnackbar({ text: 'Changes saved' });
283
+ result.current.dismissSnackbar();
284
+ });
285
+
286
+ expect(result.current.active).toBeNull();
287
+ expect(result.current.visible).toBe(false);
288
+ });
289
+ });
@@ -1,7 +1,8 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useMemo } from 'react';
2
2
 
3
- import SnackbarPortal, { SnackbarProps } from './Snackbar';
3
+ import SnackbarPortal from './SnackbarPortal';
4
4
  import { SnackbarContext } from './SnackbarContext';
5
+ import useSnackbarManager from './useSnackbarManager';
5
6
 
6
7
  export interface SnackbarProviderProps {
7
8
  timeout?: number;
@@ -9,24 +10,24 @@ export interface SnackbarProviderProps {
9
10
  }
10
11
 
11
12
  export default function SnackbarProvider({ timeout = 4500, children }: SnackbarProviderProps) {
12
- const [state, setState] = useState<Omit<SnackbarProps, 'timeout'>>({
13
- text: '',
14
- timestamp: 0,
15
- });
16
- const { action, text, timestamp } = state;
13
+ const { active, announcementKey, dismissSnackbar, handleExited, showSnackbar, visible } =
14
+ useSnackbarManager();
15
+ const contextValue = useMemo(() => ({ createSnackbar: showSnackbar }), [showSnackbar]);
17
16
 
18
17
  return (
19
- <SnackbarContext.Provider
20
- value={useMemo(
21
- () => ({
22
- createSnackbar: ({ action, text }) => {
23
- setState({ action, text, timestamp: Date.now() });
24
- },
25
- }),
26
- [],
27
- )}
28
- >
29
- <SnackbarPortal action={action} text={text} timestamp={timestamp} timeout={timeout} />
18
+ <SnackbarContext.Provider value={contextValue}>
19
+ {active ? (
20
+ <SnackbarPortal
21
+ // Each instance gets a fresh appear transition and live-region announcement.
22
+ key={active.id}
23
+ {...active.props}
24
+ announcementKey={announcementKey}
25
+ timeout={timeout}
26
+ visible={visible}
27
+ onDismissRequest={dismissSnackbar}
28
+ onExited={handleExited}
29
+ />
30
+ ) : null}
30
31
  {children}
31
32
  </SnackbarContext.Provider>
32
33
  );
@@ -1,8 +1,11 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-webpack5';
3
3
 
4
4
  import { useRtlDiffI18n, withRTLDiff } from '../../../.storybook/decorators/RtlDiffDecorator';
5
5
  import { Snackbar } from '../Snackbar';
6
+ import type { SnackbarCreationProps } from '../Snackbar.types';
7
+ import SnackbarProvider from '../SnackbarProvider';
8
+ import useSnackbar from '../useSnackbar';
6
9
 
7
10
  export default {
8
11
  component: Snackbar,
@@ -14,16 +17,23 @@ type Story = StoryObj<typeof Snackbar>;
14
17
 
15
18
  const TIMEOUT = 10_000_000;
16
19
 
17
- function SnackbarStory({ text, action }: { text: string; action?: { label: string } }) {
18
- const [timestamp, setTimestamp] = useState(0);
20
+ function ShowOnMount(props: SnackbarCreationProps) {
21
+ const createSnackbar = useSnackbar();
19
22
 
20
23
  useEffect(() => {
21
- // eslint-disable-next-line react-hooks/set-state-in-effect
22
- setTimestamp(1);
24
+ createSnackbar(props);
25
+ // This visual fixture creates once per mount, not on every render of its props.
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
23
27
  }, []);
24
28
 
29
+ return null;
30
+ }
31
+
32
+ function SnackbarStory({ text, action }: { text: string; action?: { label: string } }) {
25
33
  return (
26
- <Snackbar text={<span>{text}</span>} timestamp={timestamp} timeout={TIMEOUT} action={action} />
34
+ <SnackbarProvider timeout={TIMEOUT}>
35
+ <ShowOnMount text={<span>{text}</span>} action={action} />
36
+ </SnackbarProvider>
27
37
  );
28
38
  }
29
39
 
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-webpack5';
2
2
  import { fn, userEvent, within } from 'storybook/test';
3
+ import { Card } from '@transferwise/icons';
3
4
 
4
5
  import { wait } from '../../test-utils/wait';
5
6
  import {
@@ -7,7 +8,7 @@ import {
7
8
  globalScope,
8
9
  } from '../../../.storybook/components/sandbox/SandboxEditor';
9
10
  import Button from '../../Button';
10
- import { Snackbar } from '../Snackbar';
11
+ import { Snackbar, type SnackbarProps } from '../Snackbar';
11
12
  import { SnackbarConsumer } from '../SnackbarContext';
12
13
  import SnackbarProvider from '../SnackbarProvider';
13
14
 
@@ -17,6 +18,27 @@ const meta: Meta = {
17
18
  };
18
19
  export default meta;
19
20
 
21
+ type SnackbarExample = {
22
+ label: string;
23
+ props: Omit<SnackbarProps, 'timeout' | 'timestamp'>;
24
+ };
25
+
26
+ const SnackbarExamples = ({ examples }: { examples: SnackbarExample[] }) => (
27
+ <SnackbarProvider>
28
+ <SnackbarConsumer>
29
+ {({ createSnackbar }) => (
30
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--size-8)' }}>
31
+ {examples.map(({ label, props }) => (
32
+ <Button key={label} v2 size="md" onClick={() => createSnackbar(props)}>
33
+ {label}
34
+ </Button>
35
+ ))}
36
+ </div>
37
+ )}
38
+ </SnackbarConsumer>
39
+ </SnackbarProvider>
40
+ );
41
+
20
42
  export const Sandbox = createSandboxStory({
21
43
  code: `<SnackbarProvider>
22
44
  <SnackbarConsumer>
@@ -87,3 +109,42 @@ export const Basic: StoryObj = {
87
109
  await userEvent.click(canvas.getByRole('button'));
88
110
  },
89
111
  };
112
+
113
+ /**
114
+ * Use `sentiment` to match the meaning of the feedback. Sentiment controls appearance and default
115
+ * media; it does not change the polite screen-reader announcement.
116
+ */
117
+ export const Sentiments: StoryObj = {
118
+ render: () => (
119
+ <SnackbarExamples
120
+ examples={[
121
+ { label: 'Neutral', props: { text: 'Details updated', sentiment: 'neutral' } },
122
+ { label: 'Negative', props: { text: 'Transfer failed', sentiment: 'negative' } },
123
+ { label: 'Warning', props: { text: 'Check the recipient details', sentiment: 'warning' } },
124
+ { label: 'Success', props: { text: 'Transfer completed', sentiment: 'success' } },
125
+ {
126
+ label: 'Proposition',
127
+ props: { text: 'You can earn a referral reward', sentiment: 'proposition' },
128
+ },
129
+ ]}
130
+ />
131
+ ),
132
+ };
133
+
134
+ /**
135
+ * `media` overrides the sentiment-derived media. Custom media is displayed in the 24×24px media
136
+ * slot and should provide its own accessibility attributes when meaningful.
137
+ */
138
+ export const Media: StoryObj = {
139
+ render: () => (
140
+ <SnackbarExamples
141
+ examples={[
142
+ { label: 'Default media', props: { text: 'Transfer completed', sentiment: 'success' } },
143
+ {
144
+ label: 'Custom media',
145
+ props: { text: 'Card added', media: <Card size={24} title="Card" /> },
146
+ },
147
+ ]}
148
+ />
149
+ ),
150
+ };
@@ -1,4 +1,4 @@
1
- import { userEvent, within, expect } from 'storybook/test';
1
+ import { userEvent, within, expect, waitFor } from 'storybook/test';
2
2
  import { action } from 'storybook/actions';
3
3
  import { withVariantConfig } from '../../../.storybook/helpers';
4
4
  import { lorem20 } from '../../test-utils';
@@ -46,7 +46,7 @@ const Default = {
46
46
  block
47
47
  onClick={() => {
48
48
  createSnackbar({
49
- action: {
49
+ action: args.action ?? {
50
50
  label: 'View',
51
51
  onClick: () => action('View button clicked.'),
52
52
  },
@@ -72,6 +72,27 @@ export const Basic = {
72
72
  },
73
73
  };
74
74
 
75
+ export const NavigationAction = {
76
+ ...Default,
77
+ args: {
78
+ ...Default.args,
79
+ action: {
80
+ label: 'View transfer',
81
+ href: '/transfers/123',
82
+ target: '_blank',
83
+ },
84
+ },
85
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
86
+ await launchSnackbar(canvasElement);
87
+
88
+ const link = await waitFor(() =>
89
+ within(document.body).getByRole('link', { name: 'View transfer' }),
90
+ );
91
+ await expect(link).toHaveAttribute('href', '/transfers/123');
92
+ await expect(link).toHaveAttribute('target', '_blank');
93
+ },
94
+ };
95
+
75
96
  export const Mobile = {
76
97
  ...Default,
77
98
  args: {
@@ -88,9 +109,9 @@ const switchToDarkMode = async (context: { canvasElement: HTMLElement }) => {
88
109
  if (body) {
89
110
  body.setAttribute('class', 'np-theme-personal--dark');
90
111
  }
91
- const snackbar = document.querySelector('div.snackbar');
112
+ const snackbar = document.querySelector('div.wds-Snackbar');
92
113
  if (snackbar) {
93
- snackbar.setAttribute('class', 'snackbar np-theme-personal--dark');
114
+ snackbar.setAttribute('class', 'wds-Snackbar np-theme-personal--dark');
94
115
  }
95
116
  };
96
117
 
@@ -134,6 +155,6 @@ export const MultipleClicks = {
134
155
  await userEvent.click(canvas.getByRole('button'));
135
156
  }
136
157
 
137
- await expect(within(document.body).getAllByRole('alert')).toHaveLength(1);
158
+ await expect(within(document.body).getAllByRole('status')).toHaveLength(1);
138
159
  },
139
160
  };
@@ -0,0 +1,2 @@
1
+ export const SNACKBAR_ENTER_DURATION = 300;
2
+ export const SNACKBAR_EXIT_DURATION = 150;
@@ -0,0 +1,122 @@
1
+ import { useCallback, useEffect, useReducer, useRef } from 'react';
2
+
3
+ import type { SnackbarCreationProps } from './Snackbar.types';
4
+
5
+ type SnackbarInstance = {
6
+ id: number;
7
+ props: SnackbarCreationProps;
8
+ };
9
+
10
+ type SnackbarManagerState = {
11
+ active: SnackbarInstance | null;
12
+ activeHasRendered: boolean;
13
+ announcementKey: number;
14
+ pending: SnackbarInstance | null;
15
+ visible: boolean;
16
+ };
17
+
18
+ type SnackbarManagerAction =
19
+ | { type: 'show'; instance: SnackbarInstance }
20
+ | { type: 'rendered'; instanceId: number }
21
+ | { type: 'dismiss' }
22
+ | { type: 'exited'; instanceId: number };
23
+
24
+ const emptyState: SnackbarManagerState = {
25
+ active: null,
26
+ activeHasRendered: false,
27
+ announcementKey: 0,
28
+ pending: null,
29
+ visible: false,
30
+ };
31
+
32
+ const snackbarManagerReducer = (
33
+ state: SnackbarManagerState,
34
+ action: SnackbarManagerAction,
35
+ ): SnackbarManagerState => {
36
+ switch (action.type) {
37
+ case 'show':
38
+ if (!state.active || !state.activeHasRendered) {
39
+ // Batched creates collapse to the latest instance before anything has entered.
40
+ return {
41
+ active: action.instance,
42
+ activeHasRendered: false,
43
+ announcementKey: state.announcementKey + 1,
44
+ pending: null,
45
+ visible: true,
46
+ };
47
+ }
48
+
49
+ // Keep the active content until it exits; newer creates replace only the pending instance.
50
+ return { ...state, pending: action.instance, visible: false };
51
+ case 'rendered':
52
+ if (state.active?.id !== action.instanceId || state.activeHasRendered) {
53
+ return state;
54
+ }
55
+
56
+ return { ...state, activeHasRendered: true };
57
+ case 'dismiss':
58
+ if (!state.active) {
59
+ return state;
60
+ }
61
+
62
+ return state.activeHasRendered
63
+ ? { ...state, visible: false }
64
+ : { ...emptyState, announcementKey: state.announcementKey };
65
+ case 'exited':
66
+ // Ignore callbacks from an instance that has already been replaced.
67
+ if (state.active?.id !== action.instanceId) {
68
+ return state;
69
+ }
70
+
71
+ if (state.pending) {
72
+ return {
73
+ active: state.pending,
74
+ activeHasRendered: false,
75
+ announcementKey: state.announcementKey + 1,
76
+ pending: null,
77
+ visible: true,
78
+ };
79
+ }
80
+
81
+ return { ...emptyState, announcementKey: state.announcementKey };
82
+ default:
83
+ return state;
84
+ }
85
+ };
86
+
87
+ /** Owns one active Snackbar and its latest replacement, waiting for exit before the next enters. */
88
+ const useSnackbarManager = () => {
89
+ const [state, dispatch] = useReducer(snackbarManagerReducer, emptyState);
90
+ const nextInstanceId = useRef(0);
91
+
92
+ const showSnackbar = useCallback((props: SnackbarCreationProps) => {
93
+ nextInstanceId.current += 1;
94
+ dispatch({ type: 'show', instance: { id: nextInstanceId.current, props } });
95
+ }, []);
96
+
97
+ const dismissSnackbar = useCallback(() => dispatch({ type: 'dismiss' }), []);
98
+
99
+ const handleExited = useCallback(() => {
100
+ if (state.active) {
101
+ dispatch({ type: 'exited', instanceId: state.active.id });
102
+ }
103
+ }, [state.active]);
104
+
105
+ useEffect(() => {
106
+ if (state.active && state.visible && !state.activeHasRendered) {
107
+ // Later creates can now wait for this instance's exit instead of coalescing before render.
108
+ dispatch({ type: 'rendered', instanceId: state.active.id });
109
+ }
110
+ }, [state.active, state.activeHasRendered, state.visible]);
111
+
112
+ return {
113
+ active: state.active,
114
+ announcementKey: state.announcementKey,
115
+ dismissSnackbar,
116
+ handleExited,
117
+ showSnackbar,
118
+ visible: state.visible,
119
+ };
120
+ };
121
+
122
+ export default useSnackbarManager;
package/src/i18n/th.json CHANGED
@@ -35,7 +35,7 @@
35
35
  "neptune.PhoneNumberInput.SelectInput.placeholder": "เลือกตัวเลือก...",
36
36
  "neptune.PhoneNumberInput.countryCodeLabel": "รหัสประเทศ",
37
37
  "neptune.PhoneNumberInput.phoneNumberLabel": "หมายเลขโทรศัพท์",
38
- "neptune.PhoneNumberInput.phoneNumberPrefix": "รหัสประเทศ",
38
+ "neptune.PhoneNumberInput.phoneNumberPrefix": "คำนำหน้า",
39
39
  "neptune.Select.searchPlaceholder": "ค้นหา...",
40
40
  "neptune.SelectInput.noResultsFound": "ไม่พบผลลัพธ์",
41
41
  "neptune.StatusIcon.iconLabel.error": "ข้อผิดพลาด:",
@@ -35,7 +35,6 @@
35
35
  "neptune.PhoneNumberInput.SelectInput.placeholder": "选择其中一项...",
36
36
  "neptune.PhoneNumberInput.countryCodeLabel": "国家/地区代码",
37
37
  "neptune.PhoneNumberInput.phoneNumberLabel": "电话号码",
38
- "neptune.PhoneNumberInput.phoneNumberPrefix": "前缀",
39
38
  "neptune.Select.searchPlaceholder": "搜索",
40
39
  "neptune.SelectInput.noResultsFound": "找不到结果",
41
40
  "neptune.StatusIcon.iconLabel.error": "错误:",