import React, { useState } from 'react' import { autoPlacement, autoUpdate, flip, FloatingPortal, type FloatingPortalProps, offset, type Placement, shift, useClick, useDismiss, useFloating, useInteractions, useRole, useTransitionStyles, } from '@floating-ui/react' import styles from './_popover.module.scss' import { type PopoverHeaderProps } from '../ComponentTypes.models' import isClient from '../../services/isClient' import PopoverHeader from '../PopoverHeader/PopoverHeader' import { useMediaQuery } from '../../hooks/responsiveHooks' type ChildrenProp = ({ setVisible, visible, floatingRef, }: { setVisible: React.Dispatch> visible: boolean floatingRef: React.MutableRefObject }) => React.JSX.Element export type PopoverProps = { /** The element that needs to be clicked to open the popover */ children: ChildrenProp /** The content inside the popover */ popoverContent: ChildrenProp | React.JSX.Element /** Optional className added to the popover */ className?: string /** Function that is called on blur of the popover */ onClickOutside?: () => void /** Optional PopoverHeader props available */ header?: PopoverHeaderProps /** Optional `noPadding` prop is used to remove the padding of the content. */ noPadding?: boolean /** Optional placement prop for the popover */ position?: Placement | 'auto' | 'auto-start' | 'auto-end' /** Optional prop to restrict the max width of the popover */ maxWidth?: string /** Optional element to append the popover to */ appendTo?: FloatingPortalProps['root'] | 'parent' /** Optional prop to disable the popover */ disabled?: boolean /** Optional prop to set the color of the arrow */ /** * @deprecated This prop is no longer supported. **/ arrowColor?: | 'medium-purple' | 'dark-red' | 'dark-green' | 'dark-blue' | 'lighter-gray' | 'white' /** Optional prop to disable closing the Popover on scrolling */ disableCloseOnScroll?: boolean /** Optional prop to add a test id to the popover for QA testing */ qaTestId?: string } const Popover = ({ children, popoverContent, position = 'right', className = '', onClickOutside, header, noPadding, maxWidth, appendTo, disabled = false, disableCloseOnScroll = false, qaTestId = 'popover', }: PopoverProps): React.JSX.Element => { const [visible, setVisible] = useState(false) const isAutoPosition = position === 'auto' || position === 'auto-start' || position === 'auto-end' const { refs, floatingStyles, context } = useFloating({ ...(!isAutoPosition ? { placement: position } : {}), open: visible && !disabled, onOpenChange(_, event, reason) { if (reason === 'outside-press' && !disabled) { onClickOutside?.() setVisible(false) } if (event?.type === 'scroll' && !disableCloseOnScroll) { setVisible(false) } }, middleware: [ offset(8), ...(isAutoPosition ? [ autoPlacement({ ...(position.includes('start') ? { alignment: 'start' } : position.includes('end') ? { alignment: 'end' } : {}), }), ] : [flip()]), shift({ padding: 4 }), ], whileElementsMounted: autoUpdate, }) const click = useClick(context, { keyboardHandlers: false, }) const dismiss = useDismiss(context, { ancestorScroll: !disableCloseOnScroll, outsidePressEvent: 'click', }) const role = useRole(context, { role: 'dialog' }) const { getReferenceProps, getFloatingProps } = useInteractions([ click, dismiss, role, ]) const { isMounted, styles: transitionStyles } = useTransitionStyles(context, { initial: { opacity: 0, transform: 'scale(0.8)', }, duration: 200, // transition duration when opening and closing the Popover }) const screenIsMdMin = useMediaQuery({ type: 'min', breakpoint: 'md' }) const popoverWidth = screenIsMdMin ? maxWidth ? maxWidth : '400px' : 'calc(100vw - 60px)' // 10px padding in the popover + 20px space on each side of the popover const renderContent = () => ( <> {header ? : null}
{typeof popoverContent === 'function' ? popoverContent({ setVisible, visible, floatingRef: refs.floating }) : popoverContent}
) const resolveAppendTo = (): FloatingPortalProps['root'] | null => { if (!isClient) return null // Reason: `document.body` can be null transiently during navigation/teardown. // Fall back to `document.documentElement` to ensure the portal always has a stable root. const defaultRoot = document.body ?? document.documentElement if (!appendTo) return defaultRoot if (appendTo === 'parent') { const referenceElement = refs.reference?.current const parentElement = referenceElement instanceof HTMLElement ? referenceElement.parentElement : null return parentElement ?? defaultRoot } return appendTo ?? defaultRoot } const portalRoot = resolveAppendTo() return ( <> {children({ setVisible, visible, floatingRef: refs.floating })} {isMounted && popoverContent && portalRoot ? (
{renderContent()}
) : null} ) } export default Popover