Skip to content

Commit 0600a5c

Browse files
feat(toaster): add support for multiple toasters with unique identifiers (#665)
- Introduced `toasterId` prop in `ToastT` and `ToastOptions` interfaces to allow targeting specific toasters. - Updated `Toaster` component to filter toasts based on `toasterId`. - Enhanced documentation to demonstrate usage of multiple toasters. - Added tests to verify toast rendering in the correct toaster. Co-authored-by: Emil Kowalski <[email protected]>
1 parent c14bf44 commit 0600a5c

File tree

6 files changed

+89
-26
lines changed

6 files changed

+89
-26
lines changed

src/index.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ function useSonner() {
592592

593593
const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(props, ref) {
594594
const {
595+
id,
595596
invert,
596597
position = 'bottom-right',
597598
hotkey = ['altKey', 'KeyT'],
@@ -612,11 +613,17 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
612613
containerAriaLabel = 'Notifications',
613614
} = props;
614615
const [toasts, setToasts] = React.useState<ToastT[]>([]);
616+
const filteredToasts = React.useMemo(() => {
617+
if (id) {
618+
return toasts.filter((toast) => toast.toasterId === id);
619+
}
620+
return toasts.filter((toast) => !toast.toasterId);
621+
}, [toasts, id]);
615622
const possiblePositions = React.useMemo(() => {
616623
return Array.from(
617-
new Set([position].concat(toasts.filter((toast) => toast.position).map((toast) => toast.position))),
624+
new Set([position].concat(filteredToasts.filter((toast) => toast.position).map((toast) => toast.position))),
618625
);
619-
}, [toasts, position]);
626+
}, [filteredToasts, position]);
620627
const [heights, setHeights] = React.useState<HeightT[]>([]);
621628
const [expanded, setExpanded] = React.useState(false);
622629
const [interacting, setInteracting] = React.useState(false);
@@ -776,7 +783,7 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
776783
{possiblePositions.map((position, index) => {
777784
const [y, x] = position.split('-');
778785

779-
if (!toasts.length) return null;
786+
if (!filteredToasts.length) return null;
780787

781788
return (
782789
<ol
@@ -836,7 +843,7 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
836843
}}
837844
onPointerUp={() => setInteracting(false)}
838845
>
839-
{toasts
846+
{filteredToasts
840847
.filter((toast) => (!toast.position && index === 0) || toast.position === position)
841848
.map((toast, index) => (
842849
<Toast
@@ -860,7 +867,7 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
860867
actionButtonStyle={toastOptions?.actionButtonStyle}
861868
closeButtonAriaLabel={toastOptions?.closeButtonAriaLabel}
862869
removeToast={removeToast}
863-
toasts={toasts.filter((t) => t.position == toast.position)}
870+
toasts={filteredToasts.filter((t) => t.position == toast.position)}
864871
heights={heights.filter((h) => h.position == toast.position)}
865872
setHeights={setHeights}
866873
expandByDefault={expand}

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface Action {
6262

6363
export interface ToastT {
6464
id: number | string;
65+
toasterId?: string;
6566
title?: (() => React.ReactNode) | React.ReactNode;
6667
type?: ToastTypes;
6768
icon?: React.ReactNode;
@@ -111,6 +112,7 @@ interface ToastOptions {
111112
unstyled?: boolean;
112113
classNames?: ToastClassnames;
113114
closeButtonAriaLabel?: string;
115+
toasterId?: string;
114116
}
115117

116118
type Offset =
@@ -124,6 +126,7 @@ type Offset =
124126
| number;
125127

126128
export interface ToasterProps {
129+
id?: string;
127130
invert?: boolean;
128131
theme?: 'light' | 'dark' | 'system';
129132
position?: Position;
@@ -192,4 +195,5 @@ export interface ToastToDismiss {
192195

193196
export type ExternalToast = Omit<ToastT, 'id' | 'type' | 'title' | 'jsx' | 'delete' | 'promise'> & {
194197
id?: number | string;
198+
toasterId?: string;
195199
};

test/src/app/page.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,6 @@ export default function Home({ searchParams }: any) {
245245
>
246246
Extended Promise Toast
247247
</button>
248-
249248

250249
<button
251250
data-testid="extended-promise-error"
@@ -314,6 +313,14 @@ export default function Home({ searchParams }: any) {
314313
With custom ARIA labels
315314
</button>
316315
<button
316+
data-testid="toast-secondary"
317+
className="button"
318+
onClick={() => toast('Secondary Toaster Toast', { toasterId: 'secondary' })}
319+
>
320+
Render Toast in Secondary Toaster
321+
</button>
322+
<button data-testid="toast-global" className="button" onClick={() => toast('Global Toaster Toast')}>
323+
Render Toast in Global Toaster
317324
data-testid="testid-toast-button"
318325
className="button"
319326
onClick={() => toast('Toast with test ID', { testId: 'my-test-toast' })}
@@ -367,6 +374,13 @@ export default function Home({ searchParams }: any) {
367374
) : undefined,
368375
}}
369376
/>
377+
<Toaster
378+
id="secondary"
379+
position="top-left"
380+
toastOptions={{
381+
className: 'secondary-toaster',
382+
}}
383+
/>
370384
</>
371385
);
372386
}

test/tests/basic.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,20 @@ test.describe('Basic functionality', () => {
294294
await expect(page.getByLabel('Yeet the notice', { exact: true })).toHaveCount(1);
295295
});
296296

297+
test('toast with toasterId only appears in the correct Toaster', async ({ page }) => {
298+
await page.getByTestId('toast-secondary').click();
299+
const secondaryToaster = page.locator('[data-sonner-toaster][data-x-position="left"][data-y-position="top"]');
300+
await expect(secondaryToaster.getByText('Secondary Toaster Toast')).toHaveCount(1);
301+
const globalToaster = page.locator('[data-sonner-toaster][data-x-position="right"][data-y-position="bottom"]');
302+
await expect(globalToaster.getByText('Secondary Toaster Toast')).toHaveCount(0);
303+
});
304+
305+
test('toast without toasterId only appears in the global Toaster', async ({ page }) => {
306+
await page.getByTestId('toast-global').click();
307+
const globalToaster = page.locator('[data-sonner-toaster][data-x-position="right"][data-y-position="bottom"]');
308+
await expect(globalToaster.getByText('Global Toaster Toast')).toHaveCount(1);
309+
const secondaryToaster = page.locator('[data-sonner-toaster][data-x-position="left"][data-y-position="top"]');
310+
await expect(secondaryToaster.getByText('Global Toaster Toast')).toHaveCount(0);
297311
test('toast with testId renders data-testid attribute correctly', async ({ page }) => {
298312
await page.getByTestId('testid-toast-button').click();
299313
await expect(page.getByTestId('my-test-toast')).toBeVisible();

website/src/pages/toast.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,15 @@ toast(
224224
);
225225
```
226226

227+
### Targeting a specific Toaster
228+
229+
You can target a specific Toaster by passing a `toasterId` option:
230+
231+
```jsx
232+
// This toast will only appear in the Toaster with id="canvas"
233+
toast('This will show in the canvas Toaster', { toasterId: 'canvas' });
234+
```
235+
227236
## API Reference
228237

229238
| Property | Description | Default |
@@ -243,4 +252,3 @@ toast(
243252
| unstyled | Removes the default styling, which allows for easier customization. | `false` |
244253
| actionButtonStyle | Styles for the action button | `{}` |
245254
| cancelButtonStyle | Styles for the cancel button | `{}` |
246-

website/src/pages/toaster.mdx

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ This component renders all the toasts, you can place it anywhere in your app.
66

77
You can see examples of most of the scenarios described below on the [homepage](/).
88

9+
### Multiple Toasters
10+
11+
You can render multiple Toaster components with different ids and target toasts to each one:
12+
13+
```jsx
14+
<Toaster id="global" position="top-right" />
15+
<Toaster id="canvas" position="bottom-left" />
16+
17+
<button onClick={() => toast('Global toast', { toasterId: 'global' })}>
18+
Show in Global Toaster
19+
</button>
20+
<button onClick={() => toast('Canvas toast', { toasterId: 'canvas' })}>
21+
Show in Canvas Toaster
22+
</button>
23+
```
24+
925
### Expand
1026

1127
When you hover on one of the toasts, they will expand. You can make that the default behavior by setting the `expand` prop to `true`, and customize it even further with the `visibleToasts` prop.
@@ -53,26 +69,26 @@ You can customize the default ARIA label for the notification container and the
5369

5470
```jsx
5571
// example in Finnish
56-
<Toaster containerAriaLabel="Ilmoitukset" toastOptions={{closeButtonAriaLabel: 'Sulje'}} />
72+
<Toaster containerAriaLabel="Ilmoitukset" toastOptions={{ closeButtonAriaLabel: 'Sulje' }} />
5773
```
5874

5975
## API Reference
6076

61-
| Property | Description | Default |
62-
| :-------------------- | :-----------------------------------------------------------------------------------------------------------------------------: | ----------------------------------: |
63-
| theme | Toast's theme, either `light`, `dark`, or `system` | `light` |
64-
| richColors | Makes error and success state more colorful | `false` |
65-
| expand | Toasts will be expanded by default | `false` |
66-
| visibleToasts | Amount of visible toasts | `3` |
67-
| position | Place where the toasts will be rendered | `bottom-right` |
68-
| closeButton | Adds a close button to all toasts | `false` |
69-
| offset | Offset from the edges of the screen. | `32px` |
70-
| mobileOffset | Offset from the left/right edges of the screen on screens with width smaller than 600px. | `16px` |
71-
| dir | Directionality of toast's text | `ltr` |
72-
| hotkey | Keyboard shortcut that will move focus to the toaster area. | `⌥/alt + T` |
73-
| invert | Dark toasts in light mode and vice versa. | `false` |
74-
| toastOptions | These will act as default options for all toasts. See [toast()](/toast) for all available options. | `4000` |
75-
| gap | Gap between toasts when expanded | `14` |
76-
| loadingIcon | Changes the default loading icon | `-` |
77-
| pauseWhenPageIsHidden | Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked. | `false` |
78-
| icons | Changes the default icons | `-` |
77+
| Property | Description | Default |
78+
| :-------------------- | :-----------------------------------------------------------------------------------------------------------------------------: | -------------: |
79+
| theme | Toast's theme, either `light`, `dark`, or `system` | `light` |
80+
| richColors | Makes error and success state more colorful | `false` |
81+
| expand | Toasts will be expanded by default | `false` |
82+
| visibleToasts | Amount of visible toasts | `3` |
83+
| position | Place where the toasts will be rendered | `bottom-right` |
84+
| closeButton | Adds a close button to all toasts | `false` |
85+
| offset | Offset from the edges of the screen. | `32px` |
86+
| mobileOffset | Offset from the left/right edges of the screen on screens with width smaller than 600px. | `16px` |
87+
| dir | Directionality of toast's text | `ltr` |
88+
| hotkey | Keyboard shortcut that will move focus to the toaster area. | `⌥/alt + T` |
89+
| invert | Dark toasts in light mode and vice versa. | `false` |
90+
| toastOptions | These will act as default options for all toasts. See [toast()](/toast) for all available options. | `4000` |
91+
| gap | Gap between toasts when expanded | `14` |
92+
| loadingIcon | Changes the default loading icon | `-` |
93+
| pauseWhenPageIsHidden | Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked. | `false` |
94+
| icons | Changes the default icons | `-` |

0 commit comments

Comments
 (0)