Skip to content

Commit 7f34790

Browse files
authored
[toast] Enable closing all toasts (#3979)
1 parent bc77b07 commit 7f34790

File tree

8 files changed

+141
-19
lines changed

8 files changed

+141
-19
lines changed

docs/reference/generated/toast-provider.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"toastManager": {
1212
"type": "ToastManager",
1313
"description": "A global manager for toasts to use outside of a React component.",
14-
"detailedType": "| {\n subscribe: (\n listener: (data: ToastManagerEvent) => void,\n ) => () => void\n add: (options: ToastManagerAddOptions<any>) => string\n close: (id: string) => void\n update: (\n id: string,\n updates: ToastManagerUpdateOptions<any>,\n ) => void\n promise: (\n promiseValue: Promise<Value>,\n options: ToastManagerPromiseOptions<Value, any>,\n ) => Promise<Value>\n }\n| undefined"
14+
"detailedType": "| {\n subscribe: (\n listener: (data: ToastManagerEvent) => void,\n ) => () => void\n add: (options: ToastManagerAddOptions<any>) => string\n close: (id: string | undefined) => void\n update: (\n id: string,\n updates: ToastManagerUpdateOptions<any>,\n ) => void\n promise: (\n promiseValue: Promise<Value>,\n options: ToastManagerPromiseOptions<Value, any>,\n ) => Promise<Value>\n }\n| undefined"
1515
},
1616
"timeout": {
1717
"type": "number",

docs/src/app/(docs)/react/components/toast/page.mdx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,9 @@ const toastManager = Toast.useToastManager();
281281
description: 'Add a toast to the toast list.',
282282
},
283283
close: {
284-
type: '(toastId: string) => void',
285-
description: 'Closes and removes a toast from the toast list.',
284+
type: '(toastId?: string) => void',
285+
description:
286+
'Closes and removes a toast from the toast list. If no ID is passed, all toasts will be closed.',
286287
},
287288
update: {
288289
type: '(toastId: string, options: ToastManagerUpdateOptions) => void',
@@ -397,6 +398,12 @@ Closes the toast, removing it from the toast list after any animations complete.
397398
toastManager.close(toastId);
398399
```
399400

401+
Or you can close all toasts at once by not passing an ID:
402+
403+
```jsx title="Close all toasts"
404+
toastManager.close();
405+
```
406+
400407
### `promise` method
401408

402409
Creates an asynchronous toast with three possible states: `loading`, `success`, and `error`.

packages/react/src/toast/createToastManager.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,5 +628,49 @@ describe.skipIf(!isJSDOM)('createToastManager', () => {
628628

629629
expect(screen.queryByTestId('title')).to.equal(null);
630630
});
631+
632+
it('closes all toasts', async () => {
633+
const toastManager = Toast.createToastManager();
634+
635+
function add() {
636+
toastManager.add({ title: 'title' });
637+
}
638+
639+
function close() {
640+
toastManager.close();
641+
}
642+
643+
function Buttons() {
644+
return (
645+
<React.Fragment>
646+
<button type="button" onClick={add}>
647+
add
648+
</button>
649+
<button type="button" onClick={close}>
650+
close
651+
</button>
652+
</React.Fragment>
653+
);
654+
}
655+
656+
await render(
657+
<Toast.Provider toastManager={toastManager}>
658+
<Toast.Viewport>
659+
<List />
660+
</Toast.Viewport>
661+
<Buttons />
662+
</Toast.Provider>,
663+
);
664+
665+
const button = screen.getByRole('button', { name: 'add' });
666+
Array.from({ length: 5 }).forEach(() => {
667+
fireEvent.click(button);
668+
});
669+
670+
const closeButton = screen.getByRole('button', { name: 'close' });
671+
fireEvent.click(closeButton);
672+
673+
expect(screen.queryByTestId('title')).to.equal(null);
674+
});
631675
});
632676
});

packages/react/src/toast/createToastManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function createToastManager<Data extends object = any>(): ToastManager<Da
4242
return id;
4343
},
4444

45-
close(id: string): void {
45+
close(id?: string): void {
4646
emit({
4747
action: 'close',
4848
options: { id },
@@ -84,7 +84,7 @@ export function createToastManager<Data extends object = any>(): ToastManager<Da
8484
export interface ToastManager<Data extends object = any> {
8585
' subscribe': (listener: (data: ToastManagerEvent) => void) => () => void;
8686
add: <T extends Data = Data>(options: ToastManagerAddOptions<T>) => string;
87-
close: (id: string) => void;
87+
close: (id?: string) => void;
8888
update: <T extends Data = Data>(id: string, updates: ToastManagerUpdateOptions<T>) => void;
8989
promise: <Value, T extends Data = Data>(
9090
promiseValue: Promise<Value>,

packages/react/src/toast/provider/ToastProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const ToastProvider: React.FC<ToastProvider.Props> = function ToastProvid
4343
store.promiseToast(options.promise, options);
4444
} else if (action === 'update' && id) {
4545
store.updateToast(id, options);
46-
} else if (action === 'close' && id) {
46+
} else if (action === 'close') {
4747
store.closeToast(id);
4848
} else {
4949
store.addToast(options);

packages/react/src/toast/store.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,36 @@ export class ToastStore extends ReactStore<State, {}, typeof selectors> {
233233
}
234234
};
235235

236-
closeToast = (toastId: string) => {
237-
const toast = selectors.toast(this.state, toastId);
238-
toast?.onClose?.();
236+
closeToast = (toastId?: string) => {
237+
const closeAll = toastId === undefined;
238+
const toast = closeAll ? undefined : selectors.toast(this.state, toastId);
239+
if (!closeAll && !toast) {
240+
return;
241+
}
239242

240243
const { limit, toasts } = this.state;
241244

245+
if (closeAll) {
246+
toasts.forEach((item) => {
247+
item.onClose?.();
248+
});
249+
this.timers.forEach((timer) => {
250+
timer.timeout?.clear();
251+
});
252+
this.timers.clear();
253+
} else {
254+
toast?.onClose?.();
255+
256+
const timer = this.timers.get(toastId);
257+
if (timer?.timeout) {
258+
timer.timeout.clear();
259+
this.timers.delete(toastId);
260+
}
261+
}
262+
242263
let activeIndex = 0;
243264
const newToasts = toasts.map((item) => {
244-
if (item.id === toastId) {
265+
if (closeAll || item.id === toastId) {
245266
return { ...item, transitionStatus: 'ending' as const, height: 0 };
246267
}
247268
if (item.transitionStatus === 'ending') {
@@ -252,14 +273,14 @@ export class ToastStore extends ReactStore<State, {}, typeof selectors> {
252273
return item.limited !== isLimited ? { ...item, limited: isLimited } : item;
253274
});
254275

255-
const timer = this.timers.get(toastId);
256-
if (timer && timer.timeout) {
257-
timer.timeout.clear();
258-
this.timers.delete(toastId);
259-
}
260-
261276
this.handleFocusManagement(toastId);
262-
this.setToasts(newToasts);
277+
278+
const updates: Partial<State> = { toasts: newToasts };
279+
if (closeAll || toasts.length === 1) {
280+
updates.hovering = false;
281+
updates.focused = false;
282+
}
283+
this.update(updates);
263284
};
264285

265286
promiseToast = <Value, Data extends object>(
@@ -381,7 +402,7 @@ export class ToastStore extends ReactStore<State, {}, typeof selectors> {
381402
this.update(updates);
382403
}
383404

384-
private handleFocusManagement(toastId: string) {
405+
private handleFocusManagement(toastId: string | undefined) {
385406
const activeEl = activeElement(ownerDocument(this.state.viewport));
386407
if (
387408
!this.state.viewport ||
@@ -391,6 +412,11 @@ export class ToastStore extends ReactStore<State, {}, typeof selectors> {
391412
return;
392413
}
393414

415+
if (toastId === undefined) {
416+
this.restoreFocusToPrevElement();
417+
return;
418+
}
419+
394420
const toasts = selectors.toasts(this.state);
395421
const currentIndex = selectors.toastIndex(this.state, toastId);
396422
let nextToast: ToastObject<any> | null = null;

packages/react/src/toast/useToastManager.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,51 @@ describe.skipIf(!isJSDOM)('useToast', () => {
12841284

12851285
expect(screen.queryByTestId('root')).to.equal(null);
12861286
});
1287+
1288+
it('closes all toasts', async () => {
1289+
function AddButton() {
1290+
const { add, close } = useToastManager();
1291+
return (
1292+
<React.Fragment>
1293+
<button
1294+
onClick={() => {
1295+
add({ title: 'test' });
1296+
}}
1297+
>
1298+
add
1299+
</button>
1300+
<button
1301+
onClick={() => {
1302+
close();
1303+
}}
1304+
>
1305+
close
1306+
</button>
1307+
</React.Fragment>
1308+
);
1309+
}
1310+
1311+
await render(
1312+
<Toast.Provider>
1313+
<Toast.Viewport>
1314+
<CustomList />
1315+
</Toast.Viewport>
1316+
<AddButton />
1317+
</Toast.Provider>,
1318+
);
1319+
1320+
const addButton = screen.getByRole('button', { name: 'add' });
1321+
Array.from({ length: 5 }).forEach(() => {
1322+
fireEvent.click(addButton);
1323+
});
1324+
1325+
expect(screen.getAllByTestId('root')).to.have.length(5);
1326+
1327+
const closeButton = screen.getByRole('button', { name: 'close' });
1328+
fireEvent.click(closeButton);
1329+
1330+
expect(screen.queryByTestId('root')).to.equal(null);
1331+
});
12871332
});
12881333

12891334
describe('prop: limit', () => {

packages/react/src/toast/useToastManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export interface ToastManagerPositionerProps extends Omit<
109109
export interface UseToastManagerReturnValue<Data extends object = any> {
110110
toasts: ToastObject<Data>[];
111111
add: <T extends Data = Data>(options: ToastManagerAddOptions<T>) => string;
112-
close: (toastId: string) => void;
112+
close: (toastId?: string) => void;
113113
update: <T extends Data = Data>(toastId: string, options: ToastManagerUpdateOptions<T>) => void;
114114
promise: <Value, T extends Data = Data>(
115115
promise: Promise<Value>,

0 commit comments

Comments
 (0)