import * as anime from "animejs"; import * as classNames from "classnames"; import * as React from "react"; import * as _ from "lodash"; import { Avatar, IAvatarProps } from "../Avatar"; import { Button, IButtonProps } from "../Button"; import { Icon, IIconProps } from "../Icon"; import { Image, IImageProps } from "../Image"; interface IModalSection { title?: string; icon?: IIconProps; subtitle?: string; action?: IButtonProps; image?: IImageProps; separator?: string | boolean; } export interface IModalProps { sections?: IModalSection[]; /** * Instead of conforming to the standard modal layout, * one can provide custom render functions. */ renderSections?: Array<() => any>; /** * If provided, will render a gray lip at the top of the modal */ lip?: string | boolean; /** * If provided, an will be shown at the top of the modal */ avatar?: IAvatarProps; /** * If provided, will open a bottom "lip" with the contents the result of the "tuck" render function */ tuck?: () => any; isOpen?: boolean; /** * If provided, will add a tap gesture to the surrounding overlay to dismiss the modal */ hammer?: any; /** * Animation callbacks */ onOpenStart?: () => void; onOpenEnd?: () => void; onCloseStart?: () => void; onCloseEnd?: () => void; className?: string; } /** * When the modal is used with an the component extends beyond the * edges of the modal. Therefore the total calculated height is not correct. * * TODO: Remove this. Use dynamic calculation. */ const BOTTOM_BUFFER = 100; export class Modal extends React.PureComponent { public ref: React.RefObject; constructor(props) { super(props); this.ref = React.createRef(); } public componentDidMount() { this.initializeOverlay(); setTimeout(() => { if (this.props.isOpen) { this.open(); } else if (!this.props.isOpen) { this.close(null, true); } }, 750); if (this.props.tuck) { this.openTuck(); } else { this.closeTuck(); } } public componentDidUpdate(prevProps: IModalProps) { if (!prevProps.isOpen &&this.props.isOpen) { this.open(); } else if (prevProps.isOpen && !this.props.isOpen) { this.close(); } if (!prevProps.tuck && this.props.tuck) { this.openTuck(); } else if (prevProps.tuck && !this.props.tuck) { this.closeTuck(); } if (!prevProps.hammer && this.props.hammer) { this.initializeOverlay(); } } public render() { return (
{this.props.avatar && (
)} {(_.isString(this.props.lip) || (_.isBoolean(this.props.lip) && this.props.lip)) && (
{_.isString(this.props.lip) && (
{this.props.lip}
)}
)}
{!_.isEmpty(this.props.renderSections) && this.props.renderSections.map((renderSection, index) => (
{renderSection()}
))} {!_.isEmpty(this.props.sections) && this.props.sections.map((section, index) => (
{_.isString(section.title) && (
{_.isObject(section.icon) && (
)}
{section.title}
)} {_.isObject(section.image) && (
)} {_.isObject(section.action) && (
)} {_.isString(section.subtitle) && (
{section.subtitle}
)}
{_.isBoolean(section.separator) || _.isString(section.separator) && (
{_.isString(section.separator) && (
{section.separator}
)}
)} ))}
{_.invoke(this.props, 'tuck')}
); } private close = (e?: React.SyntheticEvent, immediate?: boolean) => { this.withNode((node, parent) => { if (this.needsToCloseOverlay()) { parent.classList.remove("overlay--open"); parent.classList.add("overlay--close"); } anime.remove(node); anime({ targets: node, bottom: [`${this.getBottomValueInCenterOfParent()}px`, `${this.getBottomValueBeneathParent() - BOTTOM_BUFFER}px`], elasticity: 0, begin: this.props.onCloseStart, complete: this.props.onCloseEnd, ...(immediate && { duration: 0, }), }) }); } private open = () => { this.withNode((node, parent) => { // The initial render of a has visibility set to `none` if (node.style.visibility !== "visible") { node.style.visibility = "visible"; } if (this.needsToOpenOverlay()) { parent.classList.remove("overlay--close"); parent.classList.add("overlay--open"); } anime.remove(node); anime({ targets: node, bottom: [`${this.getBottomValueBeneathParent()}px`, `${this.getBottomValueInCenterOfParent()}px`], begin: this.props.onOpenStart, complete: this.props.onOpenEnd }); }); } private closeTuck = () => { this.withTuckNode((node) => { anime.remove(node); anime({ targets: node, height: [node.scrollHeight, 0], elasticity: 0, }); this.withNode((modalNode) => { anime.remove(modalNode); anime({ targets: modalNode, translateY: [Math.round(node.scrollHeight / 2), 0], elasticity: 0, }); }); }); } private openTuck = () => { this.withTuckNode((node) => { anime.remove(node); anime({ targets: node, height: node.scrollHeight, }); this.withNode((modalNode) => { anime.remove(modalNode); anime({ targets: modalNode, translateY: Math.round(node.scrollHeight / 2), }); }); }); } private initializeOverlay = () => { this.withNode((node, parent) => { if (!parent.classList.contains("overlay")) { parent.classList.add("overlay"); } // @ts-ignore parent.style.willChange = "background-color"; if (this.props.hammer) { const hammer = new this.props.hammer.Manager(parent, { domEvents: true, }); hammer.add(new this.props.hammer.Tap({ event: "tap" })); hammer.on("tap", (e) => { // Tap should be inside the parent yet not inside the modal if (!node.contains(e.target) && parent.contains(e.target) && _.get(anime, 'running.length', 0) === 0 && this.props.isOpen) { this.close(); } }); } }); } private needsToCloseOverlay = (): boolean => { return this.withNode((node, parent) => parent.classList.contains("overlay--open") || !parent.classList.contains("overlay--close"), () => true); } private needsToOpenOverlay = (): boolean => { return this.withNode((node, parent) => parent.classList.contains("overlay--close") || !parent.classList.contains("overlay--open"), () => true); } private getBottomValueBeneathParent = (): number => { return this.withNode((node, parent, tuck) => { return -(node.clientHeight + (Math.round(tuck.scrollHeight / 2))); }); } private getBottomValueInCenterOfParent = (): number => { return this.withNode((node, parent, tuck) => { return Math.round((parent.clientHeight - node.clientHeight + tuck.scrollHeight) / 2); }); } private withNode = (found?: (node: HTMLDivElement, parent?: HTMLElement, tuck?: HTMLDivElement) => any, notFound?: () => any): any => { const node = _.get(this.ref, 'current'); if (node) { return found(node, node.parentElement, this.withTuckNode((tuck) => tuck)); } else if (_.isFunction(notFound)) { return notFound(); } } private withTuckNode = (found?: (node: HTMLDivElement) => any): any => { const node = _.get(this.ref, 'current'); if (node) { const tuckNode = _.get(node.getElementsByClassName("modal__tuck"), 0) as HTMLDivElement; if (tuckNode) { return found(tuckNode); } } } }