import * as React from 'react'; import * as ReactDOM from 'react-dom'; import getScrollParents from '../services/scroll-parents'; import noop from '../services/noop'; export interface Props { children: React.ReactNode; portalElement?: React.ReactElement; portalContent: | React.ReactNode | ((params: PortalContentRenderProps) => React.ReactNode); onOpen?: () => void; onClose?: () => void; onShouldClose?: () => void; closeOnScroll?: boolean; closeOnOutsideClick?: boolean; closeOnKeyDown?: (event: KeyboardEvent) => boolean; isOpen?: boolean; positionStrategy?: PositioningStrategy; rootNode?: HTMLElement; /** * The component or HTML tag to render as the wrapper for the children. * * @default 'span' */ as?: React.ElementType; } export interface PortalContentRenderProps { close: () => void; isOpen: boolean; isPositioned: boolean; strategy: Strategy; relatedWidth: number; relatedHeight: number; transitionStarted: () => void; transitionEnded: () => void; } export type PositioningStrategy = ( parentRect: ClientRect, portalRect: ClientRect, props: Props ) => { top: number; left: number; strategy: Strategy; }; export enum Position { ABOVE_LEFT = 'ABOVE_LEFT', ABOVE_RIGHT = 'ABOVE_RIGHT', BELOW_LEFT = 'BELOW_LEFT', BELOW_RIGHT = 'BELOW_RIGHT', } const renderProps: ( element: Props['portalContent'], props: PortalContentRenderProps ) => React.ReactNode = (element, props) => typeof element === 'function' ? element(props) : element; export const defaultPositionStrategy: PositioningStrategy = ( parentRect: ClientRect, portalRect: ClientRect /* props: Props */ ) => { // Open the content portal above the child if there is not enough space to the bottom, // but if there also isn't enough space at the top, open to the bottom. const openAbove = parentRect.top + parentRect.height + portalRect.height > (window.document.documentElement || window.document.body).clientHeight && parentRect.top - portalRect.height > 0; const top = openAbove ? parentRect.top - portalRect.height + window.scrollY : parentRect.top + parentRect.height + window.scrollY; // Open the content portal to the left if there is not enough space at the right, // but if there also isn't enough space at the right, open to the left. const alignRight = parentRect.left + portalRect.width > (window.document.documentElement || window.document.body).clientWidth && parentRect.left - portalRect.width > 0; const left = !alignRight ? parentRect.left + window.scrollX : window.scrollX + parentRect.left - portalRect.width + parentRect.width; let strategy = Position.BELOW_RIGHT; if (openAbove && alignRight) { strategy = Position.ABOVE_LEFT; } if (openAbove && !alignRight) { strategy = Position.ABOVE_RIGHT; } if (!openAbove && alignRight) { strategy = Position.BELOW_LEFT; } if (!openAbove && !alignRight) { strategy = Position.BELOW_RIGHT; } return { top, left, strategy, }; }; interface State { top?: number; left?: number; portalRect?: ClientRect | DOMRect; parentRect?: ClientRect | DOMRect; isPositioned: boolean; isOpen: boolean; transitionActive: boolean; shouldRender: boolean; scrollParents: Array; strategy?: Strategy; } const KEYCODES = { ESCAPE: 27, }; const EVENT_CONTEXT_KEY = 'PositioningPortal-context'; class PositioningPortal extends React.Component< Props, State > { public static defaultProps = { isOpen: false, closeOnScroll: true, onOpen: noop, onClose: noop, onShouldClose: noop, closeOnOutsideClick: true, closeOnKeyDown: (event: KeyboardEvent) => event.keyCode === KEYCODES.ESCAPE, positionStrategy: defaultPositionStrategy, }; public state: State = { top: null, left: null, portalRect: null, parentRect: null, isPositioned: false, isOpen: false, transitionActive: false, shouldRender: false, scrollParents: [], strategy: null, }; private portalRef = React.createRef(); private parentRef = React.createRef(); public componentDidMount() { window.document.addEventListener('keydown', this.handleKeydown, false); window.document.addEventListener( 'click', this.handleOutsideMouseClick, false ); // Do not render on server side. this.setState({ shouldRender: true }); if (this.props.isOpen) { this.onOpen(); } } public componentDidUpdate(prevProps: Props) { if (this.props.isOpen !== prevProps.isOpen) { if (this.props.isOpen) { this.onOpen(); } else { this.onClose(); } } } public componentWillUnmount() { window.document.removeEventListener('keydown', this.handleKeydown, false); window.document.removeEventListener( 'click', this.handleOutsideMouseClick, false ); // Remove scroll event listeners if (this.props.closeOnScroll) { this.state.scrollParents.forEach((node) => node.removeEventListener('scroll', this.close, false) ); } } public close = () => { if (this.props.onShouldClose) { this.props.onShouldClose(); } }; public transitionStarted = () => { this.setState({ transitionActive: true }); }; public transitionEnded = () => { this.setState({ transitionActive: false }); }; private handleOutsideMouseClick = ( event: MouseEvent & { [EVENT_CONTEXT_KEY]?: PositioningPortal[]; } ) => { if (!this.props.closeOnOutsideClick) { return; } if (!this.state.isOpen) { return; } if ( this.portalRef.current && this.portalRef.current.contains(event.target as Node) ) { return; } if ((event[EVENT_CONTEXT_KEY] || []).includes(this)) { return; } const parentDom = this.parentRef.current; if (parentDom && parentDom.contains(event.target as Node)) { return; } this.close(); }; private handleKeydown = (event: KeyboardEvent) => { if ( this.state.isOpen && this.props.closeOnKeyDown && this.props.closeOnKeyDown(event) ) { this.close(); } }; private onOpen = () => { if (!this.state.isOpen) { // 1) Prerender portal to get stable portal rect. this.preRenderPortal() // 2) Position portal with positioning strategy and trigger final render .then(this.finalRenderPortal) // 3) Communicate that portal has opened .then(() => { this.props.onOpen(); }); } }; private onClose = () => { if (!this.state.isOpen) { return; } // Remove scroll event listeners this.state.scrollParents.forEach((node) => node.removeEventListener('scroll', this.close, false) ); this.setState({ isOpen: false, scrollParents: [], }); this.props.onClose(); }; private markClickEvent = ( event: React.MouseEvent< HTMLElement, MouseEvent & { [EVENT_CONTEXT_KEY]?: PositioningPortal[]; } > ) => { event.nativeEvent[EVENT_CONTEXT_KEY] = [ ...(event.nativeEvent[EVENT_CONTEXT_KEY] || []), this, ]; }; private preRenderPortal = () => new Promise((resolve) => { // A tricky way to get the first child DOM element of the fragment of this component. // Unfortunately there seems to be no way to achieve this with refs. const parentDom = this.parentRef.current; if (parentDom && parentDom.nodeType === Node.ELEMENT_NODE) { const parentRect = (parentDom as Element).getBoundingClientRect(); let scrollParents: (HTMLElement | Window)[] = []; // Register scroll listener on all scrollable parents to close the portal on scroll if (this.props.closeOnScroll) { scrollParents = getScrollParents(parentDom as HTMLElement); scrollParents.forEach((node) => node.addEventListener('scroll', this.close, false) ); } this.setState( { isOpen: true, transitionActive: false, isPositioned: false, left: 0, top: 0, strategy: null, parentRect, portalRect: null, scrollParents, }, resolve ); } else { resolve(); } }); private finalRenderPortal = () => new Promise((resolve) => { if ( this.state.isOpen && !this.state.isPositioned && this.portalRef.current && this.state.parentRect ) { const portalRect = this.portalRef.current.getBoundingClientRect(); const { top, left, strategy } = this.props.positionStrategy( this.state.parentRect, portalRect, this.props ); this.setState( { isPositioned: true, left, strategy, top, portalRect, }, resolve ); } else { resolve(); } }); public render() { const { children, portalContent, portalElement, rootNode } = this.props; const { top, left, parentRect, portalRect, isPositioned, isOpen, strategy, transitionActive, shouldRender, } = this.state; const relatedWidth = parentRect ? parentRect.width : 0; const relatedHeight = parentRect ? parentRect.height : 0; const portalStyle = { position: 'absolute', width: portalRect ? `${portalRect.width}px` : 'auto', left: `${left}px`, top: `${top}px`, visibility: isPositioned ? 'visible' : 'hidden', }; const renderPortal = () => ReactDOM.createPortal( React.cloneElement( portalElement ||
, { ref: this.portalRef, style: { ...portalStyle, ...((portalElement && portalElement.props.style) || {}), }, onClick: this.markClickEvent, }, renderProps(portalContent, { close: this.close, transitionStarted: this.transitionStarted, transitionEnded: this.transitionEnded, strategy, isOpen, isPositioned, relatedWidth, relatedHeight, }) ), rootNode || window.document.body ); const shouldRenderPortal = shouldRender && (isOpen || transitionActive); return ( React.createElement( this.props.as || 'span', { ref: this.parentRef }, children, shouldRenderPortal && renderPortal() ) ); } } export default PositioningPortal;