From a28a30df2db14a17157f077e06413f9881c15bb9 Mon Sep 17 00:00:00 2001 From: Poafs1 Date: Wed, 25 Feb 2026 18:29:45 +0700 Subject: [PATCH] feat(react): add Tooltip and TooltipProvider components Headless, accessible Tooltip powered by @floating-ui/react with: - Props-driven flat API (content, placement, showArrow, etc.) - 12 placements with collision-aware flip/shift - Controlled and uncontrolled modes - WCAG 2.1 hoverable content via safePolygon - Keyboard support (focus open, Escape close) - Optional arrow, follow-cursor, portal rendering - Skip-delay grouping via TooltipProvider - CSS exit animations via data-state + useExitTransition - --ck-tooltip-transform-origin CSS custom property - forceMount for animation library integration - 36 tests covering rendering, interactions, a11y, and edge cases Co-Authored-By: Claude Opus 4.6 --- libs/react/src/components/index.ts | 1 + libs/react/src/components/tooltip/index.ts | 2 + .../src/components/tooltip/tooltip.test.tsx | 650 ++++++++++++++++++ libs/react/src/components/tooltip/tooltip.tsx | 516 ++++++++++++++ .../src/components/tooltip/use-tooltip.ts | 335 +++++++++ 5 files changed, 1504 insertions(+) create mode 100644 libs/react/src/components/tooltip/index.ts create mode 100644 libs/react/src/components/tooltip/tooltip.test.tsx create mode 100644 libs/react/src/components/tooltip/tooltip.tsx create mode 100644 libs/react/src/components/tooltip/use-tooltip.ts diff --git a/libs/react/src/components/index.ts b/libs/react/src/components/index.ts index e31f8a5..c64e00f 100644 --- a/libs/react/src/components/index.ts +++ b/libs/react/src/components/index.ts @@ -20,3 +20,4 @@ export * from "./tabs"; export * from "./text"; export * from "./textarea"; export * from "./toast"; +export * from "./tooltip"; diff --git a/libs/react/src/components/tooltip/index.ts b/libs/react/src/components/tooltip/index.ts new file mode 100644 index 0000000..51bcfcc --- /dev/null +++ b/libs/react/src/components/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from "./tooltip"; +export * from "./use-tooltip"; diff --git a/libs/react/src/components/tooltip/tooltip.test.tsx b/libs/react/src/components/tooltip/tooltip.test.tsx new file mode 100644 index 0000000..124c2ba --- /dev/null +++ b/libs/react/src/components/tooltip/tooltip.test.tsx @@ -0,0 +1,650 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Tooltip, TooltipProvider } from "./tooltip"; + +describe("Tooltip Component", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("Basic Rendering", () => { + it("renders the trigger child", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Trigger" })).toBeInTheDocument(); + }); + + it("does not render tooltip content when closed", () => { + render( + + + , + ); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("renders tooltip content when defaultOpen is true", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + expect(screen.getByRole("tooltip")).toHaveTextContent("Tooltip text"); + }); + + it("renders with data-ck='tooltip' attribute", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toBeInTheDocument(); + }); + + it("renders with data-variant attribute", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveAttribute("data-variant", "primary"); + }); + + it("sets data-state='open' when open", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveAttribute("data-state", "open"); + }); + + it("has correct displayName", () => { + expect( + (Tooltip as unknown as { displayName?: string }).displayName, + ).toBe("Tooltip"); + }); + }); + + describe("Trigger Interaction", () => { + it("opens on mouse hover after delay", async () => { + const { baseElement } = render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.hover(trigger); + vi.advanceTimersByTime(200); + }); + + await waitFor(() => { + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveAttribute("data-state", "open"); + }); + }); + + it("closes on mouse leave after delay", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.unhover(trigger); + }); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + await waitFor(() => { + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + it("opens on keyboard focus", async () => { + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + trigger.focus(); + vi.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + }); + }); + + it("closes on Escape key", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + + await act(async () => { + await userEvent.keyboard("{Escape}"); + }); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + it("does not open when disabled", async () => { + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.hover(trigger); + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + describe("Controlled Mode", () => { + it("opens when open prop is true", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + }); + + it("stays closed when open prop is false", async () => { + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.hover(trigger); + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("calls onOpenChange when hover triggers open", async () => { + const onOpenChange = vi.fn(); + + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.hover(trigger); + vi.advanceTimersByTime(100); + }); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe("Content", () => { + it("renders string content", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("tooltip")).toHaveTextContent("Simple text"); + }); + + it("renders ReactNode content", async () => { + render( + + Bold content + + } + defaultOpen + portal={false} + > + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByTestId("rich-content")).toBeInTheDocument(); + expect(screen.getByText("Bold")).toBeInTheDocument(); + }); + + it("renders arrow when showArrow is true", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const arrow = baseElement.querySelector('[data-ck="tooltip-arrow"]'); + expect(arrow).toBeInTheDocument(); + }); + + it("does not render arrow when showArrow is false", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const arrow = baseElement.querySelector('[data-ck="tooltip-arrow"]'); + expect(arrow).not.toBeInTheDocument(); + }); + }); + + describe("Placement & Data Attributes", () => { + it("sets data-side attribute based on resolved placement", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveAttribute("data-side", "top"); + }); + + it("sets data-align attribute based on resolved alignment", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveAttribute("data-align", "start"); + }); + + it("defaults data-align to 'center' for simple placements", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveAttribute("data-align", "center"); + }); + + it("sets --ck-tooltip-transform-origin CSS custom property", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveStyle({ + "--ck-tooltip-transform-origin": "bottom center", + }); + }); + }); + + describe("Portal", () => { + it("renders inline when portal is false", async () => { + const { container } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = container.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("tooltip content has role='tooltip'", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + }); + + it("trigger has aria-describedby when tooltip is open", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + // useRole from @floating-ui/react manages aria-describedby + // with its own internal ID linking trigger to tooltip + expect(trigger).toHaveAttribute("aria-describedby"); + }); + + it("uses auto-generated id when not provided", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveAttribute("id"); + expect(tooltip.id).toMatch(/^ck-tooltip-/); + }); + + it("uses provided id", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveAttribute("id", "my-tooltip"); + }); + }); + + describe("forceMount", () => { + it("keeps content mounted when closed (forceMount=true)", async () => { + const { baseElement } = render( + + + , + ); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveAttribute("data-state", "closed"); + }); + + it("applies visibility:hidden when closed and forceMount", () => { + const { baseElement } = render( + + + , + ); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveStyle({ visibility: "hidden" }); + }); + }); + + describe("Ref Forwarding", () => { + it("forwards ref to the tooltip content element", async () => { + const ref = React.createRef(); + + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current?.getAttribute("data-ck")).toBe("tooltip"); + }); + }); + + describe("className", () => { + it("applies className to the tooltip content", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveClass("custom-tooltip"); + }); + }); + + describe("maxWidth", () => { + it("applies maxWidth as number (px)", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveStyle({ maxWidth: "200px" }); + }); + + it("applies maxWidth as string", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const tooltip = baseElement.querySelector('[data-ck="tooltip"]'); + expect(tooltip).toHaveStyle({ maxWidth: "50vw" }); + }); + }); +}); + +describe("TooltipProvider", () => { + it("renders children", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Child" })).toBeInTheDocument(); + }); + + it("has correct displayName", () => { + expect( + (TooltipProvider as unknown as { displayName?: string }).displayName, + ).toBe("TooltipProvider"); + }); +}); diff --git a/libs/react/src/components/tooltip/tooltip.tsx b/libs/react/src/components/tooltip/tooltip.tsx new file mode 100644 index 0000000..37a86f6 --- /dev/null +++ b/libs/react/src/components/tooltip/tooltip.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { + FloatingArrow, + FloatingDelayGroup, + FloatingPortal, + type Placement, + type Strategy, +} from "@floating-ui/react"; +import { + cloneElement, + type CSSProperties, + forwardRef, + isValidElement, + type ReactElement, + type ReactNode, + useId, + useRef, +} from "react"; + +import type { VariantFor } from "../../types/register"; + +import { useExitTransition } from "../../hooks"; +import { mergeRefs } from "../../utils/merge-refs"; +import { useTooltip } from "./use-tooltip"; + +// --------------------------------------------------------------------------- +// Transform-origin map: placement → CSS transform-origin value +// --------------------------------------------------------------------------- + +const TRANSFORM_ORIGIN: Record = { + bottom: "top center", + "bottom-end": "top right", + "bottom-start": "top left", + left: "center right", + "left-end": "bottom right", + "left-start": "top right", + right: "center left", + "right-end": "bottom left", + "right-start": "top left", + top: "bottom center", + "top-end": "bottom right", + "top-start": "bottom left", +}; + +// --------------------------------------------------------------------------- +// TooltipProvider +// --------------------------------------------------------------------------- + +/** + * Configuration for the TooltipProvider component. + */ +export interface TooltipProviderProps { + children: ReactNode; + + /** + * Delay configuration for the group. + * When a tooltip was recently closed, the next one opens with this shorter delay. + * @default \{ open: 200, close: 300 \} + */ + delay?: number | { close?: number; open?: number }; + + /** + * How long the skip-delay effect lasts after the last tooltip closes (ms). + * @default 300 + */ + timeoutMs?: number; +} + +/** + * Provides skip-delay grouping for multiple Tooltip components. + * + * @description + * When users move quickly between tooltips inside a `TooltipProvider`, + * subsequent tooltips open with a shorter delay instead of the full open delay. + * This wraps `@floating-ui/react`'s `FloatingDelayGroup`. + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ +function TooltipProvider({ + children, + delay = { close: 300, open: 200 }, + timeoutMs = 300, +}: TooltipProviderProps) { + return ( + + {children} + + ); +} + +TooltipProvider.displayName = "TooltipProvider"; + +// --------------------------------------------------------------------------- +// Tooltip +// --------------------------------------------------------------------------- + +/** + * Props for the Tooltip component. + */ +export interface TooltipProps { + // --- Arrow --- + + /** + * Arrow height in px. + * @default 7 + */ + arrowHeight?: number; + + /** + * Arrow width in px. + * @default 14 + */ + arrowWidth?: number; + + // --- Content --- + + /** + * The trigger element. Must accept a ref. + */ + children: ReactElement; + + /** + * Additional className on the tooltip content element. + */ + className?: string; + + /** + * Close delay in ms. + * @default 300 + */ + closeDelay?: number; + + /** + * Tooltip content. Can be a string or ReactNode for rich content. + */ + content: ReactNode; + + /** + * Default open state for uncontrolled mode. + * @default false + */ + defaultOpen?: boolean; + + /** + * When true, uses `aria-label` instead of `aria-describedby` on the trigger. + * Use for icon-only buttons where the tooltip IS the accessible label. + * @default false + */ + describeChild?: boolean; + + /** + * Whether the tooltip is disabled (never opens). + * @default false + */ + disabled?: boolean; + + /** + * Exit animation duration in ms (fallback timeout for CSS animations). + * @default 150 + */ + exitDuration?: number; + + /** + * Enable focus trigger. + * @default true + */ + focus?: boolean; + + /** + * Follow cursor mode. + * - `false`: disabled + * - `true`: follows on both axes + * - `"x"`: follows horizontally only + * - `"y"`: follows vertically only + * @default false + */ + followCursor?: boolean | "x" | "y"; + + /** + * Keep content mounted when closed. Useful for animation libraries + * like Framer Motion that manage their own enter/exit animations. + * Content is rendered with `data-state="closed"` and `visibility: hidden`. + * @default false + */ + forceMount?: boolean; + + /** + * Enable hover trigger. + * @default true + */ + hover?: boolean; + + /** + * ID for the tooltip element. Auto-generated if not provided. + */ + id?: string; + + /** + * Whether tooltip content is hoverable (WCAG 2.1 SC 1.4.13). + * When true, users can hover over the tooltip without it closing. + * @default true + */ + interactive?: boolean; + + /** + * Max width as a CSS value. Prevents overly wide tooltips. + */ + maxWidth?: number | string; + + /** + * Offset distance from the trigger in px. + * @default 8 + */ + offset?: number; + + /** + * Callback when open state changes. Used with `open` for controlled mode. + */ + onOpenChange?: (open: boolean) => void; + + /** + * Controlled open state. Use with `onOpenChange`. + */ + open?: boolean; + + /** + * Open delay in ms. + * @default 700 + */ + openDelay?: number; + + /** + * Placement of the tooltip relative to the trigger. + * @default 'top' + */ + placement?: Placement; + + /** + * Whether to render the tooltip in a portal. Prevents overflow clipping. + * @default true + */ + portal?: boolean; + + /** + * Explicit portal root element. Defaults to `document.body`. + */ + portalRoot?: HTMLElement | null; + + /** + * Rest time in ms before opening. Provides a more natural feel + * than a fixed delay — the tooltip opens after the cursor rests. + * @default 0 (disabled) + */ + restMs?: number; + + /** + * Show a pointing arrow from the tooltip to the trigger. + * @default false + */ + showArrow?: boolean; + + /** + * CSS positioning strategy. + * @default 'absolute' + */ + strategy?: Strategy; + + /** + * Variant name for the `data-variant` attribute. + */ + variantName?: VariantFor<"tooltip">; + + /** + * Viewport padding in px for collision detection. + * @default 8 + */ + viewportPadding?: number; +} + +/** + * A headless, accessible Tooltip component. + * + * @description + * Provides a floating label that appears on hover/focus, powered by + * `@floating-ui/react` for positioning and interaction management. + * + * ## Features + * - Props-driven API — no compound components needed + * - 12 placement options with collision-aware flip/shift + * - Controlled and uncontrolled modes + * - Configurable open/close delays with skip-delay grouping + * - WCAG 2.1 hoverable content (safe polygon) + * - Optional pointing arrow + * - Follow cursor mode + * - Portal rendering (on by default) + * - CSS exit animations via `data-state` attribute + * - CSS custom property `--ck-tooltip-transform-origin` for scale animations + * + * ## Keyboard Support + * + * | Key | Action | + * | --- | --- | + * | `Tab` | Focus trigger → tooltip opens | + * | `Escape` | Close tooltip | + * + * ## Accessibility + * + * Follows the WAI-ARIA Tooltip pattern: + * - `role="tooltip"` on the tooltip element + * - `aria-describedby` on the trigger pointing to the tooltip `id` + * - Opens on keyboard focus (visible-only) for screen reader / keyboard users + * - Dismissible via Escape key + * - Content is hoverable by default (WCAG 2.1 SC 1.4.13) + * + * ## Data Attributes + * + * | Attribute | Values | Description | + * | --- | --- | --- | + * | `data-ck` | `"tooltip"` | Component identifier | + * | `data-state` | `"open"` \| `"closed"` | Animation state | + * | `data-side` | `"top"` \| `"bottom"` \| `"left"` \| `"right"` | Resolved side | + * | `data-align` | `"start"` \| `"center"` \| `"end"` | Resolved alignment | + * | `data-variant` | string | User-defined variant | + * + * @example + * ```tsx + * // Basic usage + * + * + * + * ``` + * + * @example + * ```tsx + * // With arrow and custom placement + * + * + * + * ``` + * + * @example + * ```tsx + * // Icon-only button (tooltip as label) + * + * + * + * ``` + * + * @example + * ```tsx + * // Skip-delay grouping + * + * + * + * + * ``` + */ +const Tooltip = forwardRef( + ( + { + arrowHeight = 7, + arrowWidth = 14, + children, + className, + closeDelay = 300, + content, + defaultOpen, + describeChild, + disabled, + exitDuration = 150, + focus, + followCursor, + forceMount = false, + hover, + id: externalId, + interactive, + maxWidth, + offset: offsetDistance, + onOpenChange, + open, + openDelay = 700, + placement, + portal = true, + portalRoot, + restMs, + showArrow, + strategy, + variantName, + viewportPadding, + }, + ref, + ) => { + const autoId = useId(); + const tooltipId = externalId ?? `ck-tooltip-${autoId}`; + const contentNodeRef = useRef(null); + + const tooltip = useTooltip({ + closeDelay, + defaultOpen, + describeChild, + disabled, + focus, + followCursor, + hover, + interactive, + offsetDistance, + onOpenChange, + open, + openDelay, + placement, + restMs, + showArrow, + strategy, + viewportPadding, + }); + + const { dataState, isMounted } = useExitTransition({ + animateOnMount: true, + duration: exitDuration, + isOpen: tooltip.isOpen, + nodeRef: contentNodeRef, + }); + + const [side, align = "center"] = tooltip.resolvedPlacement.split("-") as [ + string, + string | undefined, + ]; + + const shouldRender = forceMount || isMounted; + + // Clone the trigger child and merge interaction props + ref + const trigger = isValidElement(children) + ? cloneElement( + children as ReactElement>, + tooltip.getReferenceProps({ + ref: mergeRefs( + tooltip.referenceRef, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as any).ref, + ), + }), + ) + : children; + + const tooltipContent = shouldRender ? ( +
( + tooltip.floatingRef, + contentNodeRef, + ref, + )} + > + {content} + {showArrow && ( + + )} +
+ ) : null; + + return ( + <> + {trigger} + {portal ? ( + + {tooltipContent} + + ) : ( + tooltipContent + )} + + ); + }, +); + +Tooltip.displayName = "Tooltip"; + +export { Tooltip, TooltipProvider }; diff --git a/libs/react/src/components/tooltip/use-tooltip.ts b/libs/react/src/components/tooltip/use-tooltip.ts new file mode 100644 index 0000000..4ebf89c --- /dev/null +++ b/libs/react/src/components/tooltip/use-tooltip.ts @@ -0,0 +1,335 @@ +"use client"; + +import { + arrow, + autoUpdate, + flip, + hide, + offset, + type Placement, + shift, + type Strategy, + useClientPoint, + useDelayGroup, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole, +} from "@floating-ui/react"; +import { safePolygon } from "@floating-ui/react"; +import { useCallback, useMemo, useRef, useState } from "react"; + +/** + * Configuration options for the Tooltip positioning and interaction hook. + * + * @remarks + * This hook wraps `@floating-ui/react` directly (not via the generic `useFloating` wrapper) + * because tooltip interaction hooks (`useHover`, `useFocus`, `useDismiss`, `useRole`) + * require the full `FloatingContext` with `open`/`onOpenChange` wired in. + */ +export interface UseTooltipOptions { + /** + * Close delay in ms. + * @default 300 + */ + closeDelay?: number; + + /** + * Default open state for uncontrolled mode. + * @default false + */ + defaultOpen?: boolean; + + /** + * When true, uses `aria-label` on the trigger instead of `aria-describedby`. + * Useful for icon-only buttons where the tooltip IS the accessible label. + * @default false + */ + describeChild?: boolean; + + /** + * Whether the tooltip is disabled (never opens). + * @default false + */ + disabled?: boolean; + + /** + * Enable focus trigger. + * @default true + */ + focus?: boolean; + + /** + * Follow cursor mode. + * - `false`: disabled + * - `true`: follows on both axes + * - `"x"`: follows horizontally only + * - `"y"`: follows vertically only + * @default false + */ + followCursor?: boolean | "x" | "y"; + + /** + * Enable hover trigger. + * @default true + */ + hover?: boolean; + + /** + * Whether tooltip content is hoverable (WCAG 2.1 SC 1.4.13). + * When true, users can hover over the tooltip without it closing. + * @default true + */ + interactive?: boolean; + + /** + * Offset distance in pixels between trigger and tooltip. + * @default 8 + */ + offsetDistance?: number; + + /** + * Callback when open state changes. Used for controlled mode. + */ + onOpenChange?: (open: boolean) => void; + + /** + * Controlled open state. + */ + open?: boolean; + + /** + * Open delay in ms. + * @default 700 + */ + openDelay?: number; + + /** + * Placement of the tooltip relative to the trigger. + * @default 'top' + */ + placement?: Placement; + + /** + * Rest time in ms before opening. + * When set, the tooltip opens after the cursor rests for this duration + * instead of using the open delay. Provides a more natural feel. + * @default 0 (disabled) + */ + restMs?: number; + + /** + * Whether to show an arrow pointing from the tooltip to the trigger. + * @default false + */ + showArrow?: boolean; + + /** + * CSS positioning strategy. + * @default 'absolute' + */ + strategy?: Strategy; + + /** + * Padding from viewport edges in pixels. + * @default 8 + */ + viewportPadding?: number; +} + +/** + * Return type for the useTooltip hook. + */ +export interface UseTooltipReturn { + /** + * Ref for the arrow SVG element. Pass to `FloatingArrow`. + */ + arrowRef: React.RefObject; + + /** + * Floating UI context. Required by `FloatingArrow`. + */ + context: ReturnType["context"]; + + /** + * Ref setter for the floating (tooltip content) element. + */ + floatingRef: (node: HTMLElement | null) => void; + + /** + * Inline styles for the floating element. + */ + floatingStyles: React.CSSProperties; + + /** + * Returns props to spread on the floating (tooltip) element. + */ + getFloatingProps: ( + userProps?: React.HTMLProps, + ) => Record; + + /** + * Returns props to spread on the reference (trigger) element. + */ + getReferenceProps: ( + userProps?: React.HTMLProps, + ) => Record; + + /** + * Whether the tooltip is currently open. + */ + isOpen: boolean; + + /** + * Ref setter for the reference (trigger) element. + */ + referenceRef: (node: HTMLElement | null) => void; + + /** + * Resolved placement after middleware (may differ from requested placement after flip). + */ + resolvedPlacement: Placement; + + /** + * Set the open state programmatically. + */ + setIsOpen: (open: boolean) => void; +} + +/** + * Core hook for Tooltip positioning and interaction. + * + * @description + * Wraps `@floating-ui/react` with tooltip-specific interactions: + * - Hover trigger with configurable delay and WCAG safe polygon + * - Focus trigger for keyboard accessibility + * - Dismiss on Escape and ancestor scroll + * - Tooltip ARIA role with `aria-describedby` + * - Delay group support for skip-delay behavior + * - Follow cursor mode + * - Optional arrow positioning + * + * @remarks + * Uses `@floating-ui/react`'s `useFloating` directly because the interaction hooks + * require the full `FloatingContext` with `open`/`onOpenChange`. This follows the same + * pattern as `useFloatingSelect`. + * + * @see {@link UseTooltipOptions} for configuration + * @see {@link https://floating-ui.com/docs/tooltip | Floating UI Tooltip docs} + */ +export function useTooltip(options: UseTooltipOptions = {}): UseTooltipReturn { + const { + closeDelay = 300, + defaultOpen = false, + describeChild = false, + disabled = false, + focus: focusEnabled = true, + followCursor = false, + hover: hoverEnabled = true, + interactive = true, + offsetDistance = 8, + onOpenChange: controlledOnOpenChange, + open: controlledOpen, + openDelay = 700, + placement = "top", + restMs = 0, + showArrow = false, + strategy = "absolute", + viewportPadding = 8, + } = options; + + const isControlled = controlledOpen !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const setIsOpen = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + controlledOnOpenChange?.(nextOpen); + }, + [controlledOnOpenChange, isControlled], + ); + + const arrowRef = useRef(null); + + const middleware = useMemo( + () => [ + offset(offsetDistance), + flip({ fallbackAxisSideDirection: "start", padding: viewportPadding }), + shift({ padding: viewportPadding }), + ...(showArrow ? [arrow({ element: arrowRef })] : []), + hide({ strategy: "referenceHidden" }), + ], + [offsetDistance, showArrow, viewportPadding], + ); + + const floating = useFloating({ + middleware, + onOpenChange: disabled ? undefined : setIsOpen, + open: isOpen, + placement, + strategy, + whileElementsMounted: autoUpdate, + }); + + const { context } = floating; + + // Delay group: enables skip-delay when inside a TooltipProvider (FloatingDelayGroup). + // When no provider is present, this is a no-op and individual delays are used. + const { delay: groupDelay } = useDelayGroup(context); + const computedDelay = groupDelay ?? { close: closeDelay, open: openDelay }; + + const hoverInteraction = useHover(context, { + delay: computedDelay, + enabled: hoverEnabled && !disabled, + handleClose: interactive ? safePolygon() : null, + move: !followCursor, + restMs, + }); + + const focusInteraction = useFocus(context, { + enabled: focusEnabled && !disabled, + visibleOnly: true, + }); + + const dismissInteraction = useDismiss(context, { + ancestorScroll: true, + enabled: !disabled, + }); + + const roleInteraction = useRole(context, { + enabled: !disabled, + role: describeChild ? "label" : "tooltip", + }); + + const clientPointInteraction = useClientPoint(context, { + axis: + followCursor === "x" ? "x" : followCursor === "y" ? "y" : "both", + enabled: !!followCursor && !disabled, + }); + + const { getFloatingProps, getReferenceProps } = useInteractions([ + hoverInteraction, + focusInteraction, + dismissInteraction, + roleInteraction, + clientPointInteraction, + ]); + + return { + arrowRef, + context, + floatingRef: floating.refs.setFloating, + floatingStyles: floating.floatingStyles, + getFloatingProps, + getReferenceProps, + isOpen, + referenceRef: floating.refs.setReference, + resolvedPlacement: floating.placement, + setIsOpen, + }; +}