@transferwise/components 0.0.0-experimental-85015e0 → 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 +16 -4
- package/build/types/Snackbar/Snackbar.d.ts.map +1 -1
- package/build/types/Snackbar/Snackbar.types.d.ts +2 -0
- package/build/types/Snackbar/Snackbar.types.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/useSnackbarAutoDismiss.d.ts +19 -0
- package/build/types/Snackbar/useSnackbarAutoDismiss.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/Snackbar/Snackbar.test.tsx +293 -4
- package/src/Snackbar/Snackbar.tsx +30 -14
- package/src/Snackbar/Snackbar.types.ts +3 -0
- 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 +37 -0
- package/src/Snackbar/_stories/Snackbar.test.story.tsx +3 -5
- package/src/Snackbar/constants.ts +5 -0
- package/src/Snackbar/useSnackbarAutoDismiss.ts +152 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSnackbarAutoDismiss.mjs","sources":["../../src/Snackbar/useSnackbarAutoDismiss.ts"],"sourcesContent":["import { useCallback, useEffect, useRef } from 'react';\n\nimport { SNACKBAR_DEFAULT_DURATION, SNACKBAR_EXTENDED_DURATION } from './constants';\nimport type { SnackbarDuration } from './Snackbar.types';\n\ntype UseSnackbarAutoDismissOptions = {\n duration?: SnackbarDuration;\n onDismissRequest: () => void;\n resetKey: number;\n timeout?: number;\n visible: boolean;\n};\n\nconst getAutoDismissDuration = ({\n duration,\n timeout,\n}: Pick<UseSnackbarAutoDismissOptions, 'duration' | 'timeout'>) => {\n if (duration === 'indefinite') {\n return null;\n }\n\n if (duration === 'extended') {\n return SNACKBAR_EXTENDED_DURATION;\n }\n\n if (duration === 'default') {\n return SNACKBAR_DEFAULT_DURATION;\n }\n\n return timeout ?? SNACKBAR_DEFAULT_DURATION;\n};\n\n/** Starts after entry finishes and pauses on hover or focus, preserving the remaining time. */\nconst useSnackbarAutoDismiss = ({\n duration,\n onDismissRequest,\n resetKey,\n timeout,\n visible,\n}: UseSnackbarAutoDismissOptions) => {\n const surfaceRef = useRef<HTMLDivElement>(null);\n const hasEnteredRef = useRef(false);\n const timeoutRef = useRef(0);\n const remainingDurationRef = useRef<number | null>(null);\n const timerStartedAtRef = useRef(0);\n const interactionStateRef = useRef({ focusWithin: false, hovered: false });\n\n const isInterrupted = useCallback(() => {\n const { focusWithin, hovered } = interactionStateRef.current;\n return focusWithin || hovered;\n }, []);\n\n const clearTimer = useCallback(() => {\n window.clearTimeout(timeoutRef.current);\n timeoutRef.current = 0;\n }, []);\n\n const startTimer = useCallback(\n (remainingDuration = getAutoDismissDuration({ duration, timeout })) => {\n clearTimer();\n remainingDurationRef.current = remainingDuration;\n\n if (!visible || !hasEnteredRef.current || remainingDuration === null || isInterrupted()) {\n return;\n }\n\n timerStartedAtRef.current = Date.now();\n timeoutRef.current = window.setTimeout(() => {\n timeoutRef.current = 0;\n remainingDurationRef.current = 0;\n onDismissRequest();\n }, remainingDuration);\n },\n [clearTimer, duration, isInterrupted, onDismissRequest, timeout, visible],\n );\n\n const pauseTimer = useCallback(() => {\n if (!timeoutRef.current || remainingDurationRef.current === null) {\n return;\n }\n\n clearTimer();\n // Count only unpaused time; resuming must not grant another full duration.\n remainingDurationRef.current = Math.max(\n 0,\n remainingDurationRef.current - (Date.now() - timerStartedAtRef.current),\n );\n }, [clearTimer]);\n\n const resumeTimer = useCallback(() => {\n if (isInterrupted() || remainingDurationRef.current === null) {\n return;\n }\n\n startTimer(remainingDurationRef.current);\n }, [isInterrupted, startTimer]);\n\n const handleMouseEnter = useCallback(() => {\n interactionStateRef.current = { ...interactionStateRef.current, hovered: true };\n pauseTimer();\n }, [pauseTimer]);\n\n const handleMouseLeave = useCallback(() => {\n interactionStateRef.current = { ...interactionStateRef.current, hovered: false };\n resumeTimer();\n }, [resumeTimer]);\n\n const handleFocus = useCallback(() => {\n interactionStateRef.current = { ...interactionStateRef.current, focusWithin: true };\n pauseTimer();\n }, [pauseTimer]);\n\n const handleBlur = useCallback(\n (event: React.FocusEvent<HTMLDivElement>) => {\n if (event.currentTarget.contains(event.relatedTarget)) {\n return;\n }\n\n interactionStateRef.current = { ...interactionStateRef.current, focusWithin: false };\n resumeTimer();\n },\n [resumeTimer],\n );\n\n const handleEntered = useCallback(() => {\n hasEnteredRef.current = true;\n const snackbar = surfaceRef.current;\n // Entry can finish beneath the pointer or with keyboard focus already inside the surface.\n interactionStateRef.current = {\n focusWithin: Boolean(snackbar?.contains(document.activeElement)),\n hovered: snackbar?.matches(':hover') ?? false,\n };\n startTimer();\n }, [startTimer]);\n\n useEffect(() => {\n if (!visible) {\n hasEnteredRef.current = false;\n clearTimer();\n return undefined;\n }\n\n // Before onEntered this only prepares the duration; it cannot start the countdown.\n startTimer();\n\n return clearTimer;\n }, [clearTimer, resetKey, startTimer, visible]);\n\n return { handleBlur, handleEntered, handleFocus, handleMouseEnter, handleMouseLeave, surfaceRef };\n};\n\nexport default useSnackbarAutoDismiss;\n"],"names":["getAutoDismissDuration","duration","timeout","SNACKBAR_EXTENDED_DURATION","SNACKBAR_DEFAULT_DURATION","useSnackbarAutoDismiss","onDismissRequest","resetKey","visible","surfaceRef","useRef","hasEnteredRef","timeoutRef","remainingDurationRef","timerStartedAtRef","interactionStateRef","focusWithin","hovered","isInterrupted","useCallback","current","clearTimer","window","clearTimeout","startTimer","remainingDuration","Date","now","setTimeout","pauseTimer","Math","max","resumeTimer","handleMouseEnter","handleMouseLeave","handleFocus","handleBlur","event","currentTarget","contains","relatedTarget","handleEntered","snackbar","Boolean","document","activeElement","matches","useEffect","undefined"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ThemeDark, ThemeLight } from '../common';
|
|
2
2
|
import type { Sentiment } from '../SentimentSurface';
|
|
3
|
-
import type { SnackbarAction } from './Snackbar.types';
|
|
3
|
+
import type { SnackbarAction, SnackbarDuration } from './Snackbar.types';
|
|
4
4
|
/**
|
|
5
5
|
* Props for the controlled Snackbar surface, including provider-managed lifecycle controls.
|
|
6
6
|
* Use SnackbarCreationProps when creating notifications through SnackbarProvider/useSnackbar.
|
|
@@ -10,6 +10,13 @@ export interface SnackbarProps {
|
|
|
10
10
|
action?: SnackbarAction;
|
|
11
11
|
/** Changes for each new Snackbar so LiveRegion re-announces it. */
|
|
12
12
|
announcementKey: number;
|
|
13
|
+
/**
|
|
14
|
+
* Controls auto-dismiss with a four-second, six-second or indefinite duration after entry finishes.
|
|
15
|
+
* Takes precedence over the deprecated `timeout`.
|
|
16
|
+
* When neither option is supplied, the default is 4000ms (previously 4500ms).
|
|
17
|
+
* @default 'default'
|
|
18
|
+
*/
|
|
19
|
+
duration?: SnackbarDuration;
|
|
13
20
|
/** Overrides sentiment-derived media with content for the 24×24px media slot. */
|
|
14
21
|
media?: React.ReactNode;
|
|
15
22
|
/** Requests an exit; the provider retains the surface until onExited confirms it has finished. */
|
|
@@ -25,13 +32,18 @@ export interface SnackbarProps {
|
|
|
25
32
|
text: React.ReactNode;
|
|
26
33
|
/** @deprecated Has no effect. Snackbar theming is based on ThemeProvider. */
|
|
27
34
|
theme?: ThemeLight | ThemeDark;
|
|
28
|
-
/**
|
|
29
|
-
|
|
35
|
+
/**
|
|
36
|
+
* @deprecated Use `duration` instead. Explicit millisecond values remain supported and are not
|
|
37
|
+
* automatically remapped when no `duration` is supplied.
|
|
38
|
+
* To migrate, use `default` for 0–5000ms, `extended` for values above 5000ms, and `indefinite`
|
|
39
|
+
* for intentionally persistent notifications.
|
|
40
|
+
*/
|
|
41
|
+
timeout?: number;
|
|
30
42
|
/** @deprecated Has no effect when using SnackbarProvider, which manages Snackbar identity. */
|
|
31
43
|
timestamp?: number;
|
|
32
44
|
/** Controls entry/exit; the surface does not manage visibility itself. */
|
|
33
45
|
visible: boolean;
|
|
34
46
|
}
|
|
35
|
-
export declare const Snackbar: ({ action, announcementKey, media, onDismissRequest, onExited, sentiment, text, timeout, visible, }: SnackbarProps) => import("react").JSX.Element;
|
|
47
|
+
export declare const Snackbar: ({ action, announcementKey, duration, media, onDismissRequest, onExited, sentiment, text, timeout, visible, }: SnackbarProps) => import("react").JSX.Element;
|
|
36
48
|
export default Snackbar;
|
|
37
49
|
//# sourceMappingURL=Snackbar.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Snackbar.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/Snackbar.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Snackbar.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/Snackbar.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAIrD,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGzE;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,4FAA4F;IAC5F,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,mEAAmE;IACnE,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,iFAAiF;IACjF,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,kGAAkG;IAClG,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,kGAAkG;IAClG,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,kGAAkG;IAClG,IAAI,EAAE,KAAK,CAAC,SAAS,CAAC;IACtB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC;IAC/B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8FAA8F;IAC9F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0EAA0E;IAC1E,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,eAAO,MAAM,QAAQ,GAAI,8GAWtB,aAAa,gCA0Ff,CAAC;AAEF,eAAe,QAAQ,CAAC"}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ButtonProps, ButtonReferenceType } from '../Button/Button.types';
|
|
2
2
|
import type { SnackbarProps } from './Snackbar';
|
|
3
|
+
/** Semantic auto-dismiss durations. `indefinite` disables the timer. */
|
|
4
|
+
export type SnackbarDuration = 'default' | 'extended' | 'indefinite';
|
|
3
5
|
/** One optional action. Supports callbacks and navigation through the Button API. */
|
|
4
6
|
export type SnackbarAction = Pick<ButtonProps, 'href' | 'target'> & {
|
|
5
7
|
label: ButtonProps['children'];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Snackbar.types.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/Snackbar.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC/E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,qFAAqF;AACrF,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,QAAQ,CAAC,GAAG;IAClE,KAAK,EAAE,WAAW,CAAC,UAAU,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,KAAK,CAAC,iBAAiB,CAAC,mBAAmB,CAAC,CAAC;CACxD,CAAC;AAEF,+DAA+D;AAC/D,MAAM,MAAM,qBAAqB,GAAG,IAAI,CACtC,aAAa,EACb,iBAAiB,GAAG,kBAAkB,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAC5E,CAAC"}
|
|
1
|
+
{"version":3,"file":"Snackbar.types.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/Snackbar.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC/E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,UAAU,GAAG,YAAY,CAAC;AAErE,qFAAqF;AACrF,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,QAAQ,CAAC,GAAG;IAClE,KAAK,EAAE,WAAW,CAAC,UAAU,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,KAAK,CAAC,iBAAiB,CAAC,mBAAmB,CAAC,CAAC;CACxD,CAAC;AAEF,+DAA+D;AAC/D,MAAM,MAAM,qBAAqB,GAAG,IAAI,CACtC,aAAa,EACb,iBAAiB,GAAG,kBAAkB,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAC5E,CAAC"}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export interface SnackbarProviderProps {
|
|
2
|
+
/**
|
|
3
|
+
* @deprecated Use per-Snackbar `duration` instead. Explicit millisecond values remain supported
|
|
4
|
+
* and are not automatically remapped when no `duration` is supplied.
|
|
5
|
+
* When neither option is supplied, the default is 4000ms (previously 4500ms), after entry finishes.
|
|
6
|
+
* To migrate, use `default` for 0–5000ms, `extended` for values above 5000ms, and `indefinite`
|
|
7
|
+
* for intentionally persistent notifications.
|
|
8
|
+
*/
|
|
2
9
|
timeout?: number;
|
|
3
10
|
children?: React.ReactNode;
|
|
4
11
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SnackbarProvider.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/SnackbarProvider.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,qBAAqB;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B;AAED,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EAAE,
|
|
1
|
+
{"version":3,"file":"SnackbarProvider.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/SnackbarProvider.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B;AAED,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,qBAAqB,+BAsBpF"}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export declare const SNACKBAR_ENTER_DURATION = 300;
|
|
2
2
|
export declare const SNACKBAR_EXIT_DURATION = 150;
|
|
3
|
+
/** Default auto-dismiss duration for short feedback, in milliseconds. */
|
|
4
|
+
export declare const SNACKBAR_DEFAULT_DURATION = 4000;
|
|
5
|
+
/** Extended auto-dismiss duration for messages needing more reading time, in milliseconds. */
|
|
6
|
+
export declare const SNACKBAR_EXTENDED_DURATION = 6000;
|
|
3
7
|
//# sourceMappingURL=constants.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,eAAO,MAAM,sBAAsB,MAAM,CAAC"}
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C,yEAAyE;AACzE,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAC9C,8FAA8F;AAC9F,eAAO,MAAM,0BAA0B,OAAO,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SnackbarDuration } from './Snackbar.types';
|
|
2
|
+
type UseSnackbarAutoDismissOptions = {
|
|
3
|
+
duration?: SnackbarDuration;
|
|
4
|
+
onDismissRequest: () => void;
|
|
5
|
+
resetKey: number;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
visible: boolean;
|
|
8
|
+
};
|
|
9
|
+
/** Starts after entry finishes and pauses on hover or focus, preserving the remaining time. */
|
|
10
|
+
declare const useSnackbarAutoDismiss: ({ duration, onDismissRequest, resetKey, timeout, visible, }: UseSnackbarAutoDismissOptions) => {
|
|
11
|
+
handleBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
12
|
+
handleEntered: () => void;
|
|
13
|
+
handleFocus: () => void;
|
|
14
|
+
handleMouseEnter: () => void;
|
|
15
|
+
handleMouseLeave: () => void;
|
|
16
|
+
surfaceRef: import("react").RefObject<HTMLDivElement>;
|
|
17
|
+
};
|
|
18
|
+
export default useSnackbarAutoDismiss;
|
|
19
|
+
//# sourceMappingURL=useSnackbarAutoDismiss.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSnackbarAutoDismiss.d.ts","sourceRoot":"","sources":["../../../src/Snackbar/useSnackbarAutoDismiss.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,KAAK,6BAA6B,GAAG;IACnC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAqBF,+FAA+F;AAC/F,QAAA,MAAM,sBAAsB,GAAI,6DAM7B,6BAA6B;wBA0EpB,KAAK,CAAC,UAAU,CAAC,cAAc,CAAC;;;;;;CAoC3C,CAAC;AAEF,eAAe,sBAAsB,CAAC"}
|
package/package.json
CHANGED
|
@@ -4,10 +4,15 @@ import { act, mockMatchMedia, render, screen, userEvent } from '../test-utils';
|
|
|
4
4
|
import { WDS_LIVE_REGION_DELAY_MS } from '../common/constants';
|
|
5
5
|
import { resetLiveRegionAnnouncementQueue } from '../common/LiveRegion/LiveRegion';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
SNACKBAR_DEFAULT_DURATION,
|
|
9
|
+
SNACKBAR_ENTER_DURATION,
|
|
10
|
+
SNACKBAR_EXIT_DURATION,
|
|
11
|
+
SNACKBAR_EXTENDED_DURATION,
|
|
12
|
+
} from './constants';
|
|
8
13
|
import { Snackbar, type SnackbarProps } from './Snackbar';
|
|
9
14
|
|
|
10
|
-
const TIMEOUT =
|
|
15
|
+
const TIMEOUT = SNACKBAR_DEFAULT_DURATION;
|
|
11
16
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
|
|
12
17
|
|
|
13
18
|
mockMatchMedia();
|
|
@@ -31,6 +36,12 @@ const renderSnackbar = (props: Partial<SnackbarProps> = {}) => {
|
|
|
31
36
|
return { ...render(<Snackbar {...snackbarProps} />), snackbarProps };
|
|
32
37
|
};
|
|
33
38
|
|
|
39
|
+
const renderEnteredSnackbar = (props: Partial<SnackbarProps> = {}) => {
|
|
40
|
+
const view = renderSnackbar(props);
|
|
41
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
42
|
+
return view;
|
|
43
|
+
};
|
|
44
|
+
|
|
34
45
|
beforeEach(() => {
|
|
35
46
|
resetLiveRegionAnnouncementQueue();
|
|
36
47
|
jest.useFakeTimers();
|
|
@@ -88,7 +99,7 @@ describe('Snackbar controlled surface', () => {
|
|
|
88
99
|
|
|
89
100
|
it('requests dismissal after the timeout without changing its own visibility', () => {
|
|
90
101
|
const onDismissRequest = jest.fn();
|
|
91
|
-
|
|
102
|
+
renderEnteredSnackbar({ onDismissRequest });
|
|
92
103
|
|
|
93
104
|
advanceTimers(TIMEOUT - 1);
|
|
94
105
|
expect(onDismissRequest).not.toHaveBeenCalled();
|
|
@@ -100,7 +111,7 @@ describe('Snackbar controlled surface', () => {
|
|
|
100
111
|
|
|
101
112
|
it('clears the dismissal timer when it begins exiting', () => {
|
|
102
113
|
const onDismissRequest = jest.fn();
|
|
103
|
-
const { rerender, snackbarProps } =
|
|
114
|
+
const { rerender, snackbarProps } = renderEnteredSnackbar({ onDismissRequest });
|
|
104
115
|
|
|
105
116
|
rerender(<Snackbar {...snackbarProps} visible={false} />);
|
|
106
117
|
advanceTimers(TIMEOUT);
|
|
@@ -122,6 +133,284 @@ describe('Snackbar controlled surface', () => {
|
|
|
122
133
|
});
|
|
123
134
|
});
|
|
124
135
|
|
|
136
|
+
describe('Snackbar duration', () => {
|
|
137
|
+
it('starts the full reading duration only after the entry transition finishes', () => {
|
|
138
|
+
const { snackbarProps } = renderSnackbar({ duration: 'default' });
|
|
139
|
+
|
|
140
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
141
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
142
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
143
|
+
advanceTimers(1);
|
|
144
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not start the timer when hover ends before entry finishes', async () => {
|
|
148
|
+
const { snackbarProps } = renderSnackbar({ duration: 'default' });
|
|
149
|
+
const message = screen.getByText('Changes saved');
|
|
150
|
+
await user.hover(message);
|
|
151
|
+
await user.unhover(message);
|
|
152
|
+
|
|
153
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
154
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
155
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
156
|
+
advanceTimers(1);
|
|
157
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('starts paused when keyboard focus enters during the entry transition', () => {
|
|
161
|
+
const { snackbarProps } = renderSnackbar({ duration: 'default', action: { label: 'Undo' } });
|
|
162
|
+
const action = screen.getByRole('button', { name: 'Undo' });
|
|
163
|
+
act(() => action.focus());
|
|
164
|
+
|
|
165
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
166
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION * 2);
|
|
167
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
168
|
+
|
|
169
|
+
act(() => action.blur());
|
|
170
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
171
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
172
|
+
advanceTimers(1);
|
|
173
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does not start the timer when focus leaves before entry finishes', () => {
|
|
177
|
+
const { snackbarProps } = renderSnackbar({ duration: 'default', action: { label: 'Undo' } });
|
|
178
|
+
const action = screen.getByRole('button', { name: 'Undo' });
|
|
179
|
+
act(() => action.focus());
|
|
180
|
+
act(() => action.blur());
|
|
181
|
+
|
|
182
|
+
advanceTimers(SNACKBAR_ENTER_DURATION);
|
|
183
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
184
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
185
|
+
advanceTimers(1);
|
|
186
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('uses four seconds when no duration or legacy timeout is supplied', () => {
|
|
190
|
+
const { snackbarProps } = renderEnteredSnackbar({ timeout: undefined });
|
|
191
|
+
|
|
192
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
193
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
194
|
+
advanceTimers(1);
|
|
195
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it.each([
|
|
199
|
+
['default', SNACKBAR_DEFAULT_DURATION],
|
|
200
|
+
['extended', SNACKBAR_EXTENDED_DURATION],
|
|
201
|
+
] as const)('uses the %s duration instead of the legacy timeout', (duration, milliseconds) => {
|
|
202
|
+
const { snackbarProps } = renderEnteredSnackbar({ duration, timeout: 1 });
|
|
203
|
+
|
|
204
|
+
advanceTimers(milliseconds - 1);
|
|
205
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
206
|
+
advanceTimers(1);
|
|
207
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it.each([1000, 4500, 5000, 5001, 9000])(
|
|
211
|
+
'preserves an explicit legacy timeout of %sms',
|
|
212
|
+
(timeout) => {
|
|
213
|
+
const { snackbarProps } = renderEnteredSnackbar({ timeout });
|
|
214
|
+
|
|
215
|
+
advanceTimers(timeout - 1);
|
|
216
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
217
|
+
advanceTimers(1);
|
|
218
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
it('preserves a zero legacy timeout rather than using the default duration', () => {
|
|
223
|
+
const { snackbarProps } = renderEnteredSnackbar({ timeout: 0 });
|
|
224
|
+
|
|
225
|
+
advanceTimers(0);
|
|
226
|
+
|
|
227
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('does not auto-dismiss an indefinite Snackbar, even with a legacy timeout', () => {
|
|
231
|
+
const { snackbarProps } = renderEnteredSnackbar({ duration: 'indefinite', timeout: 1 });
|
|
232
|
+
|
|
233
|
+
advanceTimers(60_000);
|
|
234
|
+
|
|
235
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
236
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('pauses on hover and resumes with only the remaining time', async () => {
|
|
240
|
+
const { snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
241
|
+
const message = screen.getByText('Changes saved');
|
|
242
|
+
|
|
243
|
+
advanceTimers(2000);
|
|
244
|
+
await user.hover(message);
|
|
245
|
+
advanceTimers(10_000);
|
|
246
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
247
|
+
|
|
248
|
+
await user.unhover(message);
|
|
249
|
+
advanceTimers(1999);
|
|
250
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
251
|
+
advanceTimers(1);
|
|
252
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('pauses on keyboard focus and resumes when focus leaves', () => {
|
|
256
|
+
const { snackbarProps } = renderEnteredSnackbar({
|
|
257
|
+
duration: 'default',
|
|
258
|
+
action: { label: 'Undo' },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
advanceTimers(2000);
|
|
262
|
+
act(() => screen.getByRole('button', { name: 'Undo' }).focus());
|
|
263
|
+
advanceTimers(10_000);
|
|
264
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
265
|
+
|
|
266
|
+
act(() => screen.getByRole('button', { name: 'Undo' }).blur());
|
|
267
|
+
advanceTimers(1999);
|
|
268
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
269
|
+
advanceTimers(1);
|
|
270
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('stays paused when focus moves between Snackbar controls', () => {
|
|
274
|
+
const { snackbarProps } = renderEnteredSnackbar({
|
|
275
|
+
duration: 'default',
|
|
276
|
+
action: { label: 'Undo' },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
advanceTimers(2000);
|
|
280
|
+
act(() => screen.getByRole('button', { name: 'Undo' }).focus());
|
|
281
|
+
act(() => screen.getByRole('button', { name: 'Close' }).focus());
|
|
282
|
+
advanceTimers(10_000);
|
|
283
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
284
|
+
|
|
285
|
+
act(() => screen.getByRole('button', { name: 'Close' }).blur());
|
|
286
|
+
advanceTimers(2000);
|
|
287
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it.each(['hover', 'focus'] as const)(
|
|
291
|
+
'resumes only after both hover and focus end, with %s ending first',
|
|
292
|
+
async (firstToEnd) => {
|
|
293
|
+
const { snackbarProps } = renderEnteredSnackbar({
|
|
294
|
+
duration: 'default',
|
|
295
|
+
action: { label: 'Undo' },
|
|
296
|
+
});
|
|
297
|
+
const message = screen.getByText('Changes saved');
|
|
298
|
+
const action = screen.getByRole('button', { name: 'Undo' });
|
|
299
|
+
|
|
300
|
+
advanceTimers(2000);
|
|
301
|
+
await user.hover(message);
|
|
302
|
+
act(() => action.focus());
|
|
303
|
+
if (firstToEnd === 'hover') {
|
|
304
|
+
await user.unhover(message);
|
|
305
|
+
} else {
|
|
306
|
+
act(() => action.blur());
|
|
307
|
+
}
|
|
308
|
+
advanceTimers(10_000);
|
|
309
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
310
|
+
|
|
311
|
+
if (firstToEnd === 'hover') {
|
|
312
|
+
act(() => action.blur());
|
|
313
|
+
} else {
|
|
314
|
+
await user.unhover(message);
|
|
315
|
+
}
|
|
316
|
+
advanceTimers(1999);
|
|
317
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
318
|
+
advanceTimers(1);
|
|
319
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
it('keeps the remaining time across repeated pauses', async () => {
|
|
324
|
+
const { snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
325
|
+
const message = screen.getByText('Changes saved');
|
|
326
|
+
|
|
327
|
+
advanceTimers(1000);
|
|
328
|
+
await user.hover(message);
|
|
329
|
+
advanceTimers(10_000);
|
|
330
|
+
await user.unhover(message);
|
|
331
|
+
advanceTimers(1000);
|
|
332
|
+
await user.hover(message);
|
|
333
|
+
advanceTimers(10_000);
|
|
334
|
+
await user.unhover(message);
|
|
335
|
+
advanceTimers(1999);
|
|
336
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
337
|
+
advanceTimers(1);
|
|
338
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('cleans up a resumed timer when unmounted', async () => {
|
|
342
|
+
const { unmount, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
343
|
+
const message = screen.getByText('Changes saved');
|
|
344
|
+
|
|
345
|
+
advanceTimers(1000);
|
|
346
|
+
await user.hover(message);
|
|
347
|
+
await user.unhover(message);
|
|
348
|
+
unmount();
|
|
349
|
+
advanceTimers(10_000);
|
|
350
|
+
|
|
351
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
352
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('starts paused if the surface is already hovered when shown', async () => {
|
|
356
|
+
const matches = jest
|
|
357
|
+
.spyOn(HTMLElement.prototype, 'matches')
|
|
358
|
+
.mockImplementation((selector) => selector === ':hover');
|
|
359
|
+
let view: ReturnType<typeof renderSnackbar>;
|
|
360
|
+
try {
|
|
361
|
+
view = renderEnteredSnackbar({ duration: 'default' });
|
|
362
|
+
} finally {
|
|
363
|
+
matches.mockRestore();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
advanceTimers(10_000);
|
|
367
|
+
expect(view.snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
368
|
+
const message = screen.getByText('Changes saved');
|
|
369
|
+
await user.hover(message);
|
|
370
|
+
await user.unhover(message);
|
|
371
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
372
|
+
expect(view.snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
373
|
+
advanceTimers(1);
|
|
374
|
+
expect(view.snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('does not restart a paused timer while the Snackbar is exiting', async () => {
|
|
378
|
+
const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
379
|
+
const message = screen.getByText('Changes saved');
|
|
380
|
+
advanceTimers(1000);
|
|
381
|
+
await user.hover(message);
|
|
382
|
+
|
|
383
|
+
rerender(<Snackbar {...snackbarProps} visible={false} />);
|
|
384
|
+
await user.unhover(message);
|
|
385
|
+
advanceTimers(10_000);
|
|
386
|
+
|
|
387
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
388
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('clears a running timer when the duration becomes indefinite', () => {
|
|
392
|
+
const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
393
|
+
advanceTimers(2000);
|
|
394
|
+
|
|
395
|
+
rerender(<Snackbar {...snackbarProps} duration="indefinite" />);
|
|
396
|
+
advanceTimers(60_000);
|
|
397
|
+
|
|
398
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
399
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('resets the duration when a new announcement key is supplied', () => {
|
|
403
|
+
const { rerender, snackbarProps } = renderEnteredSnackbar({ duration: 'default' });
|
|
404
|
+
advanceTimers(2000);
|
|
405
|
+
|
|
406
|
+
rerender(<Snackbar {...snackbarProps} announcementKey={2} />);
|
|
407
|
+
advanceTimers(SNACKBAR_DEFAULT_DURATION - 1);
|
|
408
|
+
expect(snackbarProps.onDismissRequest).not.toHaveBeenCalled();
|
|
409
|
+
advanceTimers(1);
|
|
410
|
+
expect(snackbarProps.onDismissRequest).toHaveBeenCalledTimes(1);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
125
414
|
describe('Snackbar live region', () => {
|
|
126
415
|
it('announces the notification as a polite status message', () => {
|
|
127
416
|
renderSnackbar();
|
|
@@ -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,7 +10,8 @@ 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 } from './Snackbar.types';
|
|
13
|
+
import type { SnackbarAction, SnackbarDuration } from './Snackbar.types';
|
|
14
|
+
import useSnackbarAutoDismiss from './useSnackbarAutoDismiss';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Props for the controlled Snackbar surface, including provider-managed lifecycle controls.
|
|
@@ -22,6 +22,13 @@ export interface SnackbarProps {
|
|
|
22
22
|
action?: SnackbarAction;
|
|
23
23
|
/** Changes for each new Snackbar so LiveRegion re-announces it. */
|
|
24
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;
|
|
25
32
|
/** Overrides sentiment-derived media with content for the 24×24px media slot. */
|
|
26
33
|
media?: React.ReactNode;
|
|
27
34
|
/** Requests an exit; the provider retains the surface until onExited confirms it has finished. */
|
|
@@ -37,8 +44,13 @@ export interface SnackbarProps {
|
|
|
37
44
|
text: React.ReactNode;
|
|
38
45
|
/** @deprecated Has no effect. Snackbar theming is based on ThemeProvider. */
|
|
39
46
|
theme?: ThemeLight | ThemeDark;
|
|
40
|
-
/**
|
|
41
|
-
|
|
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;
|
|
42
54
|
/** @deprecated Has no effect when using SnackbarProvider, which manages Snackbar identity. */
|
|
43
55
|
timestamp?: number;
|
|
44
56
|
/** Controls entry/exit; the surface does not manage visibility itself. */
|
|
@@ -48,6 +60,7 @@ export interface SnackbarProps {
|
|
|
48
60
|
export const Snackbar = ({
|
|
49
61
|
action,
|
|
50
62
|
announcementKey,
|
|
63
|
+
duration,
|
|
51
64
|
media,
|
|
52
65
|
onDismissRequest,
|
|
53
66
|
onExited,
|
|
@@ -56,16 +69,14 @@ export const Snackbar = ({
|
|
|
56
69
|
timeout,
|
|
57
70
|
visible,
|
|
58
71
|
}: SnackbarProps) => {
|
|
59
|
-
const surfaceRef =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return () => window.clearTimeout(id);
|
|
68
|
-
}, [announcementKey, onDismissRequest, timeout, visible]);
|
|
72
|
+
const { handleBlur, handleEntered, handleFocus, handleMouseEnter, handleMouseLeave, surfaceRef } =
|
|
73
|
+
useSnackbarAutoDismiss({
|
|
74
|
+
duration,
|
|
75
|
+
onDismissRequest,
|
|
76
|
+
resetKey: announcementKey,
|
|
77
|
+
timeout,
|
|
78
|
+
visible,
|
|
79
|
+
});
|
|
69
80
|
|
|
70
81
|
const renderMedia = () => {
|
|
71
82
|
if (media) {
|
|
@@ -104,6 +115,7 @@ export const Snackbar = ({
|
|
|
104
115
|
exit: SNACKBAR_EXIT_DURATION,
|
|
105
116
|
}}
|
|
106
117
|
unmountOnExit
|
|
118
|
+
onEntered={handleEntered}
|
|
107
119
|
onExited={onExited}
|
|
108
120
|
>
|
|
109
121
|
<PrimitivePrompt
|
|
@@ -113,6 +125,10 @@ export const Snackbar = ({
|
|
|
113
125
|
emphasis="elevated"
|
|
114
126
|
media={renderMedia()}
|
|
115
127
|
onDismiss={onDismissRequest}
|
|
128
|
+
onMouseEnter={handleMouseEnter}
|
|
129
|
+
onMouseLeave={handleMouseLeave}
|
|
130
|
+
onFocus={handleFocus}
|
|
131
|
+
onBlur={handleBlur}
|
|
116
132
|
>
|
|
117
133
|
<div className="wds-Snackbar__content">
|
|
118
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'];
|