import React, { cloneElement, createContext, forwardRef, HTMLProps, isValidElement, PropsWithChildren, useContext, useMemo, useRef, useState, } from "react"; import mergeRefs from "react-merge-refs"; import { useFloating, autoUpdate, offset, flip, shift, arrow, useHover, useFocus, useDismiss, useRole, useInteractions, FloatingPortal, } from "@floating-ui/react"; import type { OffsetOptions, Placement, Strategy } from "@floating-ui/react"; import classNames from "classnames"; import { tokens } from "../../style-dictionary/dist/tokens"; import { bemHOF } from "../../utilities/bem"; import { Box } from "../Box"; import { PortalRootElementType } from "../Portal"; const cn = bemHOF("FloatingTooltip"); interface FloatingTooltipOptions { initialOpen?: boolean; placement?: Placement; open?: boolean; onOpenChange?: (open: boolean) => void; strategy?: Strategy; disableShiftMiddleware?: boolean; offset?: OffsetOptions; } export function useFloatingTooltip({ initialOpen = false, placement = "top", open: controlledOpen, onOpenChange: setControlledOpen, strategy, disableShiftMiddleware, offset: offsetValue = 8, }: FloatingTooltipOptions = {}) { const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen); const arrowRef = useRef(null); const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; const shiftMiddleware = disableShiftMiddleware ? [] : [shift()]; const data = useFloating({ placement, open, onOpenChange: setOpen, whileElementsMounted: autoUpdate, middleware: [ offset(offsetValue), flip(), ...shiftMiddleware, arrow({ element: arrowRef }), ], strategy, }); const { context } = data; const hover = useHover(context, { move: false, enabled: controlledOpen == null, }); const focus = useFocus(context, { enabled: controlledOpen == null, }); const dismiss = useDismiss(context, { escapeKey: false, referencePress: true, referencePressEvent: "click", }); const role = useRole(context, { role: "tooltip" }); const interactions = useInteractions([hover, focus, dismiss, role]); return useMemo( () => ({ open, setOpen, arrowRef, ...interactions, ...data, }), [open, setOpen, arrowRef, interactions, data], ) as any; } type ContextType = ReturnType | null; const FloatingTooltipContext = createContext(null); export const useFloatingTooltipState: any = () => { const context = useContext(FloatingTooltipContext); if (context == null) { throw new Error("Tooltip components must be wrapped in "); } return context; }; export function FloatingTooltipRoot({ children, ...options }: PropsWithChildren) { // This can accept any props as options, e.g. `placement`, // or other positioning options. const tooltip = useFloatingTooltip(options); return ( {children} ); } export const FloatingTooltipTrigger = forwardRef< HTMLElement, HTMLProps & { asChild?: boolean } >(function FloatingTooltipTrigger( { children, asChild = false, ...props }, propRef, ) { const state = useFloatingTooltipState(); const childrenRef = (children as any).ref; const ref = useMemo( () => mergeRefs([state.reference, propRef, childrenRef]), [state.reference, propRef, childrenRef], ); // `asChild` allows the user to pass any element as the anchor if (asChild && isValidElement(children)) { return cloneElement( children, state.getReferenceProps({ ref, ...props, ...children.props, "data-state": state.open ? "open" : "closed", }), ); } return ( {children} ); }); export interface FloatingTooltipContentProps extends HTMLProps { portalRoot?: PortalRootElementType; disablePortal?: boolean; } export const FloatingTooltipContent = forwardRef< HTMLDivElement, FloatingTooltipContentProps >(function FloatingTooltipContent( { className, style, portalRoot, disablePortal, ...props }, propRef, ) { const state = useFloatingTooltipState(); const ref = useMemo(() => mergeRefs([state.floating, propRef]), [ state.floating, propRef, ]); const portalProps = useMemo(() => { if (disablePortal) { return {}; } return { root: portalRoot, }; }, [portalRoot, disablePortal]); const Portal = disablePortal ? React.Fragment : FloatingPortal; return ( <> {state.open && ( )} ); }); export type FloatingTooltipArrowProps = { fill?: string; size?: number; }; export const FloatingTooltipArrow = ({ fill = tokens.color.gray[900], size = 6, }: FloatingTooltipArrowProps) => { const { arrowRef, placement, middlewareData: { arrow: { x: arrowX = undefined, y: arrowY = undefined } = {}, }, } = useFloatingTooltipState(); const staticSide = { top: "bottom", right: "left", bottom: "top", left: "right", }[placement.split("-")[0] as string]; const props = { ref: arrowRef, className: `${cn({ e: "arrow" })}`, style: { backgroundColor: fill, width: `${size}px`, height: `${size}px`, left: arrowX != null ? `${arrowX}px` : "", top: arrowY != null ? `${arrowY}px` : "", right: "", bottom: "", [staticSide as string]: `${size / -2}px`, }, }; return ; }; // eslint-disable-next-line prefer-object-spread export const FloatingTooltip = Object.assign({}, FloatingTooltipRoot, { Root: FloatingTooltipRoot, Trigger: FloatingTooltipTrigger, Content: FloatingTooltipContent, Arrow: FloatingTooltipArrow, });