import React, { forwardRef, RefObject, useCallback, useEffect, useRef, } from 'react'; import {m} from 'framer-motion'; import {mergeProps, useObjectRef} from '@react-aria/utils'; import {PopoverAnimation} from './popover-animation'; import {OverlayProps} from './overlay-props'; import {useOverlayViewport} from './use-overlay-viewport'; import {FocusScope} from '@react-aria/focus'; import {VirtualElement} from '@floating-ui/react-dom'; export const Popover = forwardRef( ( { children, style, autoFocus = false, restoreFocus = true, isDismissable, isContextMenu, isOpen, onClose, triggerRef, arrowRef, arrowStyle, onPointerLeave, onPointerEnter, }, ref ) => { const viewPortStyle = useOverlayViewport(); const objRef = useObjectRef(ref); const {domProps} = useCloseOnInteractOutside( { isDismissable, isOpen, onClose, triggerRef, isContextMenu, }, objRef ); return ( {children} ); } ); // this should only be rendered when overlay is open const visibleOverlays: RefObject[] = []; interface useCloseOnInteractOutsideProps { isOpen: boolean; onClose: () => void; isDismissable: boolean; isContextMenu?: boolean; triggerRef: OverlayProps['triggerRef']; } function useCloseOnInteractOutside( { onClose, isDismissable = true, triggerRef, isContextMenu = false, }: useCloseOnInteractOutsideProps, ref: RefObject ) { const stateRef = useRef({ isPointerDown: false, isContextMenu, onClose, }); const state = stateRef.current; state.isContextMenu = isContextMenu; state.onClose = onClose; const isValidEvent = useCallback( (e: PointerEvent | MouseEvent) => { // if (e.button > 0 && (!state.isContextMenu || e.button !== 2)) { // return false; // } const target = e.target as Element; // if the event target is no longer in the document if (target) { const ownerDocument = target.ownerDocument; if (!ownerDocument || !ownerDocument.documentElement.contains(target)) { return false; } } return ref.current && !ref.current.contains(target); }, [ref] ); // Only hide the overlay when it is the topmost visible overlay in the stack. // For context menu, hide it regardless const isTopMostPopover = useCallback(() => { return visibleOverlays[visibleOverlays.length - 1] === ref; }, [ref]); const hideOverlay = useCallback(() => { if (isTopMostPopover()) { state.onClose(); } }, [isTopMostPopover, state]); const clickedOnTriggerElement = useCallback( (el: Element) => { if (triggerRef.current && 'contains' in triggerRef.current) { return triggerRef.current.contains?.(el); } return false; }, [triggerRef] ); const onInteractOutsideStart = useCallback( (e: PointerEvent) => { if (!clickedOnTriggerElement(e.target as Element)) { if (isTopMostPopover()) { e.stopPropagation(); e.preventDefault(); } } }, [clickedOnTriggerElement, isTopMostPopover] ); const onInteractOutside = useCallback( (e: PointerEvent) => { if (!clickedOnTriggerElement(e.target as Element)) { if (isTopMostPopover()) { e.stopPropagation(); e.preventDefault(); } // don't close context menu on right click, it will be done in "onInteractOutsideStart" already. // And it would prevent repositioning of context menu when right-clicking on the same element if (!state.isContextMenu || e.button !== 2) { hideOverlay(); } } }, [clickedOnTriggerElement, hideOverlay, state, isTopMostPopover] ); // Add popover ref to the stack of visible popovers on mount, and remove on unmount. useEffect(() => { visibleOverlays.push(ref); // handle pointer up and down events const onPointerDown = (e: PointerEvent) => { if (isValidEvent(e)) { onInteractOutsideStart(e); stateRef.current.isPointerDown = true; } }; const onPointerUp = (e: PointerEvent) => { if (stateRef.current.isPointerDown && isValidEvent(e)) { stateRef.current.isPointerDown = false; onInteractOutside(e); } }; // handle context menu event const onContextMenu = (e: MouseEvent) => { e.preventDefault(); if (isValidEvent(e)) { hideOverlay(); } }; // handle closing on scroll const onScroll = (e: Event) => { if (!triggerRef.current) { return; } const scrollableRegion = e.target; let triggerEl: Element | undefined; if (triggerRef.current instanceof Node) { triggerEl = triggerRef.current; } else if ('contextElement' in triggerRef.current) { triggerEl = (triggerRef.current as VirtualElement).contextElement; } // window is not a Node and doesn't have "contain", but window contains everything if ( !(scrollableRegion instanceof Node) || !triggerEl || scrollableRegion.contains(triggerEl) ) { state.onClose(); } }; document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener('pointerup', onPointerUp, true); document.addEventListener('contextmenu', onContextMenu, true); document.addEventListener('scroll', onScroll, true); return () => { const index = visibleOverlays.indexOf(ref); if (index >= 0) { visibleOverlays.splice(index, 1); } document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener('pointerup', onPointerUp, true); document.removeEventListener('contextmenu', onContextMenu, true); document.removeEventListener('scroll', onScroll, true); }; }, [ ref, isValidEvent, state, onInteractOutside, onInteractOutsideStart, triggerRef, clickedOnTriggerElement, hideOverlay, ]); // Handle the escape key const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); hideOverlay(); } }; return { domProps: { onKeyDown, }, }; }