src/toast/toaster.tsx (243 lines of code) (raw):
/*
Copyright (c) Uber Technologies, Inc.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
import * as React from 'react';
import ReactDOM from 'react-dom';
import { getOverrides, mergeOverrides } from '../helpers/overrides';
import { KIND, PLACEMENT } from './constants';
import {
Root as StyledRoot,
Body as StyledBody,
CloseIconSvg as StyledCloseIcon,
InnerContainer as StyledInnerContainer,
} from './styled-components';
import Toast from './toast';
import type { ToasterProps, ToastPropsShape, ToasterContainerState, ToastProps } from './types';
let toasterRef: ToasterContainer | undefined;
export class ToasterContainer extends React.Component<
Partial<ToasterProps>,
ToasterContainerState
> {
static defaultProps: ToasterProps = {
autoFocus: false,
autoHideDuration: 0,
children: null,
closeable: true,
overrides: {},
placement: PLACEMENT.top,
resetAutoHideTimerOnUpdate: true,
usePortal: true,
};
constructor(props: ToasterProps) {
super(props);
toasterRef = this;
}
state = {
isMounted: false,
// @ts-ignore
toasts: [],
};
dismissHandlers = {};
toastId = 0;
componentDidMount() {
this.setState({ isMounted: true });
}
getToastProps = (
props: ToastProps
): ToastProps & {
key: React.Key;
} => {
const { autoFocus, autoHideDuration, closeable } = this.props;
const key: React.Key = props.key || `toast-${this.toastId++}`;
return { autoFocus, autoHideDuration, closeable, ...props, key };
};
// @ts-expect-error todo(flow->ts): default value does not look correct and also probably do is never used
show = (props: ToastProps = {}): React.Key => {
// @ts-ignore
if (this.state.toasts.map((t) => t.key).includes(props.key)) {
// @ts-ignore
this.update(props.key, props);
// @ts-ignore
return props.key;
}
const toastProps = this.getToastProps(props);
this.setState(({ toasts }) => {
return { toasts: [...toasts, toastProps] };
});
return toastProps.key;
};
update = (key: React.Key, props: ToastProps): void => {
this.setState(({ toasts }) => {
const updatedToasts = toasts.map((toast) => {
if (toast.key === key) {
const updatedToastProps = {
...toast,
...this.getToastProps({
autoHideDuration: toast.autoHideDuration,
...props,
}),
key,
...(this.props.resetAutoHideTimerOnUpdate
? // @ts-ignore
{ __updated: (+toast.__updated || 0) + 1 }
: {}),
};
return updatedToastProps;
}
return toast;
});
return {
toasts: updatedToasts,
};
});
};
dismiss = (key: React.Key) => {
// @ts-ignore
if (this.dismissHandlers[key]) {
// @ts-ignore
this.dismissHandlers[key]();
}
};
clearAll = () => {
Object.keys(this.dismissHandlers).forEach((key) => {
// @ts-ignore
this.dismissHandlers[key]();
});
};
clear = (key?: React.Key) => {
key === undefined ? this.clearAll() : this.dismiss(key);
};
internalOnClose = (key: React.Key) => {
// @ts-ignore
delete this.dismissHandlers[key];
this.setState(({ toasts }) => ({
toasts: toasts.filter((t) => {
return !(t.key === key);
}),
}));
};
getOnCloseHandler = (key: React.Key, onClose?: (() => unknown) | null) => {
return () => {
this.internalOnClose(key);
typeof onClose === 'function' && onClose();
};
};
renderToast = (
toastProps: ToastProps & {
key: React.Key;
}
): React.ReactNode => {
const { onClose, children, key, ...restProps } = toastProps;
const {
// @ts-ignore
ToastBody: BodyOverride,
// @ts-ignore
ToastCloseIcon: CloseIconOverride,
// @ts-ignore
ToastInnerContainer: InnerContainerOverride,
} = this.props.overrides;
const globalToastOverrides = mergeOverrides(
{
Body: StyledBody,
CloseIcon: StyledCloseIcon,
InnerContainer: StyledInnerContainer,
},
{
Body: BodyOverride || {},
CloseIcon: CloseIconOverride || {},
InnerContainer: InnerContainerOverride || {},
}
);
const toastOverrides = mergeOverrides(globalToastOverrides, toastProps.overrides);
return (
<Toast
{...restProps}
overrides={toastOverrides}
key={key}
onClose={this.getOnCloseHandler(key, onClose)}
>
{/* @ts-ignore */}
{({ dismiss }) => {
// @ts-ignore
this.dismissHandlers[key] = dismiss;
return children;
}}
</Toast>
);
};
getSharedProps = () => {
const { placement } = this.props;
return {
$placement: placement,
};
};
render() {
const sharedProps = this.getSharedProps();
// @ts-ignore
const { Root: RootOverride } = this.props.overrides;
const [Root, rootProps] = getOverrides(RootOverride, StyledRoot);
const toastsLength = this.state.toasts.length;
const toastsToRender = [];
// render the toasts from the newest at the start
// to the oldest at the end
// eslint-disable-next-line for-direction
for (let i = toastsLength - 1; i >= 0; i--) {
// @ts-ignore
toastsToRender.push(this.renderToast(this.state.toasts[i]));
}
const root = (
<Root data-baseweb="toaster" {...sharedProps} {...rootProps}>
{toastsToRender}
</Root>
);
let maybePortal: React.ReactNode;
if (this.state.isMounted) {
//Only render the portal in the browser, otherwise render the toasts and children
maybePortal =
this.props.usePortal && __BROWSER__ && document.body
? ReactDOM.createPortal(root, document.body)
: root;
}
return (
<>
{maybePortal}
{this.props.children}
</>
);
}
}
const toaster = {
getRef: function (): ToasterContainer | undefined {
return toasterRef;
},
show: function (
children: React.ReactNode,
props: ToastPropsShape = {}
): React.Key | undefined | null {
// toasts can not be added until Toaster is mounted
// no SSR for the `toaster.show()`
const toasterInstance = this.getRef();
if (toasterInstance) {
return toasterInstance.show({ ...props, children });
} else if (__DEV__) {
throw new Error(
'Please make sure to add the ToasterContainer to your application, and it is mounted, before adding toasts! You can find more information here: https://baseweb.design/components/toast'
);
}
},
info: function (children: React.ReactNode, props: ToastPropsShape = {}): React.Key {
// @ts-ignore
return this.show(children, { ...props, kind: KIND.info });
},
positive: function (children: React.ReactNode, props: ToastPropsShape = {}): React.Key {
// @ts-ignore
return this.show(children, { ...props, kind: KIND.positive });
},
warning: function (children: React.ReactNode, props: ToastPropsShape = {}): React.Key {
// @ts-ignore
return this.show(children, { ...props, kind: KIND.warning });
},
negative: function (children: React.ReactNode, props: ToastPropsShape = {}): React.Key {
// @ts-ignore
return this.show(children, { ...props, kind: KIND.negative });
},
update: function (key: React.Key, props: Partial<ToastProps>): void {
const toasterInstance = this.getRef();
if (toasterInstance) {
// @ts-ignore
toasterInstance.update(key, props);
} else if (__DEV__) {
// eslint-disable-next-line no-console
console.error('No ToasterContainer is mounted yet.');
}
},
clear: function (key?: React.Key | undefined | null): void {
const toasterInstance = this.getRef();
if (toasterInstance) {
// @ts-ignore
toasterInstance.clear(key);
} else if (__DEV__) {
// eslint-disable-next-line no-console
console.error('No ToasterContainer is mounted yet.');
}
},
};
export default toaster;