@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.
- package/build/Snackbar/Snackbar.js +22 -9
- package/build/Snackbar/Snackbar.js.map +1 -1
- package/build/Snackbar/Snackbar.mjs +22 -9
- package/build/Snackbar/Snackbar.mjs.map +1 -1
- package/build/Snackbar/SnackbarProvider.js +1 -1
- package/build/Snackbar/SnackbarProvider.js.map +1 -1
- package/build/Snackbar/SnackbarProvider.mjs +1 -1
- package/build/Snackbar/SnackbarProvider.mjs.map +1 -1
- package/build/Snackbar/constants.js +6 -0
- package/build/Snackbar/constants.js.map +1 -1
- package/build/Snackbar/constants.mjs +5 -1
- package/build/Snackbar/constants.mjs.map +1 -1
- package/build/Snackbar/useSnackbarAutoDismiss.js +143 -0
- package/build/Snackbar/useSnackbarAutoDismiss.js.map +1 -0
- package/build/Snackbar/useSnackbarAutoDismiss.mjs +139 -0
- package/build/Snackbar/useSnackbarAutoDismiss.mjs.map +1 -0
- package/build/types/Snackbar/Snackbar.d.ts +26 -6
- package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
- package/build/types/Snackbar/Snackbar.types.d.ts +3 -14
- package/build/types/Snackbar/Snackbar.types.d.ts.map +1 -1
- package/build/types/Snackbar/SnackbarPortal.d.ts +1 -1
- package/build/types/Snackbar/SnackbarPortal.d.ts.map +1 -1
- package/build/types/Snackbar/SnackbarProvider.d.ts +7 -0
- package/build/types/Snackbar/SnackbarProvider.d.ts.map +1 -1
- package/build/types/Snackbar/constants.d.ts +4 -0
- package/build/types/Snackbar/constants.d.ts.map +1 -1
- package/build/types/Snackbar/index.d.ts +1 -1
- package/build/types/Snackbar/index.d.ts.map +1 -1
- package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts +19 -0
- package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
- package/build/types/index.d.ts +1 -1
- package/build/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Snackbar/Snackbar.test.tsx +297 -9
- package/src/Snackbar/Snackbar.tsx +41 -17
- package/src/Snackbar/Snackbar.types.ts +7 -15
- package/src/Snackbar/SnackbarProvider.test.tsx +64 -4
- package/src/Snackbar/SnackbarProvider.tsx +8 -1
- package/src/Snackbar/_stories/Snackbar.rtl.test.story.tsx +2 -4
- package/src/Snackbar/_stories/Snackbar.story.tsx +40 -2
- package/src/Snackbar/_stories/Snackbar.test.story.tsx +6 -7
- package/src/Snackbar/constants.ts +5 -0
- package/src/Snackbar/index.ts +1 -1
- package/src/Snackbar/useSnackbarAutoDismiss.ts +152 -0
- 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,
|
|
13
|
+
import type { SnackbarAction, SnackbarDuration } from './Snackbar.types';
|
|
14
|
+
import useSnackbarAutoDismiss from './useSnackbarAutoDismiss';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
/**
|
|
35
|
-
|
|
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
|
-
}:
|
|
51
|
-
const surfaceRef =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 {
|
|
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 =
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/Snackbar/index.ts
CHANGED
|
@@ -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 {
|
|
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';
|