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. */}