import { useKeyUp } from '@vev/utils'; import { clamp } from 'lodash'; import React, { useCallback, useContext, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { isInside, isInsideRole, MouseStateEvent, useHTMLElement, useInterval, useMouseDown, useParentScroll, useSize, useWindowResize, } from '../../hooks'; import { usePopoverContext } from './popover-context'; import styles from './silke-popover.scss'; export type PopoverOrigin = | 'top-left' | 'top-center' | 'top-right' | 'center-right' | 'bottom-right' | 'bottom-center' | 'bottom-left' | 'center-left' | 'center-center'; export type PopoverAnchor = React.RefObject | [x: number, y: number]; const PopoverWrapperContext = React.createContext>({ current: null }); export const SilkePopoverContext = ({ target, children, }: { target: React.RefObject; children: React.ReactElement; }) => { return {children}; }; const OriginMap: Record = { top: 0, bottom: 1, left: 0, right: 1, center: 0.5, }; export interface SilkePopoverProps { /** Element or position to anchor the popover to, if not defined the center of screen is used */ anchor?: PopoverAnchor; /** Children to add inside the popover */ children?: React.ReactElement | React.ReactElement[]; /** * If popover belongs to a group of popovers, * using contextId will close all other with same id when hide = false on this * @example overflow menu - only one overflow menu should be one at any time * */ contextId?: string; /** SHould the popover be hidden */ hide?: boolean; /** What point of the target should be used for centering default center-center */ targetOrigin?: PopoverOrigin; /** What point of the anchor should be used for centering default center-center */ anchorOrigin?: PopoverOrigin; /** Offset the popover position */ offsetX?: number; /** Offset the popover position */ offsetY?: number; /** If transforming popover children, don't want popover container to be clickable */ pointerEventsNone?: boolean; /** Z-index of popover */ zIndex?: number; /** Set the target width to the same width of anchor */ matchAnchorWidth?: boolean; /** Set the target height to the same height of anchor */ matchAnchorHeight?: boolean; /** Disabled request close on click outside */ disabledClickOutside?: boolean; translateAnimation?: boolean; shadow?: 'level1' | 'level2' | 'level3' | 'level4'; /** Triggered when it's desired that the popover is hidden */ onRequestClose?: (event?: MouseStateEvent) => void; onKeyUp?: () => void; } const PopoverAttrs = { className: styles.popover, role: 'popover', }; export function SilkePopover({ anchor, children, contextId, hide, targetOrigin, anchorOrigin, offsetX, offsetY, pointerEventsNone, matchAnchorWidth, matchAnchorHeight, disabledClickOutside, zIndex, shadow, translateAnimation, onRequestClose, }: SilkePopoverProps) { const targetId = contextId || 'modal'; const parentContext = useContext(PopoverWrapperContext); const isMountedAndOpenRef = useRef(false); const attrs: Record = { ...PopoverAttrs, id: targetId }; if (shadow) attrs['data-shadow'] = shadow; const targetRef = useHTMLElement('div', attrs, !hide ? document.body : undefined); useEffect(() => { // Need timeout ensure mouse down is not triggered on the popover right away setTimeout(() => { isMountedAndOpenRef.current = !hide; }); }, [hide]); const updatePos = () => { if (!hide) { applyPopoverStyles( targetRef.current as HTMLElement, anchor, anchorOrigin, targetOrigin, offsetX, offsetY, zIndex, matchAnchorWidth, matchAnchorHeight, pointerEventsNone, translateAnimation, ); } }; // Update position if anchor changes useEffect(() => { // Hack to fix issue when matchAnchorWidth is true updatePos(); updatePos(); }, [anchor]); // Register the popover context (used to prevent multiple of same popover to open at same time) usePopoverContext(contextId, hide, onRequestClose); // Update position on size change of target useSize(targetRef, updatePos); useWindowResize(updatePos); // Interval update pos, fallback mechanism for updating position (in case size and parent scroll fails) useInterval(updatePos, hide ? 0 : 1000); // Update position of popover if target has a parent which scrolls useParentScroll(!hide && isRefObject(anchor) ? anchor : null, updatePos); // Request close on click outside (disabled if disabled click outside or hidden or not requestClose function) useMouseDown( (e) => { const el = targetRef.current; const parentPopover = parentContext.current; if (!el || !onRequestClose || !isMountedAndOpenRef.current) return; // if click inside parent popover then close const isInsideParentPopover = parentPopover && isInside(e, parentPopover); const isInsidePopover = isInsideRole(e, 'popover'); const isInDOM = document.contains(e.target as HTMLElement); // CLose popover if el is DOM and event is not inside element itself or popover, or event is in parent popover if (!isInside(e, el) && isInDOM && (isInsideParentPopover || !isInsidePopover)) { onRequestClose(e); } }, disabledClickOutside || hide || !onRequestClose, ); const onEscape = useCallback(() => { if (!hide && typeof onRequestClose === 'function') { onRequestClose(); } }, [hide, onRequestClose]); useKeyUp('Escape', onEscape, [hide]); return createPortal( {children}, targetRef.current as HTMLElement, ); } export function isRefObject(value?: PopoverAnchor): value is React.RefObject { return typeof value !== 'undefined' && 'current' in value; } function getOriginOffset(origin: PopoverOrigin): [x: number, y: number] { const [vertical, horizontal] = origin.split('-'); return [OriginMap[horizontal], OriginMap[vertical]]; } export function applyPopoverStyles( target: HTMLElement, anchor: PopoverAnchor = [window.innerWidth / 2, window.innerHeight / 2], anchorOrigin: PopoverOrigin = 'center-center', targetOrigin: PopoverOrigin = 'center-center', offsetX = 0, offsetY = 0, zIndex?: number, matchAnchorWidth?: boolean, matchAnchorHeight?: boolean, pointerEventsNone?: boolean | undefined, translateAnimation?: boolean, ): void { let width: string | null = null; let height: string | null = null; if (isRefObject(anchor)) { if (anchor.current) { const anchorRect = anchor.current.getBoundingClientRect(); const [originX, originY] = getOriginOffset(anchorOrigin); anchor = [ anchorRect.left + offsetX + anchorRect.width * originX, anchorRect.top + offsetY + anchorRect.height * originY, ]; if (matchAnchorWidth) width = anchorRect.width + 'px'; if (matchAnchorHeight) height = anchorRect.height + 'px'; } else { anchor = [0, 0]; } } else if (Array.isArray(anchor)) anchor = anchor.slice() as [number, number]; const targetRect = target.getBoundingClientRect(); const [originX, originY] = getOriginOffset(targetOrigin); const maxX = window.innerWidth - targetRect.width; const maxY = window.innerHeight - targetRect.height; anchor[0] = clamp(anchor[0] + offsetX - targetRect.width * originX, 0, maxX); anchor[1] = clamp(anchor[1] + offsetY - targetRect.height * originY, 0, maxY); if (translateAnimation) target.style.transition = 'all 0.1s ease-out'; Object.assign(target.style, { left: anchor[0] + 'px', top: anchor[1] + 'px', width, height, ...(pointerEventsNone && { pointerEvents: 'none' }), ...(targetRect.height >= window.innerHeight && { overflow: 'auto' }), }); if (zIndex) target.style.zIndex = zIndex.toString(); }