From ca70cccd128bfa8248171cf5d56319bc25c89bc0 Mon Sep 17 00:00:00 2001 From: Jess Telford Date: Fri, 12 Jun 2020 13:20:17 +1000 Subject: [PATCH] Support multiple nested Contexts --- README.md | 81 ++++++++++++++++++------- examples/src/index.js | 138 ++++++++++++++++++++++-------------------- src/ToastProvider.js | 65 ++++++++++++++------ src/types.js | 3 +- src/utils.js | 8 +++ 5 files changed, 187 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index d1ad211..a00f181 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ const FormWithToasts = () => { } else { addToast('Saved Successfully', { appearance: 'success' }) } - } + }; return
...
-} +}; const App = () => ( @@ -49,37 +49,44 @@ For brevity: - `PlacementType` is equal to `'bottom-left' | 'bottom-center' | 'bottom-right' | 'top-left' | 'top-center' | 'top-right'`. - `TransitionState` is equal to `'entering' | 'entered' | 'exiting' | 'exited'`. -| Property | Description | -| -------------------------------------- | ---------------------------------------------------------------------------------------- | -| autoDismissTimeout `number` | Default `5000`. The time until a toast will be dismissed automatically, in milliseconds. | -| autoDismiss `boolean` | Default: `false`. Whether or not to dismiss the toast automatically after a timeout. | +| Property | Description | +| --------------------------- | ---------------------------------------------------------------------------------------- | +| autoDismissTimeout `number` | Default `5000`. The time until a toast will be dismissed automatically, in milliseconds. | +| autoDismiss `boolean` | Default: `false`. Whether or not to dismiss the toast automatically after a timeout. | -| children `Node` | Required. Your app content. | -| components `{ ToastContainer, Toast }` | Replace the underlying components. | -| placement `PlacementType` | Default `top-right`. Where, in relation to the viewport, to place the toasts. | -| transitionDuration `number` | Default `220`. The duration of the CSS transition on the `Toast` component. | +| children `Node` | Required. Your app content. | +| components `{ ToastContainer, Toast }` | Replace the underlying components. | +| placement `PlacementType` | Default `top-right`. Where, in relation to the viewport, to place the toasts. | +| transitionDuration `number` | Default `220`. The duration of the CSS transition on the `Toast` component. | +| name `string` | Default `default`. Provide a unique name when using [Nested Providers](#nested-providers). | ## Toast Props -| Property | Description | -| ---------------------------------- | ------------------------------------------------------------------ | -| appearance | Required. One of `success`, `error`, `warning`, `info` | -| children | Required. The content of the toast notification. | -| autoDismiss `boolean` | Inherited from `ToastProvider` if not provided. | -| autoDismissTimeout `number` | Inherited from `ToastProvider`. | -| onDismiss: `Id => void` | Passed in dynamically. Can be called in a custom toast to dismiss it.| -| placement `PlacementType` | Inherited from `ToastProvider`. | -| transitionDuration `number` | Inherited from `ToastProvider`. | -| transitionState: `TransitionState` | Passed in dynamically. | +| Property | Description | +| ---------------------------------- | --------------------------------------------------------------------- | +| appearance | Required. One of `success`, `error`, `warning`, `info` | +| children | Required. The content of the toast notification. | +| autoDismiss `boolean` | Inherited from `ToastProvider` if not provided. | +| autoDismissTimeout `number` | Inherited from `ToastProvider`. | +| onDismiss: `Id => void` | Passed in dynamically. Can be called in a custom toast to dismiss it. | +| placement `PlacementType` | Inherited from `ToastProvider`. | +| transitionDuration `number` | Inherited from `ToastProvider`. | +| transitionState: `TransitionState` | Passed in dynamically. | ## Hook The `useToast` hook has the following signature: ```jsx -const { addToast, removeToast, removeAllToasts, updateToast, toastStack } = useToasts(); +const { addToast, removeToast, removeAllToasts, updateToast, toastStack } = useToasts(options); ``` +`options` passed to `useToasts` are: + +| Option | Description | +| ------------- | ------------------------------------------------------------------------------------------ | +| name `string` | Default `default`. Provide a unique name when using [Nested Providers](#nested-providers). | + The `addToast` method has three arguments: 1. The first is the content of the toast, which can be any renderable `Node`. @@ -104,8 +111,8 @@ The `toastStack` is an array of objects representing the current toasts, e.g. ```jsx [ { content: 'Something went wrong', id: 'generated-string', appearance: 'error' }, - { content: 'Item saved', id: 'generated-string', appearance: 'success' } -] + { content: 'Item saved', id: 'generated-string', appearance: 'success' }, +]; ``` ## Replaceable Components @@ -137,6 +144,34 @@ export const MyCustomToast = ({ children, ...props }) => ( ); ``` +## Nested Providers + +Displaying individual toasts differently is done using nested ``s. + +For example, [the docs +page](https://jossmac.github.io/react-toast-notifications) displays both +"notification" style & "snack bar" style toasts simultaneously on the same page. + +Nested Providers must be given a unique `name` prop, which is then also passed +to the `` component or `useToasts()` hook. + +```jsx +import { ToastProvider, ToastConsumer } from 'react-toast-notifications'; + +const App = () => ( + + + + {({ add }) => } + + + {({ add }) => } + + + +); +``` + ## Alternatives This library may not meet your needs. Here are some alternative I came across whilst searching for this solution: diff --git a/examples/src/index.js b/examples/src/index.js index c6e2c54..4a25e5b 100644 --- a/examples/src/index.js +++ b/examples/src/index.js @@ -208,49 +208,50 @@ function App() { return ( - -
- -
- - 🍞 - React Toast Notifications - - -
+ + +
+ +
+ + 🍞 + React Toast Notifications + + +
- - - + + + - -
-
- {/* + +
+
+ {/* ============================== CONFIGURATION ============================== */} -
- +
@@ -260,7 +261,7 @@ function App() {

Replace or configure any part of the notification system.

- + {({ add, toasts }) => (
- {/* +
+ {/* ============================== EXAMPLE ============================== */} -
- - - - - Let's get real. -
-

- You're probably not firing off notifications - haphazardly, from some random buttons in your app.* -

-

- To see an example of how you might use this IRL, toggle the{' '} - Offline checkbox in the Network pane of your - dev tools. If you're on mobile, just turn on flight-mode. -

-

- * It's totally cool if you are, no judgement. -

-
-
- {exampleText} -
- -
-
+
+ + + + + Let's get real. +
+

+ You're probably not firing off notifications + haphazardly, from some random buttons in your app.* +

+

+ To see an example of how you might use this IRL, toggle + the Offline checkbox in the Network pane of + your dev tools. If you're on mobile, just turn on + flight-mode. +

+

+ + * It's totally cool if you are, no judgement. + +

+
+
+ {exampleText} +
+ +
+
+
); } diff --git a/src/ToastProvider.js b/src/ToastProvider.js index 5ce3a2f..cd9a131 100644 --- a/src/ToastProvider.js +++ b/src/ToastProvider.js @@ -16,7 +16,7 @@ import { ToastContainer, type ToastContainerProps } from './ToastContainer'; import { type ToastProps, DefaultToast } from './ToastElement'; const defaultComponents = { Toast: DefaultToast, ToastContainer }; -import { generateUEID, NOOP } from './utils'; +import { generateUEID, NOOP, omit } from './utils'; import type { AddFn, UpdateFn, @@ -28,9 +28,9 @@ import type { Id, } from './types'; -// $FlowFixMe `createContext` -const ToastContext = React.createContext(); -const { Consumer, Provider } = ToastContext; +const contexts = {}; + +const DEFAULT_CONTEXT_NAME = 'default'; const canUseDOM = !!( typeof window !== 'undefined' && @@ -60,8 +60,12 @@ type Props = { // A convenience prop; the duration of the toast transition, in milliseconds. // Note that specifying this will override any defaults set on individual children Toasts. transitionDuration: number, + name: string, +}; +type State = { + toasts: ToastsType, + context: Object, }; -type State = { toasts: ToastsType }; type Context = { add: AddFn, remove: RemoveFn, @@ -72,6 +76,7 @@ type Context = { export class ToastProvider extends Component { static defaultProps = { + name: DEFAULT_CONTEXT_NAME, autoDismiss: false, autoDismissTimeout: 5000, components: defaultComponents, @@ -79,12 +84,22 @@ export class ToastProvider extends Component { transitionDuration: 220, }; - state = { toasts: [] }; + constructor(props: Props) { + super(props); + + contexts[props.name] = contexts[props.name] || React.createContext(); + const context = contexts[props.name]; + + this.state = { + toasts: [], + context, + }; + } // Internal Helpers // ------------------------------ - has = (id) => { + has = (id: Id) => { if (!this.state.toasts.length) { return false; } @@ -100,7 +115,7 @@ export class ToastProvider extends Component { // ------------------------------ add = (content: Node, options?: Options = {}, cb: Callback = NOOP) => { - const id = options.id || generateUEID(); + const id: Id = options.id || generateUEID(); const callback = () => cb(id); // bail if a toast exists with this ID @@ -110,7 +125,7 @@ export class ToastProvider extends Component { // update the toast stack this.setState(state => { - const newToast = { content, id, ...options }; + const newToast = { content, id, ...omit(options, 'id') }; const toasts = [...state.toasts, newToast]; return { toasts }; @@ -151,7 +166,7 @@ export class ToastProvider extends Component { this.setState(state => { const old = state.toasts; const i = old.findIndex(t => t.id === id); - const updatedToast = { ...old[i], ...options }; + const updatedToast = { ...old[i], ...omit(options, 'id') }; const toasts = [ ...old.slice(0, i), updatedToast, ...old.slice(i + 1)]; return { toasts }; @@ -173,6 +188,7 @@ export class ToastProvider extends Component { const hasToasts = Boolean(toasts.length); const portalTarget = canUseDOM ? document.body : null; // appease flow + const { Provider } = this.state.context; return ( @@ -229,20 +245,35 @@ export class ToastProvider extends Component { } } -export const ToastConsumer = ({ children }: { children: Context => Node }) => ( - {context => children(context)} -); +export const ToastConsumer = ({ + name = DEFAULT_CONTEXT_NAME, + children, +}: { + name: string, + children: Context => Node, +}) => { + const context = contexts[name]; + if (!context) { + throw Error('The `ToastConsumer` component must be nested as a descendent of the `ToastProvider`.'); + } + const { Consumer } = context; + return {ctx => children(ctx)}; +}; -export const withToastManager = (Comp: ComponentType<*>) => +export const withToastManager = ( + Comp: ComponentType<*>, + { name = DEFAULT_CONTEXT_NAME }: { name: string } = {} +) => // $FlowFixMe `forwardRef` React.forwardRef((props: *, ref: Ref<*>) => ( - + {context => } )); -export const useToasts = () => { - const ctx = useContext(ToastContext); +export const useToasts = ({ name = DEFAULT_CONTEXT_NAME }: { name: string } = {}) => { + const context = contexts[name] || {}; + const ctx = useContext(context); if (!ctx) { throw Error('The `useToasts` hook must be called from a descendent of the `ToastProvider`.'); diff --git a/src/types.js b/src/types.js index e6202c9..fc65d5f 100644 --- a/src/types.js +++ b/src/types.js @@ -6,6 +6,7 @@ export type AppearanceTypes = 'error' | 'info' | 'success' | 'warning'; export type Id = string; export type Callback = Id => void; export type Options = { + id?: Id, appearance: AppearanceTypes, autoDismiss?: boolean, onDismiss?: Callback, @@ -25,5 +26,5 @@ export type Placement = | 'top-center' | 'top-right'; -export type ToastType = Options & { appearance: AppearanceTypes, content: Node, id: Id }; +export type ToastType = { content: Node, id: Id } & Options; export type ToastsType = Array; diff --git a/src/utils.js b/src/utils.js index 2b6590d..3ca1310 100644 --- a/src/utils.js +++ b/src/utils.js @@ -6,3 +6,11 @@ export function generateUEID() { second = ('000' + second.toString(36)).slice(-3); return first + second; } +export function omit(obj, keyToOmit) { + return Object.entries(obj).reduce((memo, [key, value]) => { + if (key !== keyToOmit) { + memo[key] = value; + } + return memo; + }, {}); +}