/** * Component pop-up window (Popover) * * Implemented on the basis of the library Popper.js (см: https://popper.js.org/) * and react-wrappers above it - react-popper (см: https://github.com/FezVrasta/react-popper) * */ import memo from 'memoize-one'; import {Modifier} from '@popperjs/core'; import * as React from 'react'; import {Manager, Popper, PopperChildrenProps, Reference, ReferenceChildrenProps} from 'react-popper'; import {PLACEMENT, POPOVER_BOUNDARIES, POPOVER_THEME, TRIGGER} from '../../constants/constants'; import {capitalize} from '../../utils/capitalize'; import {composeEventHandlers} from '../../utils/composeEventHandlers'; import {filterProps} from '../../utils/filterProps'; import {joinClassNames} from '../../utils/joinClassNames'; import {safeInvoke} from '../../utils/safeInvoke'; import {uniqueId} from '../../utils/uniqueId'; import {Overlay} from '../modal/core/overlay/Overlay'; import {IProps as OverlayProps} from '../modal/core/overlay/Overlay.types'; import * as modifierFn from './modifiers'; import * as styles from './popover.m.scss'; import { DefaultProps, GetDerivedStateFromProps, GetPlacementThemeKey, IProps, IState, PalcementThemeKeys, PropsWithDefault } from './Popover.types'; import {getSizeThemeKey} from '../../utils/getSizeThemeKey'; import {SizeClassNames} from '../../type/SizeTypes'; // Storage for all active Poppers instances const poppers: {[popperKey: string]: any} = {}; const getPlacementThemeKey: GetPlacementThemeKey = (placement) => `popoverPlacement${placement .split('-') .map(capitalize) .join('')}` as PalcementThemeKeys; export class Popover extends React.PureComponent { static defaultProps: DefaultProps = { modifiers: [], trigger: TRIGGER.CLICK, placement: PLACEMENT.AUTO, boundaries: POPOVER_BOUNDARIES.CLIPPING_PARENTS, mouseLeaveDelay: 0, mouseEnterDelay: 0, hasBackdrop: false, hasArrow: true, hasPaddings: true, hasShadow: false, canFlip: true, theme: POPOVER_THEME.LIGHT }; static excludingProps: Array = [ 'isOpen', 'isDefaultOpen', 'overlayId', 'target', 'placement', 'boundaries', 'trigger', 'modifiers', 'hasBackdrop', 'hasArrow', 'hasShadow', 'hasPaddings', 'mouseLeaveDelay', 'mouseEnterDelay', 'canFlip', 'theme', 'onOpen', 'onClose', 'onVisibleChange' ]; static getDerivedStateFromProps: GetDerivedStateFromProps = (nextProps, prevState) => { const isControlledMode = nextProps.isOpen !== undefined; const isOpen = isControlledMode ? (nextProps.isOpen as boolean) : prevState.isOpen; return { ...prevState, isControlledMode, isOpen }; }; override state = Popover.getDerivedStateFromProps(this.props, { isOpen: this.props.isDefaultOpen === undefined ? false : this.props.isDefaultOpen, popperKey: uniqueId() }); targetElement: HTMLElement | null = null; innerRefs = { target: (node: HTMLElement | null) => { this.targetElement = node; } }; scheduleUpdate: (() => void) | null = null; /** * The cursor is on the popup window? */ isMouseOnPopper: boolean = false; /** * Is the cursor on the target? */ isMouseOnTarget: boolean = false; timer: number = 0; get isAnyHoverInteraction () { const {trigger} = this.props as PropsWithDefault; return [TRIGGER.HOVER, TRIGGER.HOVER_TARGET_ONLY].includes(trigger); } get isAnyClickInteraction () { const {trigger} = this.props as PropsWithDefault; return [ TRIGGER.CLICK, TRIGGER.CLICK_TARGET_ONLY, TRIGGER.CLICK_TARGET_ONCE, TRIGGER.CONTEXT_MENU ].includes(trigger); } handleTargetClick: React.MouseEventHandler = (e) => { if (this.timer) { clearTimeout(this.timer); } if (this.isAnyClickInteraction) { this.setState((prevState, prevProps) => { const {isControlledMode, isOpen} = prevState; const {trigger} = prevProps as PropsWithDefault; if (isControlledMode) { return null; } if (trigger === TRIGGER.CLICK_TARGET_ONCE && isOpen) { return null; } return {isOpen: !isOpen}; }); if (this.state.isOpen) { if (this.props.trigger !== TRIGGER.CLICK_TARGET_ONCE) { safeInvoke(this.props.onClose, e); safeInvoke(this.props.onVisibleChange, false); } } else { safeInvoke(this.props.onOpen, e); safeInvoke(this.props.onVisibleChange, true); } } }; handleTargetMouseEnter: React.MouseEventHandler = (e) => { this.isMouseOnTarget = true; if (this.timer) { clearTimeout(this.timer); } if (this.isAnyHoverInteraction) { this.timer = window.setTimeout(() => { this.setState((prevState) => { if (prevState.isControlledMode) { return null; } if (prevState.isOpen === false) { safeInvoke(this.props.onOpen, e); safeInvoke(this.props.onVisibleChange, true); } return {isOpen: true}; }); }, this.props.mouseEnterDelay); } }; handleTargetMouseLeave: React.MouseEventHandler = (e) => { this.isMouseOnTarget = false; const {mouseLeaveDelay} = this.props as PropsWithDefault; if (this.timer) { clearTimeout(this.timer); } if (this.isAnyHoverInteraction) { // setTimeout required to move the mouse from the target to the popup window without closing it. this.timer = window.setTimeout(() => { this.setState((prevState) => { if (prevState.isControlledMode) { return null; } return {isOpen: false}; }); safeInvoke(this.props.onClose, e); safeInvoke(this.props.onVisibleChange, false); }, mouseLeaveDelay); } }; handlePopperMouseEnter: React.MouseEventHandler = () => { const {trigger} = this.props as PropsWithDefault; this.isMouseOnPopper = true; if (trigger === TRIGGER.HOVER) { if (this.timer) { clearTimeout(this.timer); } this.setState((prevState) => { if (prevState.isControlledMode) { return null; } return {isOpen: true}; }); } }; handlePopperMouseLeave: React.MouseEventHandler = (e) => { this.isMouseOnPopper = false; if (this.isAnyHoverInteraction) { // setTimeout required to move the mouse from the popup window to the target without closing it. setTimeout(() => { if (!this.isMouseOnTarget) { this.setState((prevState) => { if (prevState.isControlledMode) { return null; } return {isOpen: false}; }); safeInvoke(this.props.onClose, e); safeInvoke(this.props.onVisibleChange, false); } }, this.props.mouseLeaveDelay); } }; handleClose: OverlayProps['onClose'] = (e) => { const {trigger} = this.props as PropsWithDefault; const isTargetOrItsChilds = this.targetElement === e.target || (this.targetElement && this.targetElement.contains(e.target as Element)); const isClickInteraction = trigger === TRIGGER.CLICK || trigger === TRIGGER.CLICK_TARGET_ONCE || trigger === TRIGGER.CONTEXT_MENU; if (isClickInteraction && !isTargetOrItsChilds) { this.setState((prevState) => { if (prevState.isControlledMode) { return null; } return {isOpen: false}; }); safeInvoke(this.props.onClose, e); safeInvoke(this.props.onVisibleChange, false); } }; composeClickHandlers = memo(composeEventHandlers); composeMouseEnterHandlers = memo(composeEventHandlers); composeMouseLeaveHandlers = memo(composeEventHandlers); override componentDidUpdate (prevProps: IProps, prevState: IState) { // Properties that change the modifiers, and therefore need to // force remount Popper by changing popperKey if ( prevProps.modifiers !== this.props.modifiers || prevProps.boundaries !== this.props.boundaries || prevProps.canFlip !== this.props.canFlip || prevProps.hasArrow !== this.props.hasArrow ) { this.setState({popperKey: uniqueId()}); } if (prevState.popperKey !== this.state.popperKey) { delete poppers[prevState.popperKey]; } // When you change the margins, the size of the pop-up window changes, and you need to recalculate its position // on the purpose. To do this, you must forcibly update the popper instance.js using forceUpdate() if (prevProps.hasPaddings !== this.props.hasPaddings && this.scheduleUpdate) { this.scheduleUpdate(); } } override componentWillUnmount (): void { delete poppers[this.state.popperKey]; if (this.timer) { clearTimeout(this.timer); } } hide = () => { this.setState({isOpen: false}); }; override render () { const {isOpen, popperKey} = this.state; const {placement, hasBackdrop, overlayId} = this.props as PropsWithDefault; const modifiers = this.getModifiers(); return ( {this.renderTarget} {this.renderPopper} ); } renderTarget = (referenceProps: ReferenceChildrenProps) => { const {target} = this.props as PropsWithDefault; if (typeof target === 'function') { const props = { forwardedRef: referenceProps.ref, onClick: this.handleTargetClick, onMouseEnter: this.handleTargetMouseEnter, onMouseLeave: this.handleTargetMouseLeave }; return target(props); } const commonProps = { onClick: this.composeClickHandlers(target.props.onClick, this.handleTargetClick), onMouseEnter: this.composeMouseEnterHandlers(target.props.onMouseEnter, this.handleTargetMouseEnter), onMouseLeave: this.composeMouseLeaveHandlers(target.props.onMouseLeave, this.handleTargetMouseLeave) }; const props = typeof target.type === 'function' ? { ...commonProps, forwardedRef: referenceProps.ref } : { ...commonProps, ref: referenceProps.ref }; return React.cloneElement(target, props); }; renderPopper = (popperProps: PopperChildrenProps) => { this.scheduleUpdate = popperProps.forceUpdate; const {hasPaddings, theme, size} = this.props as PropsWithDefault; const placement = (popperProps.placement as PLACEMENT | undefined) || this.props.placement; let placementClassName; let sizeClassName; /* istanbul ignore next line */ if (placement) { const placementThemeKey = getPlacementThemeKey(placement); placementClassName = styles[placementThemeKey]; } if (size) { const sizeThemeKey = getSizeThemeKey('', size); sizeClassName = styles[sizeThemeKey]; } const className = joinClassNames( styles.popover, placementClassName, sizeClassName, [styles.popoverHasPaddings, hasPaddings], [styles.lightTheme, theme === POPOVER_THEME.LIGHT], [styles.darkTheme, theme === POPOVER_THEME.DARK], [styles.transparentTheme, theme === POPOVER_THEME.TRANSPARENT] ); return (
{this.renderBlock()} {this.renderArrow(popperProps)}
); }; renderBlock () { const className = joinClassNames( styles.block, [styles.hasShadow, Boolean(this.props.hasShadow)], ); const htmlProps = { ...filterProps>(this.props, Popover.excludingProps), className }; return
; } renderArrow (popperProps: PopperChildrenProps) { const {hasArrow} = this.props as PropsWithDefault; const arrowContainerStyle = popperProps.arrowProps.style; return hasArrow ? (
) : null; } /** * Get modifiers * * Merge the default modifiers the modifiers of the properties. */ getModifiers (): Array>> { const { modifiers, hasArrow, canFlip } = this.props as PropsWithDefault; const {popperKey} = this.state; return [ { name: 'preventOverflow', enabled: true, options: { altBoundary: true, padding: 4, boundary: POPOVER_BOUNDARIES.VIEWPORT } }, { name: 'arrow', enabled: hasArrow, options: { padding: 8 } }, { name: 'flip', enabled: canFlip }, { name: 'offset', options: { offset: hasArrow ? [0, 8] : [0, 2] } }, ...modifiers, { name: 'updateChildPoppers', enabled: true, phase: 'main', fn: modifierFn.createUpdateChildPoppersModifier(poppers, popperKey) }, { name: 'hideWhenReferenceHidden', enabled: true, phase: 'write', fn: modifierFn.hideWhenReferenceHidden } ]; } }