diff --git a/.changeset/toast-provider-container-ref.md b/.changeset/toast-provider-container-ref.md new file mode 100644 index 000000000..1bd9667cd --- /dev/null +++ b/.changeset/toast-provider-container-ref.md @@ -0,0 +1,5 @@ +--- +"reshaped": minor +--- + +ToastProvider: Added `containerRef` prop to render toast regions into a specific DOM element, and a `global` option in `show` to render a toast in the root provider, ignoring any nested provider's `containerRef` diff --git a/packages/reshaped/src/components/Toast/Toast.constants.ts b/packages/reshaped/src/components/Toast/Toast.constants.ts index 028c48446..a14b969ef 100644 --- a/packages/reshaped/src/components/Toast/Toast.constants.ts +++ b/packages/reshaped/src/components/Toast/Toast.constants.ts @@ -26,4 +26,5 @@ export const defaultContextData = { hide: () => {}, remove: () => {}, add: () => "", + root: null, }; diff --git a/packages/reshaped/src/components/Toast/Toast.types.ts b/packages/reshaped/src/components/Toast/Toast.types.ts index 94f09adc0..bd519d8c4 100644 --- a/packages/reshaped/src/components/Toast/Toast.types.ts +++ b/packages/reshaped/src/components/Toast/Toast.types.ts @@ -49,6 +49,8 @@ export type ProviderProps = { } > >; + /** Container to render the toast regions in using a portal, must be CSS-positioned */ + containerRef?: React.RefObject; }; export type RegionProps = { @@ -64,7 +66,12 @@ export type ContainerProps = { inspected: boolean; }; -export type ShowOptions = { timeout?: Timeout; position?: Position }; +export type ShowOptions = { + timeout?: Timeout; + position?: Position; + /** Render the toast in the root provider, ignoring any nested provider's */ + global?: boolean; +}; export type ShowProps = Props & ShowOptions; export type Context = { @@ -72,9 +79,16 @@ export type Context = { queues: Record>; add: (toast: ShowProps) => string; show: (id: string) => void; - hide: (id: string) => void; + hide: (id: string, options?: { global?: boolean }) => void; remove: (id: string) => void; id: string; + /** Topmost provider in the tree, used to render global toasts */ + root: RootContext | null; +}; + +export type RootContext = { + add: (toast: ShowProps) => string; + hide: (id: string) => void; }; type AddAction = { diff --git a/packages/reshaped/src/components/Toast/ToastProvider.tsx b/packages/reshaped/src/components/Toast/ToastProvider.tsx index 9d3372624..223c7c254 100644 --- a/packages/reshaped/src/components/Toast/ToastProvider.tsx +++ b/packages/reshaped/src/components/Toast/ToastProvider.tsx @@ -1,12 +1,12 @@ "use client"; import React from "react"; +import { createPortal } from "react-dom"; import { positions, defaultContextData } from "./Toast.constants"; import ToastContext from "./Toast.context"; import * as T from "./Toast.types"; import ToastRegion from "./ToastRegion"; -import useToast from "./useToast"; let counter = 0; const generateId = () => `__rs-toast-${counter++}`; @@ -65,25 +65,47 @@ const toastReducer: T.Reducer = (state, action) => { }; const ToastProvider: React.FC = (props) => { - const { children, options } = props; - const toast = useToast(); + const { children, options, containerRef } = props; + const parent = React.useContext(ToastContext); const id = React.useId(); const [data, dispatch] = React.useReducer(toastReducer, defaultContextData.queues); - const add = React.useCallback((toastProps) => { + const localAdd = React.useCallback((toastProps: T.ShowProps) => { const id = generateId(); dispatch({ type: "add", payload: { toastProps, id } }); return id; }, []); + const localHide = React.useCallback((id: string) => { + dispatch({ type: "hide", payload: { id } }); + }, []); + + // Topmost provider in the tree — global toasts are delegated here + const root = React.useMemo( + () => parent.root ?? { add: localAdd, hide: localHide }, + [parent.root, localAdd, localHide] + ); + + const add = React.useCallback( + (toastProps) => { + if (toastProps.global) return root.add(toastProps); + return localAdd(toastProps); + }, + [root, localAdd] + ); + const show = React.useCallback((id: string) => { dispatch({ type: "show", payload: { id } }); }, []); - const hide = React.useCallback((id: string) => { - dispatch({ type: "hide", payload: { id } }); - }, []); + const hide = React.useCallback( + (id, hideOptions) => { + if (hideOptions?.global) return root.hide(id); + return localHide(id); + }, + [root, localHide] + ); const remove = React.useCallback((id: string) => { dispatch({ type: "remove", payload: { id } }); @@ -99,16 +121,21 @@ const ToastProvider: React.FC = (props) => { remove, inspecting: false, options, + root, }), - [data, show, hide, add, remove, id, options] + [data, show, hide, add, remove, id, options, root] ); + const regions = positions.map((position) => ( + + )); + + const portalTarget = containerRef?.current ?? null; + return ( {children} - {positions.map((position) => ( - - ))} + {portalTarget ? createPortal(<>{regions}, portalTarget) : regions} ); }; diff --git a/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx b/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx index dd2528e27..fdbe25c5d 100644 --- a/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx +++ b/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx @@ -1,4 +1,5 @@ import { StoryObj } from "@storybook/react-vite"; +import React from "react"; import { expect, userEvent, within } from "storybook/test"; import Button from "@/components/Button"; @@ -546,3 +547,119 @@ export const edgeCases = { ), }; + +const ContainerRefDemo = () => { + const toast = useToast(); + + return ( + + ); +}; + +export const containerRef: StoryObj = { + name: "containerRef", + render: () => { + const ref = React.useRef(null); + + return ( + + + + + + + + + + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + const button = canvas.getAllByRole("button")[0]; + + await userEvent.click(button); + + const container = canvas.getByTestId("test-container-ref-id"); + const toast = within(container).getByText("Content"); + + expect(toast).toBeInTheDocument(); + }, +}; + +const GlobalDemo = () => { + const toast = useToast(); + + return ( + + ); +}; + +export const global: StoryObj = { + name: "global", + render: () => { + const ref = React.useRef(null); + + return ( + + + + + + + + + + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + const button = canvas.getAllByRole("button")[0]; + + await userEvent.click(button); + + const toast = canvas.getByText("Global content"); + const container = canvas.getByTestId("test-global-container-id"); + + // Global toast renders outside the containerRef target + expect(toast).toBeInTheDocument(); + expect(container).not.toContainElement(toast); + }, +};