/** * Tooltip - a small hint shown next to a trigger on hover / focus. * * Wrap any focusable trigger; the bubble appears after `delay` on pointerenter * or focus, and hides on pointerleave / blur / Escape. `placement` picks the * side (top / bottom / left / right). The trigger gets aria-describedby pointing * at the bubble so screenreaders read it. * * Touch note: touch devices have no hover, so the bubble appears when the * trigger receives focus (a tap on a focusable element). For rich/critical * content prefer a Popover - a Tooltip is for short supplementary labels. * * Colours from the theme; radii/spacing from tokens. */ import { useId, useRef, useState } from "react"; import type { CSSProperties, ReactNode } from "react"; import { FS, FW, R, SP } from "@rukin/tokens"; import { useTheme } from "./theme"; export type TooltipPlacement = "top" | "bottom" | "left" | "right"; export type TooltipProps = { content: ReactNode; children: ReactNode; placement?: TooltipPlacement; /** ms before it appears; default 300. */ delay?: number; style?: CSSProperties; }; const GAP = 8; function bubblePosition(placement: TooltipPlacement): CSSProperties { switch (placement) { case "bottom": return { top: `calc(100% + ${GAP}px)`, left: "50%", transform: "translateX(-50%)" }; case "left": return { right: `calc(100% + ${GAP}px)`, top: "50%", transform: "translateY(-50%)" }; case "right": return { left: `calc(100% + ${GAP}px)`, top: "50%", transform: "translateY(-50%)" }; case "top": default: return { bottom: `calc(100% + ${GAP}px)`, left: "50%", transform: "translateX(-50%)" }; } } export function Tooltip({ content, children, placement = "top", delay = 300, style }: TooltipProps) { const { C } = useTheme(); const id = useId(); const [open, setOpen] = useState(false); const timer = useRef | null>(null); const clear = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } }; const openSoon = () => { clear(); timer.current = setTimeout(() => setOpen(true), delay); }; const hide = () => { clear(); setOpen(false); }; return ( { if (e.key === "Escape") hide(); }} > {children} {open && ( {content} )} ); }