diff --git a/.changeset/great-keys-admire.md b/.changeset/great-keys-admire.md new file mode 100644 index 0000000000..0d7f86fbe3 --- /dev/null +++ b/.changeset/great-keys-admire.md @@ -0,0 +1,5 @@ +--- +"@heroui/toast": patch +--- + +Support multiple ToastProvider with unique IDs diff --git a/apps/docs/content/components/toast/index.ts b/apps/docs/content/components/toast/index.ts index 183dbe6181..0a35a45dc8 100644 --- a/apps/docs/content/components/toast/index.ts +++ b/apps/docs/content/components/toast/index.ts @@ -6,6 +6,7 @@ import placement from "./placement"; import usage from "./usage"; import customCloseIcon from "./custom-close-icon"; import close from "./close"; +import multipleToastProvider from "./multiple-ToastProvider"; export const toastContent = { color, @@ -16,4 +17,5 @@ export const toastContent = { placement, usage, customCloseIcon, + multipleToastProvider, }; diff --git a/apps/docs/content/components/toast/multiple-ToastProvider.raw.jsx b/apps/docs/content/components/toast/multiple-ToastProvider.raw.jsx new file mode 100644 index 0000000000..cf94145441 --- /dev/null +++ b/apps/docs/content/components/toast/multiple-ToastProvider.raw.jsx @@ -0,0 +1,42 @@ +import {addToast, Button, ToastProvider} from "@heroui/react"; +import React from "react"; + +export default function App() { + const topToasterId = "multi-toaster-top"; + const bottomToasterId = "multi-toaster-bottom"; + + return ( + <> +
+ + +
+
+ + +
+ + ); +} diff --git a/apps/docs/content/components/toast/multiple-ToastProvider.ts b/apps/docs/content/components/toast/multiple-ToastProvider.ts new file mode 100644 index 0000000000..e00175cb4f --- /dev/null +++ b/apps/docs/content/components/toast/multiple-ToastProvider.ts @@ -0,0 +1,9 @@ +import App from "./multiple-ToastProvider.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/toast.mdx b/apps/docs/content/docs/components/toast.mdx index afe3cd16d5..8f4a695e5d 100644 --- a/apps/docs/content/docs/components/toast.mdx +++ b/apps/docs/content/docs/components/toast.mdx @@ -134,6 +134,15 @@ You can pass a custom close icon to the toast by passing the `closeIcon` prop an files={toastContent.customCloseIcon} /> +### Multiple ToastProvider + +You can have multiple ToastProvider with different configurations and use `toasterId` to distinguish them. By passing `toasterId` to `addToast` function, you can add a toast to the corresponding `ToastProvider`. + + + ### Global Toast Props You can pass global toast props to the `ToastProvider` to apply to all toasts. diff --git a/packages/components/toast/__tests__/toast.test.tsx b/packages/components/toast/__tests__/toast.test.tsx index c846b2dcab..e16e7f11b9 100644 --- a/packages/components/toast/__tests__/toast.test.tsx +++ b/packages/components/toast/__tests__/toast.test.tsx @@ -174,4 +174,75 @@ describe("Toast", () => { expect(loadingIcon).toBeTruthy(); }); + + it("should work with multiple ToastProvider", async () => { + const leftToasterId = "left"; + const leftTitle = "Left Toast Title"; + const leftDescription = "Left Toast Description"; + + const rightToasterId = "right"; + const rightTitle = "Right Toast Title"; + const rightDescription = "Right Toast Description"; + + render( + <> + + + + + , + ); + + const leftButton = screen.getByTestId("left-button"); + const rightButton = screen.getByTestId("right-button"); + + await user.click(leftButton); + await user.click(rightButton); + const regions = await screen.findAllByRole("region"); + + expect(regions).toHaveLength(2); + + const leftRegion = regions.find( + (region) => region.getAttribute("data-placement") === "bottom-left", + ); + const rightRegion = regions.find( + (region) => region.getAttribute("data-placement") === "bottom-right", + ); + + // check for left ToastProvider + expect(leftRegion).toHaveAttribute("data-placement", "bottom-left"); + expect(leftRegion).toContainHTML(leftTitle); + expect(leftRegion).toContainHTML(leftDescription); + expect(leftRegion).not.toContainHTML(rightTitle); + expect(leftRegion).not.toContainHTML(rightDescription); + + // check for right ToastProvider + expect(rightRegion).toHaveAttribute("data-placement", "bottom-right"); + expect(rightRegion).toContainHTML(rightTitle); + expect(rightRegion).toContainHTML(rightDescription); + expect(rightRegion).not.toContainHTML(leftTitle); + expect(rightRegion).not.toContainHTML(leftDescription); + }); }); diff --git a/packages/components/toast/src/toast-provider.tsx b/packages/components/toast/src/toast-provider.tsx index d203129a08..cc74e7c9a9 100644 --- a/packages/components/toast/src/toast-provider.tsx +++ b/packages/components/toast/src/toast-provider.tsx @@ -10,7 +10,8 @@ import {ToastRegion} from "./toast-region"; const loadFeatures = () => import("framer-motion").then((res) => res.domMax); -let globalToastQueue: ToastQueue | null = null; +const defaultToasterId = "heroui"; +let globalToastQueues: Record> = {}; interface ToastProviderProps { maxVisibleToasts?: number; @@ -19,17 +20,22 @@ interface ToastProviderProps { toastProps?: ToastProps; toastOffset?: number; regionProps?: RegionProps; + toasterId?: string; } -export const getToastQueue = () => { - if (!globalToastQueue) { - globalToastQueue = new ToastQueue({ +export function getToastQueue(): ToastQueue; +export function getToastQueue(args: {toasterId: string}): ToastQueue; +export function getToastQueue(args?: {toasterId: string}) { + const toasterId = args?.toasterId || defaultToasterId; + + if (!globalToastQueues[toasterId]) { + globalToastQueues[toasterId] = new ToastQueue({ maxVisibleToasts: Infinity, }); } - return globalToastQueue; -}; + return globalToastQueues[toasterId]; +} export const ToastProvider = ({ placement = "bottom-right", @@ -37,9 +43,10 @@ export const ToastProvider = ({ maxVisibleToasts = 3, toastOffset = 0, toastProps = {}, + toasterId = defaultToasterId, regionProps, }: ToastProviderProps) => { - const toastQueue = useToastQueue(getToastQueue()); + const toastQueue = useToastQueue(getToastQueue({toasterId})); const globalContext = useProviderContext(); const disableAnimation = disableAnimationProp ?? globalContext?.disableAnimation ?? false; @@ -60,29 +67,36 @@ export const ToastProvider = ({ ); }; -export const addToast = ({...props}: ToastProps & ToastOptions) => { - if (!globalToastQueue) { +export const addToast = ({toasterId = defaultToasterId, ...props}: ToastProps & ToastOptions) => { + if (!globalToastQueues[toasterId]) { return null; } - return globalToastQueue.add(props); + return globalToastQueues[toasterId].add(props); }; -export const closeToast = (key: string) => { - if (!globalToastQueue) { +export function closeToast(key: string): void; +export function closeToast(args: {key: string; toasterId?: string}): void; +export function closeToast(args: string | {key: string; toasterId?: string}) { + const {key, toasterId} = + typeof args === "string" + ? {key: args, toasterId: defaultToasterId} + : {key: args.key, toasterId: args.toasterId || defaultToasterId}; + + if (!globalToastQueues[toasterId]) { return; } - globalToastQueue.close(key); -}; + globalToastQueues[toasterId].close(key); +} -export const closeAll = () => { - if (!globalToastQueue) { +export const closeAll = (toasterId = defaultToasterId) => { + if (!globalToastQueues[toasterId]) { return; } - const keys = globalToastQueue.visibleToasts.map((toast) => toast.key); + const keys = globalToastQueues[toasterId].visibleToasts.map((toast) => toast.key); keys.map((key) => { - globalToastQueue?.close(key); + globalToastQueues[toasterId]?.close(key); }); }; diff --git a/packages/components/toast/src/use-toast.ts b/packages/components/toast/src/use-toast.ts index faf659d1dd..a8bf4dd766 100644 --- a/packages/components/toast/src/use-toast.ts +++ b/packages/components/toast/src/use-toast.ts @@ -110,6 +110,11 @@ export interface ToastProps extends ToastVariantProps { * @default "default" */ severity?: "default" | "primary" | "secondary" | "success" | "warning" | "danger"; + /** + * The id of the ToastProvider. The decides which ToastProvider will handle the toast. + * @default "heroui" + */ + toasterId?: string; } interface Props extends Omit, "title">, ToastProps { diff --git a/packages/components/toast/stories/toast.stories.tsx b/packages/components/toast/stories/toast.stories.tsx index ae1656af92..4060df9a09 100644 --- a/packages/components/toast/stories/toast.stories.tsx +++ b/packages/components/toast/stories/toast.stories.tsx @@ -425,6 +425,52 @@ const CloseToastTemplate = (args: ToastProps) => { ); }; +const MultiToasterTemplate = (args) => { + const topToasterId = "multi-toaster-top"; + const bottomToasterId = "multi-toaster-bottom"; + + return ( + <> + + +
+ + +
+ + ); +}; + export const Default = { render: Template, args: { @@ -528,3 +574,10 @@ export const CloseToast = { ...defaultProps, }, }; + +export const MultiToaster = { + render: MultiToasterTemplate, + args: { + ...defaultProps, + }, +};