From f9775b6df81a41888348a1eec9b95b8288498bac Mon Sep 17 00:00:00 2001 From: Sergei Malinin Date: Wed, 27 May 2026 09:19:06 +0700 Subject: [PATCH 1/2] feat(Toast): containerRef --- .changeset/toast-provider-container-ref.md | 5 ++ .../src/components/Toast/Toast.types.ts | 2 + .../src/components/Toast/ToastProvider.tsx | 13 ++-- .../components/Toast/tests/Toast.stories.tsx | 59 +++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 .changeset/toast-provider-container-ref.md diff --git a/.changeset/toast-provider-container-ref.md b/.changeset/toast-provider-container-ref.md new file mode 100644 index 000000000..7a601ff46 --- /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 diff --git a/packages/reshaped/src/components/Toast/Toast.types.ts b/packages/reshaped/src/components/Toast/Toast.types.ts index 94f09adc0..87a264734 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 = { diff --git a/packages/reshaped/src/components/Toast/ToastProvider.tsx b/packages/reshaped/src/components/Toast/ToastProvider.tsx index 9d3372624..80380f309 100644 --- a/packages/reshaped/src/components/Toast/ToastProvider.tsx +++ b/packages/reshaped/src/components/Toast/ToastProvider.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { createPortal } from "react-dom"; import { positions, defaultContextData } from "./Toast.constants"; import ToastContext from "./Toast.context"; @@ -65,7 +66,7 @@ const toastReducer: T.Reducer = (state, action) => { }; const ToastProvider: React.FC = (props) => { - const { children, options } = props; + const { children, options, containerRef } = props; const toast = useToast(); const id = React.useId(); const [data, dispatch] = React.useReducer(toastReducer, defaultContextData.queues); @@ -103,12 +104,16 @@ const ToastProvider: React.FC = (props) => { [data, show, hide, add, remove, id, options] ); + 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..780476627 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,61 @@ 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(); + }, +}; From 4f9b3eb40b7c76a28cc0d21c53101ef4a15af53a Mon Sep 17 00:00:00 2001 From: Sergei Malinin Date: Wed, 27 May 2026 11:37:01 +0700 Subject: [PATCH 2/2] feat(Toast): global show option --- .changeset/toast-provider-container-ref.md | 2 +- .../src/components/Toast/Toast.constants.ts | 1 + .../src/components/Toast/Toast.types.ts | 16 ++++- .../src/components/Toast/ToastProvider.tsx | 38 +++++++++--- .../components/Toast/tests/Toast.stories.tsx | 58 +++++++++++++++++++ 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/.changeset/toast-provider-container-ref.md b/.changeset/toast-provider-container-ref.md index 7a601ff46..1bd9667cd 100644 --- a/.changeset/toast-provider-container-ref.md +++ b/.changeset/toast-provider-container-ref.md @@ -2,4 +2,4 @@ "reshaped": minor --- -ToastProvider: Added `containerRef` prop to render toast regions into a specific DOM element +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 87a264734..bd519d8c4 100644 --- a/packages/reshaped/src/components/Toast/Toast.types.ts +++ b/packages/reshaped/src/components/Toast/Toast.types.ts @@ -66,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 = { @@ -74,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 80380f309..223c7c254 100644 --- a/packages/reshaped/src/components/Toast/ToastProvider.tsx +++ b/packages/reshaped/src/components/Toast/ToastProvider.tsx @@ -7,7 +7,6 @@ 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++}`; @@ -67,24 +66,46 @@ const toastReducer: T.Reducer = (state, action) => { const ToastProvider: React.FC = (props) => { const { children, options, containerRef } = props; - const toast = useToast(); + 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 } }); @@ -100,12 +121,13 @@ 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; diff --git a/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx b/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx index 780476627..fdbe25c5d 100644 --- a/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx +++ b/packages/reshaped/src/components/Toast/tests/Toast.stories.tsx @@ -605,3 +605,61 @@ export const containerRef: StoryObj = { 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); + }, +};