Files
wr.do/components/shared/tiptap/tiptap-ui-primitive/tooltip/tooltip.tsx
2025-10-20 11:34:05 +08:00

235 lines
5.5 KiB
TypeScript

"use client";
import * as React from "react";
import {
autoUpdate,
flip,
FloatingDelayGroup,
FloatingPortal,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useMergeRefs,
useRole,
type Placement,
type ReferenceType,
type UseFloatingReturn,
} from "@floating-ui/react";
import "@/components/shared/tiptap/tiptap-ui-primitive/tooltip/tooltip.scss";
interface TooltipProviderProps {
children: React.ReactNode;
initialOpen?: boolean;
placement?: Placement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
delay?: number;
closeDelay?: number;
timeout?: number;
useDelayGroup?: boolean;
}
interface TooltipTriggerProps
extends Omit<React.HTMLProps<HTMLElement>, "ref"> {
asChild?: boolean;
children: React.ReactNode;
}
interface TooltipContentProps
extends Omit<React.HTMLProps<HTMLDivElement>, "ref"> {
children?: React.ReactNode;
portal?: boolean;
portalProps?: Omit<React.ComponentProps<typeof FloatingPortal>, "children">;
}
interface TooltipContextValue extends UseFloatingReturn<ReferenceType> {
open: boolean;
setOpen: (open: boolean) => void;
getReferenceProps: (
userProps?: React.HTMLProps<HTMLElement>,
) => Record<string, unknown>;
getFloatingProps: (
userProps?: React.HTMLProps<HTMLDivElement>,
) => Record<string, unknown>;
}
function useTooltip({
initialOpen = false,
placement = "top",
open: controlledOpen,
onOpenChange: setControlledOpen,
delay = 600,
closeDelay = 0,
}: Omit<TooltipProviderProps, "children"> = {}) {
const [uncontrolledOpen, setUncontrolledOpen] =
React.useState<boolean>(initialOpen);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;
const data = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset(4),
flip({
crossAxis: placement.includes("-"),
fallbackAxisSideDirection: "start",
padding: 4,
}),
shift({ padding: 4 }),
],
});
const context = data.context;
const hover = useHover(context, {
mouseOnly: true,
move: false,
restMs: delay,
enabled: controlledOpen == null,
delay: {
close: closeDelay,
},
});
const focus = useFocus(context, {
enabled: controlledOpen == null,
});
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });
const interactions = useInteractions([hover, focus, dismiss, role]);
return React.useMemo(
() => ({
open,
setOpen,
...interactions,
...data,
}),
[open, setOpen, interactions, data],
);
}
const TooltipContext = React.createContext<TooltipContextValue | null>(null);
function useTooltipContext() {
const context = React.useContext(TooltipContext);
if (context == null) {
throw new Error(
"Tooltip components must be wrapped in <TooltipProvider />",
);
}
return context;
}
export function Tooltip({ children, ...props }: TooltipProviderProps) {
const tooltip = useTooltip(props);
if (!props.useDelayGroup) {
return (
<TooltipContext.Provider value={tooltip}>
{children}
</TooltipContext.Provider>
);
}
return (
<FloatingDelayGroup
delay={{ open: props.delay ?? 0, close: props.closeDelay ?? 0 }}
timeoutMs={props.timeout}
>
<TooltipContext.Provider value={tooltip}>
{children}
</TooltipContext.Provider>
</FloatingDelayGroup>
);
}
export const TooltipTrigger = React.forwardRef<
HTMLElement,
TooltipTriggerProps
>(function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
const context = useTooltipContext();
const childrenRef = React.isValidElement(children)
? parseInt(React.version, 10) >= 19
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(children as { props: { ref?: React.Ref<any> } }).props.ref
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
(children as any).ref
: undefined;
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
if (asChild && React.isValidElement(children)) {
const dataAttributes = {
"data-tooltip-state": context.open ? "open" : "closed",
};
return React.cloneElement(
children,
context.getReferenceProps({
ref,
...props,
...(typeof children.props === "object" ? children.props : {}),
...dataAttributes,
}),
);
}
return (
<button
ref={ref}
data-tooltip-state={context.open ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
{children}
</button>
);
});
export const TooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps
>(function TooltipContent(
{ style, children, portal = true, portalProps = {}, ...props },
propRef,
) {
const context = useTooltipContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);
if (!context.open) return null;
const content = (
<div
ref={ref}
style={{
...context.floatingStyles,
...style,
}}
{...context.getFloatingProps(props)}
className="tiptap-tooltip"
>
{children}
</div>
);
if (portal) {
return <FloatingPortal {...portalProps}>{content}</FloatingPortal>;
}
return content;
});
Tooltip.displayName = "Tooltip";
TooltipTrigger.displayName = "TooltipTrigger";
TooltipContent.displayName = "TooltipContent";