-
Notifications
You must be signed in to change notification settings - Fork 173
Support multiple nested Contexts #115
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,19 +76,30 @@ type Context = { | |
|
||
export class ToastProvider extends Component<Props, State> { | ||
static defaultProps = { | ||
name: DEFAULT_CONTEXT_NAME, | ||
autoDismiss: false, | ||
autoDismissTimeout: 5000, | ||
components: defaultComponents, | ||
placement: 'top-right', | ||
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<Props, State> { | |
// ------------------------------ | ||
|
||
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<Props, State> { | |
|
||
// 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<Props, State> { | |
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') }; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These |
||
const toasts = [ ...old.slice(0, i), updatedToast, ...old.slice(i + 1)]; | ||
|
||
return { toasts }; | ||
|
@@ -173,6 +188,7 @@ export class ToastProvider extends Component<Props, State> { | |
|
||
const hasToasts = Boolean(toasts.length); | ||
const portalTarget = canUseDOM ? document.body : null; // appease flow | ||
const { Provider } = this.state.context; | ||
|
||
return ( | ||
<Provider value={{ add, remove, removeAll, update, toasts }}> | ||
|
@@ -229,20 +245,35 @@ export class ToastProvider extends Component<Props, State> { | |
} | ||
} | ||
|
||
export const ToastConsumer = ({ children }: { children: Context => Node }) => ( | ||
<Consumer>{context => children(context)}</Consumer> | ||
); | ||
export const ToastConsumer = ({ | ||
name = DEFAULT_CONTEXT_NAME, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Each of the 3 ways of consuming toast context ( That If the named context isn't known, then an error is thrown. Otherwise, that named context is the one used for the life of the component. In this way, any deeply nested consumers can connect to any parent provider no matter how many they are as long as they have unique |
||
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 <Consumer>{ctx => children(ctx)}</Consumer>; | ||
}; | ||
|
||
export const withToastManager = (Comp: ComponentType<*>) => | ||
export const withToastManager = ( | ||
Comp: ComponentType<*>, | ||
{ name = DEFAULT_CONTEXT_NAME }: { name: string } = {} | ||
) => | ||
// $FlowFixMe `forwardRef` | ||
React.forwardRef((props: *, ref: Ref<*>) => ( | ||
<ToastConsumer> | ||
<ToastConsumer name={name}> | ||
{context => <Comp toastManager={context} {...props} ref={ref} />} | ||
</ToastConsumer> | ||
)); | ||
|
||
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`.'); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixes a flow issue I was having where it thought |
||
export type ToastsType = Array<ToastType>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We only create a new context if there isn't already one for that name.
Then we continue using the created context within this component for its lifetime.