@transferwise/components 0.0.0-experimental-d72e9b7 → 0.0.0-experimental-f4ca4fa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/build/Prompt/PrimitivePrompt/PrimitivePrompt.js +2 -1
  2. package/build/Prompt/PrimitivePrompt/PrimitivePrompt.js.map +1 -1
  3. package/build/Prompt/PrimitivePrompt/PrimitivePrompt.mjs +2 -1
  4. package/build/Prompt/PrimitivePrompt/PrimitivePrompt.mjs.map +1 -1
  5. package/build/Provider/Provider.js +24 -1
  6. package/build/Provider/Provider.js.map +1 -1
  7. package/build/Provider/Provider.mjs +24 -1
  8. package/build/Provider/Provider.mjs.map +1 -1
  9. package/build/Snackbar/Snackbar.js +179 -102
  10. package/build/Snackbar/Snackbar.js.map +1 -1
  11. package/build/Snackbar/Snackbar.messages.js +14 -0
  12. package/build/Snackbar/Snackbar.messages.js.map +1 -0
  13. package/build/Snackbar/Snackbar.messages.mjs +10 -0
  14. package/build/Snackbar/Snackbar.messages.mjs.map +1 -0
  15. package/build/Snackbar/Snackbar.mjs +181 -101
  16. package/build/Snackbar/Snackbar.mjs.map +1 -1
  17. package/build/Snackbar/SnackbarContext.js.map +1 -1
  18. package/build/Snackbar/SnackbarContext.mjs.map +1 -1
  19. package/build/Snackbar/SnackbarPortal.js +12 -0
  20. package/build/Snackbar/SnackbarPortal.js.map +1 -0
  21. package/build/Snackbar/SnackbarPortal.mjs +8 -0
  22. package/build/Snackbar/SnackbarPortal.mjs.map +1 -0
  23. package/build/Snackbar/SnackbarProvider.js +24 -28
  24. package/build/Snackbar/SnackbarProvider.js.map +1 -1
  25. package/build/Snackbar/SnackbarProvider.mjs +25 -29
  26. package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
  27. package/build/Snackbar/constants.js +17 -0
  28. package/build/Snackbar/constants.js.map +1 -0
  29. package/build/Snackbar/constants.mjs +11 -0
  30. package/build/Snackbar/constants.mjs.map +1 -0
  31. package/build/Snackbar/useSnackbarAutoDismiss.js +66 -0
  32. package/build/Snackbar/useSnackbarAutoDismiss.js.map +1 -0
  33. package/build/Snackbar/useSnackbarAutoDismiss.mjs +62 -0
  34. package/build/Snackbar/useSnackbarAutoDismiss.mjs.map +1 -0
  35. package/build/Snackbar/useSnackbarManager.js +153 -0
  36. package/build/Snackbar/useSnackbarManager.js.map +1 -0
  37. package/build/Snackbar/useSnackbarManager.mjs +149 -0
  38. package/build/Snackbar/useSnackbarManager.mjs.map +1 -0
  39. package/build/Snackbar/useSnackbarSwipeDismiss.js +135 -0
  40. package/build/Snackbar/useSnackbarSwipeDismiss.js.map +1 -0
  41. package/build/Snackbar/useSnackbarSwipeDismiss.mjs +131 -0
  42. package/build/Snackbar/useSnackbarSwipeDismiss.mjs.map +1 -0
  43. package/build/i18n/en.json +1 -0
  44. package/build/i18n/en.json.js +1 -0
  45. package/build/i18n/en.json.js.map +1 -1
  46. package/build/i18n/en.json.mjs +1 -0
  47. package/build/i18n/en.json.mjs.map +1 -1
  48. package/build/main.css +119 -57
  49. package/build/styles/IconButton/IconButton.css +3 -4
  50. package/build/styles/Snackbar/Snackbar.css +117 -54
  51. package/build/styles/main.css +119 -57
  52. package/build/types/Prompt/PrimitivePrompt/PrimitivePrompt.d.ts +11 -0
  53. package/build/types/Prompt/PrimitivePrompt/PrimitivePrompt.d.ts.map +1 -1
  54. package/build/types/Snackbar/Snackbar.d.ts +55 -9
  55. package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
  56. package/build/types/Snackbar/Snackbar.messages.d.ts +9 -0
  57. package/build/types/Snackbar/Snackbar.messages.d.ts.map +1 -0
  58. package/build/types/Snackbar/Snackbar.types.d.ts +12 -0
  59. package/build/types/Snackbar/Snackbar.types.d.ts.map +1 -0
  60. package/build/types/Snackbar/SnackbarContext.d.ts +2 -2
  61. package/build/types/Snackbar/SnackbarContext.d.ts.map +1 -1
  62. package/build/types/Snackbar/SnackbarPortal.d.ts +4 -0
  63. package/build/types/Snackbar/SnackbarPortal.d.ts.map +1 -0
  64. package/build/types/Snackbar/SnackbarProvider.d.ts +7 -0
  65. package/build/types/Snackbar/SnackbarProvider.d.ts.map +1 -1
  66. package/build/types/Snackbar/constants.d.ts +9 -0
  67. package/build/types/Snackbar/constants.d.ts.map +1 -0
  68. package/build/types/Snackbar/index.d.ts +1 -1
  69. package/build/types/Snackbar/index.d.ts.map +1 -1
  70. package/build/types/Snackbar/useSnackbar.d.ts +1 -1
  71. package/build/types/Snackbar/useSnackbar.d.ts.map +1 -1
  72. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts +13 -0
  73. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
  74. package/build/types/Snackbar/useSnackbarManager.d.ts +20 -0
  75. package/build/types/Snackbar/useSnackbarManager.d.ts.map +1 -0
  76. package/build/types/Snackbar/useSnackbarSwipeDismiss.d.ts +22 -0
  77. package/build/types/Snackbar/useSnackbarSwipeDismiss.d.ts.map +1 -0
  78. package/build/types/index.d.ts +1 -1
  79. package/build/types/index.d.ts.map +1 -1
  80. package/build/types/test-utils/index.d.ts +2 -0
  81. package/build/types/test-utils/index.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/src/IconButton/IconButton.css +3 -4
  84. package/src/IconButton/IconButton.less +14 -1
  85. package/src/Prompt/PrimitivePrompt/PrimitivePrompt.test.tsx +24 -1
  86. package/src/Prompt/PrimitivePrompt/PrimitivePrompt.tsx +8 -2
  87. package/src/Provider/Provider.tsx +1 -1
  88. package/src/Snackbar/Snackbar.css +117 -54
  89. package/src/Snackbar/Snackbar.less +139 -60
  90. package/src/Snackbar/Snackbar.messages.ts +9 -0
  91. package/src/Snackbar/Snackbar.test.tsx +992 -119
  92. package/src/Snackbar/Snackbar.tsx +209 -93
  93. package/src/Snackbar/Snackbar.types.ts +17 -0
  94. package/src/Snackbar/SnackbarContext.ts +2 -2
  95. package/src/Snackbar/SnackbarPortal.tsx +8 -0
  96. package/src/Snackbar/SnackbarProvider.test.tsx +783 -0
  97. package/src/Snackbar/SnackbarProvider.tsx +27 -19
  98. package/src/Snackbar/_stories/Snackbar.accessibility.docs.mdx +58 -0
  99. package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +16 -8
  100. package/src/Snackbar/_stories/Snackbar.story.tsx +388 -61
  101. package/src/Snackbar/_stories/Snackbar.test.story.tsx +171 -47
  102. package/src/Snackbar/constants.ts +10 -0
  103. package/src/Snackbar/index.ts +1 -1
  104. package/src/Snackbar/useSnackbarAutoDismiss.ts +74 -0
  105. package/src/Snackbar/useSnackbarManager.ts +169 -0
  106. package/src/Snackbar/useSnackbarSwipeDismiss.ts +169 -0
  107. package/src/i18n/en.json +1 -0
  108. package/src/index.ts +1 -1
  109. package/src/main.css +119 -57
