Tooltip
Tooltip overlay for Tile UI.
packages/react/src/components/tooltip/tooltip.tsx
import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Slot } from '@radix-ui/react-slot';
import { getTooltipPosition, getTooltipState, tooltipStyleKeys, TOOLTIP_CLOSE_DELAY_MS } from '../lib/core';
import type { TooltipBaseProps, TooltipContentBaseProps, TooltipPosition, TooltipProviderBaseProps, TooltipTriggerBaseProps } from '../lib/core';
import { usePortalContainer, type PortalContainer } from '../portal';
import styles from './tooltip.module.scss';
interface TooltipContextValue {
open: boolean;
triggerRef: React.RefObject<HTMLElement | null>;
contentId: string;
setOpen: (open: boolean) => void;
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext(): TooltipContextValue {
const context = useContext(TooltipContext);
if (!context) {
throw new Error('Tooltip sub-components must be used within <Tooltip>.');
}
return context;
}
interface TooltipProviderContextValue {
delayDuration: number;
}
const TooltipProviderContext = createContext<TooltipProviderContextValue>({ delayDuration: 0 });
export interface TooltipProviderProps extends TooltipProviderBaseProps {
children?: React.ReactNode;
}
function TooltipProvider({ delayDuration = 0, children }: TooltipProviderProps) {
return <TooltipProviderContext.Provider value={{ delayDuration }}>{children}</TooltipProviderContext.Provider>;
}
TooltipProvider.displayName = 'TooltipProvider';
export interface TooltipProps extends React.HTMLAttributes<HTMLDivElement>, TooltipBaseProps {}
const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(({ className = '', open, defaultOpen = false, onOpenChange, children, ...props }, ref) => {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = open !== undefined ? open : internalOpen;
const triggerRef = useRef<HTMLElement | null>(null);
const contentId = useId();
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) {
setInternalOpen(next);
}
onOpenChange?.(next);
},
[open, onOpenChange],
);
return (
<TooltipContext.Provider value={{ open: isOpen, triggerRef, contentId, setOpen }}>
<div ref={ref} className={`${styles[tooltipStyleKeys.root]} ${className}`} {...props}>
{children}
</div>
</TooltipContext.Provider>
);
});
Tooltip.displayName = 'Tooltip';
export interface TooltipTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, TooltipTriggerBaseProps {}
const TooltipTrigger = React.forwardRef<HTMLButtonElement, TooltipTriggerProps>(({ className = '', asChild = false, children, onKeyDown, ...props }, ref) => {
const context = useTooltipContext();
const provider = useContext(TooltipProviderContext);
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function clearOpenTimer() {
if (openTimer.current) {
clearTimeout(openTimer.current);
openTimer.current = null;
}
}
function clearCloseTimer() {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}
function scheduleOpen() {
clearOpenTimer();
openTimer.current = setTimeout(() => context.setOpen(true), provider.delayDuration);
}
function scheduleClose() {
clearCloseTimer();
closeTimer.current = setTimeout(() => context.setOpen(false), TOOLTIP_CLOSE_DELAY_MS);
}
function handleMouseEnter() {
clearCloseTimer();
scheduleOpen();
}
function handleMouseLeave() {
clearOpenTimer();
scheduleClose();
}
function handleFocus() {
clearCloseTimer();
scheduleOpen();
}
function handleBlur() {
clearOpenTimer();
clearCloseTimer();
context.setOpen(false);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (event.key === 'Escape') {
clearOpenTimer();
clearCloseTimer();
context.setOpen(false);
}
onKeyDown?.(event);
}
useEffect(() => {
return () => {
clearOpenTimer();
clearCloseTimer();
};
}, []);
function setRef(element: HTMLElement | null) {
context.triggerRef.current = element;
if (typeof ref === 'function') {
ref(element as HTMLButtonElement | null);
} else if (ref) {
ref.current = element as HTMLButtonElement | null;
}
}
const Comp = asChild ? Slot : 'button';
const state = getTooltipState(context.open);
return (
<Comp
ref={setRef}
aria-describedby={context.open ? context.contentId : undefined}
data-state={state}
className={asChild ? undefined : `${styles[tooltipStyleKeys.trigger]} ${className}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
{...props}>
{children}
</Comp>
);
});
TooltipTrigger.displayName = 'TooltipTrigger';
export interface TooltipContentProps extends React.HTMLAttributes<HTMLDivElement>, TooltipContentBaseProps {
container?: PortalContainer;
}
const TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(({ className = '', side = 'top', sideOffset = 0, container, children, ...props }, ref) => {
const { open, triggerRef, contentId, setOpen } = useTooltipContext();
const portalContainer = usePortalContainer(container);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function clearCloseTimer() {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}
function handlePointerEnter() {
clearCloseTimer();
setOpen(true);
}
function handlePointerLeave() {
clearCloseTimer();
closeTimer.current = setTimeout(() => setOpen(false), TOOLTIP_CLOSE_DELAY_MS);
}
useEffect(() => {
return () => {
clearCloseTimer();
};
}, []);
useLayoutEffect(() => {
if (!open) {
return;
}
function updatePosition() {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const contentSize = { width: content.offsetWidth, height: content.offsetHeight };
const viewport = { width: window.innerWidth, height: window.innerHeight };
setPosition(getTooltipPosition({ triggerRect, contentSize, side, sideOffset, viewport }));
}
updatePosition();
window.addEventListener('resize', updatePosition);
document.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
document.removeEventListener('scroll', updatePosition, true);
};
}, [open, side, sideOffset, triggerRef]);
function setRef(element: HTMLDivElement | null) {
contentRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
const state = getTooltipState(open);
const classes = [styles[tooltipStyleKeys.content], className].filter(Boolean).join(' ');
const content = (
<div
ref={setRef}
id={contentId}
role="tooltip"
data-state={state}
data-side={side}
className={classes}
style={position ? { top: `${position.top}px`, left: `${position.left}px` } : undefined}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
{...props}>
{children}
<span className={styles[tooltipStyleKeys.arrow]} aria-hidden="true" />
</div>
);
if (!open || !portalContainer) {
return null;
}
return createPortal(content, portalContainer);
});
TooltipContent.displayName = 'TooltipContent';
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export default Tooltip;
- Interaction
- tooltip
DependenciesBundle weight · light
- ui-primitives
- @radix-ui/react-slot