import { isTouch } from '@audentio/utils/src/isTouch'; import React, { Component, Fragment } from 'react'; import ReactDOM from 'react-dom'; import { OverlayTriggerProps, OverlayTriggerState } from './OverlayTrigger.types'; export class OverlayTrigger extends Component { __trigger: HTMLElement; _autoHideTimer: NodeJS.Timer; __overlay: HTMLElement; __overlayNode: HTMLElement; static getDerivedStateFromProps(nextProps: OverlayTriggerProps, prevState: OverlayTriggerState) { // if value is passed we need to update local state to use it if (typeof nextProps.visible === 'boolean') { return { visible: nextProps.visible, }; } return {}; } static defaultProps = { trigger: 'click', position: 'bottom', }; state: OverlayTriggerState = { visible: false, containerStyle: {}, }; /** * Cleanup */ public componentWillUnmount(): void { document.body.removeEventListener('mousedown', this.onBodyClick); if (this.__overlayNode) { document.body.removeChild(this.__overlayNode); } if (this.__trigger) { this.__trigger.removeEventListener('mouseleave', this.startAutohideTimer); this.__trigger.removeEventListener('mouseenter', this.stopAutohideTimer); this.__trigger.removeEventListener('mouseenter', this.show); this.__trigger.removeEventListener('click', this.toggle); this.__trigger.removeEventListener('mouseleave', this.hide); } if (this.__overlay) { this.__overlay.removeEventListener('mouseenter', this.stopAutohideTimer); this.__overlay.removeEventListener('mouseleave', this.startAutohideTimer); } } private childMount = (ref: HTMLElement): void => { if (!this.__trigger) { this.__trigger = ref; if (this.hasTrigger('hover')) { this.__trigger.addEventListener('mouseenter', this.show); } if (this.hasTrigger('click')) { this.__trigger.addEventListener('click', this.toggle); } // autohide 0 (hide immediately on mouseleave) if (this.props.autoHide === 0) { this.__trigger.addEventListener('mouseleave', this.hide); } // this.setState({ // containerStyle: this.getContainerStyle(this.__trigger), // }); } }; private overlayMount = (ref: HTMLElement): void => { if (this.__overlay !== ref) { this.__overlay = ref; if (ref) { this.__overlay.addEventListener('mouseenter', this.stopAutohideTimer); this.__overlay.addEventListener('mouseleave', this.startAutohideTimer); } } }; private getContainerStyle(elm: HTMLElement) { const { position, fixed, triggerStyle = {} } = this.props; const bcr = elm.getBoundingClientRect(); const style: React.CSSProperties = { position: fixed ? 'fixed' : 'absolute', width: bcr.width, height: bcr.height, display: 'flex', alignItems: 'center', justifyContent: 'center', ...triggerStyle, }; const top = bcr.top + (fixed ? 0 : window.scrollY); if (position === 'top') { style.top = top - bcr.height; style.left = bcr.left; } if (position === 'right') { style.top = top; style.left = bcr.left + bcr.width; } if (position === 'left') { style.top = top; style.left = bcr.left - bcr.width; } if (position === 'bottom') { style.top = top + bcr.height; style.left = bcr.left; } return style; } private show = (): void => { // Add Autohide listeners this.__trigger.addEventListener('mouseleave', this.startAutohideTimer); this.__trigger.addEventListener('mouseenter', this.stopAutohideTimer); const { hideOnOutsideClick, onChange } = this.props; const visible = true; if (hideOnOutsideClick) { document.body.addEventListener('mousedown', this.onBodyClick); } if (onChange) { this.setState({ containerStyle: this.getContainerStyle(this.__trigger), }); onChange(visible); } else { this.setState({ visible, containerStyle: this.getContainerStyle(this.__trigger), }); } }; private hide = (): void => { // Remove Autohide listeners this.__trigger.removeEventListener('mouseleave', this.startAutohideTimer); this.__trigger.removeEventListener('mouseenter', this.stopAutohideTimer); const { hideOnOutsideClick, onChange } = this.props; const visible = false; if (hideOnOutsideClick) { document.body.removeEventListener('mousedown', this.onBodyClick); } if (onChange) { onChange(visible); } else { this.setState({ visible, }); } }; private onBodyClick = (e: MouseEvent): void => { if (!(e.target instanceof HTMLElement)) { return; } // check if clicked on trigger if (this.__trigger && (e.target === this.__trigger || this.__trigger.contains(e.target))) { return; } if (this.__overlay && (e.target === this.__overlay || this.__overlay.contains(e.target))) { return; } e.preventDefault(); this.hide(); }; private startAutohideTimer = (): void => { const { autoHide } = this.props; if (!autoHide) return; this._autoHideTimer = setTimeout(this.hide, autoHide); }; private stopAutohideTimer = (): void => { if (this._autoHideTimer) { clearTimeout(this._autoHideTimer); } }; private toggle = (): void => { if (this.state.visible) { this.hide(); } else { this.show(); } }; private hasTrigger = (triggerName): boolean => { const { trigger } = this.props; if (typeof trigger === 'object') { return trigger.indexOf(triggerName) > -1; } return trigger === triggerName; }; /** * Create a mount node for overlay * re-use if it already exists */ getOverlayNode() { if (this.__overlayNode) return this.__overlayNode; this.__overlayNode = document.createElement('div'); document.body.appendChild(this.__overlayNode); return this.__overlayNode; } renderTarget() { const { children } = this.props; let child = children; if (children.constructor === Array) { if (children.length > 1) { throw new Error('OverlayTrigger expects a single child'); } child = children[0]; } if (typeof child.type === 'function') { // Components return React.cloneElement(child, { innerRef: this.childMount, }); } // elements return React.cloneElement(child, { ref: this.childMount, }); } public render(): React.ReactNode { const { overlay, trigger } = this.props; const { visible, containerStyle } = this.state; if (trigger === 'hover' && isTouch()) { // render target as-is for hover overlays on touch devices return this.props.children; } return ( {/* Render overlay */} {visible && ReactDOM.createPortal(
{overlay}
, this.getOverlayNode() )} {this.renderTarget()}
); } }