@transferwise/components 0.0.0-experimental-cf159cc → 0.0.0-experimental-7990d38

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 (45) hide show
  1. package/build/Snackbar/Snackbar.js +22 -9
  2. package/build/Snackbar/Snackbar.js.map +1 -1
  3. package/build/Snackbar/Snackbar.mjs +22 -9
  4. package/build/Snackbar/Snackbar.mjs.map +1 -1
  5. package/build/Snackbar/SnackbarProvider.js +1 -1
  6. package/build/Snackbar/SnackbarProvider.js.map +1 -1
  7. package/build/Snackbar/SnackbarProvider.mjs +1 -1
  8. package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
  9. package/build/Snackbar/constants.js +6 -0
  10. package/build/Snackbar/constants.js.map +1 -1
  11. package/build/Snackbar/constants.mjs +5 -1
  12. package/build/Snackbar/constants.mjs.map +1 -1
  13. package/build/Snackbar/useSnackbarAutoDismiss.js +143 -0
  14. package/build/Snackbar/useSnackbarAutoDismiss.js.map +1 -0
  15. package/build/Snackbar/useSnackbarAutoDismiss.mjs +139 -0
  16. package/build/Snackbar/useSnackbarAutoDismiss.mjs.map +1 -0
  17. package/build/types/Snackbar/Snackbar.d.ts +26 -6
  18. package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
  19. package/build/types/Snackbar/Snackbar.types.d.ts +3 -14
  20. package/build/types/Snackbar/Snackbar.types.d.ts.map +1 -1
  21. package/build/types/Snackbar/SnackbarPortal.d.ts +1 -1
  22. package/build/types/Snackbar/SnackbarPortal.d.ts.map +1 -1
  23. package/build/types/Snackbar/SnackbarProvider.d.ts +7 -0
  24. package/build/types/Snackbar/SnackbarProvider.d.ts.map +1 -1
  25. package/build/types/Snackbar/constants.d.ts +4 -0
  26. package/build/types/Snackbar/constants.d.ts.map +1 -1
  27. package/build/types/Snackbar/index.d.ts +1 -1
  28. package/build/types/Snackbar/index.d.ts.map +1 -1
  29. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts +19 -0
  30. package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
  31. package/build/types/index.d.ts +1 -1
  32. package/build/types/index.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Snackbar/Snackbar.test.tsx +297 -9
  35. package/src/Snackbar/Snackbar.tsx +41 -17
  36. package/src/Snackbar/Snackbar.types.ts +7 -15
  37. package/src/Snackbar/SnackbarProvider.test.tsx +64 -4
  38. package/src/Snackbar/SnackbarProvider.tsx +8 -1
  39. package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +2 -4
  40. package/src/Snackbar/_stories/Snackbar.story.tsx +40 -2
  41. package/src/Snackbar/_stories/Snackbar.test.story.tsx +6 -7
  42. package/src/Snackbar/constants.ts +5 -0
  43. package/src/Snackbar/index.ts +1 -1
  44. package/src/Snackbar/useSnackbarAutoDismiss.ts +152 -0
  45. package/src/index.ts +1 -1
@@ -1,5 +1,4 @@
1
1
  import { Tags } from '@transferwise/icons';
2
- import { useEffect, useRef } from 'react';
3
2
  import { CSSTransition } from 'react-transition-group';
4
3
 
5
4
  import Button from '../Button';
@@ -11,17 +10,31 @@ import type { Sentiment } from '../SentimentSurface';
11
10
  import StatusIcon from '../StatusIcon';
12
11
 
13
12
  import { SNACKBAR_ENTER_DURATION, SNACKBAR_EXIT_DURATION } from './constants';
14
- import type { SnackbarAction, SnackbarInternalProps } from './Snackbar.types';
13
+ import type { SnackbarAction, SnackbarDuration } from './Snackbar.types';
14
+ import useSnackbarAutoDismiss from './useSnackbarAutoDismiss';
15
15
 
16
16
  /**
17
- * Message options retained for public type compatibility. Create notifications through
18
- * SnackbarProvider/useSnackbar; the controlled surface also needs provider-only lifecycle props.
17
+ * Props for the controlled Snackbar surface, including provider-managed lifecycle controls.
18
+ * Use SnackbarCreationProps when creating notifications through SnackbarProvider/useSnackbar.
19
19
  */
