"use client"; import { type Placement, FloatingArrow, FloatingPortal, useFloating, useInteractions, useHover, useFocus, useRole, useDismiss, useTransitionStyles, useMergeRefs, useDelayGroup, autoPlacement, arrow, offset, autoUpdate, flip, shift, } from "@floating-ui/react"; import { type ComponentProps, type ElementType, type Ref, useState, useMemo, createContext, useContext, forwardRef, useRef, createElement, } from "react"; type TooltipOptions = { arrow?: { width: number; height: number }; gap?: number; initialOpen?: boolean; placement?: Placement; open?: boolean; onOpenChange?: (open: boolean) => void; }; const TooltipBase = ({ children, ...options }: { children: React.ReactNode } & TooltipOptions) => { const tooltip = useTooltip(options); return ( {children} ); }; const TooltipContext = createContext | undefined>( undefined, ); const useTooltip = ({ arrow: arrowConfig, gap = 5, initialOpen = false, placement, open: controlledOpen, onOpenChange: setControlledOpen, }: TooltipOptions = {}) => { const arrowRef = useRef(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen); const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; const data = useFloating({ ...(placement && { placement }), open, onOpenChange: setOpen, whileElementsMounted: autoUpdate, middleware: [ offset(gap + (arrowConfig?.width ?? 0)), placement ? flip() : autoPlacement(), shift(), ...(arrowConfig ? [arrow({ element: arrowRef })] : []), ], }); const context = data.context; const { delay } = useDelayGroup(context); const hover = useHover(context, { move: false, enabled: controlledOpen == undefined, delay, }); const focus = useFocus(context, { enabled: controlledOpen == undefined, }); const dismiss = useDismiss(context); const role = useRole(context, { role: "tooltip" }); const interactions = useInteractions([hover, focus, dismiss, role]); return useMemo( () => ({ open, setOpen, ...(arrowConfig && { arrow: { config: arrowConfig, ref: arrowRef } }), ...interactions, ...data, }), [open, setOpen, interactions, data, arrowConfig], ); }; const useTooltipState = () => { const context = useContext(TooltipContext); if (context === undefined) { throw new Error("Tooltip components must be wrapped in "); } else { return context; } }; const TooltipTrigger = ( { as, ...props }: Omit, "as"> & { as?: T }, propRef: Ref, ) => { const state = useTooltipState(); return createElement(as ?? "button", { ref: useMergeRefs([state.refs.setReference, propRef]), "data-state": state.open ? "open" : "closed", ...state.getReferenceProps(props), }); }; const TooltipContent = ( { as, ...props }: Omit, "as"> & { as?: T }, propRef: Ref, ) => { const state = useTooltipState(); const delayGroupContext = useDelayGroup(state.context); const ref = useMergeRefs([state.refs.setFloating, propRef]); useDelayGroup(state.context, { id: state.context.floatingId }); const instantDuration = 0; const duration = 250; const { isMounted, styles } = useTransitionStyles(state.context, { duration: delayGroupContext.isInstantPhase ? { open: instantDuration, // `id` is this component's `id` // `currentId` is the current group's `id` close: delayGroupContext.currentId === state.context.floatingId ? duration : instantDuration, } : duration, initial: { opacity: 0, }, }); return isMounted ? ( {createElement(as ?? "div", { ref, style: { ...state.floatingStyles, ...props.style, ...styles, }, ...state.getFloatingProps(props), })} ) : // eslint-disable-next-line unicorn/no-null null; }; const TooltipArrow = ( props: Omit< ComponentProps, "ref" | "context" | "height" | "width" >, ) => { const { arrow, context } = useTooltipState(); if (!arrow) { throw new Error( "You must set the `arrow` option on the Tooltip component to use arrows", ); } return ( ); }; export const Tooltip = Object.assign(TooltipBase, { Trigger: forwardRef(TooltipTrigger), Content: forwardRef(TooltipContent), Arrow: TooltipArrow, });