From 406cf77079d0693243831076483e24a0c199604e Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Fri, 5 Jun 2026 15:49:51 +0200 Subject: [PATCH] ECHOES-1357 Aggregate repeated same-text toasts of the same variety with a counter badge --- i18n/keys.json | 4 + src/common/components/Toast.tsx | 84 ++++++-- .../__tests__/toasts-aggregation-test.tsx | 144 +++++++++++++ src/utils/__tests__/toasts-test.tsx | 199 ++++++++++-------- .../repeated-toast-tracking-test.tsx | 172 +++++++++++++++ .../repeated-toast-tracking.ts | 127 +++++++++++ src/utils/toasts.tsx | 71 ++++++- stories/Toast-stories.tsx | 70 +++++- 8 files changed, 763 insertions(+), 108 deletions(-) create mode 100644 src/utils/__tests__/toasts-aggregation-test.tsx create mode 100644 src/utils/toast-internals/__tests__/repeated-toast-tracking-test.tsx create mode 100644 src/utils/toast-internals/repeated-toast-tracking.ts diff --git a/i18n/keys.json b/i18n/keys.json index a4c1300f1..3a8ad95b1 100644 --- a/i18n/keys.json +++ b/i18n/keys.json @@ -215,6 +215,10 @@ "toast.prefix.warning": { "defaultMessage": "Warning:" }, + "toast.repetition-count": { + "defaultMessage": "Shown {count} times", + "description": "Screen reader label for a toast shown multiple times" + }, "toasts.keyboard_shortcut_aria_label": { "defaultMessage": "Focus toasts messages with", "description": "ARIA-label for the toasts container keyboard shortcut" diff --git a/src/common/components/Toast.tsx b/src/common/components/Toast.tsx index ef05f6bed..2ab130250 100644 --- a/src/common/components/Toast.tsx +++ b/src/common/components/Toast.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { css, Global } from '@emotion/react'; import styled from '@emotion/styled'; import { forwardRef, ReactNode, useCallback, useMemo } from 'react'; @@ -24,6 +25,7 @@ import { useIntl } from 'react-intl'; import { toast as sonnerToast } from 'sonner'; import { TextNode, TextNodeOptional } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; +import { BadgeCounter } from '../../components/badges/BadgeCounter'; import { ButtonIcon, Spinner, Text } from '../../components'; import { IconCheckCircle, IconError, IconInfo, IconWarning, IconX } from '../../components/icons'; import { ScreenReaderPrefix } from './ScreenReaderPrefix'; @@ -68,7 +70,8 @@ interface ToastActionsParams { */ id: ToastId; /** - * Function to programmatically dismiss the toast, after clicking an action we should normally also dismiss the toast. + * Function to programmatically dismiss the toast. After clicking an action, + * we should normally also dismiss the toast. */ dismiss: VoidFunction; } @@ -77,7 +80,8 @@ 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 it's duration set to infinite. + * If provided, the toast should also have the `isDismissable` prop set to true + * and its duration set to infinite. */ actions?: ({ id, dismiss }: ToastActionsParams) => ReactNode; /** @@ -95,6 +99,11 @@ export interface ToastProps { * The default is false. */ isDismissable?: boolean; + /** + * Number of times the same toast has been repeated. When greater than 1, a counter badge is + * displayed next to the toast. + */ + repetitionCount?: number; /** * Optional prefix text for screen readers, providing additional context. * If not provided, a default message based on the toast variety will be used. @@ -142,12 +151,18 @@ export const Toast = forwardRef>((props, re description, id, isDismissable = false, + repetitionCount: repetitionCountProp, screenReaderPrefix, title, variety, ...htmlProps } = props; + const intl = useIntl(); + const repetitionCount = repetitionCountProp ?? 0; + + // Keep the visible badge compact while screen readers still hear the full count. + const repetitionBadgeValue = repetitionCount > 99 ? '99+' : repetitionCount; const handleDismiss = useCallback(() => { sonnerToast.dismiss(id); @@ -156,27 +171,56 @@ export const Toast = forwardRef>((props, re return ( {TOAST_VARIETY_ICONS[variety]} + {screenReaderPrefix ?? } + {title && {title}} + {description} + {actions?.({ id, dismiss: handleDismiss })} - {isDismissable && ( - + + {(repetitionCount > 1 || isDismissable) && ( + + {repetitionCount > 1 && ( + <> + + {intl.formatMessage( + { + id: 'toast.repetition-count', + defaultMessage: 'Shown {count} times', + description: 'Screen reader label for a toast shown multiple times', + }, + { count: repetitionCount }, + )} + + + + + + + )} + + {isDismissable && ( + + )} + )} ); @@ -217,6 +261,15 @@ const ToastContent = styled.div` `; ToastContent.displayName = 'ToastContent'; +const ToastTrailingContent = styled.div` + display: flex; + align-items: center; + gap: ${cssVar('dimension-space-100')}; + flex-shrink: 0; + min-height: ${cssVar('dimension-height-600')}; +`; +ToastTrailingContent.displayName = 'ToastTrailingContent'; + const ToastDismissButton = styled(ButtonIcon)` --button-padding: ${cssVar('dimension-space-0')}; --button-height: ${cssVar('dimension-height-600')}; @@ -224,6 +277,11 @@ const ToastDismissButton = styled(ButtonIcon)` `; ToastDismissButton.displayName = 'ToastDismissButton'; +const ToastRepetitionCounter = styled.span` + flex-shrink: 0; +`; +ToastRepetitionCounter.displayName = 'ToastRepetitionCounter'; + const TOAST_VARIETY_ICONS = { [ToastVariety.Info]: , [ToastVariety.Danger]: , diff --git a/src/utils/__tests__/toasts-aggregation-test.tsx b/src/utils/__tests__/toasts-aggregation-test.tsx new file mode 100644 index 000000000..15c0447b4 --- /dev/null +++ b/src/utils/__tests__/toasts-aggregation-test.tsx @@ -0,0 +1,144 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { type ToastId } from '~common/components/Toast'; +import { render } from '~common/helpers/test-utils'; +import { toast, ToastDuration } from '..'; + +const trackedToastIds = new Set(); + +const uploadCompleteToast = { + description: 'File uploaded', + title: 'Upload complete', +} as const; + +function trackToastId(toastId: ToastId) { + trackedToastIds.add(toastId); + + return toastId; +} + +function resetToastTestState() { + act(() => { + for (const toastId of trackedToastIds) { + toast.dismiss(toastId); + } + }); + + trackedToastIds.clear(); + jest.useRealTimers(); +} + +describe('toast utility - automatic aggregation', () => { + afterEach(resetToastTestState); + + it('should aggregate repeated toasts with the same plain-text content and variety', async () => { + render(
); + + trackToastId(toast.success(uploadCompleteToast)); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + trackToastId(toast.success(uploadCompleteToast)); + trackToastId(toast.success(uploadCompleteToast)); + + expect(await screen.findByText('Shown 3 times')).toBeInTheDocument(); + expect(screen.getAllByText('File uploaded')).toHaveLength(1); + expect(screen.getByText('Upload complete')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should cap the visible repetition counter at 99 plus', async () => { + render(
); + + for (let i = 0; i < 100; i++) { + trackToastId(toast.success(uploadCompleteToast)); + } + + expect(await screen.findByText('Shown 100 times')).toBeInTheDocument(); + expect(screen.getByText('99+')).toBeInTheDocument(); + }); + + it('should reset the aggregation count after a UI dismiss', async () => { + const { user } = render(
); + + trackToastId( + toast.success({ + ...uploadCompleteToast, + duration: ToastDuration.Infinite, + isDismissable: true, + }), + ); + + trackToastId( + toast.success({ + ...uploadCompleteToast, + duration: ToastDuration.Infinite, + isDismissable: true, + }), + ); + + expect(await screen.findByText('Shown 2 times')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Dismiss toast' })); + await waitForElementToBeRemoved(() => screen.queryByText('File uploaded')); + + trackToastId(toast.success(uploadCompleteToast)); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + expect(screen.getAllByText('File uploaded')).toHaveLength(1); + expect(screen.queryByText('Shown 2 times')).not.toBeInTheDocument(); + expect(screen.queryByText('2')).not.toBeInTheDocument(); + }); + + it('should reset aggregation after the first occurrence auto-closes', async () => { + jest.useFakeTimers(); + render(
); + + trackToastId( + toast.info({ + ...uploadCompleteToast, + duration: ToastDuration.Short, + }), + ); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(8000); + jest.runOnlyPendingTimers(); + }); + + await waitFor(() => { + expect(screen.queryByText('File uploaded')).not.toBeInTheDocument(); + }); + + trackToastId( + toast.info({ + ...uploadCompleteToast, + duration: ToastDuration.Short, + }), + ); + + expect(await screen.findByText('File uploaded')).toBeInTheDocument(); + expect(screen.queryByText('Shown 2 times')).not.toBeInTheDocument(); + }); +}); diff --git a/src/utils/__tests__/toasts-test.tsx b/src/utils/__tests__/toasts-test.tsx index 0152b41f3..4b56c82fa 100644 --- a/src/utils/__tests__/toasts-test.tsx +++ b/src/utils/__tests__/toasts-test.tsx @@ -18,9 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { act, screen, waitForElementToBeRemoved } from '@testing-library/react'; -import { toast as sonnerToast } from 'sonner'; -import { ToastVariety } from '~common/components/Toast'; +import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { type ToastId, ToastVariety } from '~common/components/Toast'; import { render } from '~common/helpers/test-utils'; import { toast, ToastDuration } from '..'; import { Button } from '../../components'; @@ -28,37 +27,57 @@ import { Button } from '../../components'; const TEST_MESSAGE = 'Test message'; const SUCCESS_MESSAGE = 'Success message'; const WARNING_DESCRIPTION = 'Warning description'; +const trackedToastIds = new Set(); -describe('toast utility - basic functionality', () => { - afterEach(() => { - sonnerToast.dismiss(); +function trackToastId(toastId: ToastId) { + trackedToastIds.add(toastId); + + return toastId; +} + +function resetToastTestState() { + act(() => { + for (const toastId of trackedToastIds) { + toast.dismiss(toastId); + } }); + trackedToastIds.clear(); + jest.useRealTimers(); +} + +describe('toast utility - basic functionality', () => { + afterEach(resetToastTestState); + it('should create and render a toast with required parameters', async () => { render(
); - toast({ - variety: ToastVariety.Info, - description: TEST_MESSAGE, - }); + trackToastId( + toast({ + variety: ToastVariety.Info, + description: TEST_MESSAGE, + }), + ); expect(await screen.findByText(TEST_MESSAGE)).toBeInTheDocument(); }); - it('should create a toast with all optional parameters, render actions and dismiss button', async () => { + it('should render optional fields, actions, and a dismiss button', async () => { const actions = jest.fn(() => ); const { container } = render(
); - toast({ - variety: ToastVariety.Success, - title: 'Success title', - description: SUCCESS_MESSAGE, - id: 'custom-id', - duration: ToastDuration.Infinite, - isDismissable: true, - screenReaderPrefix: 'Toast prefix', - actions, - }); + trackToastId( + toast({ + variety: ToastVariety.Success, + title: 'Success title', + description: SUCCESS_MESSAGE, + id: 'custom-id', + duration: ToastDuration.Infinite, + isDismissable: true, + screenReaderPrefix: 'Toast prefix', + actions, + }), + ); expect(await screen.findByText('Success title')).toBeInTheDocument(); expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument(); @@ -71,9 +90,7 @@ describe('toast utility - basic functionality', () => { }); describe('toast utility - variety shortcuts', () => { - afterEach(() => { - sonnerToast.dismiss(); - }); + afterEach(resetToastTestState); const shortcutTests = [ { method: 'success', description: SUCCESS_MESSAGE, a11yPrefix: 'Success:' }, @@ -86,10 +103,12 @@ describe('toast utility - variety shortcuts', () => { it(`should create ${method} toast with correct variety and content`, async () => { render(
); - toast[method]({ - description, - title: `${method} title`, - }); + trackToastId( + toast[method]({ + description, + title: `${method} title`, + }), + ); expect(await screen.findByText(description)).toBeInTheDocument(); expect(screen.getByText(`${method} title`)).toBeInTheDocument(); @@ -99,22 +118,22 @@ describe('toast utility - variety shortcuts', () => { }); describe('toast utility - dismissal and interaction', () => { - afterEach(() => { - sonnerToast.dismiss(); - }); + afterEach(resetToastTestState); it('should dismiss toast when dismiss button is clicked', async () => { const onAutoClose = jest.fn(); const onDismiss = jest.fn(); const { user } = render(
); - toast({ - variety: ToastVariety.Info, - description: TEST_MESSAGE, - isDismissable: true, - onAutoClose, - onDismiss, - }); + trackToastId( + toast({ + variety: ToastVariety.Info, + description: TEST_MESSAGE, + isDismissable: true, + onAutoClose, + onDismiss, + }), + ); expect(await screen.findByText(TEST_MESSAGE)).toBeInTheDocument(); @@ -129,12 +148,14 @@ describe('toast utility - dismissal and interaction', () => { const onDismiss = jest.fn(); render(
); - const toastId = toast({ - variety: ToastVariety.Info, - description: TEST_MESSAGE, - onAutoClose, - onDismiss, - }); + const toastId = trackToastId( + toast({ + variety: ToastVariety.Info, + description: TEST_MESSAGE, + onAutoClose, + onDismiss, + }), + ); expect(await screen.findByText(TEST_MESSAGE)).toBeInTheDocument(); @@ -152,24 +173,26 @@ describe('toast utility - dismissal and interaction', () => { const { user } = render(
); - toast({ - variety: ToastVariety.Success, - description: actionToastMessage, - duration: ToastDuration.Infinite, - isDismissable: true, - actions: ({ dismiss }) => ( - - ), - - onAutoClose, - onDismiss, - }); + trackToastId( + toast({ + variety: ToastVariety.Success, + description: actionToastMessage, + duration: ToastDuration.Infinite, + isDismissable: true, + actions: ({ dismiss }) => ( + + ), + + onAutoClose, + onDismiss, + }), + ); expect(await screen.findByText(actionToastMessage)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Undo Action' })).toBeInTheDocument(); @@ -189,13 +212,15 @@ describe('toast utility - dismissal and interaction', () => { const onDismiss = jest.fn(); render(
); - toast({ - variety: ToastVariety.Warning, - description: WARNING_DESCRIPTION, - duration: ToastDuration.Short, - onAutoClose, - onDismiss, - }); + trackToastId( + toast({ + variety: ToastVariety.Warning, + description: WARNING_DESCRIPTION, + duration: ToastDuration.Short, + onAutoClose, + onDismiss, + }), + ); act(() => { jest.advanceTimersByTime(2000); @@ -210,36 +235,40 @@ describe('toast utility - dismissal and interaction', () => { // It should auto-close after 8 seconds, we are at 6 seconds now act(() => { jest.advanceTimersByTime(4000); + jest.runOnlyPendingTimers(); + }); + + await waitFor(() => { + expect(screen.queryByText(WARNING_DESCRIPTION)).not.toBeInTheDocument(); }); - expect(screen.queryByText(WARNING_DESCRIPTION)).not.toBeInTheDocument(); + expect(onAutoClose).toHaveBeenCalled(); expect(onDismiss).not.toHaveBeenCalled(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); }); }); describe('toast utility - stable id updates', () => { - afterEach(() => { - sonnerToast.dismiss(); - }); + afterEach(resetToastTestState); it('should replace an existing toast when the same stable id is reused', async () => { render(
); - toast.info({ - description: 'Synchronizing repository settings...', - id: 'repository-sync', - }); + trackToastId( + toast.info({ + description: 'Synchronizing repository settings...', + id: 'repository-sync', + }), + ); expect(await screen.findByText('Synchronizing repository settings...')).toBeInTheDocument(); - toast.success({ - description: 'Repository settings synchronized.', - id: 'repository-sync', - title: 'Sync complete', - }); + trackToastId( + toast.success({ + description: 'Repository settings synchronized.', + id: 'repository-sync', + title: 'Sync complete', + }), + ); expect(await screen.findByText('Repository settings synchronized.')).toBeInTheDocument(); expect(screen.getByText('Sync complete')).toBeInTheDocument(); diff --git a/src/utils/toast-internals/__tests__/repeated-toast-tracking-test.tsx b/src/utils/toast-internals/__tests__/repeated-toast-tracking-test.tsx new file mode 100644 index 000000000..7a656e801 --- /dev/null +++ b/src/utils/toast-internals/__tests__/repeated-toast-tracking-test.tsx @@ -0,0 +1,172 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { type ToastId, ToastVariety } from '~common/components/Toast'; +import { clearRepeatedToastTracking, trackRepeatedToast } from '../repeated-toast-tracking'; + +const trackedToastIds = new Set(); + +const uploadCompleteToast = { + description: 'File uploaded', + title: 'Upload complete', + variety: ToastVariety.Success, +} as const; + +const iterableUploadCompleteToast = { + description: ['File', ' uploaded'], + title: ['Upload', ' complete'], + variety: ToastVariety.Success, +} as const; + +function rememberTrackedToast(trackedToastState: ReturnType) { + if (trackedToastState !== undefined) { + trackedToastIds.add(trackedToastState.id); + } + + return trackedToastState; +} + +afterEach(() => { + for (const toastId of trackedToastIds) { + clearRepeatedToastTracking(toastId); + } + + trackedToastIds.clear(); +}); + +describe('repeated toast tracking', () => { + it('should increment the count for repeated plain-text toasts', () => { + const firstToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + expect(firstToast).toEqual({ + count: 1, + id: expect.any(String), + }); + + const secondToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + expect(secondToast).toEqual({ + count: 2, + id: firstToast?.id, + }); + }); + + it('should use different synthetic ids when the variety differs', () => { + const firstToast = rememberTrackedToast( + trackRepeatedToast({ + description: 'Test message', + title: 'Notification', + variety: ToastVariety.Info, + }), + ); + + const secondToast = rememberTrackedToast( + trackRepeatedToast({ + description: 'Test message', + title: 'Notification', + variety: ToastVariety.Danger, + }), + ); + + expect(firstToast?.id).not.toBe(secondToast?.id); + expect(firstToast?.count).toBe(1); + expect(secondToast?.count).toBe(1); + }); + + it('should not track toasts with an explicit id or actions', () => { + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + id: 'custom-toast-id', + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + actions: () => 'Undo action', + }), + ).toBeUndefined(); + }); + + it('should not track toasts when the title is missing or falsy', () => { + expect( + trackRepeatedToast({ + description: 'File uploaded', + variety: ToastVariety.Success, + }), + ).toBeUndefined(); + + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + title: false, + }), + ).toBeUndefined(); + }); + + it('should not track toasts with non-text content', () => { + expect( + trackRepeatedToast({ + ...uploadCompleteToast, + description: File uploaded, + }), + ).toBeUndefined(); + }); + + it('should track iterable plain-text content', () => { + const firstToast = rememberTrackedToast(trackRepeatedToast(iterableUploadCompleteToast)); + + expect(firstToast).toEqual({ + count: 1, + id: expect.any(String), + }); + + const secondToast = rememberTrackedToast(trackRepeatedToast(iterableUploadCompleteToast)); + + expect(secondToast).toEqual({ + count: 2, + id: firstToast?.id, + }); + }); + + it('should not track iterable content with non-text children', () => { + expect( + trackRepeatedToast({ + description: ['File', uploaded], + title: ['Upload', complete], + variety: ToastVariety.Info, + }), + ).toBeUndefined(); + }); + + it('should reset the count after clearing a tracked toast', () => { + const firstToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + clearRepeatedToastTracking(firstToast!.id); + + const secondToast = rememberTrackedToast(trackRepeatedToast(uploadCompleteToast)); + + expect(secondToast).toEqual({ + count: 1, + id: firstToast?.id, + }); + }); +}); diff --git a/src/utils/toast-internals/repeated-toast-tracking.ts b/src/utils/toast-internals/repeated-toast-tracking.ts new file mode 100644 index 000000000..56b534179 --- /dev/null +++ b/src/utils/toast-internals/repeated-toast-tracking.ts @@ -0,0 +1,127 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { isValidElement } from 'react'; +import { type ToastId, type ToastProps } from '~common/components/Toast'; +import { isDefined } from '~common/helpers/types'; +import { type TextNodeOptional } from '~types/utils'; + +/** + * Figures out whether a new toast call should reuse an existing visible toast instead of creating + * a second one. + */ +interface RepeatedToastTrackingParams extends Pick< + ToastProps, + 'actions' | 'description' | 'title' | 'variety' +> { + id?: ToastId; +} + +interface RepeatedToastTrackingState { + count: number; +} + +interface RepeatedToastTrackingStateWithId extends RepeatedToastTrackingState { + id: ToastId; +} + +const REPEATED_TOAST_ID_PREFIX = 'echoes-toast-auto::'; +// Keyed by a synthetic id derived from variety + title + description for the currently visible +// toast. +const repeatedToastStateById = new Map(); + +export function trackRepeatedToast( + params: Readonly, +): RepeatedToastTrackingStateWithId | undefined { + const repeatedToastId = getRepeatedToastId(params); + + if (!isDefined(repeatedToastId)) { + return undefined; + } + + const currentState = repeatedToastStateById.get(repeatedToastId); + + const nextState = isDefined(currentState) + ? { + count: currentState.count + 1, + } + : { + count: 1, + }; + + repeatedToastStateById.set(repeatedToastId, nextState); + + return { id: repeatedToastId, ...nextState }; +} + +export function clearRepeatedToastTracking(repeatedToastId: ToastId) { + repeatedToastStateById.delete(repeatedToastId); +} + +function getRepeatedToastId(params: Readonly): ToastId | undefined { + if (isDefined(params.id) || isDefined(params.actions)) { + return undefined; + } + + // Only toasts with plain-text title and description participate in automatic aggregation. + const titleText = getToastPlainText(params.title); + const descriptionText = getToastPlainText(params.description); + + if (!isDefined(titleText) || !isDefined(descriptionText)) { + return undefined; + } + + return `${REPEATED_TOAST_ID_PREFIX}${encodeURIComponent( + JSON.stringify([params.variety, titleText, descriptionText]), + )}`; +} + +function getToastPlainText(node: TextNodeOptional | undefined): string | undefined { + if (!isDefined(node) || typeof node === 'boolean') { + return undefined; + } + + if (typeof node === 'string') { + return node; + } + + if ( + isValidElement(node) || + typeof node !== 'object' || + node === null || + !(Symbol.iterator in node) + ) { + return undefined; + } + + let text = ''; + + for (const child of node) { + const childText = getToastPlainText(child); + + if (!isDefined(childText)) { + return undefined; + } + + text += childText; + } + + return text; +} diff --git a/src/utils/toasts.tsx b/src/utils/toasts.tsx index 9037b0884..1281507f8 100644 --- a/src/utils/toasts.tsx +++ b/src/utils/toasts.tsx @@ -22,6 +22,10 @@ import { Ref } from 'react'; import { toast as sonnerToast } from 'sonner'; import { Toast, ToastId, ToastProps } from '~common/components/Toast'; import { isDefined } from '~common/helpers/types'; +import { + clearRepeatedToastTracking, + trackRepeatedToast, +} from './toast-internals/repeated-toast-tracking'; export { ToastVariety } from '~common/components/Toast'; @@ -48,12 +52,13 @@ export enum ToastDuration { Infinite = 'infinite', } -export interface ToastParams extends Omit { +export interface ToastParams extends Omit { /** * Optional stable identifier for the toast. If not provided, one will be generated automatically. * This ID is used to manage the toast's lifecycle, such as dismissing it manually or updating it. * Reusing the same `id` is the supported way to intentionally replace or update the existing - * toast instead of creating a second one. + * toast instead of creating a second one. Providing an explicit `id` also opts out of automatic + * same-text toast aggregation. */ id?: ToastId; /** @@ -62,7 +67,8 @@ export interface ToastParams extends Omit { */ duration?: `${ToastDuration}`; /** - * Callback function executed when the toast auto-closes due reaching the end of its duration (optional). + * Callback function executed when the toast auto-closes due to reaching the + * end of its duration (optional). */ onAutoClose?: VoidFunction; /** @@ -84,21 +90,63 @@ function toastFn(params: ToastParams, ref?: Ref): ToastId { ...toastProps } = params; + // Repeated plain-text toasts of the same variety reuse a synthetic id so they stay as one + // visible toast with an incrementing counter instead of stacking duplicates. + const repeatedToastState = trackRepeatedToast(params); + const repeatedToastId = repeatedToastState?.id; + const repeatedToastCount = repeatedToastState?.count; + const durationValue = isDefined(toastProps.actions) ? TOAST_DURATION_MAP[ToastDuration.Infinite] : TOAST_DURATION_MAP[duration]; const isDismissableValue = durationValue === Infinity || isDismissable; + const visibleToastId = repeatedToastId ?? id; + + const clearRepeatedToastTrackingIfNeeded = () => { + if (!isDefined(repeatedToastId)) { + return; + } + + clearRepeatedToastTracking(repeatedToastId); + }; + + // Repeated toasts share the same id and count, but Sonner still owns their lifetime. + const handleToastAutoClose = + isDefined(repeatedToastId) || isDefined(onAutoClose) + ? () => { + clearRepeatedToastTrackingIfNeeded(); + + onAutoClose?.(); + } + : undefined; + + const handleToastDismiss = + isDefined(repeatedToastId) || isDefined(onDismiss) + ? () => { + clearRepeatedToastTrackingIfNeeded(); + + onDismiss?.(); + } + : undefined; return sonnerToast.custom( - (id) => , + (id) => ( + + ), { className, duration: durationValue, - onAutoClose, - onDismiss, + onAutoClose: handleToastAutoClose, + onDismiss: handleToastDismiss, // Passing id={undefined} breaks the dismiss functionality in Sonner - ...(isDefined(id) ? { id } : {}), + ...(isDefined(visibleToastId) ? { id: visibleToastId } : {}), }, ); } @@ -165,6 +213,15 @@ type ToastFn = { * Calling `toast` or one of the variety shortcuts again with that `id` updates the existing toast * instead of creating a second one. * + * **Aggregating repeated toasts** + * + * When no explicit `id` is provided, repeated calls with the same plain-text `title`, the same + * plain-text `description`, and the same `variety` reuse the existing toast instead of creating + * duplicates. The toast keeps its original text and shows a counter badge on the right. + * + * Toasts with actions, non-text content, or missing title/description are not automatically + * aggregated. + * * **Important Rules** * * - Toasts with actions must be dismissible and have infinite duration diff --git a/stories/Toast-stories.tsx b/stories/Toast-stories.tsx index 7dbdebd80..2a13dd62a 100644 --- a/stories/Toast-stories.tsx +++ b/stories/Toast-stories.tsx @@ -44,11 +44,17 @@ const meta: Meta = { duration: { control: { type: 'select' }, table: { - defaultValue: { summary: 'short' }, + defaultValue: { summary: 'medium' }, }, options: ['short', 'medium', 'long', 'infinite'], }, + isDismissable: { + control: { type: 'boolean' }, + table: { + defaultValue: { summary: 'false' }, + }, + }, }, decorators: [basicWrapperDecorator], }; @@ -119,7 +125,8 @@ export const Shortcuts: Story = { return ( <> - The toast function provide shortcuts for each variety: + The toast function provides shortcuts for each variety: +
             {`toast.success({ description: 'Success toast message' });
@@ -128,6 +135,7 @@ toast.warning({ description: 'Warning toast message' });
 toast.error({ description: 'Error toast message' });`}
           
+ + ); + }, +};