import React, { forwardRef, Fragment, useCallback } from 'react'; import { Transition } from 'react-transition-group'; import { autoUpdate, flip, offset, size, SizeOptions, useFloating, } from '@floating-ui/react'; import { useMergeRefs } from '@leafygreen-ui/hooks'; import { usePopoverContext } from '@leafygreen-ui/leafygreen-provider'; import { consoleOnce } from '@leafygreen-ui/lib'; import { Portal } from '@leafygreen-ui/portal'; import { spacing as spacingToken } from '@leafygreen-ui/tokens'; import { getExtendedPlacementValues, getFloatingPlacement, getOffsetValue, getWindowSafePlacementValues, } from '../utils/positionUtils'; import { useContentNode, usePopoverProps, useReferenceElement, useReferenceElementPosition, } from './hooks'; import { contentClassName, getPopoverStyles, hiddenPlaceholderStyle, popoverCSSProperties, TRANSITION_DURATION, } from './Popover.styles'; import { Align, DismissMode, Justify, PopoverComponentProps, PopoverProps, RenderMode, } from './Popover.types'; /** * * React Component that handles positioning of content relative to another element. * * ``` * * ``` * @param props.active Boolean to describe whether or not Popover is active. * @param props.spacing The spacing (in pixels) between the reference element, and the popover. * @param props.align Alignment of Popover component relative to another element: `top`, `bottom`, `left`, `right`, `center-horizontal`, `center-vertical`. * @param props.justify Justification of Popover component relative to another element: `start`, `middle`, `end`. * @param props.adjustOnMutation: Should the Popover auto adjust its content when the DOM changes (using MutationObserver). * @param props.children Content to appear inside of Popover container. * @param props.className Classname applied to Popover container. * @param props.popoverZIndex Number that controls the z-index of the popover element directly. * @param props.refEl Reference element that Popover component should be positioned against. * @param props.renderMode Options to render the popover element: `inline`, `portal`, `top-layer`. * @param props.portalClassName Classname applied to root element of the portal. * @param props.portalContainer HTML element that the popover is portaled within. * @param props.portalRef A ref for the Portal element. * @param props.scrollContainer HTML ancestor element that's scrollable to position the popover accurately within scrolling containers. */ export const Popover = forwardRef( ( { active = false, // FIXME: // eslint-disable-next-line @typescript-eslint/no-unused-vars adjustOnMutation = false, align = Align.Bottom, children, className, justify = Justify.Start, maxHeight, maxWidth, refEl, ...rest }: PopoverProps, fwdRef, ) => { const { renderMode = RenderMode.TopLayer, /** top layer props */ dismissMode = DismissMode.Auto, onToggle, /** portal props */ usePortal, portalClassName, portalContainer, portalRef, scrollContainer, /** react-transition-group props */ onEnter, onEntering, onEntered, onExit, onExiting, onExited, /** style props */ popoverZIndex, spacing = spacingToken[100], ...restProps } = usePopoverProps(rest); const { setIsPopoverOpen } = usePopoverContext(); /** * When `usePortal` is true and a `scrollContainer` is defined, * log a warning if the `portalContainer` is not inside of the `scrollContainer`. * * Note: If no `portalContainer` is provided, * the `Portal` component will create a `div` and append it to the body. */ if (usePortal && scrollContainer) { if (!scrollContainer.contains(portalContainer as HTMLElement)) { consoleOnce.warn( 'To ensure correct positioning make sure that the portalContainer element is inside of the scrollContainer', ); } } const Root = usePortal ? Portal : Fragment; const portalProps = { className: portalContainer ? undefined : portalClassName, container: portalContainer ?? undefined, portalRef, }; const rootProps = usePortal ? portalProps : {}; const { referenceElement, setPlaceholderElement } = useReferenceElement(refEl); const referenceElDocumentPos = useReferenceElementPosition( referenceElement, scrollContainer, ); const { contentNodeRef, setContentNode } = useContentNode(); /** * The `size` middleware does not return any values, * Instead we need to set the values to a CSS property * that we then read directly with `var()` * @see https://floating-ui.com/docs/size#apply */ const calculateSize = useCallback['apply']>( ({ elements, availableHeight, availableWidth }) => { // `availableHeight` & availableWidth can be negative so we need to clamp above 0 // If no maxHeight/maxWidth are provided, we default to the availableHeight/availableWidth const clampedMaxHeight = Math.max( Math.min(maxHeight ?? Number.MAX_SAFE_INTEGER, availableHeight), 0, ); const clampedMaxWidth = Math.max( Math.min(maxWidth ?? Number.MAX_SAFE_INTEGER, availableWidth), 0, ); // Transform to a CSS string const maxHeightCSSValue = clampedMaxHeight.toFixed(0) + 'px'; const maxWidthCSSValue = clampedMaxWidth.toFixed(0) + 'px'; // Set a known CSS property that is read in the stylesheet elements.floating.style.setProperty( popoverCSSProperties.maxHeight, maxHeightCSSValue, ); elements.floating.style.setProperty( popoverCSSProperties.maxWidth, maxWidthCSSValue, ); }, [maxHeight, maxWidth], ); const { context, elements, placement, refs, strategy, x, y } = useFloating({ elements: { reference: referenceElement, }, middleware: [ offset( ({ rects }) => getOffsetValue(align, spacing, rects), [align, spacing], ), flip({ boundary: scrollContainer ?? 'clippingAncestors', mainAxis: true, crossAxis: true, fallbackAxisSideDirection: 'start', }), size({ apply: calculateSize, }), ], open: active, placement: getFloatingPlacement(align, justify), strategy: 'absolute', transform: false, whileElementsMounted: autoUpdate, }); const popoverRef = useMergeRefs([refs.setFloating, fwdRef]); const { align: windowSafeAlign, justify: windowSafeJustify } = getWindowSafePlacementValues(placement); const { placement: extendedPlacement, transformAlign } = getExtendedPlacementValues({ placement, align, }); const renderChildren = () => { if (children === null) { return null; } if (typeof children === 'function') { return children({ align: windowSafeAlign, justify: windowSafeJustify, referenceElPos: referenceElDocumentPos, }); } return children; }; const handleEntering = (isAppearing: boolean) => { if (renderMode === RenderMode.TopLayer) { // @ts-expect-error - `toggle` event not supported pre-typescript v5 elements.floating?.addEventListener('toggle', onToggle); elements.floating?.showPopover?.(); } onEntering?.(isAppearing); }; const handleEntered = (isAppearing: boolean) => { setIsPopoverOpen(true); onEntered?.(isAppearing); }; const handleExited = () => { setIsPopoverOpen(false); if (renderMode === RenderMode.TopLayer) { // @ts-expect-error - `toggle` event not supported pre-typescript v5 elements.floating?.removeEventListener('toggle', onToggle); elements.floating?.hidePopover?.(); } onExited?.(); }; return ( <> {/* Using as placeholder to prevent validateDOMNesting warnings Warnings will still show up if `usePortal` is false */} {state => ( <>
{/* We need to put `setContentNode` ref on this inner wrapper because placing the ref on the parent will create an infinite loop in some cases when dynamic styles are applied. */}
{renderChildren()}
)}
); }, ); Popover.displayName = 'Popover';