import { Instance, Modifier, Options, Placement as PopperJSPlacement, createPopper, } from '@popperjs/core'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import {useLocalRef} from '@workday/canvas-kit-react/common'; import {fallbackPlacementsModifier} from './fallbackPlacements'; import {usePopupStack} from './hooks'; export type Placement = `${PopperJSPlacement}`; // Use template literals to make documentation list them out export type PopperOptions = Options; export const defaultFallbackPlacements: Placement[] = ['top', 'right', 'bottom', 'left']; export interface PopperProps { /** * The reference element used to position the Popper. Popper content will try to follow the * `anchorElement` if it moves and will reposition itself if there is no longer room in the * window. */ anchorElement?: React.RefObject | Element | null; /** * The content of the Popper. If a function is provided, it will be treated as a Render Prop and * pass the `placement` chosen by PopperJS. This `placement` value is useful if your popup needs * to animate and that animation depends on the direction of the content in relation to the * `anchorElement`. */ children: ((props: {placement: Placement}) => React.ReactNode) | React.ReactNode; /** * When provided, this optional callback will be used to determine positioning for the Popper element * instead of calling `getBoundingClientRect` on the `anchorElement` prop. Use this when you need * complete control over positioning. When this prop is specified, it is safe to pass `null` into the * `anchorElement` prop. If `null` is passed into the `anchorElement` prop, an `owner` will not be * provided for the `PopupStack`. If an `anchorElement` is also provided, Popper listens to its * scroll containers and uses it for clipping, so the Popper still updates when it scrolls. */ getAnchorClientRect?: () => DOMRect; /** * Determines if `Popper` content should be rendered. The content only exists in the DOM when * `open` is `true` * @default true */ open?: boolean; /** * The placement of the `Popper` contents relative to the `anchorElement`. Accepts `auto`, `top`, * `right`, `bottom`, or `left`. Each placement can also be modified using any of the following * variations: `-start` or `-end`. * @default bottom */ placement?: Placement; /** * Define fallback placements by providing a list of {@link Placement} in array (in order of preference). * The default preference is following the order of `top`, `right`, `bottom`, and `left`. Once the initial * and opposite placements are not available, the fallback placements will be in use. Use an empty array to * disable the fallback placements. */ fallbackPlacements?: Placement[]; /** * A callback function that will be called whenever PopperJS chooses a placement that is different * from the provided `placement` preference. If a `placement` preference doesn't fit, PopperJS * will choose a new one and call this callback. */ onPlacementChange?: (placement: Placement) => void; /** * The additional options passed to the Popper's `popper.js` instance. */ popperOptions?: Partial; /** * If false, render the Popper within the * DOM hierarchy of its parent. A non-portal Popper will constrained by the parent container * overflows. If you set this to `false`, you may experience issues where you content gets cut off * by scrollbars or `overflow: hidden` * @default true */ portal?: boolean; /** * Reference to the PopperJS instance. Useful for making direct method calls on the popper * instance like `update`. */ popperInstanceRef?: React.Ref; } /** * A thin wrapper component around the Popper.js positioning engine. For reference: * https://popper.js.org/. `Popper` also automatically works with the {@link PopupStack} system. * `Popper` has no UI and will render any children to the `body` element and position around a * provided `anchorElement`. * * Prefer using {@link PopupPopper Popup.Popper} instead. Use this to make Popups that don't utilize * a PopupModel or any associate popup [hooks](#hooks). * * > **Note:** `Popper` renders any children to a `div` element created by the `PopupStack`. This * > element is not controlled by React, so any extra element props will _not_ be forwarded. The * > `ref` will point to the `div` element created by the `PopupStack`, however. In v4, an extra * > `div` element was rendered and that's where extra props were spread to. In v5+, you can provide * > your own element if you wish. */ export const Popper = React.forwardRef( ({portal = true, open = true, ...elemProps}: PopperProps, ref) => { if (!open) { return null; } return ; } ); const getElementFromRefOrElement = ( input: React.RefObject | Element | null ): Element | undefined => { if (input === null) { return undefined; } else if ('current' in input) { return input.current || undefined; } else { return input; } }; // prevent unnecessary renders if popperOptions are not passed const defaultPopperOptions: PopperProps['popperOptions'] = {}; // Popper bails early if `open` is false and React hooks cannot be called conditionally, // so we're breaking out the open version into another component. const OpenPopper = React.forwardRef( ( { anchorElement, getAnchorClientRect, popperOptions = defaultPopperOptions, placement: popperPlacement = 'bottom', fallbackPlacements = defaultFallbackPlacements, onPlacementChange, children, portal, popperInstanceRef, }: PopperProps, ref ) => { const firstRender = React.useRef(true); const {localRef, elementRef} = useLocalRef(popperInstanceRef); const [placement, setPlacement] = React.useState(popperPlacement); const stackRef = usePopupStack(ref, anchorElement as HTMLElement); const placementRef = React.useRef(popperPlacement); placementRef.current = placement; const placementModifier = React.useMemo((): Modifier => { return { name: 'setPlacement', enabled: true, phase: 'afterWrite', fn({state}) { setPlacement(state.placement); onPlacementChange?.(state.placement); }, }; }, [setPlacement, onPlacementChange]); // useLayoutEffect prevents flashing of the popup before position is determined React.useLayoutEffect(() => { const anchorEl = getAnchorClientRect ? { getBoundingClientRect: getAnchorClientRect, contextElement: getElementFromRefOrElement(anchorElement ?? null), } : getElementFromRefOrElement(anchorElement ?? null); if (!anchorEl) { console.warn( `Popper: neither anchorElement or getAnchorClientRect was defined. A valid anchorElement or getAnchorClientRect callback must be provided to render a Popper` ); return undefined; } if (stackRef.current) { const instance = createPopper(anchorEl, stackRef.current, { placement: popperPlacement, ...popperOptions, modifiers: [ placementModifier, { ...fallbackPlacementsModifier, options: { fallbackPlacements, }, }, ...(popperOptions.modifiers || []), ], }); elementRef(instance); // update the ref with the instance return () => { instance?.destroy(); }; } return undefined; // We will maintain our own list of dependencies. We need to separate "create" and "update" // prop dependencies. We do _not_ want to destroy the Popper instance if options or placement // change, only if anchor or target refs change // eslint-disable-next-line react-hooks/exhaustive-deps }, [anchorElement, getAnchorClientRect, stackRef]); React.useLayoutEffect(() => { // Only update options if this is _not_ the first render if (!firstRender.current) { localRef.current?.setOptions({ placement: popperPlacement, ...popperOptions, modifiers: [ placementModifier, { ...fallbackPlacementsModifier, options: { fallbackPlacements, }, }, ...(popperOptions.modifiers || []), ], }); } firstRender.current = false; }, [popperOptions, popperPlacement, fallbackPlacements, placementModifier, localRef]); const contents = <>{isRenderProp(children) ? children({placement}) : children}; if (!portal) { return contents; } return ReactDOM.createPortal(contents, stackRef.current!); } ); // Typescript threw an error about non-callable signatures. Using typeof as a 'function' returns // a type of `Function` which isn't descriptive enough for Typescript. We don't do any detection // against the _type_ of function that gets passed, but we'll assume it is a render prop for now... function isRenderProp( children: any ): children is (props: {placement: Placement}) => React.ReactNode { if (typeof children === 'function') { return true; } return false; }