import { FlipOptions, Placement, autoUpdate, flip, arrow as floatingArrow, hide, limitShift, offset, shift, size, useFloating, } from "@floating-ui/react-dom"; import React, { HTMLAttributes, forwardRef, useEffect, useRef, useState, } from "react"; import { useModalContext } from "../../../modal/Modal.context"; import { useClientLayoutEffect } from "../../../utils-external"; import { createStrictContext } from "../../helpers"; import { useEventCallback, useMergeRefs, useOpenChangeAnimationComplete, } from "../../hooks"; import type { AsChildProps } from "../../types"; import { Slot } from "../slot/Slot"; import { type Align, type Measurable, type Side, getSideAndAlignFromPlacement, transformOrigin, } from "./Floating.utils"; /** * Floating Root */ type FloatingContextValue = { anchor: Measurable | null; onAnchorChange: (anchor: Measurable | null) => void; }; export const { Provider: FloatingProvider, useContext: useFloatingContext } = createStrictContext({ name: "FloatingContext", }); interface FloatingProps { children: React.ReactNode; } interface FloatingComponent extends React.FC { Anchor: typeof FloatingAnchor; Content: typeof FloatingContent; } const Floating: FloatingComponent = ({ children }: FloatingProps) => { const [anchor, setAnchor] = useState(null); return ( {children} ); }; /** * Floating Anchor */ type FloatingAnchorProps = HTMLAttributes & AsChildProps & { virtualRef?: Measurable; }; /** * `FloatingAnchor` provides an anchor for a Floating instance. * Allows anchoring to non-DOM nodes like a cursor position when used with `virtualRef`. */ const FloatingAnchor = forwardRef( ({ virtualRef, asChild, ...rest }: FloatingAnchorProps, forwardedRef) => { const context = useFloatingContext(); const ref = useRef(null); const mergedRef = useMergeRefs(forwardedRef, ref); useEffect(() => { // Allows anchoring the floating to non-DOM nodes like a cursor position. // We replace `anchorRef` with a virtual ref in such cases. context.onAnchorChange(virtualRef || ref.current); }); const Comp = asChild ? Slot : "div"; return virtualRef ? null : ; }, ); /** * Floating Arrow */ const OPPOSITE_SIDE: Record = { top: "bottom", right: "left", bottom: "top", left: "right", }; interface FloatingArrowProps { className?: string; width?: number; height?: number; } const FloatingArrow = ({ width, height, className }: FloatingArrowProps) => { const context = useFloatingContentContext(); const side = OPPOSITE_SIDE[context.placedSide]; return ( ); }; /** * Floating Content */ type FloatingContentContextValue = { placedSide: Side; onArrowChange: (arrow: HTMLSpanElement | null) => void; arrowX?: number; arrowY?: number; hideArrow: boolean; }; const { Provider: FloatingContentProvider, useContext: useFloatingContentContext, } = createStrictContext({ name: "FloatingContentContext", }); type Boundary = Element | null; interface FloatingContentProps extends HTMLAttributes { side?: Side; sideOffset?: number; align?: Align; alignOffset?: number; avoidCollisions?: boolean; collisionBoundary?: Boundary | Boundary[]; collisionPadding?: number | Partial>; hideWhenDetached?: boolean; updatePositionStrategy?: "optimized" | "always"; fallbackPlacements?: FlipOptions["fallbackPlacements"]; onPlaced?: () => void; /** * @default true */ enabled?: boolean; /** * Only use this option if your floating element is conditionally rendered, not hidden with CSS. * @default true */ autoUpdateWhileMounted?: boolean; arrow?: { className?: string; padding?: number; width: number; height: number; }; } const FloatingContent = forwardRef( ( { children, side = "bottom", sideOffset = 0, align = "center", alignOffset = 0, avoidCollisions = true, collisionBoundary = [], collisionPadding: collisionPaddingProp = 0, hideWhenDetached = false, updatePositionStrategy = "optimized", onPlaced, arrow: _arrow, fallbackPlacements, enabled = true, autoUpdateWhileMounted = true, ...contentProps }: FloatingContentProps, forwardedRef, ) => { const context = useFloatingContext(); const modalContext = useModalContext(false); const arrowDefaults = { padding: 5, width: 0, height: 0, ..._arrow, }; const [arrow, setArrow] = useState(null); const arrowWidth = arrowDefaults.width; const arrowHeight = arrowDefaults.height; const desiredPlacement = (side + (align !== "center" ? "-" + align : "")) as Placement; const collisionPadding = typeof collisionPaddingProp === "number" ? collisionPaddingProp : { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp }; const boundary = Array.isArray(collisionBoundary) ? collisionBoundary : [collisionBoundary]; const hasExplicitBoundaries = boundary.length > 0; /** * .filter(x => x !== null) does not narrow the type of the array enough. */ function isNotNull(value: T | null): value is T { return value !== null; } const detectOverflowOptions: FlipOptions = { padding: collisionPadding, boundary: boundary.filter(isNotNull), // with `strategy: 'fixed'`, this is the only way to get it to respect boundaries altBoundary: hasExplicitBoundaries, /* https://floating-ui.com/docs/flip#fallbackaxissidedirection */ fallbackAxisSideDirection: "end", fallbackPlacements, }; const { refs, floatingStyles, placement, isPositioned, middlewareData, elements: floatingElements, update, } = useFloating({ open: enabled, // default to `fixed` strategy so users don't have to pick and we also avoid focus scroll issues strategy: "fixed", placement: desiredPlacement, whileElementsMounted: autoUpdateWhileMounted ? (...args) => { const cleanup = autoUpdate(...args, { animationFrame: updatePositionStrategy === "always", }); return cleanup; } : undefined, elements: { reference: context.anchor, }, middleware: [ offset({ mainAxis: sideOffset + arrowHeight, alignmentAxis: alignOffset, }), avoidCollisions && shift({ mainAxis: true, crossAxis: false, limiter: limitShift(), }), avoidCollisions && flip({ ...detectOverflowOptions }), size({ ...detectOverflowOptions, apply: ({ elements, rects, availableWidth, availableHeight }) => { const { width: anchorWidth, height: anchorHeight } = rects.reference; const contentStyle = elements.floating.style; /** * Allows styling and animations based on the available space. */ contentStyle.setProperty( "--__axc-floating-available-width", `${availableWidth}px`, ); contentStyle.setProperty( "--__axc-floating-available-height", `${availableHeight}px`, ); contentStyle.setProperty( "--__axc-floating-anchor-width", `${anchorWidth}px`, ); contentStyle.setProperty( "--__axc-floating-anchor-height", `${anchorHeight}px`, ); }, }), arrow && floatingArrow({ element: arrow, padding: arrowDefaults.padding }), transformOrigin({ arrowWidth, arrowHeight }), hideWhenDetached && hide({ strategy: "referenceHidden", ...detectOverflowOptions }), ], }); useEffect(() => { if (autoUpdateWhileMounted || !enabled) { return; } if (floatingElements.reference && floatingElements.floating) { const cleanup = autoUpdate( floatingElements.reference, floatingElements.floating, update, ); return () => { cleanup(); }; } }, [autoUpdateWhileMounted, enabled, floatingElements, update]); useOpenChangeAnimationComplete({ enabled: !!modalContext?.modalRef, open: enabled, ref: modalContext?.modalRef, onComplete: update, }); const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement); const handlePlaced = useEventCallback(onPlaced); useClientLayoutEffect(() => { isPositioned && handlePlaced?.(); }, [isPositioned, handlePlaced]); const arrowX = middlewareData.arrow?.x; const arrowY = middlewareData.arrow?.y; const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0; return (
); }, ); Floating.Anchor = FloatingAnchor; Floating.Content = FloatingContent; export { Floating };