20
20
  export interface SnackbarProps {
21
21
  /** One optional action, supporting a callback or navigation through `href` and `target`. */
22
22
  action?: SnackbarAction;
23
+ /** Changes for each new Snackbar so LiveRegion re-announces it. */
24
+ announcementKey: number;
25
+ /**
26
+ * Controls auto-dismiss with a four-second, six-second or indefinite duration after entry finishes.
27
+ * Takes precedence over the deprecated `timeout`.
28
+ * When neither option is supplied, the default is 4000ms (previously 4500ms).
29
+ * @default 'default'
30
+ */
31
+ duration?: SnackbarDuration;
23
32
  /** Overrides sentiment-derived media with content for the 24×24px media slot. */
24
33
  media?: React.ReactNode;
34
+ /** Requests an exit; the provider retains the surface until onExited confirms it has finished. */
35
+ onDismissRequest: () => void;
36
+ /** Called after the exit animation so the provider can unmount active or show its replacement. */
37
+ onExited: () => void;
25
38
  /**
26
39
  * Controls the sentiment surface and default media.
27
40
  * @default 'neutral'
@@ -31,15 +44,23 @@ export interface SnackbarProps {
31
44
  text: React.ReactNode;
32
45
  /** @deprecated Has no effect. Snackbar theming is based on ThemeProvider. */
33
46
  theme?: ThemeLight | ThemeDark;
34
- /** Auto-dismiss delay in milliseconds, supplied by the provider. */
35
- timeout: number;
47
+ /**
48
+ * @deprecated Use `duration` instead. Explicit millisecond values remain supported and are not
49
+ * automatically remapped when no `duration` is supplied.
50
+ * To migrate, use `default` for 0–5000ms, `extended` for values above 5000ms, and `indefinite`
51
+ * for intentionally persistent notifications.
52
+ */
53
+ timeout?: number;
36
54
  /** @deprecated Has no effect when using SnackbarProvider, which manages Snackbar identity. */
37
55
  timestamp?: number;
56
+ /** Controls entry/exit; the surface does not manage visibility itself. */
57
+ visible: boolean;
38
58
  }
39
59
 