@@ -1,12 +1,16 @@
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
+ import { Card } from '@transferwise/icons';
3
4
  import { withVariantConfig } from '../../../.storybook/helpers';
5
+ import { allModes } from '../../../.storybook/modes';
4
6
  import { lorem20 } from '../../test-utils';
5
7
  import { wait } from '../../test-utils/wait';
6
8
  import Button from '../../Button';
7
- import { Snackbar, type SnackbarProps } from '../Snackbar';
9
+ import { Snackbar } from '../Snackbar';
10
+ import type { SnackbarCreationProps } from '../Snackbar.types';
8
11
  import { SnackbarConsumer } from '../SnackbarContext';
9
12
  import SnackbarProvider from '../SnackbarProvider';
13
+ import useSnackbar from '../useSnackbar';
10
14
 
11
15
  export default {
12
16
  title: 'Prompts/Snackbar/Tests',
@@ -18,48 +22,41 @@ export default {
18
22
  const launchSnackbar = async (canvasElement: HTMLElement) => {
19
23
  await wait(0);
20
24
  const canvas = within(canvasElement);
21
- await userEvent.click(canvas.getByRole('button'));
25
+ await userEvent.click(canvas.getByRole('button', { name: 'Show Snackbar' }));
26
+ await waitFor(async () =>
27
+ expect(document.querySelector('.wds-Snackbar-container-appear-done')).not.toBeNull(),
28
+ );
22
29
  };
23
30
 
24
- type Args = Partial<SnackbarProps> & {
25
- extraContext: React.ReactNode;
26
- timeout: number;
31
+ type Args = Partial<SnackbarCreationProps> & {
32
+ extraContext?: React.ReactNode;
27
33
  };
28
34
 
29
- const TIMEOUT = 10000000;
35
+ function SnackbarTestTrigger({ text = 'Some text', extraContext, ...props }: Args) {
36
+ const createSnackbar = useSnackbar();
37
+
38
+ return (
39
+ <>
40
+ <Button v2 block onClick={() => createSnackbar({ text, ...props })}>
41
+ Show Snackbar
42
+ </Button>
43
+ {extraContext}
44
+ </>
45
+ );
46
+ }
30
47
 
31
48
  const Default = {
32
49
  args: {
33
- timeout: TIMEOUT,
50
+ duration: 'indefinite',
51
+ action: { label: 'View', onClick: action('View button clicked.') },
34
52
  } as Args,
35
53
  play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
36
54
  await launchSnackbar(canvasElement);
37
55
  },
38
56
  render: (args: Args) => {
39
57
  return (
40
- <SnackbarProvider timeout={args.timeout}>
41
- <SnackbarConsumer>
42
- {({ createSnackbar }) => (
43
- <>
44
- <Button
45
- v2
46
- block
47
- onClick={() => {
48
- createSnackbar({
49
- action: {
50
- label: 'View',
51
- onClick: () => action('View button clicked.'),
52
- },
53
- text: <span>Some text</span>,
54
- });
55
- }}
56
- >
57
- Show Snackbar
58
- </Button>
59
- {args.extraContext}
60
- </>
61
- )}
62
- </SnackbarConsumer>
58
+ <SnackbarProvider>
59
+ <SnackbarTestTrigger {...args} />
63
60
  </SnackbarProvider>
64
61
  );
65
62
  },
@@ -72,26 +69,100 @@ export const Basic = {
72
69
  },
73
70
  };
74
71
 
75
- export const Mobile = {
72
+ export const Success = {
73
+ ...Default,
74
+ args: { ...Default.args, sentiment: 'success' },
75
+ };
76
+
77
+ export const Warning = {
78
+ ...Default,
79
+ args: { ...Default.args, sentiment: 'warning' },
80
+ };
81
+
82
+ export const Negative = {
83
+ ...Default,
84
+ args: { ...Default.args, sentiment: 'negative' },
85
+ };
86
+
87
+ export const Proposition = {
88
+ ...Default,
89
+ args: { ...Default.args, sentiment: 'proposition' },
90
+ parameters: {
91
+ chromatic: {
92
+ modes: { Desktop: allModes.desktop, 'Zoom 400%': allModes.zoom400 },
93
+ },
94
+ },
95
+ };
96
+
97
+ export const WithoutAction = {
98
+ ...Default,
99
+ args: { ...Default.args, action: undefined },
100
+ };
101
+
102
+ export const CustomMedia = {
103
+ ...Default,
104
+ render: (args: Args) => (
105
+ <SnackbarProvider>
106
+ <SnackbarTestTrigger {...args} media={<Card aria-hidden />} />
107
+ </SnackbarProvider>
108
+ ),
109
+ parameters: {
110
+ chromatic: {
111
+ modes: {
112
+ Desktop: allModes.desktop,
113
+ Dark: { ...allModes.desktop, ...allModes.dark },
114
+ Mobile: allModes.largeMobile,
115
+ 'Zoom 400%': allModes.zoom400,
116
+ },
117
+ },
118
+ },
119
+ };
120
+
121
+ export const Loading = {
76
122
  ...Default,
77
123
  args: {
78
124
  ...Default.args,
125
+ loading: true,
126
+ },
127
+ parameters: {
128
+ chromatic: {
129
+ modes: {
130
+ Desktop: allModes.desktop,
131
+ Dark: { ...allModes.desktop, ...allModes.dark },
132
+ Mobile: allModes.largeMobile,
133
+ 'Zoom 400%': allModes.zoom400,
134
+ },
135
+ },
79
136
  },
80
- ...withVariantConfig(['mobile'], Default),
81
137
  };
82
138
 
83
- const switchToDarkMode = async (context: { canvasElement: HTMLElement }) => {
84
- await Default.play(context);
139
+ export const NavigationAction = {
140
+ ...Default,
141
+ args: {
142
+ ...Default.args,
143
+ action: {
144
+ label: 'View transfer',
145
+ href: '/transfers/123',
146
+ target: '_blank',
147
+ },
148
+ },
149
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
150
+ await launchSnackbar(canvasElement);
151
+
152
+ const link = await waitFor(() =>
153
+ within(document.body).getByRole('link', { name: 'View transfer' }),
154
+ );
155
+ await expect(link).toHaveAttribute('href', '/transfers/123');
156
+ await expect(link).toHaveAttribute('target', '_blank');
157
+ },
158
+ };
85
159
 
86
- // the snackbar is outside the storybook dom so dark mode doesn't apply
87
- const body = document.querySelector('div.np-theme-personal');
88
- if (body) {
89
- body.setAttribute('class', 'np-theme-personal--dark');
90
- }
91
- const snackbar = document.querySelector('div.snackbar');
92
- if (snackbar) {
93
- snackbar.setAttribute('class', 'snackbar np-theme-personal--dark');
94
- }
160
+ export const Mobile = {
161
+ ...Default,
162
+ args: {
163
+ ...Default.args,
164
+ },
165
+ ...withVariantConfig(['mobile'], Default),
95
166
  };
96
167
 
97
168
  export const DarkMode = {
@@ -99,8 +170,7 @@ export const DarkMode = {
99
170
  args: {
100
171
  ...Default.args,
101
172
  },
102
- play: switchToDarkMode,
103
- ...withVariantConfig(['dark'], Default),
173
+ globals: { screenMode: 'dark' },
104
174
  };
105
175
 
106
176
  export const Zoom400 = {
@@ -120,6 +190,31 @@ export const Zoom400 = {
120
190
  ...withVariantConfig(['400%'], Default),
121
191
  };
122
192
 
193
+ export const Zoom400ShortMessage = {
194
+ ...Default,
195
+ ...withVariantConfig(['400%'], Default),
196
+ };
197
+
198
+ /** Deliberately unsuitable content: records how unbroken words can overflow the layout. */
199
+ export const LongUnbrokenText = {
200
+ ...Default,
201
+ args: {
202
+ ...Default.args,
203
+ text: `Your transfer is ready. ${'VeryLongUnbrokenTransferReference'.repeat(5)}`,
204
+ },
205
+ parameters: {
206
+ docs: {
207
+ description: {
208
+ story:
209
+ 'Stress case, not recommended usage. Very long unbroken words can overflow the current layout; keep Snackbar messages short and readable.',
210
+ },
211
+ },
212
+ chromatic: {
213
+ modes: { Desktop: allModes.desktop, Mobile: allModes.largeMobile },
214
+ },
215
+ },
216
+ };
217
+
123
218
  /**
124
219
  * Snackbar is only shown once even if trigger is clicked many times
125
220
  */
@@ -134,6 +229,35 @@ export const MultipleClicks = {
134
229
  await userEvent.click(canvas.getByRole('button'));
135
230
  }
136
231
 
137
- await expect(within(document.body).getAllByRole('alert')).toHaveLength(1);
232
+ await expect(within(document.body).getAllByRole('status')).toHaveLength(1);
233
+ },
234
+ };
235
+
236
+ /** Empty text dismisses the current Snackbar without rendering a blank replacement. */
237
+ export const ClearedMessage = {
238
+ ...Default,
239
+ args: {
240
+ ...Default.args,
241
+ extraContext: (
242
+ <SnackbarConsumer>
243
+ {({ createSnackbar }) => (
244
+ <Button v2 onClick={() => createSnackbar({ text: '' })}>
245
+ Clear Snackbar
246
+ </Button>
247
+ )}
248
+ </SnackbarConsumer>
249
+ ),
250
+ },
251
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
252
+ const canvas = within(canvasElement);
253
+ await userEvent.click(canvas.getByRole('button', { name: 'Show Snackbar' }));
254
+ await waitFor(async () => {
255
+ await expect(within(document.body).getByRole('status')).toBeInTheDocument();
256
+ });
257
+ await userEvent.click(canvas.getByRole('button', { name: 'Clear Snackbar' }));
258
+ await waitFor(async () => {
259
+ await expect(within(document.body).queryByRole('status')).not.toBeInTheDocument();
260
+ await expect(document.querySelector('.wds-Snackbar')).toBeNull();
261
+ });
138
262
  },
139
263
  };
@@ -0,0 +1,10 @@
1
+ export const SNACKBAR_ENTER_DURATION = 300;
2
+ export const SNACKBAR_EXIT_DURATION = 150;
3
+
4
+ /** Default auto-dismiss duration for short feedback, in milliseconds. */
5
+ export const SNACKBAR_DEFAULT_DURATION = 4000;
6
+ /** Extended auto-dismiss duration for messages needing more reading time, in milliseconds. */
7
+ export const SNACKBAR_EXTENDED_DURATION = 6000;
8
+
9
+ /** Fraction of the Snackbar surface height a downward swipe must travel to dismiss (0.5 = half). */
10
+ export const SNACKBAR_SWIPE_DISMISS_THRESHOLD = 0.5;
@@ -1,4 +1,4 @@
1
1
  export { default } from './Snackbar';
2
2
 
3
- export type { SnackbarProps } from './Snackbar';
3
+ export type { SnackbarCreationProps } from './Snackbar.types';
4
4
  export type { SnackbarProviderProps } from './SnackbarProvider';
@@ -0,0 +1,74 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import { SNACKBAR_DEFAULT_DURATION, SNACKBAR_EXTENDED_DURATION } from './constants';
4
+ import type { SnackbarProps } from './Snackbar';
5
+ import type { SnackbarDuration } from './Snackbar.types';
6
+
7
+ const DURATIONS: Record<SnackbarDuration, number | null> = {
8
+ default: SNACKBAR_DEFAULT_DURATION,
9
+ extended: SNACKBAR_EXTENDED_DURATION,
10
+ indefinite: null,
11
+ };
12
+
13
+ /** Starts after entry finishes and pauses on hover, focus or swipe, preserving the remaining time. */
14
+ const useSnackbarAutoDismiss = ({
15
+ duration,
16
+ loading,
17
+ onDismissRequest,
18
+ timeout,
19
+ visible,
20
+ }: Pick<SnackbarProps, 'duration' | 'loading' | 'onDismissRequest' | 'timeout' | 'visible'>) => {
21
+ const surfaceRef = useRef<HTMLDivElement>(null);
22
+ const milliseconds = loading
23
+ ? null
24
+ : duration
25
+ ? DURATIONS[duration]
26
+ : (timeout ?? SNACKBAR_DEFAULT_DURATION);
27
+ const remaining = useRef(milliseconds);
28
+ const [entered, setEntered] = useState(false);
29
+ const [hovered, setHovered] = useState(false);
30
+ const [focusWithin, setFocusWithin] = useState(false);
31
+ const [swiping, setSwiping] = useState(false);
32
+
33
+ const running = visible && entered && !hovered && !focusWithin && !swiping;
34
+
35
+ useEffect(() => {
36
+ // Only a timing change starts a fresh budget; interaction and callback changes preserve it.
37
+ remaining.current = milliseconds;
38
+ }, [milliseconds]);
39
+
40
+ useEffect(() => {
41
+ const ms = remaining.current;
42
+ if (!running || ms === null) {
43
+ return undefined;
44
+ }
45
+
46
+ const startedAt = Date.now();
47
+ const id = window.setTimeout(() => onDismissRequest(), ms);
48
+ return () => {
49
+ window.clearTimeout(id);
50
+ remaining.current = Math.max(0, ms - (Date.now() - startedAt));
51
+ };
52
+ }, [running, onDismissRequest, milliseconds]);
53
+
54
+ return {
55
+ handleBlur: (event: React.FocusEvent<HTMLDivElement>) => {
56
+ if (!event.currentTarget.contains(event.relatedTarget)) {
57
+ setFocusWithin(false);
58
+ }
59
+ },
60
+ handleEntered: () => {
61
+ // Entry can finish beneath the pointer or with focus already inside the surface.
62
+ setHovered(surfaceRef.current?.matches(':hover') ?? false);
63
+ setFocusWithin(Boolean(surfaceRef.current?.contains(document.activeElement)));
64
+ setEntered(true);
65
+ },
66
+ handleFocus: () => setFocusWithin(true),
67
+ handleMouseEnter: () => setHovered(true),
68
+ handleMouseLeave: () => setHovered(false),
69
+ setSwiping,
70
+ surfaceRef,
71
+ };
72
+ };
73
+
74
+ export default useSnackbarAutoDismiss;
@@ -0,0 +1,169 @@
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
+ /** The instance currently entering, visible or exiting. `null` means the provider is idle. */
12
+ active: SnackbarInstance | null;
13
+ /** Marks the active provider render as committed, not its entry animation as finished. */
14
+ activeHasRendered: boolean;
15
+ /** Changes when a new instance becomes active so repeated message text can be announced. */
16
+ announcementKey: number;
17
+ /** The latest replacement waiting for active to exit. `null` means no replacement is waiting. */
18
+ pending: SnackbarInstance | null;
19
+ /** Whether active should enter/stay visible; false requests its exit or indicates idle state. */
20
+ visible: boolean;
21
+ };
22
+
23
+ type SnackbarManagerAction =
24
+ | { type: 'show'; instance: SnackbarInstance }
25
+ | {
26
+ /** Ends pre-render coalescing; subsequent creates must wait for the committed instance to exit. */
27
+ type: 'rendered';
28
+ instanceId: number;
29
+ }
30
+ | { type: 'dismiss' }
31
+ | {
32
+ /** Like `dismiss`, but also cancels a queued replacement. Sent when `createSnackbar` gets empty text. */
33
+ type: 'clear';
34
+ }
35
+ | { type: 'exited'; instanceId: number };
36
+
37
+ const emptyState: SnackbarManagerState = {
38
+ active: null,
39
+ activeHasRendered: false,
40
+ announcementKey: 0,
41
+ pending: null,
42
+ visible: false,
43
+ };
44
+
45
+ /**
46
+ * Coalesces creates before the first commit, then keeps active content until its exit finishes.
47
+ * Only the latest pending replacement is retained. Exited promotes pending or returns to idle.
48
+ * Instance IDs prevent stale exit callbacks from affecting a newer Snackbar.
49
+ */
50
+ const snackbarManagerReducer = (
51
+ state: SnackbarManagerState,
52
+ action: SnackbarManagerAction,
53
+ ): SnackbarManagerState => {
54
+ switch (action.type) {
55
+ case 'show':
56
+ if (!state.active || !state.activeHasRendered) {
57
+ // Batched creates collapse to the latest instance before anything has entered.
58
+ return {
59
+ active: action.instance,
60
+ activeHasRendered: false,
61
+ announcementKey: state.announcementKey + 1,
62
+ pending: null,
63
+ visible: true,
64
+ };
65
+ }
66
+
67
+ // Keep the active content until it exits; newer creates replace only the pending instance.
68
+ return { ...state, pending: action.instance, visible: false };
69
+ case 'rendered':
70
+ if (state.active?.id !== action.instanceId || state.activeHasRendered) {
71
+ return state;
72
+ }
73
+
74
+ return { ...state, activeHasRendered: true };
75
+ case 'dismiss':
76
+ case 'clear':
77
+ if (!state.active) {
78
+ return state;
79
+ }
80
+
81
+ if (!state.activeHasRendered) {
82
+ return { ...emptyState, announcementKey: state.announcementKey };
83
+ }
84
+
85
+ // Dismissal lets a queued replacement show next; clearing cancels it as well.
86
+ return { ...state, pending: action.type === 'clear' ? null : state.pending, visible: false };
87
+ case 'exited':
88
+ // Ignore callbacks from an instance that has already been replaced.
89
+ if (state.active?.id !== action.instanceId) {
90
+ return state;
91
+ }
92
+
93
+ if (state.pending) {
94
+ return {
95
+ active: state.pending,
96
+ activeHasRendered: false,
97
+ announcementKey: state.announcementKey + 1,
98
+ pending: null,
99
+ visible: true,
100
+ };
101
+ }
102
+
103
+ return { ...emptyState, announcementKey: state.announcementKey };
104
+ default:
105
+ return state;
106
+ }
107
+ };
108
+
109
+ /**
110
+ * Owns latest-wins replacement through reducer actions, separate from rendering and animation.
111
+ * Stable create/dismiss callbacks request state changes; the surface's onExited callback completes
112
+ * dismissal. The provider uses this state to mount/unmount the portal without a notification queue.
113
+ */
114
+ const useSnackbarManager = () => {
115
+ const [state, dispatch] = useReducer(snackbarManagerReducer, emptyState);
116
+ const nextInstanceId = useRef(0);
117
+
118
+ const createSnackbar = useCallback((props: SnackbarCreationProps) => {
119
+ const { text } = props;
120
+ if (
121
+ text == null ||
122
+ typeof text === 'boolean' ||
123
+ (typeof text === 'string' && text.trim() === '')
124
+ ) {
125
+ // A clear request must dismiss active and cancel pending, never create a blank replacement.
126
+ dispatch({ type: 'clear' });
127
+ return;
128
+ }
129
+
130
+ nextInstanceId.current += 1;
131
+ dispatch({ type: 'show', instance: { id: nextInstanceId.current, props } });
132
+ }, []);
133
+
134
+ const dismissSnackbar = useCallback(() => dispatch({ type: 'dismiss' }), []);
135
+
136
+ const handleExited = useCallback(() => {
137
+ if (!state.active) {
138
+ return;
139
+ }
140
+
141
+ try {
142
+ state.active.props.onDismiss?.();
143
+ } catch (error) {
144
+ // This runs in a transition commit; an escaping error could unmount the provider.
145
+ // eslint-disable-next-line no-console
146
+ console.error('Snackbar onDismiss callback failed:', error);
147
+ } finally {
148
+ dispatch({ type: 'exited', instanceId: state.active.id });
149
+ }
150
+ }, [state.active]);
151
+
152
+ useEffect(() => {
153
+ if (state.active && state.visible && !state.activeHasRendered) {
154
+ // Later creates can now wait for this instance's exit instead of coalescing before render.
155
+ dispatch({ type: 'rendered', instanceId: state.active.id });
156
+ }
157
+ }, [state.active, state.activeHasRendered, state.visible]);
158
+
159
+ return {
160
+ active: state.active,
161
+ announcementKey: state.announcementKey,
162
+ createSnackbar,
163
+ dismissSnackbar,
164
+ handleExited,
165
+ visible: state.visible,
166
+ };
167
+ };
168
+
169
+ export default useSnackbarManager;