/** * Tooltip — small CSS-only hover popover. * * Used by KPI cards on Dashboard / Reports for the `?` info icon * that explains confusable metrics (Booked vs Collected Revenue, * Conversion Rate, Occupancy Rate, etc.). * * Why not the native HTML `title` attribute: native title has a * ~700ms delay, paints in OS-default style (gray, small, easy to * miss), and isn't focusable — keyboard users never see it. This * component shows instantly on pointer-enter AND on keyboard focus, * is themed to match the rest of the admin UI, and uses * `role="tooltip"` + `aria-describedby` so screen readers announce it. * * Implementation is intentionally dependency-free (no Radix / * @floating-ui) — we don't need collision detection / portals for a * help-icon hint that lives next to a known anchor. The trigger is * positioned `relative`, the popover absolutely, and we use * `pointer-events: none` so the tooltip never blocks a click on * something underneath. */ import React, { useId, useState } from "react"; interface TooltipProps { /** The text shown in the popover. Pass null/empty to hide entirely. */ content: React.ReactNode; /** The anchor — usually the `?` icon. Wrapped in a focusable span. */ children: React.ReactNode; /** Where the popover appears relative to the anchor. Default: "top". */ side?: "top" | "bottom" | "left" | "right"; /** Optional className applied to the anchor wrapper. */ className?: string; } export const Tooltip: React.FC = ({ content, children, side = "top", className = "", }) => { const id = useId(); const [open, setOpen] = useState(false); if (!content) { return {children}; } // Side-specific position classes. The popover is anchored to one // edge of the trigger; a 6px arrow sits on the opposite edge // (computed from `side`). We keep arrow + popover in the same // border + bg color so they read as one unit. const popoverPos: Record, string> = { top: "bottom-full left-1/2 -translate-x-1/2 mb-1.5", bottom: "top-full left-1/2 -translate-x-1/2 mt-1.5", left: "right-full top-1/2 -translate-y-1/2 mr-1.5", right: "left-full top-1/2 -translate-y-1/2 ml-1.5", }; const arrowPos: Record, string> = { top: "top-full left-1/2 -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100", bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-gray-900 dark:border-b-gray-100", left: "left-full top-1/2 -translate-y-1/2 border-l-gray-900 dark:border-l-gray-100", right: "right-full top-1/2 -translate-y-1/2 border-r-gray-900 dark:border-r-gray-100", }; const arrowStyle: Record, string> = { top: "border-x-transparent border-b-transparent", bottom: "border-x-transparent border-t-transparent", left: "border-y-transparent border-r-transparent", right: "border-y-transparent border-l-transparent", }; return ( setOpen(true)} onMouseLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }} > {/* Trigger wrapper. We make the inner button tabbable for keyboard access; sighted users still click/hover the same target. */} {children} {open && ( {content} {/* Arrow */} )} ); }; export default Tooltip;