40
60
  export const Snackbar = ({
41
61
  action,
42
62
  announcementKey,
63
+ duration,
43
64
  media,
44
65
  onDismissRequest,
45
66
  onExited,
@@ -47,17 +68,15 @@ export const Snackbar = ({
47
68
  text,
48
69
  timeout,
49
70
  visible,
50
- }: SnackbarInternalProps) => {
51
- const surfaceRef = useRef<HTMLDivElement>(null);
52
-
53
- useEffect(() => {
54
- if (!visible) {
55
- return undefined;
56
- }
57
-
58
- const id = window.setTimeout(onDismissRequest, timeout);
59
- return () => window.clearTimeout(id);
60
- }, [announcementKey, onDismissRequest, timeout, visible]);
71
+ }: SnackbarProps) => {
72
+ const { handleBlur, handleEntered, handleFocus, handleMouseEnter, handleMouseLeave, surfaceRef } =
73
+ useSnackbarAutoDismiss({
74
+ duration,
75
+ onDismissRequest,
76
+ resetKey: announcementKey,
77
+ timeout,
78
+ visible,
79
+ });
61
80
 
62
81
  const renderMedia = () => {
63
82
  if (media) {
@@ -96,6 +115,7 @@ export const Snackbar = ({
96
115
  exit: SNACKBAR_EXIT_DURATION,
97
116
  }}
98
117
  unmountOnExit
118
+ onEntered={handleEntered}
99
119
  onExited={onExited}
100
120
  >
101
121
  <PrimitivePrompt
@@ -105,6 +125,10 @@ export const Snackbar = ({
105
125
  emphasis="elevated"
106
126
  media={renderMedia()}
107
127
  onDismiss={onDismissRequest}
128
+ onMouseEnter={handleMouseEnter}
129
+ onMouseLeave={handleMouseLeave}
130
+ onFocus={handleFocus}
131
+ onBlur={handleBlur}
108
132
  >
109
133
  <div className="wds-Snackbar__content">
110
134
  <LiveRegion
@@ -1,6 +1,9 @@
1
1
  import type { ButtonProps, ButtonReferenceType } from '../Button/Button.types';
2
2
  import type { SnackbarProps } from './Snackbar';
3
3
 
4
+ /** Semantic auto-dismiss durations. `indefinite` disables the timer. */
5
+ export type SnackbarDuration = 'default' | 'extended' | 'indefinite';
6
+
4
7
  /** One optional action. Supports callbacks and navigation through the Button API. */
5
8
  export type SnackbarAction = Pick<ButtonProps, 'href' | 'target'> & {
6
9
  label: ButtonProps['children'];
@@ -8,18 +11,7 @@ export type SnackbarAction = Pick<ButtonProps, 'href' | 'target'> & {
8
11
  };
9
12
 
10
13
  /** 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
- /** Requests an exit; the provider retains the surface until onExited confirms it has finished. */
18
- onDismissRequest: () => void;
19
- /** Called after the exit animation so the provider can unmount active or show its replacement. */
20
- onExited: () => void;
21
- /** Auto-dismiss delay in milliseconds, supplied by the provider. */
22
- timeout: number;
23
- /** Controls entry/exit; the surface does not manage visibility itself. */
24
- visible: boolean;
25
- }
14
+ export type SnackbarCreationProps = Omit<
15
+ SnackbarProps,
16
+ 'announcementKey' | 'onDismissRequest' | 'onExited' | 'timeout' | 'visible'
17
+ >;
@@ -3,13 +3,18 @@ import { renderHook } from '@testing-library/react';
3
3
  import { act, mockMatchMedia, render, screen, userEvent } from '../test-utils';
4
4
  import { resetLiveRegionAnnouncementQueue } from '../common/LiveRegion/LiveRegion';
5
5
 
6
- import { SNACKBAR_ENTER_DURATION, SNACKBAR_EXIT_DURATION } from './constants';
6
+ import {
7
+ SNACKBAR_DEFAULT_DURATION,
8
+ SNACKBAR_ENTER_DURATION,
9
+ SNACKBAR_EXIT_DURATION,
10
+ SNACKBAR_EXTENDED_DURATION,
11
+ } from './constants';
7
12
  import type { SnackbarCreationProps } from './Snackbar.types';
8
13
  import SnackbarProvider from './SnackbarProvider';
9
14
  import useSnackbar from './useSnackbar';
10
15
  import useSnackbarManager from './useSnackbarManager';
11
16
 
12
- const TIMEOUT = 4500;
17
+ const TIMEOUT = SNACKBAR_DEFAULT_DURATION;
13
18
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
14
19
 
15
20
  mockMatchMedia();
@@ -114,10 +119,11 @@ describe('SnackbarProvider orchestration', () => {
114
119
  );
115
120
  });
116
121
 
117
- it('keeps the default timeout until semantic durations are introduced', async () => {
122
+ it('dismisses after the default four-second duration', async () => {
118
123
  renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }]);
119
124
  await user.click(screen.getByRole('button', { name: 'Show' }));
120
125
 
126
+ advanceTimers(SNACKBAR_ENTER_DURATION);
121
127
  advanceTimers(TIMEOUT - 1);
122
128
  expect(screen.getByText('Changes saved')).toBeInTheDocument();
123
129
 
@@ -131,6 +137,7 @@ describe('SnackbarProvider orchestration', () => {
131
137
  renderProvider([{ label: 'Show', snackbar: { text: 'Changes saved' } }], TIMEOUT * 2);
132
138
  await user.click(screen.getByRole('button', { name: 'Show' }));
133
139
 
140
+ advanceTimers(SNACKBAR_ENTER_DURATION);
134
141
  advanceTimers(TIMEOUT * 2 - 1);
135
142
  expect(screen.getByText('Changes saved')).toBeInTheDocument();
136
143
 
@@ -139,6 +146,58 @@ describe('SnackbarProvider orchestration', () => {
139
146
  expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
140
147
  });
141
148
 
149
+ it('uses an explicit semantic duration instead of the legacy Provider timeout', async () => {
150
+ renderProvider(
151
+ [{ label: 'Show', snackbar: { text: 'Changes saved', duration: 'extended' } }],
152
+ 1,
153
+ );
154
+ await user.click(screen.getByRole('button', { name: 'Show' }));
155
+
156
+ advanceTimers(SNACKBAR_ENTER_DURATION);
157
+ advanceTimers(SNACKBAR_EXTENDED_DURATION - 1);
158
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
159
+ advanceTimers(1);
160
+ advanceTimers(SNACKBAR_EXIT_DURATION);
161
+ expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
162
+ });
163
+
164
+ it('pauses the provider timer while keyboard focus moves within the Snackbar', async () => {
165
+ renderProvider([
166
+ { label: 'Show', snackbar: { text: 'Changes saved', action: { label: 'Undo' } } },
167
+ ]);
168
+ await user.click(screen.getByRole('button', { name: 'Show' }));
169
+ advanceTimers(SNACKBAR_ENTER_DURATION);
170
+ advanceTimers(TIMEOUT / 2);
171
+
172
+ await user.tab();
173
+ expect(screen.getByRole('button', { name: 'Undo' })).toHaveFocus();
174
+ advanceTimers(TIMEOUT * 2);
175
+ await user.tab();
176
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
177
+ advanceTimers(TIMEOUT * 2);
178
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
179
+
180
+ await user.tab();
181
+ advanceTimers(TIMEOUT / 2 - 1);
182
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
183
+ advanceTimers(1);
184
+ advanceTimers(SNACKBAR_EXIT_DURATION);
185
+ expect(document.querySelector('.wds-Snackbar')).toBeNull();
186
+ });
187
+
188
+ it('keeps indefinite notifications until manually dismissed', async () => {
189
+ renderProvider([
190
+ { label: 'Show', snackbar: { text: 'Changes saved', duration: 'indefinite' } },
191
+ ]);
192
+ await user.click(screen.getByRole('button', { name: 'Show' }));
193
+
194
+ advanceTimers(60_000);
195
+ expect(screen.getByText('Changes saved')).toBeInTheDocument();
196
+ await user.click(screen.getByRole('button', { name: 'Close' }));
197
+ advanceTimers(SNACKBAR_EXIT_DURATION);
198
+ expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
199
+ });
200
+
142
201
  it('exits the active Snackbar before showing the latest replacement', async () => {
143
202
  renderProvider([
144
203
  { label: 'Show first', snackbar: { text: 'First message' } },
@@ -203,6 +262,7 @@ describe('SnackbarProvider orchestration', () => {
203
262
  await user.click(screen.getByRole('button', { name: 'Replace' }));
204
263
  advanceTimers(SNACKBAR_EXIT_DURATION);
205
264
 
265
+ advanceTimers(SNACKBAR_ENTER_DURATION);
206
266
  advanceTimers(TIMEOUT - 1);
207
267
  expect(screen.getByText('Replacement')).toBeInTheDocument();
208
268
  advanceTimers(1);
@@ -292,7 +352,7 @@ describe('SnackbarProvider orchestration', () => {
292
352
  <SnackbarTriggers triggers={triggers} />
293
353
  </SnackbarProvider>,
294
354
  );
295
- advanceTimers(TIMEOUT - SNACKBAR_ENTER_DURATION);
355
+ advanceTimers(TIMEOUT);
296
356
  advanceTimers(SNACKBAR_EXIT_DURATION);
297
357
 
298
358
  expect(document.querySelector('.wds-Snackbar')).toBeNull();
@@ -5,11 +5,18 @@ import { SnackbarContext } from './SnackbarContext';
5
5
  import useSnackbarManager from './useSnackbarManager';
6
6
 
7
7
  export interface SnackbarProviderProps {
8
+ /**
9
+ * @deprecated Use per-Snackbar `duration` instead. Explicit millisecond values remain supported
10
+ * and are not automatically remapped when no `duration` is supplied.
11
+ * When neither option is supplied, the default is 4000ms (previously 4500ms), after entry finishes.
12
+ * To migrate, use `default` for 0–5000ms, `extended` for values above 5000ms, and `indefinite`
13
+ * for intentionally persistent notifications.
14
+ */
8
15
  timeout?: number;
9
16
  children?: React.ReactNode;
10
17
  }
11
18
 
12
- export default function SnackbarProvider({ timeout = 4500, children }: SnackbarProviderProps) {
19
+ export default function SnackbarProvider({ timeout, children }: SnackbarProviderProps) {
13
20
  const { active, announcementKey, createSnackbar, dismissSnackbar, handleExited, visible } =
14
21
  useSnackbarManager();
15
22
  const contextValue = useMemo(() => ({ createSnackbar }), [createSnackbar]);
@@ -15,8 +15,6 @@ export default {
15
15
 
16
16
  type Story = StoryObj<typeof Snackbar>;
17
17
 
18
- const TIMEOUT = 10_000_000;
19
-
20
18
  function ShowOnMount(props: SnackbarCreationProps) {
21
19
  const createSnackbar = useSnackbar();
22
20
 
@@ -31,8 +29,8 @@ function ShowOnMount(props: SnackbarCreationProps) {
31
29
 
32
30
  function SnackbarStory({ text, action }: { text: string; action?: { label: string } }) {
33
31
  return (
34
- <SnackbarProvider timeout={TIMEOUT}>
35
- <ShowOnMount text={<span>{text}</span>} action={action} />
32
+ <SnackbarProvider>
33
+ <ShowOnMount text={<span>{text}</span>} action={action} duration="indefinite" />
36
34
  </SnackbarProvider>
37
35
  );
38
36
  }
@@ -8,7 +8,8 @@ import {
8
8
  globalScope,
9
9
  } from '../../../.storybook/components/sandbox/SandboxEditor';
10
10
  import Button from '../../Button';
11
- import { Snackbar, type SnackbarProps } from '../Snackbar';
11
+ import { Snackbar } from '../Snackbar';
12
+ import type { SnackbarCreationProps } from '../Snackbar.types';
12
13
  import { SnackbarConsumer } from '../SnackbarContext';
13
14
  import SnackbarProvider from '../SnackbarProvider';
14
15
 
@@ -20,7 +21,7 @@ export default meta;
20
21
 
21
22
  type SnackbarExample = {
22
23
  label: string;
23
- props: Omit<SnackbarProps, 'timeout' | 'timestamp'>;
24
+ props: Omit<SnackbarCreationProps, 'timestamp'>;
24
25
  };
25
26
 
26
27
  const SnackbarExamples = ({ examples }: { examples: SnackbarExample[] }) => (
@@ -148,3 +149,40 @@ export const Media: StoryObj = {
148
149
  />
149
150
  ),
150
151
  };
152
+
153
+ /**
154
+ * `duration` uses four-second, six-second or indefinite presets, counted after entry finishes.
155
+ * Timed Snackbars pause while hovered or focused and resume when both interactions end.
156
+ */
157
+ export const Durations: StoryObj = {
158
+ parameters: {
159
+ docs: {
160
+ description: {
161
+ story:
162
+ 'The default duration is now **4000ms**, previously **4500ms**, when neither `duration` nor a legacy Provider `timeout` is supplied. ' +
163
+ 'Timed durations begin after the entry transition finishes, pause while hovered or focused, and resume with the remaining time only after both interactions end.\n\n' +
164
+ '`timeout` is deprecated but explicit millisecond values remain supported without automatic remapping. An explicit `duration` takes precedence. ' +
165
+ 'When migrating, choose `default` for 0–5000ms, `extended` for values above 5000ms, and `indefinite` for intentionally persistent notifications.',
166
+ },
167
+ },
168
+ },
169
+ render: () => (
170
+ <SnackbarExamples
171
+ examples={[
172
+ { label: 'Default (4s)', props: { text: 'Changes saved', duration: 'default' } },
173
+ {
174
+ label: 'Extended (6s)',
175
+ props: {
176
+ text: 'Transfer deleted',
177
+ duration: 'extended',
178
+ action: { label: 'Undo', onClick: fn() },
179
+ },
180
+ },
181
+ {
182
+ label: 'Indefinite',
183
+ props: { text: 'Waiting for confirmation', duration: 'indefinite' },
184
+ },
185
+ ]}
186
+ />
187
+ ),
188
+ };
@@ -4,7 +4,8 @@ import { withVariantConfig } from '../../../.storybook/helpers';
4
4
  import { lorem20 } from '../../test-utils';
5
5
  import { wait } from '../../test-utils/wait';
6
6
  import Button from '../../Button';
7
- import { Snackbar, type SnackbarProps } from '../Snackbar';
7
+ import { Snackbar } from '../Snackbar';
8
+ import type { SnackbarCreationProps } from '../Snackbar.types';
8
9
  import { SnackbarConsumer } from '../SnackbarContext';
9
10
  import SnackbarProvider from '../SnackbarProvider';
10
11
 
@@ -21,23 +22,20 @@ const launchSnackbar = async (canvasElement: HTMLElement) => {
21
22
  await userEvent.click(canvas.getByRole('button'));
22
23
  };
23
24
 
24
- type Args = Partial<SnackbarProps> & {
25
+ type Args = Partial<SnackbarCreationProps> & {
25
26
  extraContext: React.ReactNode;
26
- timeout: number;
27
27
  };
28
28
 
29
- const TIMEOUT = 10000000;
30
-
31
29
  const Default = {
32
30
  args: {
33
- timeout: TIMEOUT,
31
+ duration: 'indefinite',
34
32
  } as Args,
35
33
  play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
36
34
  await launchSnackbar(canvasElement);
37
35
  },
38
36
  render: (args: Args) => {
39
37
  return (
40
- <SnackbarProvider timeout={args.timeout}>
38
+ <SnackbarProvider>
41
39
  <SnackbarConsumer>
42
40
  {({ createSnackbar }) => (
43
41
  <>
@@ -46,6 +44,7 @@ const Default = {
46
44
  block
47
45
  onClick={() => {
48
46
  createSnackbar({
47
+ duration: args.duration,
49
48
  action: args.action ?? {
50
49
  label: 'View',
51
50
  onClick: () => action('View button clicked.'),
@@ -1,2 +1,7 @@
1
1
  export const SNACKBAR_ENTER_DURATION = 300;
2
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;
@@ -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,152 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ import { SNACKBAR_DEFAULT_DURATION, SNACKBAR_EXTENDED_DURATION } from './constants';
4
+ import type { SnackbarDuration } from './Snackbar.types';
5
+
6
+ type UseSnackbarAutoDismissOptions = {
7
+ duration?: SnackbarDuration;
8
+ onDismissRequest: () => void;
9
+ resetKey: number;
10
+ timeout?: number;
11
+ visible: boolean;
12
+ };
13
+
14
+ const getAutoDismissDuration = ({
15
+ duration,
16
+ timeout,
17
+ }: Pick<UseSnackbarAutoDismissOptions, 'duration' | 'timeout'>) => {
18
+ if (duration === 'indefinite') {
19
+ return null;
20
+ }
21
+
22
+ if (duration === 'extended') {
23
+ return SNACKBAR_EXTENDED_DURATION;
24
+ }
25
+
26
+ if (duration === 'default') {
27
+ return SNACKBAR_DEFAULT_DURATION;
28
+ }
29
+
30
+ return timeout ?? SNACKBAR_DEFAULT_DURATION;
31
+ };
32
+
33
+ /** Starts after entry finishes and pauses on hover or focus, preserving the remaining time. */
34
+ const useSnackbarAutoDismiss = ({
35
+ duration,
36
+ onDismissRequest,
37
+ resetKey,
38
+ timeout,
39
+ visible,
40
+ }: UseSnackbarAutoDismissOptions) => {
41
+ const surfaceRef = useRef<HTMLDivElement>(null);
42
+ const hasEnteredRef = useRef(false);
43
+ const timeoutRef = useRef(0);
44
+ const remainingDurationRef = useRef<number | null>(null);
45
+ const timerStartedAtRef = useRef(0);
46
+ const interactionStateRef = useRef({ focusWithin: false, hovered: false });
47
+
48
+ const isInterrupted = useCallback(() => {
49
+ const { focusWithin, hovered } = interactionStateRef.current;
50
+ return focusWithin || hovered;
51
+ }, []);
52
+
53
+ const clearTimer = useCallback(() => {
54
+ window.clearTimeout(timeoutRef.current);
55
+ timeoutRef.current = 0;
56
+ }, []);
57
+
58
+ const startTimer = useCallback(
59
+ (remainingDuration = getAutoDismissDuration({ duration, timeout })) => {
60
+ clearTimer();
61
+ remainingDurationRef.current = remainingDuration;
62
+
63
+ if (!visible || !hasEnteredRef.current || remainingDuration === null || isInterrupted()) {
64
+ return;
65
+ }
66
+
67
+ timerStartedAtRef.current = Date.now();
68
+ timeoutRef.current = window.setTimeout(() => {
69
+ timeoutRef.current = 0;
70
+ remainingDurationRef.current = 0;
71
+ onDismissRequest();
72
+ }, remainingDuration);
73
+ },
74
+ [clearTimer, duration, isInterrupted, onDismissRequest, timeout, visible],
75
+ );
76
+
77
+ const pauseTimer = useCallback(() => {
78
+ if (!timeoutRef.current || remainingDurationRef.current === null) {
79
+ return;
80
+ }
81
+
82
+ clearTimer();
83
+ // Count only unpaused time; resuming must not grant another full duration.
84
+ remainingDurationRef.current = Math.max(
85
+ 0,
86
+ remainingDurationRef.current - (Date.now() - timerStartedAtRef.current),
87
+ );
88
+ }, [clearTimer]);
89
+
90
+ const resumeTimer = useCallback(() => {
91
+ if (isInterrupted() || remainingDurationRef.current === null) {
92
+ return;
93
+ }
94
+
95
+ startTimer(remainingDurationRef.current);
96
+ }, [isInterrupted, startTimer]);
97
+
98
+ const handleMouseEnter = useCallback(() => {
99
+ interactionStateRef.current = { ...interactionStateRef.current, hovered: true };
100
+ pauseTimer();
101
+ }, [pauseTimer]);
102
+
103
+ const handleMouseLeave = useCallback(() => {
104
+ interactionStateRef.current = { ...interactionStateRef.current, hovered: false };
105
+ resumeTimer();
106
+ }, [resumeTimer]);
107
+
108
+ const handleFocus = useCallback(() => {
109
+ interactionStateRef.current = { ...interactionStateRef.current, focusWithin: true };
110
+ pauseTimer();
111
+ }, [pauseTimer]);
112
+
113
+ const handleBlur = useCallback(
114
+ (event: React.FocusEvent<HTMLDivElement>) => {
115
+ if (event.currentTarget.contains(event.relatedTarget)) {
116
+ return;
117
+ }
118
+
119
+ interactionStateRef.current = { ...interactionStateRef.current, focusWithin: false };
120
+ resumeTimer();
121
+ },
122
+ [resumeTimer],
123
+ );
124
+
125
+ const handleEntered = useCallback(() => {
126
+ hasEnteredRef.current = true;
127
+ const snackbar = surfaceRef.current;
128
+ // Entry can finish beneath the pointer or with keyboard focus already inside the surface.
129
+ interactionStateRef.current = {
130
+ focusWithin: Boolean(snackbar?.contains(document.activeElement)),
131
+ hovered: snackbar?.matches(':hover') ?? false,
132
+ };
133
+ startTimer();
134
+ }, [startTimer]);
135
+
136
+ useEffect(() => {
137
+ if (!visible) {
138
+ hasEnteredRef.current = false;
139
+ clearTimer();
140
+ return undefined;
141
+ }
142
+
143
+ // Before onEntered this only prepares the duration; it cannot start the countdown.
144
+ startTimer();
145
+
146
+ return clearTimer;
147
+ }, [clearTimer, resetKey, startTimer, visible]);
148
+
149
+ return { handleBlur, handleEntered, handleFocus, handleMouseEnter, handleMouseLeave, surfaceRef };
150
+ };
151
+
152
+ export default useSnackbarAutoDismiss;
package/src/index.ts CHANGED
@@ -135,7 +135,7 @@ export type {
135
135
  SelectProps,
136
136
  } from './_deprecated/Select';
137
137
  export type { SlidingPanelProps } from './SlidingPanel';
138
- export type { SnackbarProps, SnackbarProviderProps } from './Snackbar';
138
+ export type { SnackbarCreationProps, SnackbarProviderProps } from './Snackbar';
139
139
  export type { SnackbarContextType } from './Snackbar/SnackbarContext';
140
140
  export type { StatusIconProps, StatusIconSentiment } from './StatusIcon';
141
141
  export type { StickyProps } from './Sticky';