Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multiple toasters #383

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
6 changes: 3 additions & 3 deletions .github/workflows/size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ jobs:
CI_JOB_NUMBER: 1
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
- uses: pnpm/action-setup@v4
with:
version: 7
- uses: andresz1/size-limit-action@v1
version: 9
- uses: andresz1/size-limit-action@v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-hot-toast",
"description": "Smoking hot React Notifications. Lightweight, customizable and beautiful by default.",
"version": "2.5.1",
"version": "2.6.0-beta.0",
"author": "Timo Lins",
"license": "MIT",
"repository": "timolins/react-hot-toast",
Expand Down Expand Up @@ -35,7 +35,7 @@
"node": ">=10"
},
"scripts": {
"start": "tsup --watch",
"dev": "tsup --watch",
"build": "tsup",
"test": "jest --runInBand",
"setup": "pnpm i && cd site && pnpm i && cd .. && pnpm run link",
Expand Down
2 changes: 2 additions & 0 deletions site/components/docs-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export default function DocsLayout({ meta, children }) {
</TableItem>
<TableHeader>Guides</TableHeader>
<TableItem href="/docs/styling">Styling</TableItem>
<TableItem href="/docs/multi-toaster">Multi Toaster</TableItem>

<TableHeader>Releases</TableHeader>
<TableItem href="/docs/version-2">New in 2.0</TableItem>
</div>
Expand Down
67 changes: 67 additions & 0 deletions site/pages/docs/multi-toaster.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Layout from '../../components/docs-layout';
import toast, { Toaster } from 'react-hot-toast';

export const meta = {
title: 'Multiple Toasters',
};

export default ({ children }) => <Layout meta={meta}>{children}</Layout>;

# Multiple Toasters

React Hot Toast supports having multiple toaster instances in your app. This allows you to have different toast configurations and positions for different parts of your application.

## Basic Usage

You can create multiple toasters by giving each one a unique `toasterId`:

```jsx
<Toaster toasterId="area1" />
<Toaster toasterId="area2" />


// Create a toast in area 1
toast('Notification for Area 1', { toasterId: 'area1' })
```

{/* Global toaster */}

<div className="flex gap-4">
<div className="relative min-h-[200px] bg-toast-200 text-toast-800 rounded-lg p-4 my-4 overflow-hidden flex-1">
<p className="text-sm mb-4">Area 1</p>
<Toaster
toasterId="area1"
position="top-center"
containerStyle={{ position: 'absolute' }}
/>
<div className="mt-8">
<button
onClick={() => toast('Notification for Area 1', { toasterId: 'area1' })}
className="bg-toast-500 text-white px-4 py-2 rounded hover:bg-toast-600"
>
Show Toast in Area 1
</button>
</div>
</div>

<div className="relative min-h-[200px] bg-toast-200 rounded-lg p-4 my-4 overflow-hidden flex-1">
<p className="text-sm text-toast-500 mb-4">Area 2</p>
<Toaster
toasterId="area2"
position="top-center"
containerStyle={{ position: 'absolute' }}
toastOptions={{
className: '!bg-toast-500 !text-white px-4 py-2 hover:bg-toast-600 border border-toast-800/10 !rounded-full',
}}
/>
<div className="mt-8">
<button
onClick={() => toast('Notification for Area 2', { toasterId: 'area2' })}
className="bg-toast-500 text-white px-4 py-2 rounded hover:bg-toast-600"
>
Show Toast in Area 2
</button>
</div>

</div>
</div>
5 changes: 3 additions & 2 deletions src/components/toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,15 @@ export const Toaster: React.FC<ToasterProps> = ({
toastOptions,
gutter,
children,
toasterId,
containerStyle,
containerClassName,
}) => {
const { toasts, handlers } = useToaster(toastOptions);
const { toasts, handlers } = useToaster(toastOptions, toasterId);

return (
<div
id="_rht_toaster"
data-rht-toaster={toasterId || ''}
style={{
position: 'fixed',
zIndex: 9999,
Expand Down
80 changes: 63 additions & 17 deletions src/core/store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { useEffect, useState } from 'react';
import { DefaultToastOptions, Toast, ToastType } from './types';

const TOAST_LIMIT = 20;
export const TOAST_EXPIRE_DISMISS_DELAY = 1000;
export const TOAST_LIMIT = 20;
export const DEFAULT_TOASTER_ID = 'default';

interface ToasterSettings {
toastLimit: number;
}

export enum ActionType {
ADD_TOAST,
Expand All @@ -13,7 +19,7 @@ export enum ActionType {
END_PAUSE,
}

type Action =
export type Action =
| {
type: ActionType.ADD_TOAST;
toast: Toast;
Expand Down Expand Up @@ -43,17 +49,24 @@ type Action =
time: number;
};

interface State {
interface ToasterState {
toasts: Toast[];
settings: ToasterSettings;
pausedAt: number | undefined;
}

export const reducer = (state: State, action: Action): State => {
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:
Expand Down Expand Up @@ -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;
} = {
Expand All @@ -141,17 +182,22 @@ export const defaultTimeouts: {
custom: 4000,
};

export const useStore = (toastOptions: DefaultToastOptions = {}): State => {
const [state, setState] = useState<State>(memoryState);
export const useStore = (
toastOptions: DefaultToastOptions = {},
toasterId: string = DEFAULT_TOASTER_ID
): ToasterState => {
const [state, setState] = useState<ToasterState>(
memoryState[toasterId] || defaultToasterState
);
useEffect(() => {
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);
}
};
}, [state]);
}, [toasterId]);

const mergedToasts = state.toasts.map((t) => ({
...toastOptions,
Expand Down
62 changes: 56 additions & 6 deletions src/core/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Renderable, Toast>;

Expand Down Expand Up @@ -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;
};
Expand All @@ -49,16 +60,55 @@ 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);
console.log('dispatch', action, toasterId);
} else {
console.log('dispatchAll', action);
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 = <T>(
promise: Promise<T> | (() => Promise<T>),
msgs: {
Expand Down
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const resolveValue = <TValue, TArg>(
export interface Toast {
type: ToastType;
id: string;
toasterId?: string;
message: ValueOrFunction<Renderable, Toast>;
icon?: Renderable;
duration?: number;
Expand Down Expand Up @@ -67,6 +68,7 @@ export type ToastOptions = Partial<
| 'style'
| 'position'
| 'iconTheme'
| 'toasterId'
| 'removeDelay'
>
>;
Expand All @@ -82,6 +84,7 @@ export interface ToasterProps {
gutter?: number;
containerStyle?: React.CSSProperties;
containerClassName?: string;
toasterId?: string;
children?: (toast: Toast) => JSX.Element;
}

Expand Down
Loading
Loading