import * as React from 'react' import { createPopper } from '@popperjs/core' import Box from '../Box' import Portal from '../Portal' import { lockScroll } from '../utils' import rewireTabOrder from './rewireTabOrder' import { getFixedParent, getModifiers } from './utils' type anchorCallbackProps = { innerRef(node: HTMLElement): void onKeyDown(event: React.SyntheticEvent): void } export type PopoverProps = { /** * Controls the default component or tag being rendered. */ as?: React.ReactNode /** * The element to attach the popover to. Clones the element's ref to calculate * position relative to the optional `relativeTo` prop or the `renderTo` element. */ anchorElement: React.ReactElement | ((anchorCallbackProps) => null) /** * Attempts to keep popover in view clipping edges if too large. */ keepInView?: boolean /** * Locks external scrollbars when open. */ lockScrollWhileOpen?: boolean /** * Matches anchor and popover widths. */ matchWidths?: boolean | 'minimum' /** * Controls rendering of the popover. */ open: boolean /** * Controls the space between the popover and its anchor. */ offset: number /** * Called when the popover should close. */ onRequestClose?: () => void /** * Where to place the popover. */ placement?: | 'top-start' | 'top' | 'top-end' | 'right-start' | 'right' | 'right-end' | 'bottom-start' | 'bottom' | 'bottom-end' | 'left-start' | 'left' | 'left-end' /** * Where to place the popover relative to. Uses `renderTo` by default. */ relativeTo?: any /** * Where to render the popover into. Accepts an HTML element or selector. * Uses the closest fixed parent or `document.body` by default. This will only * be computed once on initial render. */ renderTo?: HTMLElement | string /** * Determines if the popover should flip or not when it overflows. */ shouldFlip?: boolean /** * Calls `as` `innerRef` to calculate popover's bounds. */ innerRef?: any } & React.HTMLAttributes export const Popover = React.forwardRef( ( { anchorElement, as: Element = Box, innerRef, keepInView = true, lockScrollWhileOpen, matchWidths, offset = 8, open, onKeyDown, onRequestClose, placement = 'bottom', relativeTo, renderTo, shouldFlip = false, ...restProps }: PopoverProps, ref ) => { const anchorRef = React.useRef(null) const popperRef = React.useRef(null) const unlockScroll = React.useRef(null) const unwireTabOrder = React.useRef(null) const [renderPopperTo, setRenderPopperTo] = React.useState(renderTo) const setPopperInnerRef = (popperNode) => { if (popperNode) { if (popperRef.current === null) { const anchorNode = relativeTo ? relativeTo.current || relativeTo : anchorRef.current popperRef.current = createPopper(anchorNode, popperNode, { placement, // PopperJS is written in Flow and TS isn't playing nice :( // @ts-ignore modifiers: getModifiers({ matchWidths, offset, keepInView, shouldFlip, }), }) if (unwireTabOrder.current) { unwireTabOrder.current() } unwireTabOrder.current = rewireTabOrder(popperNode) if (lockScrollWhileOpen) { if (unlockScroll.current) { unlockScroll.current() } unlockScroll.current = lockScroll(popperNode) } } } else { cleanup() } if (typeof innerRef === 'function') { innerRef(popperNode) } else if (innerRef?.current) { innerRef.current = popperNode } } const cleanup = () => { if (popperRef.current) { popperRef.current.destroy() popperRef.current = null } if (unwireTabOrder.current) { unwireTabOrder.current() unwireTabOrder.current = null } if (unlockScroll.current) { unlockScroll.current() unlockScroll.current = null } } const requestClose = (event) => { if (event.key === 'Escape' && onRequestClose) { onRequestClose() } if (onKeyDown) { onKeyDown(event) } } // cleanup when open has changed to `false` and on component unmount React.useEffect(() => { return () => { if (open === false) { cleanup() } } }, [open]) // reposition the popover if an update in React occured while open // for instance this can happen when selecting multiple items in a menu and the anchor grows/shrinks React.useEffect(() => { if (open && popperRef.current) { popperRef.current.update() } }) // by default this will make sure popovers render in the same fixed area as the anchor React.useEffect(() => { if (renderPopperTo === undefined && anchorElement) { setRenderPopperTo(getFixedParent(anchorElement)) } }, [anchorElement]) // carried over from previous implementation, used exclusively in Dropdown React.useImperativeHandle(ref, () => ({ focusAnchor: () => { if (anchorRef.current && anchorRef.current.focus) { anchorRef.current.focus() } }, })) return ( {typeof anchorElement === 'function' ? anchorElement({ innerRef: (node) => (anchorRef.current = node), onKeyDown: requestClose, }) : React.cloneElement(anchorElement, { innerRef: (node) => { anchorRef.current = node if (typeof anchorElement.props.innerRef === 'function') { anchorElement.props.innerRef(node) } else if (anchorElement.props.innerRef?.current) { anchorElement.props.innerRef.current = node } }, onKeyDown: (event) => { requestClose(event) if (anchorElement.props.onKeyDown) { anchorElement.props.onKeyDown(event) } }, })} {open ? React.createElement(Element as any, { innerRef: setPopperInnerRef, width: 'auto', onKeyDown: (event) => { requestClose(event) if (onKeyDown) { onKeyDown(event) } }, // ideally this should be pulled out to something like ThemeProvider for predictable z indices style: { zIndex: 10000 }, ...restProps, }) : null} ) } ) Popover.displayName = 'Popover'