diff --git a/packages/headless/package.json b/packages/headless/package.json index 8080f593513..3c3e24d0d99 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -12,6 +12,10 @@ "import": "./dist/primitives/tabs/index.js", "types": "./dist/primitives/tabs/index.d.ts" }, + "./tooltip": { + "import": "./dist/primitives/tooltip/index.js", + "types": "./dist/primitives/tooltip/index.d.ts" + }, "./dialog": { "import": "./dist/primitives/dialog/index.js", "types": "./dist/primitives/dialog/index.d.ts" diff --git a/packages/headless/src/primitives/tooltip/README.md b/packages/headless/src/primitives/tooltip/README.md new file mode 100644 index 00000000000..41cca266d07 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/README.md @@ -0,0 +1,115 @@ +# Tooltip + +A floating label that appears on hover or focus. Non-interactive, used for supplementary descriptions. No focus trapping — tooltips never receive focus. + +## When to Use + +- Describing icon buttons, truncated text, or any element that benefits from a short label. +- When the content is display-only (no interactive elements inside). +- Prefer Tooltip over Popover when the content is a simple text label that should appear on hover/focus and disappear immediately. + +## Usage + +```tsx +import { Tooltip } from '@/primitives/tooltip'; + + + + + + + + Settings + + + +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + {/* ... */} +; +``` + +### Custom Delay + +```tsx + + {/* Opens after 500ms hover, closes 100ms after leaving */} + +``` + +## Parts + +| Part | Default Element | Description | +| -------------------- | --------------- | ------------------------------------------------ | +| `Tooltip` | — | Root context provider | +| `Tooltip.Trigger` | `` | Element that triggers the tooltip on hover/focus | +| `Tooltip.Portal` | — | Portals children (accepts `root` prop) | +| `Tooltip.Positioner` | `` | Floating positioned container | +| `Tooltip.Popup` | `` | The visible tooltip content | +| `Tooltip.Arrow` | `` | Optional arrow pointing at the trigger | + +## Props + +### `Tooltip` (root) + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ------- | ------------------------------------ | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"top"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and tooltip (px) | +| `delay` | `number` | `200` | Hover open delay (ms) | +| `closeDelay` | `number` | `0` | Hover close delay (ms) | + +### `Tooltip.Trigger`, `Tooltip.Positioner`, `Tooltip.Popup` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Tooltip.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Open/Close Behavior + +- **Hover**: Opens after `delay` ms, closes after `closeDelay` ms. +- **Focus**: Opens on keyboard focus (`:focus-visible`), closes on blur. +- **Dismiss**: Closes on Escape key. +- **No click handling** — tooltips are triggered by hover and focus only. + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"tooltip-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Open state | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Positioning + +Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. Repositions automatically on scroll/resize via `autoUpdate`. + +## Important Notes + +- **No `FloatingFocusManager`** — tooltips do not receive or trap focus. This is correct per ARIA guidelines. +- **Nested tooltips are supported** via `FloatingTree`. +- **`Tooltip.Trigger` wraps its child** — if your trigger is already a button, the `render` prop can forward props to it instead of wrapping. +- **For tooltip clusters** (e.g. toolbar buttons), consider adding `FloatingDelayGroup` support for instant switching between tooltips. + +## ARIA + +- Popup: `role="tooltip"` +- Trigger: `aria-describedby` (pointing to the tooltip) diff --git a/packages/headless/src/primitives/tooltip/index.ts b/packages/headless/src/primitives/tooltip/index.ts new file mode 100644 index 00000000000..3c1ebcf36e6 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/index.ts @@ -0,0 +1,10 @@ +export type { + TooltipArrowProps, + TooltipGroupProps, + TooltipPopupProps, + TooltipPortalProps, + TooltipPositionerProps, + TooltipProps, + TooltipTriggerProps, +} from './tooltip'; +export { Tooltip } from './tooltip'; diff --git a/packages/headless/src/primitives/tooltip/tooltip.test.tsx b/packages/headless/src/primitives/tooltip/tooltip.test.tsx new file mode 100644 index 00000000000..bbc344768b5 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip.test.tsx @@ -0,0 +1,309 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { axe } from '../../test-utils/axe'; +import { Tooltip } from './tooltip'; + +afterEach(() => cleanup()); + +function renderTooltip(props: Partial> = {}) { + return render( + + Hover me + + Tooltip content + + , + ); +} + +describe('Tooltip', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderTooltip(); + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-slot', 'tooltip-trigger'); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderTooltip({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens on hover', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + + it('closes on unhover', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + + await user.unhover(trigger); + + const triggerEl = screen.getByRole('button', { name: 'Hover me' }); + expect(triggerEl).toHaveAttribute('data-cl-closed', ''); + }); + + it('opens on focus', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await act(async () => { + trigger.focus(); + }); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderTooltip({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderTooltip({ onOpenChange }); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderTooltip({ open: true }); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderTooltip({ open: false }); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('trigger has tooltip role association', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + // aria-describedby must reference the tooltip positioner's id + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toBeInTheDocument(); + const positionerId = positioner!.getAttribute('id'); + expect(positionerId).toBeTruthy(); + expect(trigger.getAttribute('aria-describedby')).toBe(positionerId); + }); + + it('tooltip content has role=tooltip', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('focus stays on trigger when tooltip is open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + // Tooltip must not steal focus — active element should not be inside the tooltip + const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); + expect(popup).not.toContainElement(document.activeElement as HTMLElement); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderTooltip(); + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('placement', () => { + it('accepts custom placement', () => { + renderTooltip({ defaultOpen: true, placement: 'bottom-end' }); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + }); + + it('defaults to top placement', () => { + renderTooltip({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'top'); + }); + }); + + describe('trigger state attributes', () => { + it('trigger has data-cl-open when tooltip is visible', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + }); + + it('trigger has data-cl-closed when tooltip is hidden', () => { + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + }); + + describe('content rendering', () => { + it('renders children content when open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(screen.getByText('Tooltip content')).toBeInTheDocument(); + }); + + it('does not render content when closed', () => { + renderTooltip(); + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument(); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderTooltip(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + renderTooltip({ defaultOpen: true }); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); + + describe('Tooltip.Group', () => { + function renderTooltipGroup() { + return render( + + + Button A + + Tooltip A + + + + Button B + + Tooltip B + + + , + ); + } + + it('renders grouped tooltips', async () => { + renderTooltipGroup(); + + expect(screen.getByRole('button', { name: 'Button A' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Button B' })).toBeInTheDocument(); + }); + + it('opens tooltip within a group on hover', async () => { + const user = userEvent.setup(); + renderTooltipGroup(); + + const triggerA = screen.getByRole('button', { name: 'Button A' }); + await user.hover(triggerA); + + await waitFor(() => { + expect(screen.getByText('Tooltip A')).toBeInTheDocument(); + }); + }); + + it('switches between grouped tooltips without full delay', async () => { + const user = userEvent.setup(); + renderTooltipGroup(); + + const triggerA = screen.getByRole('button', { name: 'Button A' }); + const triggerB = screen.getByRole('button', { name: 'Button B' }); + + // Open first tooltip (wait for it to appear) + await user.hover(triggerA); + await waitFor(() => { + expect(screen.getByText('Tooltip A')).toBeInTheDocument(); + }); + + // Move to second tooltip — group instant phase should show it quickly + await user.unhover(triggerA); + await user.hover(triggerB); + + await waitFor(() => { + expect(screen.getByText('Tooltip B')).toBeInTheDocument(); + }); + + // First tooltip should no longer be visible + expect(screen.queryByText('Tooltip A')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/headless/src/primitives/tooltip/tooltip.tsx b/packages/headless/src/primitives/tooltip/tooltip.tsx new file mode 100644 index 00000000000..c3af6ad2961 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { + arrow, + autoUpdate, + type ExtendedRefs, + FloatingArrow, + type FloatingContext, + FloatingDelayGroup, + FloatingNode, + FloatingPortal, + FloatingTree, + flip, + offset, + type Placement, + type ReferenceType, + shift, + type UseInteractionsReturn, + useDelayGroup, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { type CSSProperties, createContext, type ReactNode, useContext, useMemo, useRef } from 'react'; +import { useControllableState } from '../../hooks/use-controllable-state'; +import { type TransitionProps, useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface TooltipContextValue { + open: boolean; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + mounted: boolean; + transitionProps: TransitionProps; +} + +const TooltipContext = createContext(null); + +function useTooltipContext() { + const ctx = useContext(TooltipContext); + if (!ctx) { + throw new Error('Tooltip compound components must be used within '); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// Tooltip (root) +// --------------------------------------------------------------------------- + +export interface TooltipProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + /** Delay in ms before the tooltip opens on hover. Default: 200 */ + delay?: number; + /** Delay in ms before the tooltip closes on hover out. Default: 0 */ + closeDelay?: number; + children: ReactNode; +} + +function TooltipInner(props: TooltipProps) { + const nodeId = useFloatingNodeId(); + + const { placement: placementProp = 'top', sideOffset = 4, delay = 200, closeDelay = 0, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ + crossAxis: placementProp.includes('-'), + fallbackAxisSideDirection: 'start', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + // Integrate with FloatingDelayGroup when inside a Tooltip.Group. + // Safe to call unconditionally — no-op without a parent provider. + useDelayGroup(floatingContext, { id: nodeId }); + + const hover = useHover(floatingContext, { + move: false, + delay: { open: delay, close: closeDelay }, + }); + const focus = useFocus(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext, { role: 'tooltip' }); + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + popupRef, + arrowRef, + mounted, + transitionProps, + }), + [ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +function TooltipRoot(props: TooltipProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} + +// --------------------------------------------------------------------------- +// Tooltip.Trigger +// --------------------------------------------------------------------------- + +export interface TooltipTriggerProps extends ComponentProps<'button'> {} + +function TooltipTrigger(props: TooltipTriggerProps) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useTooltipContext(); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'tooltip-trigger', + ref: refs.setReference, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Tooltip.Portal +// --------------------------------------------------------------------------- + +export interface TooltipPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +function TooltipPortal(props: TooltipPortalProps) { + const { mounted } = useTooltipContext(); + if (!mounted) return null; + return {props.children}; +} + +// --------------------------------------------------------------------------- +// Tooltip.Positioner +// --------------------------------------------------------------------------- + +export interface TooltipPositionerProps extends ComponentProps<'div'> {} + +function TooltipPositioner(props: TooltipPositionerProps) { + const { render, ...otherProps } = props; + const { mounted, refs, floatingStyles, placement, getFloatingProps } = useTooltipContext(); + + const side = placement.split('-')[0]; + + const defaultProps = { + 'data-cl-slot': 'tooltip-positioner', + 'data-cl-side': side, + ref: refs.setFloating, + style: floatingStyles, + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + }; + + return renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Tooltip.Popup +// --------------------------------------------------------------------------- + +export interface TooltipPopupProps extends ComponentProps<'div'> {} + +function TooltipPopup(props: TooltipPopupProps) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useTooltipContext(); + + const defaultProps = { + 'data-cl-slot': 'tooltip-popup', + ref: popupRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Tooltip.Arrow +// --------------------------------------------------------------------------- + +export interface TooltipArrowProps extends React.ComponentPropsWithRef {} + +function TooltipArrowComponent(props: TooltipArrowProps) { + const { floatingContext, arrowRef, placement } = useTooltipContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Tooltip.Group +// --------------------------------------------------------------------------- + +export interface TooltipGroupProps { + /** Shared delay config for grouped tooltips. Default: { open: 200, close: 100 } */ + delay?: number | { open?: number; close?: number }; + /** Time in ms before the group resets to non-instant phase. Default: 300 */ + timeoutMs?: number; + children: ReactNode; +} + +function TooltipGroupRoot(props: TooltipGroupProps) { + const { delay = { open: 200, close: 100 }, timeoutMs = 300, children } = props; + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Compound export +// --------------------------------------------------------------------------- + +export const Tooltip = Object.assign(TooltipRoot, { + Trigger: TooltipTrigger, + Portal: TooltipPortal, + Positioner: TooltipPositioner, + Popup: TooltipPopup, + Arrow: TooltipArrowComponent, + Group: TooltipGroupRoot, +}); diff --git a/packages/headless/src/utils/css-vars.test.ts b/packages/headless/src/utils/css-vars.test.ts new file mode 100644 index 00000000000..f15d2e247b0 --- /dev/null +++ b/packages/headless/src/utils/css-vars.test.ts @@ -0,0 +1,269 @@ +import type { MiddlewareState } from '@floating-ui/react'; +import { describe, expect, it, vi } from 'vitest'; +import { cssVars } from './css-vars'; + +// Build a minimal MiddlewareState mock for testing the middleware fn +function createMockState( + overrides: { + placement?: string; + referenceWidth?: number; + referenceHeight?: number; + floatingWidth?: number; + floatingHeight?: number; + arrowX?: number; + arrowY?: number; + arrowElWidth?: number; + arrowElHeight?: number; + overflow?: { top: number; right: number; bottom: number; left: number }; + } = {}, +): MiddlewareState { + const { + placement = 'bottom', + referenceWidth = 100, + referenceHeight = 40, + floatingWidth = 200, + floatingHeight = 150, + arrowX, + arrowY, + arrowElWidth = 0, + arrowElHeight = 0, + overflow = { top: 0, right: 0, bottom: 0, left: 0 }, + } = overrides; + + const style = { + setProperty: vi.fn(), + }; + + // Mock arrow element inside floating + const arrowEl = arrowElWidth || arrowElHeight ? { clientWidth: arrowElWidth, clientHeight: arrowElHeight } : null; + + const floating = { + style, + querySelector: vi.fn(() => arrowEl), + } as unknown as HTMLElement; + + return { + placement, + elements: { + floating, + reference: document.createElement('div'), + }, + rects: { + reference: { width: referenceWidth, height: referenceHeight, x: 0, y: 0 }, + floating: { width: floatingWidth, height: floatingHeight, x: 0, y: 0 }, + }, + middlewareData: { + arrow: arrowX != null || arrowY != null ? { x: arrowX ?? 0, y: arrowY ?? 0, centerOffset: 0 } : {}, + }, + platform: { + getElementRects: vi.fn(), + getDimensions: vi.fn(), + getClippingRect: vi.fn(async () => ({ + width: 1024, + height: 768, + x: 0, + y: 0, + })), + convertOffsetParentRelativeRectToViewportRelativeRect: vi.fn(async ({ rect }: { rect: unknown }) => rect), + }, + x: 0, + y: 0, + initialPlacement: placement, + strategy: 'absolute', + } as unknown as MiddlewareState; +} + +// Helper to extract setProperty calls into a map +function getVars(state: MiddlewareState): Map { + const style = (state.elements.floating as HTMLElement).style; + const calls = (style.setProperty as ReturnType).mock.calls as [string, string][]; + return new Map(calls.map(([name, value]) => [name, value])); +} + +describe('cssVars middleware', () => { + it("has name 'cssVars'", () => { + const mw = cssVars(); + expect(mw.name).toBe('cssVars'); + }); + + describe('--cl-anchor-width and --cl-anchor-height', () => { + it('sets anchor dimensions from reference rects', async () => { + const mw = cssVars(); + const state = createMockState({ + referenceWidth: 120, + referenceHeight: 36, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-width')).toBe('120px'); + expect(vars.get('--cl-anchor-height')).toBe('36px'); + }); + + it('handles zero-size reference', async () => { + const mw = cssVars(); + const state = createMockState({ + referenceWidth: 0, + referenceHeight: 0, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-width')).toBe('0px'); + expect(vars.get('--cl-anchor-height')).toBe('0px'); + }); + }); + + describe('--cl-available-width and --cl-available-height', () => { + it('sets available dimensions as CSS vars', async () => { + const mw = cssVars(); + const state = createMockState({ + floatingWidth: 200, + floatingHeight: 150, + }); + await mw.fn(state); + + const vars = getVars(state); + // Values are set (exact numbers depend on detectOverflow's padding) + expect(vars.has('--cl-available-width')).toBe(true); + expect(vars.has('--cl-available-height')).toBe(true); + expect(vars.get('--cl-available-width')).toMatch(/^\d+px$/); + expect(vars.get('--cl-available-height')).toMatch(/^\d+px$/); + }); + }); + + describe('--cl-transform-origin', () => { + it('centers on anchor when no arrow (bottom)', async () => { + const mw = cssVars({ sideOffset: 8 }); + // Reference: x=0, width=100 → center at 50. Floating: x=0. + // transformX = 0 + 100/2 - 0 = 50 + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px -8px'); + }); + + it('centers on anchor when no arrow (top)', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'top', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px calc(100% + 4px)'); + }); + + it('centers on anchor when no arrow (left)', async () => { + const mw = cssVars({ sideOffset: 6 }); + const state = createMockState({ + placement: 'left', + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformY = 0 + 40/2 - 0 = 20 + expect(vars.get('--cl-transform-origin')).toBe('calc(100% + 6px) 20px'); + }); + + it('centers on anchor when no arrow (right)', async () => { + const mw = cssVars({ sideOffset: 6 }); + const state = createMockState({ + placement: 'right', + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('-6px 20px'); + }); + + it('handles alignment variants (e.g. bottom-start)', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom-start', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + // Side is still "bottom", centers on anchor + expect(vars.get('--cl-transform-origin')).toBe('50px -4px'); + }); + + it('uses arrow position when arrow is present', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom', + arrowX: 50, + arrowElWidth: 12, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformX = arrowX + arrowWidth/2 = 50 + 6 = 56 + expect(vars.get('--cl-transform-origin')).toBe('56px -4px'); + }); + + it('uses arrow Y position on left/right placement', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'right', + arrowY: 30, + arrowElHeight: 10, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformY = arrowY + arrowHeight/2 = 30 + 5 = 35 + expect(vars.get('--cl-transform-origin')).toBe('-4px 35px'); + }); + + it('defaults sideOffset to 0 when not provided', async () => { + const mw = cssVars(); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px 0px'); + }); + }); + + describe('return value', () => { + it('returns empty object (no position changes)', async () => { + const mw = cssVars(); + const state = createMockState(); + const result = await mw.fn(state); + expect(result).toEqual({}); + }); + }); + + describe('all five CSS vars are set', () => { + it('sets exactly 5 CSS custom properties', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ placement: 'bottom' }); + await mw.fn(state); + + const style = state.elements.floating.style; + const calls = (style.setProperty as ReturnType).mock.calls as [string, string][]; + const varNames = calls.map(([name]) => name); + + expect(varNames).toEqual([ + '--cl-anchor-width', + '--cl-anchor-height', + '--cl-available-width', + '--cl-available-height', + '--cl-transform-origin', + ]); + }); + }); +}); diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts new file mode 100644 index 00000000000..1ea73a26801 --- /dev/null +++ b/packages/headless/src/utils/css-vars.ts @@ -0,0 +1,76 @@ +import { detectOverflow, type Middleware } from '@floating-ui/react'; + +/** + * Positioning middleware that sets CSS custom properties on the floating element: + * + * - `--cl-anchor-width` – reference element width (px) + * - `--cl-anchor-height` – reference element height (px) + * - `--cl-available-width` – available width between anchor and viewport edge (px) + * - `--cl-available-height` – available height between anchor and viewport edge (px) + * - `--cl-transform-origin` – CSS transform-origin pointing back toward the anchor + * + * Place **after** `arrow()` so arrow position data is available for transform-origin. + */ +export function cssVars(opts?: { sideOffset?: number }): Middleware { + return { + name: 'cssVars', + async fn(state) { + const { elements, rects, middlewareData, placement } = state; + const style = elements.floating.style; + const sideOffset = opts?.sideOffset ?? 0; + + // Anchor dimensions + style.setProperty('--cl-anchor-width', `${rects.reference.width}px`); + style.setProperty('--cl-anchor-height', `${rects.reference.height}px`); + + // Available space + const overflow = await detectOverflow(state, { padding: 5 }); + const side = placement.split('-')[0] as 'top' | 'bottom' | 'left' | 'right'; + + const availableHeight = + side === 'top' + ? rects.floating.height - overflow.top + : side === 'bottom' + ? rects.floating.height - overflow.bottom + : rects.floating.height - Math.max(overflow.top, 0) - Math.max(overflow.bottom, 0); + + const availableWidth = + side === 'left' + ? rects.floating.width - overflow.left + : side === 'right' + ? rects.floating.width - overflow.right + : rects.floating.width - Math.max(overflow.left, 0) - Math.max(overflow.right, 0); + + style.setProperty('--cl-available-width', `${availableWidth}px`); + style.setProperty('--cl-available-height', `${availableHeight}px`); + + // Transform origin — points back toward the anchor + const arrowEl = elements.floating.querySelector("[data-cl-slot$='-arrow']") as HTMLElement | null; + + let transformX: number; + let transformY: number; + + if (arrowEl) { + const arrowX = middlewareData.arrow?.x ?? 0; + const arrowY = middlewareData.arrow?.y ?? 0; + transformX = arrowX + arrowEl.clientWidth / 2; + transformY = arrowY + arrowEl.clientHeight / 2; + } else { + // No arrow — use the anchor's center relative to the floating element + transformX = rects.reference.x + rects.reference.width / 2 - state.x; + transformY = rects.reference.y + rects.reference.height / 2 - state.y; + } + + const originMap: Record = { + top: `${transformX}px calc(100% + ${sideOffset}px)`, + bottom: `${transformX}px ${-sideOffset}px`, + left: `calc(100% + ${sideOffset}px) ${transformY}px`, + right: `${-sideOffset}px ${transformY}px`, + }; + + style.setProperty('--cl-transform-origin', originMap[side]); + + return {}; + }, + }; +} diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index 53fd01599c8..ebfd4caefaa 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1 +1,2 @@ +export { cssVars } from './css-vars'; export { type ComponentProps, mergeProps, type RenderProp, renderElement } from './render-element'; diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 613b6ea8e6a..90cd1c95a41 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ entry: { 'primitives/accordion/index': 'src/primitives/accordion/index.ts', 'primitives/tabs/index': 'src/primitives/tabs/index.ts', + 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'utils/index': 'src/utils/index.ts', 'hooks/use-controllable-state': 'src/hooks/use-controllable-state.ts',