Tooltip

PreviousNext

A popup that displays information related to an element.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/tooltip.jsx
"use client";

import * as React from "react";
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";

import { cn } from "@/lib/utils";
import { buttonVariants } from "@/registry/optics/button";

const TooltipProviderContext = React.createContext({ hasProvider: false, delay: 400 });

// Context local para cada instancia de Tooltip para manejar estados de interacción
const TooltipInstanceContext = React.createContext({
	shouldKeepOpenRef: { current: false },
	setShouldKeepOpen: () => {},
	isPointerOverTriggerRef: { current: false },
	isPointerOverContentRef: { current: false },
	openTooltip: () => {},
	delay: 400,
});

function TooltipProvider({
	delay = 400,
	delayDuration = undefined,
	skipDelayDuration = 0,
	children,
	...props
} = {}) {
	const resolvedDelayDuration = delayDuration ?? delay;

	// Configuración de delays:
	// - delay/delayDuration: 400ms por defecto para la apertura inicial del tooltip
	// - skipDelayDuration: 0ms para transiciones instantáneas entre tooltips cuando ya hay uno abierto
	// Para que skipDelayDuration funcione entre múltiples tooltips, todos deben compartir el mismo TooltipProvider
	return (
		<TooltipProviderContext.Provider value={{ hasProvider: true, delay: resolvedDelayDuration }}>
			<TooltipPrimitive.Provider
				data-slot="tooltip-provider"
				delay={resolvedDelayDuration}
				delayDuration={resolvedDelayDuration}
				skipDelayDuration={skipDelayDuration}
				{...props}
			>
				{children}
			</TooltipPrimitive.Provider>
		</TooltipProviderContext.Provider>
	);
}

function Tooltip({ open: controlledOpen, onOpenChange, delay: localDelay, ...props } = {}) {
	const { hasProvider, delay: providerDelay } = React.useContext(TooltipProviderContext);
	const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);

	const isControlled = controlledOpen !== undefined;
	const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

	// El delay efectivo viene del prop local, del provider, o del default (400)
	const effectiveDelay = localDelay ?? providerDelay ?? 400;

	// Refs locales para cada instancia de Tooltip
	const shouldKeepOpenRef = React.useRef(false);
	const isPointerOverTriggerRef = React.useRef(false);
	const isPointerOverContentRef = React.useRef(false);

	const setShouldKeepOpen = React.useCallback((value) => {
		shouldKeepOpenRef.current = value;
	}, []);

	const openTooltip = React.useCallback(() => {
		if (isControlled) {
			onOpenChange?.(true);
		} else {
			setUncontrolledOpen(true);
		}
	}, [isControlled, onOpenChange]);

	function handleOpenChange(nextOpen, eventDetails) {
		// Lógica para mantener abierto si el mouse está sobre el trigger o el contenido
		if (!nextOpen) {
			if (
				isPointerOverTriggerRef.current ||
				isPointerOverContentRef.current ||
				shouldKeepOpenRef.current
			) {
				if (isControlled) {
					onOpenChange?.(true);
				} else {
					setUncontrolledOpen(true);
				}
				return;
			}
		}

		if (!isControlled) {
			setUncontrolledOpen(nextOpen);
		}

		if (nextOpen) {
			setShouldKeepOpen(true);
		} else {
			setShouldKeepOpen(false);
		}

		onOpenChange?.(nextOpen, eventDetails);
	}

	const tooltipRoot = (
		<TooltipInstanceContext.Provider
			value={{
				shouldKeepOpenRef,
				setShouldKeepOpen,
				isPointerOverTriggerRef,
				isPointerOverContentRef,
				openTooltip,
				delay: effectiveDelay,
			}}
		>
			<TooltipPrimitive.Root
				data-slot="tooltip"
				open={isOpen}
				onOpenChange={handleOpenChange}
				delay={effectiveDelay}
				{...props}
			/>
		</TooltipInstanceContext.Provider>
	);

	if (hasProvider) {
		return tooltipRoot;
	}

	return <TooltipProvider delay={effectiveDelay}>{tooltipRoot}</TooltipProvider>;
}

