diff --git a/package.json b/package.json index f164b86..18e66d2 100644 --- a/package.json +++ b/package.json @@ -61,15 +61,15 @@ }, { "path": "dist/index.mjs", - "limit": "5 KB" + "limit": "5.5 KB" }, { "path": "headless/index.js", - "limit": "2 KB" + "limit": "2.5 KB" }, { "path": "headless/index.mjs", - "limit": "2 KB" + "limit": "2.5 KB" } ], "devDependencies": { diff --git a/site/components/docs-layout.tsx b/site/components/docs-layout.tsx index a88a85b..4269d50 100644 --- a/site/components/docs-layout.tsx +++ b/site/components/docs-layout.tsx @@ -75,6 +75,8 @@ export default function DocsLayout({ meta, children }) { Guides Styling + Multi Toaster + Releases New in 2.0 diff --git a/site/pages/docs/multi-toaster.mdx b/site/pages/docs/multi-toaster.mdx new file mode 100644 index 0000000..d784930 --- /dev/null +++ b/site/pages/docs/multi-toaster.mdx @@ -0,0 +1,98 @@ +import Layout from '../../components/docs-layout'; +import toast, { Toaster } from 'react-hot-toast'; + +export const meta = { + title: 'Multiple Toasters', +}; + +export default ({ children }) => {children}; + +# Multiple Toasters + +React Hot Toast supports multiple toaster instances in your app, They can be used and configured independently of each other. This is useful for having notifications in different areas of your app. + +You can use multiple toasters by creating a [`Toaster`](/docs/toaster) with a unique `toasterId`: + +```jsx + +``` + +## Example + +This example shows two toasters, each maintaining their own state and configuration. + +
+
+

Area 1

+ + +
+ +
+

Area 2

+ + +
+
+ +## Basic Usage + +You can create multiple toasters providing unique `toasterId` to each `` component: + +```jsx +// Create a toaster with a unique id + + +// Create another toaster with a unique id + +``` + +To create a toast in a specific toaster, you can pass the `toasterId` to the `toast` function. + +```jsx +// Create a toast in area 1 +toast('Notification for Area 1', { + toasterId: 'area1', +}); +``` + +When no `toasterId` is provided, it uses `"default"` as the `toasterId`. + +### Positioning the toaster + +When placing a toaster in a specific area of your app, set the position to `absolute` and the parent element to `relative`. + +```jsx +
+ +
+``` diff --git a/site/pages/docs/toast.mdx b/site/pages/docs/toast.mdx index 23b209c..169a7f4 100644 --- a/site/pages/docs/toast.mdx +++ b/site/pages/docs/toast.mdx @@ -39,6 +39,9 @@ toast('Hello World', { // Additional Configuration removeDelay: 1000, + + // Toaster instance + toasterId: 'default', }); ``` diff --git a/site/pages/docs/toaster.mdx b/site/pages/docs/toaster.mdx index 6399682..c3897d9 100644 --- a/site/pages/docs/toaster.mdx +++ b/site/pages/docs/toaster.mdx @@ -20,6 +20,7 @@ This component will render all toasts. Alternatively you can create own renderer gutter={8} containerClassName="" containerStyle={{}} + toasterId="default" toastOptions={{ // Define default options className: '', @@ -67,6 +68,10 @@ Customize the style of toaster div. This can be used to change the offset of all Changes the gap between each toast. Defaults to `8`. +### `toasterId` Prop + +You can change the toasterId to have a different toaster instance. Learn more about [multiple toasters](/docs/multi-toaster). Defaults to `"default"`. + ### `toastOptions` Prop These will act as default options for all toasts. See [`toast()`](/docs/toast) for all available options. diff --git a/site/pages/docs/use-toaster.mdx b/site/pages/docs/use-toaster.mdx index a12daff..cf51a6a 100644 --- a/site/pages/docs/use-toaster.mdx +++ b/site/pages/docs/use-toaster.mdx @@ -9,34 +9,79 @@ export default ({ children }) => {children}; # `useToaster()` API -The `useToaster()` hook provides you a **headless system that will manage the notification state** for you. This makes building your own notification system much easier. +The `useToaster()` hook provides a **headless toast management system** for building custom notification UIs. It manages toast state and lifecycle without rendering any components. -It solves the following problems for you: +It handles pausing on hover, auto-removal, and provides a 1-second removal delay with `visible` flag for smooth animations. -- Built-in dispatch system with [`toast()`](/docs/toast) -- Handlers to pause toasts on hover -- Automatically remove expired toasts -- Support for unmount animations. Removal is delayed by 1s, but sets `visible` on the toast to `false`. +**Alternative**: Use [`useToasterStore()`](/docs/use-toaster-store) if you already have a toaster instance and only need the state. -### Importing from headless +### Importing -You can import only the core of the library with `react-hot-toast/headless`. It won't include any styles, dependencies or custom components. +```jsx +import { useToaster } from 'react-hot-toast'; +``` + +You can also import from the headless entry point to exclude UI components: ```jsx import { useToaster } from 'react-hot-toast/headless'; ``` -Be aware: [react-hot-toast 2.0](/docs/version-2) adds support for **custom render functions**, an easier method to render custom notification components. +**Note**: [React Hot Toast 2.0](/docs/version-2) includes **custom render functions** for easier custom components. -It's recommended to only have one `` or `useToaster()` in your app at a time. If you need the current state without the handlers, you should use [`useToasterStore()`](/docs/use-toaster-store) instead. +## API Reference -## Usage with React Native +### Parameters + +```tsx +useToaster( + toastOptions?: DefaultToastOptions, + toasterId?: string +) +``` + +| Parameter | Type | Default | Description | +| -------------- | --------------------- | ----------- | ----------------------------------------------- | +| `toastOptions` | `DefaultToastOptions` | `undefined` | Default options for all toasts in this instance | +| `toasterId` | `string` | `'default'` | Unique identifier for this toaster instance | + +### Returns + +```tsx +{ + toasts: Toast[]; + handlers: { + startPause: () => void; + endPause: () => void; + updateHeight: (toastId: string, height: number) => void; + calculateOffset: (toast: Toast, options?: OffsetOptions) => number; + }; +} +``` + +#### `toasts` + +Array of all toasts in this toaster instance, including hidden ones for animation purposes. + +#### `handlers` -Headless mode is perfectly suited to add notifications to your React Native app. You can check out [this example](). +- **`startPause()`**: Pause all toast timers (useful for hover states) +- **`endPause()`**: Resume all toast timers +- **`updateHeight(toastId, height)`**: Update toast height for offset calculations +- **`calculateOffset(toast, options)`**: Calculate vertical offset for toast positioning + +## Multiple Toasters + +You can create multiple independent toaster instances by providing a unique `toasterId`. See the [Multiple Toasters](/docs/multi-toaster) guide for detailed examples. + +```jsx +const sidebar = useToaster({ duration: 5000 }, 'sidebar'); +toast('Sidebar notification', { toasterId: 'sidebar' }); +``` ## Examples -### Basic Example +### Basic Implementation ```jsx import toast, { useToaster } from 'react-hot-toast/headless'; @@ -58,20 +103,20 @@ const Notifications = () => { ); }; -// Create toasts anywhere +// Create toasts from anywhere toast('Hello World'); ``` -### Animated Example +### Animated Implementation -Instead of mapping over `visibleToasts` we'll use `toasts`, which includes all hidden toasts. We animate them based on `toast.visible`. Toasts will be removed from 1 second after being dismissed, which give us enough time to animate. +This example uses all `toasts` (including hidden ones) to enable smooth animations. The `toast.visible` property controls opacity, while the 1-second removal delay provides time for exit animations. -You can play with the demo on [CodeSandbox](https://codesandbox.io/s/react-hot-toast-usetoaster-headless-example-zw7op?file=/src/App.js). +**Live Demo**: [CodeSandbox](https://codesandbox.io/s/react-hot-toast-usetoaster-headless-example-zw7op?file=/src/App.js) ```jsx import { useToaster } from 'react-hot-toast/headless'; -const Notifications = () => { +const AnimatedNotifications = () => { const { toasts, handlers } = useToaster(); const { startPause, endPause, calculateOffset, updateHeight } = handlers; @@ -92,11 +137,12 @@ const Notifications = () => { }); const ref = (el) => { - if (el && typeof toast.height !== "number") { + if (el && typeof toast.height !== 'number') { const height = el.getBoundingClientRect().height; updateHeight(toast.id, height); } }; + return (
{ ); }; ``` + + +## Usage with React Native + +The headless API works perfectly with React Native. View the [React Native example]() for implementation details. diff --git a/src/components/toaster.tsx b/src/components/toaster.tsx index 67c07e7..1b36ddb 100644 --- a/src/components/toaster.tsx +++ b/src/components/toaster.tsx @@ -88,14 +88,15 @@ export const Toaster: React.FC = ({ toastOptions, gutter, children, + toasterId, containerStyle, containerClassName, }) => { - const { toasts, handlers } = useToaster(toastOptions); + const { toasts, handlers } = useToaster(toastOptions, toasterId); return (
{ +interface State { + [toasterId: string]: ToasterState; +} + +export const reducer = (state: ToasterState, action: Action): ToasterState => { + const { toastLimit } = state.settings; + switch (action.type) { case ActionType.ADD_TOAST: return { ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + toasts: [action.toast, ...state.toasts].slice(0, toastLimit), }; case ActionType.UPDATE_TOAST: @@ -120,17 +133,45 @@ export const reducer = (state: State, action: Action): State => { } }; -const listeners: Array<(state: State) => void> = []; +const listeners: Array< + [toasterId: string, reducer: (state: ToasterState) => void] +> = []; -let memoryState: State = { toasts: [], pausedAt: undefined }; - -export const dispatch = (action: Action) => { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); +const defaultToasterState: ToasterState = { + toasts: [], + pausedAt: undefined, + settings: { + toastLimit: TOAST_LIMIT, + }, +}; +let memoryState: State = {}; + +export const dispatch = (action: Action, toasterId = DEFAULT_TOASTER_ID) => { + memoryState[toasterId] = reducer( + memoryState[toasterId] || defaultToasterState, + action + ); + listeners.forEach(([id, listener]) => { + if (id === toasterId) { + listener(memoryState[toasterId]); + } }); }; +export const dispatchAll = (action: Action) => + Object.keys(memoryState).forEach((toasterId) => dispatch(action, toasterId)); + +export const getToasterIdFromToastId = (toastId: string) => + Object.keys(memoryState).find((toasterId) => + memoryState[toasterId].toasts.some((t) => t.id === toastId) + ); + +export const createDispatch = + (toasterId = DEFAULT_TOASTER_ID) => + (action: Action) => { + dispatch(action, toasterId); + }; + export const defaultTimeouts: { [key in ToastType]: number; } = { @@ -141,23 +182,28 @@ export const defaultTimeouts: { custom: 4000, }; -export const useStore = (toastOptions: DefaultToastOptions = {}): State => { - const [state, setState] = useState(memoryState); - const initial = useRef(memoryState); +export const useStore = ( + toastOptions: DefaultToastOptions = {}, + toasterId: string = DEFAULT_TOASTER_ID +): ToasterState => { + const [state, setState] = useState( + memoryState[toasterId] || defaultToasterState + ); + const initial = useRef(memoryState[toasterId]); // TODO: Switch to useSyncExternalStore when targeting React 18+ useEffect(() => { - if (initial.current !== memoryState) { - setState(memoryState); + if (initial.current !== memoryState[toasterId]) { + setState(memoryState[toasterId]); } - listeners.push(setState); + listeners.push([toasterId, setState]); return () => { - const index = listeners.indexOf(setState); + const index = listeners.findIndex(([id]) => id === toasterId); if (index > -1) { listeners.splice(index, 1); } }; - }, []); + }, [toasterId]); const mergedToasts = state.toasts.map((t) => ({ ...toastOptions, diff --git a/src/core/toast.ts b/src/core/toast.ts index 2be7811..c8ef188 100644 --- a/src/core/toast.ts +++ b/src/core/toast.ts @@ -8,7 +8,13 @@ import { resolveValue, } from './types'; import { genId } from './utils'; -import { dispatch, ActionType } from './store'; +import { + createDispatch, + Action, + ActionType, + dispatchAll, + getToasterIdFromToastId, +} from './store'; type Message = ValueOrFunction; @@ -37,6 +43,11 @@ const createHandler = (type?: ToastType): ToastHandler => (message, options) => { const toast = createToast(message, type, options); + + const dispatch = createDispatch( + toast.toasterId || getToasterIdFromToastId(toast.id) + ); + dispatch({ type: ActionType.UPSERT_TOAST, toast }); return toast.id; }; @@ -49,16 +60,53 @@ toast.success = createHandler('success'); toast.loading = createHandler('loading'); toast.custom = createHandler('custom'); -toast.dismiss = (toastId?: string) => { - dispatch({ +/** + * Dismisses the toast with the given id. If no id is given, dismisses all toasts. + * The toast will transition out and then be removed from the DOM. + * Applies to all toasters, except when a `toasterId` is given. + */ +toast.dismiss = (toastId?: string, toasterId?: string) => { + const action: Action = { type: ActionType.DISMISS_TOAST, toastId, - }); + }; + + if (toasterId) { + createDispatch(toasterId)(action); + } else { + dispatchAll(action); + } +}; + +/** + * Dismisses all toasts. + */ +toast.dismissAll = (toasterId?: string) => toast.dismiss(undefined, toasterId); + +/** + * Removes the toast with the given id. + * The toast will be removed from the DOM without any transition. + */ +toast.remove = (toastId?: string, toasterId?: string) => { + const action: Action = { + type: ActionType.REMOVE_TOAST, + toastId, + }; + if (toasterId) { + createDispatch(toasterId)(action); + } else { + dispatchAll(action); + } }; -toast.remove = (toastId?: string) => - dispatch({ type: ActionType.REMOVE_TOAST, toastId }); +/** + * Removes all toasts. + */ +toast.removeAll = (toasterId?: string) => toast.remove(undefined, toasterId); +/** + * Create a loading toast that will automatically updates with the promise. + */ toast.promise = ( promise: Promise | (() => Promise), msgs: { diff --git a/src/core/types.ts b/src/core/types.ts index 764a5d7..2e668ee 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -34,6 +34,7 @@ export const resolveValue = ( export interface Toast { type: ToastType; id: string; + toasterId?: string; message: ValueOrFunction; icon?: Renderable; duration?: number; @@ -67,6 +68,7 @@ export type ToastOptions = Partial< | 'style' | 'position' | 'iconTheme' + | 'toasterId' | 'removeDelay' > >; @@ -82,6 +84,7 @@ export interface ToasterProps { gutter?: number; containerStyle?: React.CSSProperties; containerClassName?: string; + toasterId?: string; children?: (toast: Toast) => React.ReactElement; } diff --git a/src/core/use-toaster.ts b/src/core/use-toaster.ts index 5fb6e03..c97a0f1 100644 --- a/src/core/use-toaster.ts +++ b/src/core/use-toaster.ts @@ -1,43 +1,37 @@ -import { useEffect, useCallback } from 'react'; -import { dispatch, ActionType, useStore } from './store'; +import { useEffect, useCallback, useRef } from 'react'; +import { createDispatch, ActionType, useStore, dispatch } from './store'; import { toast } from './toast'; import { DefaultToastOptions, Toast, ToastPosition } from './types'; -const updateHeight = (toastId: string, height: number) => { - dispatch({ - type: ActionType.UPDATE_TOAST, - toast: { id: toastId, height }, - }); -}; -const startPause = () => { - dispatch({ - type: ActionType.START_PAUSE, - time: Date.now(), - }); -}; - -const toastTimeouts = new Map>(); - export const REMOVE_DELAY = 1000; -const addToRemoveQueue = (toastId: string, removeDelay = REMOVE_DELAY) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: ActionType.REMOVE_TOAST, - toastId: toastId, - }); - }, removeDelay); +export const useToaster = ( + toastOptions?: DefaultToastOptions, + toasterId: string = 'default' +) => { + const { toasts, pausedAt } = useStore(toastOptions, toasterId); + const toastTimeouts = useRef( + new Map>() + ).current; + + const addToRemoveQueue = useCallback( + (toastId: string, removeDelay = REMOVE_DELAY) => { + if (toastTimeouts.has(toastId)) { + return; + } - toastTimeouts.set(toastId, timeout); -}; + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: ActionType.REMOVE_TOAST, + toastId: toastId, + }); + }, removeDelay); -export const useToaster = (toastOptions?: DefaultToastOptions) => { - const { toasts, pausedAt } = useStore(toastOptions); + toastTimeouts.set(toastId, timeout); + }, + [] + ); useEffect(() => { if (pausedAt) { @@ -59,19 +53,38 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { } return; } - return setTimeout(() => toast.dismiss(t.id), durationLeft); + return setTimeout(() => toast.dismiss(t.id, toasterId), durationLeft); }); return () => { timeouts.forEach((timeout) => timeout && clearTimeout(timeout)); }; - }, [toasts, pausedAt]); + }, [toasts, pausedAt, toasterId]); + + const dispatch = useCallback(createDispatch(toasterId), [toasterId]); + + const startPause = useCallback(() => { + dispatch({ + type: ActionType.START_PAUSE, + time: Date.now(), + }); + }, [dispatch]); + + const updateHeight = useCallback( + (toastId: string, height: number) => { + dispatch({ + type: ActionType.UPDATE_TOAST, + toast: { id: toastId, height }, + }); + }, + [dispatch] + ); const endPause = useCallback(() => { if (pausedAt) { dispatch({ type: ActionType.END_PAUSE, time: Date.now() }); } - }, [pausedAt]); + }, [pausedAt, dispatch]); const calculateOffset = useCallback( ( @@ -104,8 +117,8 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { [toasts] ); + // Keep track of dismissed toasts and remove them after the delay useEffect(() => { - // Add dismissed toasts to remove queue toasts.forEach((toast) => { if (toast.dismissed) { addToRemoveQueue(toast.id, toast.removeDelay); @@ -118,7 +131,7 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { } } }); - }, [toasts]); + }, [toasts, addToRemoveQueue]); return { toasts, diff --git a/test/toast.test.tsx b/test/toast.test.tsx index a79d689..dea5c8e 100644 --- a/test/toast.test.tsx +++ b/test/toast.test.tsx @@ -341,3 +341,194 @@ test('"toast" can be called from useEffect hook', async () => { await screen.findByText(/MyComponent finished/i); expect(screen.queryByText(/Success toast/i)).toBeInTheDocument(); }); + +describe('Multi-Toaster behavior', () => { + test('renders toasts in correct containers and dismisses them individually', () => { + render( + <> + + + + + ); + + // Show three toasts in three different toasters + act(() => { + toast.success('Default toaster message'); + toast.error('Second toaster message', { + toasterId: 'second-toaster', + id: 'second-toast', + }); + toast.loading('Third toaster message', { toasterId: 'third-toaster' }); + }); + + const defaultContainer = document.querySelector('.default-toaster'); + const secondContainer = document.querySelector('.second-toaster'); + const thirdContainer = document.querySelector('.third-toaster'); + + // Ensure each toast is present and in the correct container + expect(defaultContainer).toContainElement( + screen.getByText('Default toaster message') + ); + expect(secondContainer).toContainElement( + screen.getByText('Second toaster message') + ); + expect(thirdContainer).toContainElement( + screen.getByText('Third toaster message') + ); + + // Dismiss only the toast in the second toaster + act(() => { + toast.dismiss('second-toast'); + }); + + waitTime(REMOVE_DELAY); + + expect( + screen.queryByText('Second toaster message') + ).not.toBeInTheDocument(); + expect(screen.queryByText('Default toaster message')).toBeInTheDocument(); + expect(screen.queryByText('Third toaster message')).toBeInTheDocument(); + + // Dismiss all toasts + act(() => { + toast.dismissAll(); + }); + + waitTime(REMOVE_DELAY); + + expect( + screen.queryByText('Default toaster message') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Second toaster message') + ).not.toBeInTheDocument(); + expect(screen.queryByText('Third toaster message')).not.toBeInTheDocument(); + }); + + test('updates a toast in a specific toaster without affecting others', () => { + render( + <> + + + + ); + + let toastId: string; + + // Create a loading toast in the second toaster + act(() => { + toastId = toast.loading('Please wait...', { + toasterId: 'updatable-toaster', + }); + }); + + const secondContainer = document.querySelector('.updatable-toaster'); + expect(secondContainer).toContainElement( + screen.getByText('Please wait...') + ); + + // Now update that toast to success + act(() => { + // Note that we are not providing a toasterId here + toast.success('Data saved!', { + id: toastId, + }); + }); + + // Confirm the updated text + expect(screen.queryByText('Please wait...')).not.toBeInTheDocument(); + expect(secondContainer).toContainElement(screen.getByText('Data saved!')); + }); + + test('dismisses all toasts from a specific toaster and leaves others intact', () => { + render( + <> + + + + ); + + // Create one toast in each toaster + act(() => { + toast.success('Default toaster toast'); + toast.success('Other toaster toast', { toasterId: 'other-toaster' }); + }); + + // Ensure both appear + expect(screen.getByText('Default toaster toast')).toBeInTheDocument(); + expect(screen.getByText('Other toaster toast')).toBeInTheDocument(); + + // Dismiss only the second toaster's toasts + act(() => { + toast.dismissAll('other-toaster'); + }); + waitTime(REMOVE_DELAY); + + // The other toaster's toast should be gone, default remains + expect(screen.queryByText('Other toaster toast')).not.toBeInTheDocument(); + expect(screen.queryByText('Default toaster toast')).toBeInTheDocument(); + }); + + test('dismisses all toasts across all toasters with dismissAll', () => { + render( + <> + + + + ); + + // Create one toast in each toaster + act(() => { + toast.success('Default toaster toast'); + toast.error('Other toaster toast', { toasterId: 'other-toaster' }); + }); + + // Dismiss every toast in all toasters + act(() => { + toast.dismissAll(); + }); + waitTime(REMOVE_DELAY); + + // Both should be removed + expect(screen.queryByText('Default toaster toast')).not.toBeInTheDocument(); + expect(screen.queryByText('Other toaster toast')).not.toBeInTheDocument(); + }); + + test('removes toasts immediately when calling toast.remove()', () => { + render( + <> + + + + ); + + act(() => { + toast.success('Removable toast #1', { + toasterId: 'instant-remove-toaster', + }); + toast.error('Removable toast #2', { toasterId: 'another-toaster' }); + }); + + expect(screen.queryByText('Removable toast #1')).toBeInTheDocument(); + expect(screen.queryByText('Removable toast #2')).toBeInTheDocument(); + + act(() => { + toast.removeAll('instant-remove-toaster'); + }); + + expect(screen.queryByText('Removable toast #1')).not.toBeInTheDocument(); + expect(screen.queryByText('Removable toast #2')).toBeInTheDocument(); + }); +});