From 4f392ba8bed547dc2242ebd1d80005e1ca07c47f Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Thu, 11 Jun 2026 09:46:44 +0200 Subject: [PATCH] ECHOES-1337 ECHOES-1338 Align toast lifecycle defaults with notification guidelines --- src/common/components/Toast.tsx | 6 +- .../echoes-provider/EchoesProvider.tsx | 6 +- .../__tests__/toasts-aggregation-ui-test.tsx | 10 +- src/utils/__tests__/toasts-test.tsx | 107 ++++++++++++++++-- src/utils/toasts.tsx | 72 ++++++------ stories/Toast-stories.tsx | 19 +++- 6 files changed, 161 insertions(+), 59 deletions(-) diff --git a/src/common/components/Toast.tsx b/src/common/components/Toast.tsx index 202a3c02c..7eff5f1d0 100644 --- a/src/common/components/Toast.tsx +++ b/src/common/components/Toast.tsx @@ -79,8 +79,7 @@ export interface ToastProps { /** * Custom actions to display in the toast (optional). Receives the toast ID * and dismiss function as parameters. It should be either one or more Buttons or Links. - * If provided, the toast should also have the `isDismissable` prop set to true - * and its duration set to infinite. + * If provided, the toast should also have the `isDismissable` prop set to true. */ actions?: ({ id, dismiss }: ToastActionsParams) => ReactNode; /** @@ -94,7 +93,8 @@ export interface ToastProps { id: ToastId; /** * When true, displays a dismiss button allowing users to manually close the toast (optional). - * It must be set to true if the duration of the toast is set to infinite though. + * It must be set to true if the duration of the toast is set to infinite or if actions are + * provided. * The default is false. */ isDismissable?: boolean; diff --git a/src/components/echoes-provider/EchoesProvider.tsx b/src/components/echoes-provider/EchoesProvider.tsx index ebf494068..ce61da1b9 100644 --- a/src/components/echoes-provider/EchoesProvider.tsx +++ b/src/components/echoes-provider/EchoesProvider.tsx @@ -35,7 +35,7 @@ export interface EchoesProviderProps { /** * Maximum number of toast notifications visible simultaneously (optional). * When this limit is reached, older toasts will be automatically dismissed - * to make room for new ones. The default is 5. + * to make room for new ones. The default is 3. */ toastsVisibleNb?: number; /** @@ -88,7 +88,7 @@ export interface EchoesProviderProps { * ``` */ export function EchoesProvider(props: PropsWithChildren) { - const { children, tooltipsDelayDuration, toastsClassName, toastsVisibleNb = 5 } = props; + const { children, tooltipsDelayDuration, toastsClassName, toastsVisibleNb = 3 } = props; const intl = useIntl(); const [portalRef, setPortalRef] = useState(null); @@ -136,7 +136,7 @@ EchoesProvider.displayName = 'EchoesProvider'; * It doesn't prevent tests using Modals to work fine. */ export function EchoesProviderForTests(props: PropsWithChildren) { - const { children, tooltipsDelayDuration, toastsClassName, toastsVisibleNb = 5 } = props; + const { children, tooltipsDelayDuration, toastsClassName, toastsVisibleNb = 3 } = props; const intl = useIntl(); return ( diff --git a/src/utils/__tests__/toasts-aggregation-ui-test.tsx b/src/utils/__tests__/toasts-aggregation-ui-test.tsx index 6e78c37d7..4a55c86b7 100644 --- a/src/utils/__tests__/toasts-aggregation-ui-test.tsx +++ b/src/utils/__tests__/toasts-aggregation-ui-test.tsx @@ -40,7 +40,7 @@ const { resetToastTestState, trackToastId } = createToastTestState({ describe('toast utility - automatic aggregation UI', () => { afterEach(resetToastTestState); - it('should aggregate repeated toasts without a title when the description and variety match', async () => { + it('should aggregate title-less toasts when description and variety match', async () => { render(
); trackToastId(toast.success(descriptionOnlyUploadCompleteToast)); @@ -55,7 +55,7 @@ describe('toast utility - automatic aggregation UI', () => { expect(screen.getByText(/^2$/)).toBeInTheDocument(); }); - it('should treat blank plain-text titles as absent when aggregating repeated toasts', async () => { + it('should treat blank plain-text titles as absent during aggregation', async () => { render(
); trackToastId( @@ -75,7 +75,7 @@ describe('toast utility - automatic aggregation UI', () => { expect(screen.getByText(/^2$/)).toBeInTheDocument(); }); - it('should aggregate repeated toasts with the same plain-text title, description, and variety', async () => { + it('should aggregate repeated toasts when title, description, and variety match', async () => { render(
); trackToastId(toast.success(uploadCompleteToast)); @@ -150,7 +150,7 @@ describe('toast utility - automatic aggregation UI', () => { expect(await screen.findByText('File uploaded')).toBeInTheDocument(); act(() => { - jest.advanceTimersByTime(8000); + jest.advanceTimersByTime(3000); jest.runOnlyPendingTimers(); }); @@ -174,7 +174,7 @@ describe('toast utility - automatic aggregation UI', () => { expect(screen.queryByText('Shown 2 times')).not.toBeInTheDocument(); }); - it('should keep the aggregation story aggregating when Storybook injects lifecycle action args', async () => { + it('should keep the aggregation story working when Storybook adds lifecycle args', async () => { jest.useFakeTimers(); const storyElement = AggregatedRepeatedToasts.render!( diff --git a/src/utils/__tests__/toasts-test.tsx b/src/utils/__tests__/toasts-test.tsx index a60510c0a..beeee44d4 100644 --- a/src/utils/__tests__/toasts-test.tsx +++ b/src/utils/__tests__/toasts-test.tsx @@ -59,7 +59,6 @@ describe('toast utility - basic functionality', () => { title: 'Success title', description: SUCCESS_MESSAGE, id: 'custom-id', - duration: ToastDuration.Infinite, isDismissable: true, screenReaderPrefix: 'Toast prefix', actions, @@ -164,7 +163,6 @@ describe('toast utility - dismissal and interaction', () => { toast({ variety: ToastVariety.Success, description: actionToastMessage, - duration: ToastDuration.Infinite, isDismissable: true, actions: ({ dismiss }) => ( , + onAutoClose, + onDismiss, + }), + ); + + expect(await screen.findByText(SUCCESS_MESSAGE)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open report' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dismiss toast' })).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(7000); + }); + + expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); + jest.runOnlyPendingTimers(); + }); + + act(() => { + // Sonner finalizes auto-dismiss on the next animation frame. + jest.advanceTimersToNextFrame(); + }); + + await waitFor(() => { + expect(screen.queryByText(SUCCESS_MESSAGE)).not.toBeInTheDocument(); + }); + + expect(onAutoClose).toHaveBeenCalled(); + expect(onDismiss).not.toHaveBeenCalled(); + }); + it('should dismiss toast after auto-close duration', async () => { jest.useFakeTimers(); @@ -215,16 +301,21 @@ describe('toast utility - dismissal and interaction', () => { expect(await screen.findByText(WARNING_DESCRIPTION)).toBeInTheDocument(); act(() => { - jest.advanceTimersByTime(4000); + jest.advanceTimersByTime(500); }); expect(await screen.findByText(WARNING_DESCRIPTION)).toBeInTheDocument(); - // It should auto-close after 8 seconds, we are at 6 seconds now + // It should auto-close after 3 seconds, we are at 2.5 seconds now. act(() => { - jest.advanceTimersByTime(4000); + jest.advanceTimersByTime(500); jest.runOnlyPendingTimers(); }); + act(() => { + // Sonner finalizes auto-dismiss on the next animation frame. + jest.advanceTimersToNextFrame(); + }); + await waitFor(() => { expect(screen.queryByText(WARNING_DESCRIPTION)).not.toBeInTheDocument(); }); @@ -262,7 +353,7 @@ describe('toast utility - stable id updates', () => { expect(screen.queryByText('Synchronizing repository settings...')).not.toBeInTheDocument(); }); - it('should stop automatic aggregation immediately when a returned aggregated id is later reused explicitly', async () => { + it('should stop automatic aggregation after explicit reuse of an aggregated id', async () => { render(
); const repeatedToastId = trackToastId( @@ -308,7 +399,7 @@ describe('toast utility - stable id updates', () => { expect(screen.queryByText(/^Shown \d+ times$/)).not.toBeInTheDocument(); }); - it('should not reuse an auto-generated repeated-toast id after the previous toast is dismissed', async () => { + it('should create a new generated id after the repeated toast is dismissed', async () => { render(
); const firstToastId = trackToastId( @@ -409,7 +500,7 @@ describe('toast utility - automatic aggregation opt-outs', () => { }); act(() => { - jest.advanceTimersByTime(8000); + jest.advanceTimersByTime(3000); jest.runOnlyPendingTimers(); }); diff --git a/src/utils/toasts.tsx b/src/utils/toasts.tsx index 5aef4df84..0bd63687e 100644 --- a/src/utils/toasts.tsx +++ b/src/utils/toasts.tsx @@ -35,20 +35,22 @@ export { ToastVariety } from '~common/components/Toast'; */ export enum ToastDuration { /** - * Short duration for toasts with only 1 line of text (8 seconds). + * Short duration (3 seconds) for brief transient toasts when a shorter-than-default lifetime is + * intentional. */ Short = 'short', /** - * Medium duration for toasts with 2 lines of text (16 seconds) - default duration. + * Medium duration (5 seconds). This is the default for regular toasts. */ Medium = 'medium', /** - * Long duration for toasts with 3 lines of text (24 seconds). + * Long duration (8 seconds). This is the default for toasts with actions, or for regular toasts + * that intentionally need a bit more time. */ Long = 'long', /** - * Infinite duration toast that remains visible until manually dismissed. - * Mandatory when the toast has actions. + * Infinite duration for toasts that must remain visible until the user dismisses them, such as + * critical or complex errors. */ Infinite = 'infinite', } @@ -63,8 +65,12 @@ export interface ToastParams extends Omit */ id?: ToastId; /** - * How long the toast should remain visible (optional). The default is `medium`. - * Note: Toasts with actions must use infinite duration. + * How long the toast should remain visible (optional). The default is `medium` for regular + * toasts and `long` for toasts with actions. + * Set `short` or `long` explicitly only when the toast should be shorter- or longer-lived than + * those defaults. + * Use `infinite` explicitly for critical or complex errors that should stay visible until + * dismissal. */ duration?: `${ToastDuration}`; /** @@ -84,15 +90,7 @@ export interface ToastParams extends Omit type ToastShortcutParams = Omit; function toastFn(params: ToastParams, ref?: Ref): ToastId { - const { - duration = ToastDuration.Medium, - className, - id, - isDismissable, - onAutoClose, - onDismiss, - ...toastProps - } = params; + const { duration, className, id, isDismissable, onAutoClose, onDismiss, ...toastProps } = params; if (isDefined(id) && isRepeatedToastId(id)) { clearRepeatedToastTracking(id); @@ -103,12 +101,12 @@ function toastFn(params: ToastParams, ref?: Ref): ToastId { const repeatedToastState = trackRepeatedToast(params); const repeatedToastId = repeatedToastState?.id; const repeatedToastCount = repeatedToastState?.count; + const hasActions = isDefined(toastProps.actions); - const durationValue = isDefined(toastProps.actions) - ? TOAST_DURATION_MAP[ToastDuration.Infinite] - : TOAST_DURATION_MAP[duration]; + const resolvedDuration = duration ?? (hasActions ? ToastDuration.Long : ToastDuration.Medium); - const isDismissableValue = durationValue === Infinity || isDismissable; + const durationValue = TOAST_DURATION_MAP[resolvedDuration]; + const isDismissableValue = durationValue === Infinity || hasActions || isDismissable; const visibleToastId = repeatedToastId ?? id; const trackedRepeatedToastId = isRepeatedToastId(visibleToastId) ? visibleToastId : undefined; @@ -237,8 +235,10 @@ type ToastFn = { * * **Important Rules** * - * - Toasts with actions must be dismissible and have infinite duration + * - Toasts with actions must be dismissible * - Infinite duration toasts must be dismissible + * - Use `ToastDuration.Infinite` explicitly for critical or complex errors that should stay + * visible until dismissal * * **Basic Usage** * @@ -273,7 +273,6 @@ type ToastFn = { * title: "File uploaded", * description: "Your file has been uploaded successfully.", * isDismissable: true, - * duration: ToastDuration.Infinite, * actions: ({ dismiss }) => ( * ); },