function TooltipTrigger({
	onClick,
	onPointerDown,
	onPointerLeave,
	onPointerEnter,
	onTouchStart,
	onTouchEnd,
	onTouchCancel,
	...props
} = {}) {
	const {
		setShouldKeepOpen,
		isPointerOverTriggerRef,
		isPointerOverContentRef,
		openTooltip,
		delay,
	} = React.useContext(TooltipInstanceContext);

	const longPressTimeoutRef = React.useRef(null);
	const isTouchActiveRef = React.useRef(false);

	React.useEffect(() => {
		return () => {
			if (longPressTimeoutRef.current) {
				clearTimeout(longPressTimeoutRef.current);
			}
		};
	}, []);

	const handleClick = React.useCallback(
		(event) => {
			event.stopPropagation();
			onClick?.(event);
		},
		[onClick],
	);

	const handlePointerDown = React.useCallback(
		(event) => {
			setShouldKeepOpen(true);
			isPointerOverTriggerRef.current = true;
			event.stopPropagation();
			onPointerDown?.(event);
		},
		[onPointerDown, setShouldKeepOpen],
	);

	const handlePointerEnter = React.useCallback(
		(event) => {
			setShouldKeepOpen(true);
			isPointerOverTriggerRef.current = true;
			onPointerEnter?.(event);
		},
		[onPointerEnter, setShouldKeepOpen],
	);

	const handlePointerLeave = React.useCallback(
		(event) => {
			isPointerOverTriggerRef.current = false;
			if (!isPointerOverContentRef.current) {
				setShouldKeepOpen(false);
			}
			onPointerLeave?.(event);
		},
		[onPointerLeave, setShouldKeepOpen, isPointerOverContentRef],
	);

	const handleTouchStart = React.useCallback(
		(event) => {
			isTouchActiveRef.current = true;
			if (longPressTimeoutRef.current) {
				clearTimeout(longPressTimeoutRef.current);
			}

			longPressTimeoutRef.current = setTimeout(() => {
				if (isTouchActiveRef.current) {
					setShouldKeepOpen(true);
					isPointerOverTriggerRef.current = true;
					openTooltip();
					// En mobile, el preventDefault evita el menú contextual al hacer long press
					if (event.cancelable) event.preventDefault();
				}
			}, delay); // Usamos el delay del componente

			onTouchStart?.(event);
		},
		[onTouchStart, setShouldKeepOpen, openTooltip, delay],
	);

	const handleTouchEnd = React.useCallback(
		(event) => {
			isTouchActiveRef.current = false;
			if (longPressTimeoutRef.current) {
				clearTimeout(longPressTimeoutRef.current);
				longPressTimeoutRef.current = null;
			}
			onTouchEnd?.(event);
		},
		[onTouchEnd],
	);

	const handleTouchCancel = React.useCallback(
		(event) => {
			isTouchActiveRef.current = false;
			if (longPressTimeoutRef.current) {
				clearTimeout(longPressTimeoutRef.current);
				longPressTimeoutRef.current = null;
			}
			onTouchCancel?.(event);
		},
		[onTouchCancel],
	);

	return (
		<TooltipPrimitive.Trigger
			data-slot="tooltip-trigger"
			onClick={handleClick}
			onPointerDown={handlePointerDown}
			onPointerEnter={handlePointerEnter}
			onPointerLeave={handlePointerLeave}
			onTouchStart={handleTouchStart}
			onTouchEnd={handleTouchEnd}
			onTouchCancel={handleTouchCancel}
			{...props}
		/>
	);
}

function TooltipContent({
	className = "",
	side = "top",
	sideOffset = 4,
	align = "center",
	alignOffset = 0,
	variant = "raised",
	children = null,
	onPointerEnter,
	onPointerLeave,
	...props
}) {
	const {
		setShouldKeepOpen,
		isPointerOverTriggerRef,
		isPointerOverContentRef,
	} = React.useContext(TooltipInstanceContext);

	const handlePointerEnter = React.useCallback(
		(event) => {
			setShouldKeepOpen(true);
			isPointerOverContentRef.current = true;
			onPointerEnter?.(event);
		},
		[onPointerEnter, setShouldKeepOpen],
	);

	const handlePointerLeave = React.useCallback(
		(event) => {
			isPointerOverContentRef.current = false;
			if (!isPointerOverTriggerRef.current) {
				setShouldKeepOpen(false);
			}
			onPointerLeave?.(event);
		},
		[onPointerLeave, setShouldKeepOpen, isPointerOverTriggerRef],
	);

	return (
		<TooltipPrimitive.Portal>
			<TooltipPrimitive.Positioner
				align={align}
				alignOffset={alignOffset}
				side={side}
				sideOffset={sideOffset}
				className="isolate z-50"
			>
				<TooltipPrimitive.Popup
					data-slot="tooltip-content"
					className={cn(
						"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-md bg-background text-foreground dark:bg-sidebar z-50 w-fit max-w-xs origin-(--transform-origin)",
						buttonVariants({ variant, size: "default", animation: "none" }),
						className,
					)}
					onPointerEnter={handlePointerEnter}
					onPointerLeave={handlePointerLeave}
					{...props}
				>
					<div className="flex flex-col gap-2 z-50">{children}</div>

					<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-background fill-background dark:bg-sidebar dark:fill-sidebar z-40 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
				</TooltipPrimitive.Popup>
			</TooltipPrimitive.Positioner>
		</TooltipPrimitive.Portal>
	);
}

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

Installation

npx shadcn@latest add @optics/tooltip

Usage

import { Tooltip } from "@/components/tooltip"
<Tooltip />