Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/toast-provider-container-ref.md
Original file line number Diff line number Diff line change
@@ -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`
1 change: 1 addition & 0 deletions packages/reshaped/src/components/Toast/Toast.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ export const defaultContextData = {
hide: () => {},
remove: () => {},
add: () => "",
root: null,
};
18 changes: 16 additions & 2 deletions packages/reshaped/src/components/Toast/Toast.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export type ProviderProps = {
}
>
>;
/** Container to render the toast regions in using a portal, must be CSS-positioned */
containerRef?: React.RefObject<HTMLElement | null>;
};

export type RegionProps = {
Expand All @@ -64,17 +66,29 @@ 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 = {
options?: ProviderProps["options"];
queues: Record<RegionProps["position"], Array<ContainerProps>>;
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 = {
Expand Down
49 changes: 38 additions & 11 deletions packages/reshaped/src/components/Toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -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++}`;
Expand Down Expand Up @@ -65,25 +65,47 @@ const toastReducer: T.Reducer = (state, action) => {
};

const ToastProvider: React.FC<T.ProviderProps> = (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<T.Context["add"]>((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<T.RootContext>(
() => parent.root ?? { add: localAdd, hide: localHide },
[parent.root, localAdd, localHide]
);

const add = React.useCallback<T.Context["add"]>(
(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<T.Context["hide"]>(
(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 } });
Expand All @@ -99,16 +121,21 @@ const ToastProvider: React.FC<T.ProviderProps> = (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) => (
<ToastRegion position={position} key={position} nested={!!parent.id} />
));

const portalTarget = containerRef?.current ?? null;

return (
<ToastContext.Provider value={value}>
{children}
{positions.map((position) => (
<ToastRegion position={position} key={position} nested={!!toast.id} />
))}
{portalTarget ? createPortal(<>{regions}</>, portalTarget) : regions}
</ToastContext.Provider>
);
};
Expand Down
117 changes: 117 additions & 0 deletions packages/reshaped/src/components/Toast/tests/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -546,3 +547,119 @@ export const edgeCases = {
</Example>
),
};

const ContainerRefDemo = () => {
const toast = useToast();

return (
<Button
onClick={() => {
toast.show({
text: "Content",
position: "top-start",
});
}}
>
Show toast
</Button>
);
};

export const containerRef: StoryObj = {
name: "containerRef",
render: () => {
const ref = React.useRef<HTMLDivElement>(null);

return (
<Example>
<Example.Item title="containerRef, regions portalled into target container">
<ToastProvider containerRef={ref}>
<View gap={4} align="start">
<ContainerRefDemo />
<View
attributes={{
ref,
"data-testid": "test-container-ref-id",
}}
position="relative"
backgroundColor="neutral-faded"
borderRadius="medium"
width="100%"
minHeight={50}
/>
</View>
</ToastProvider>
</Example.Item>
</Example>
);
},
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 (
<Button
onClick={() => {
toast.show({
text: "Global content",
position: "top-start",
global: true,
});
}}
>
Show toast
</Button>
);
};

export const global: StoryObj = {
name: "global",
render: () => {
const ref = React.useRef<HTMLDivElement>(null);

return (
<Example>
<Example.Item title="global, renders in the root provider ignoring containerRef">
<ToastProvider containerRef={ref}>
<View gap={4} align="start">
<GlobalDemo />
<View
attributes={{ ref, "data-testid": "test-global-container-id" }}
position="relative"
backgroundColor="neutral-faded"
borderRadius="medium"
width="100%"
minHeight={50}
/>
</View>
</ToastProvider>
</Example.Item>
</Example>
);
},
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);
},
};
Loading