import React, { Component, isValidElement, cloneElement } from 'react'; import PropTypes from 'prop-types'; import { css } from 'glamor'; import TransitionGroup from 'react-transition-group/TransitionGroup'; import Toast from './Toast'; import DefaultCloseButton from './DefaultCloseButton'; import DefaultTransition from './DefaultTransition'; import { POSITION, ACTION } from './constant'; import defaultStyle from './defaultStyle'; import EventManager from './util/EventManager'; import { falseOrDelay, falseOrElement, isValidDelay, objectValues } from './util/propValidator'; const getToastPositionStyle = pos => { const positionKey = pos.toUpperCase().replace('-', '_'); const positionRule = typeof POSITION[positionKey] !== 'undefined' ? defaultStyle[positionKey] : defaultStyle.TOP_RIGHT; /** define margin for center toast based on toast witdh */ if ( positionKey.indexOf('CENTER') !== -1 && typeof positionRule.marginLeft === 'undefined' ) { positionRule.marginLeft = `-${parseInt(defaultStyle.width, 10) / 2}px`; } return positionRule; }; const styles = (disablePointer, position) => css( { zIndex: defaultStyle.zIndex, position: 'fixed', padding: '4px', width: defaultStyle.width, boxSizing: 'border-box', color: '#fff', ...(disablePointer ? { pointerEvents: 'none' } : {}), [`@media ${defaultStyle.mobile}`]: { width: '100vw', padding: 0, left: 0, margin: 0, position: 'fixed', ...(position.substring(0, 3) === 'top' ? { top: 0 } : { bottom: 0 }) } }, getToastPositionStyle(position) ); class ToastContainer extends Component { static propTypes = { /** * Set toast position */ position: PropTypes.oneOf(objectValues(POSITION)), /** * Disable or set autoClose delay */ autoClose: falseOrDelay, /** * Disable or set a custom react element for the close button */ closeButton: falseOrElement, /** * Hide or not progress bar when autoClose is enabled */ hideProgressBar: PropTypes.bool, /** * Pause toast duration on hover */ pauseOnHover: PropTypes.bool, /** * Dismiss toast on click */ closeOnClick: PropTypes.bool, /** * Newest on top */ newestOnTop: PropTypes.bool, /** * An optional className */ className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), /** * An optional style */ style: PropTypes.object, /** * An optional className for the toast */ toastClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), /** * An optional className for the toast body */ bodyClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), /** * An optional className for the toast progress bar */ progressClassName: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]), /** * Define enter and exit transition using react-transition-group */ transition: PropTypes.func }; static defaultProps = { position: POSITION.TOP_RIGHT, transition: DefaultTransition, autoClose: 5000, hideProgressBar: false, closeButton: , pauseOnHover: true, closeOnClick: true, newestOnTop: false, className: null, style: null, toastClassName: '', bodyClassName: '', progressClassName: '' }; /** * Hold toast ids */ state = { toast: [], isDocumentHidden: false }; /** * Hold toast's informations: * - what to render * - position * - raw content * - options */ collection = {}; componentDidMount() { const { SHOW, CLEAR, MOUNTED } = ACTION; EventManager.on(SHOW, (content, options) => this.show(content, options)) .on(CLEAR, id => (id !== null ? this.removeToast(id) : this.clear())) .emit(MOUNTED, this); document.addEventListener('visibilitychange', this.isDocumentHidden); } componentWillUnmount() { EventManager.off(ACTION.SHOW); EventManager.off(ACTION.CLEAR); document.removeEventListener('visibilitychange', this.isDocumentHidden); } isDocumentHidden = () => this.setState({ isDocumentHidden: document.hidden }); isToastActive = id => this.state.toast.indexOf(parseInt(id, 10)) !== -1; removeToast(id) { this.setState({ toast: this.state.toast.filter(v => v !== parseInt(id, 10)) }); } makeCloseButton(toastClose, toastId, type) { let closeButton = this.props.closeButton; if (isValidElement(toastClose) || toastClose === false) { closeButton = toastClose; } return closeButton === false ? false : cloneElement(closeButton, { closeToast: () => this.removeToast(toastId), type: type }); } getAutoCloseDelay(toastAutoClose) { return toastAutoClose === false || isValidDelay(toastAutoClose) ? toastAutoClose : this.props.autoClose; } isFunction(object) { return !!(object && object.constructor && object.call && object.apply); } canBeRendered(content) { return ( isValidElement(content) || typeof content === 'string' || typeof content === 'number' || this.isFunction(content) ); } show(content, options) { if (!this.canBeRendered(content)) { throw new Error( `The element you provided cannot be rendered. You provided an element of type ${typeof content}` ); } const toastId = options.toastId; const closeToast = () => this.removeToast(toastId); const toastOptions = { id: toastId, type: options.type, closeToast: closeToast, updateId: options.updateId, position: options.position || this.props.position, transition: options.transition || this.props.transition, className: options.className || this.props.toastClassName, bodyClassName: options.bodyClassName || this.props.bodyClassName, closeButton: this.makeCloseButton( options.closeButton, toastId, options.type ), pauseOnHover: options.pauseOnHover !== null ? options.pauseOnHover : this.props.pauseOnHover, closeOnClick: options.closeOnClick !== null ? options.closeOnClick : this.props.closeOnClick, progressClassName: options.progressClassName || this.props.progressClassName, autoClose: this.getAutoCloseDelay( options.autoClose !== false ? parseInt(options.autoClose, 10) : options.autoClose ), hideProgressBar: typeof options.hideProgressBar === 'boolean' ? options.hideProgressBar : this.props.hideProgressBar }; this.isFunction(options.onOpen) && (toastOptions.onOpen = options.onOpen); this.isFunction(options.onClose) && (toastOptions.onClose = options.onClose); /** * add closeToast function to react component only */ if ( isValidElement(content) && typeof content.type !== 'string' && typeof content.type !== 'number' ) { content = cloneElement(content, { closeToast }); } else if (this.isFunction(content)) { content = content({ closeToast }); } this.collection = Object.assign({}, this.collection, { [toastId]: { position: toastOptions.position, options: toastOptions, content: content } }); this.setState({ toast: toastOptions.updateId !== null ? [...this.state.toast] : [...this.state.toast, toastId] }); } makeToast(content, options) { return ( {content} ); } clear() { this.setState({ toast: [] }); } renderToast() { const toastToRender = {}; const { className, style, newestOnTop } = this.props; const collection = newestOnTop ? Object.keys(this.collection).reverse() : Object.keys(this.collection); collection.forEach(toastId => { const item = this.collection[toastId]; toastToRender[item.position] || (toastToRender[item.position] = []); if (this.state.toast.indexOf(parseInt(toastId, 10)) !== -1) { toastToRender[item.position].push( this.makeToast(item.content, item.options) ); } else { toastToRender[item.position].push(null); delete this.collection[toastId]; } }); return Object.keys(toastToRender).map(position => { const disablePointer = toastToRender[position].length === 1 && toastToRender[position][0] === null; return ( {toastToRender[position]} ); }); } render() { return
{this.renderToast()}
; } } export default ToastContainer;