"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,
});