Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions i18n/keys.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
84 changes: 71 additions & 13 deletions src/common/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
* 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';
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';
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
/**
Expand All @@ -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.
Expand Down Expand Up @@ -142,12 +151,18 @@ export const Toast = forwardRef<HTMLDivElement, Readonly<ToastProps>>((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);
Expand All @@ -156,27 +171,56 @@ export const Toast = forwardRef<HTMLDivElement, Readonly<ToastProps>>((props, re
return (
<ToastWrapper id={`echoes-toast-${id}`} ref={ref} {...htmlProps}>
<Text>{TOAST_VARIETY_ICONS[variety]}</Text>

<ToastBody>
<ToastContent>
<ScreenReaderPrefix>
{screenReaderPrefix ?? <ToastPrefix variety={variety} />}
</ScreenReaderPrefix>

{title && <Text isHighlighted>{title}</Text>}

<Text as="p">{description}</Text>
</ToastContent>

{actions?.({ id, dismiss: handleDismiss })}
</ToastBody>
{isDismissable && (
<ToastDismissButton
Icon={IconX}
ariaLabel={intl.formatMessage({
id: 'toast.dismiss',
defaultMessage: 'Dismiss toast',
description: 'ARIA-label for the dismiss button in the top-right corner of the Toast.',
})}
onClick={handleDismiss}
variety="default-ghost"
/>

{(repetitionCount > 1 || isDismissable) && (
<ToastTrailingContent>
{repetitionCount > 1 && (
<>
<ScreenReaderPrefix>
{intl.formatMessage(
{
id: 'toast.repetition-count',
defaultMessage: 'Shown {count} times',
description: 'Screen reader label for a toast shown multiple times',
},
{ count: repetitionCount },
)}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
</ScreenReaderPrefix>

<ToastRepetitionCounter aria-hidden>
<BadgeCounter value={repetitionBadgeValue} />
</ToastRepetitionCounter>
</>
)}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

{isDismissable && (
<ToastDismissButton
Icon={IconX}
ariaLabel={intl.formatMessage({
id: 'toast.dismiss',
defaultMessage: 'Dismiss toast',
description:
'ARIA-label for the dismiss button in the top-right corner of the Toast.',
})}
onClick={handleDismiss}
variety="default-ghost"
/>
)}
</ToastTrailingContent>
)}
</ToastWrapper>
);
Expand Down Expand Up @@ -217,13 +261,27 @@ 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')};
--button-width: ${cssVar('dimension-width-300')};
`;
ToastDismissButton.displayName = 'ToastDismissButton';

const ToastRepetitionCounter = styled.span`
flex-shrink: 0;
`;
ToastRepetitionCounter.displayName = 'ToastRepetitionCounter';

const TOAST_VARIETY_ICONS = {
[ToastVariety.Info]: <IconInfo color="echoes-color-icon-info" />,
[ToastVariety.Danger]: <IconError color="echoes-color-icon-danger" />,
Expand Down
144 changes: 144 additions & 0 deletions src/utils/__tests__/toasts-aggregation-test.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastId>();

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(<div />);

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(<div />);

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(<div />);

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(<div />);

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();
});
});
Loading
Loading