diff --git a/packages/headless/README.md b/packages/headless/README.md new file mode 100644 index 00000000000..3037376ba40 --- /dev/null +++ b/packages/headless/README.md @@ -0,0 +1,64 @@ +# @clerk/headless + +Headless UI primitives for Clerk's component library. These are unstyled, accessible React components built on [Floating UI](https://floating-ui.com/) that handle positioning, keyboard navigation, focus management, and ARIA attributes. + +This package is **internal** (`private: true`) and consumed by `@clerk/ui`. It exists as a separate package because `@clerk/ui` uses `@emotion/react` as its JSX source, which conflicts with the standard `react-jsx` transform these primitives require. + +## Primitives + +| Primitive | Import | Description | +| ------------ | ------------------------------ | ------------------------------------------------------------- | +| Accordion | `@clerk/headless/accordion` | Expandable content sections with single/multiple mode | +| Autocomplete | `@clerk/headless/autocomplete` | Combobox input with filterable option list | +| Dialog | `@clerk/headless/dialog` | Modal dialog with focus trapping and scroll lock | +| Menu | `@clerk/headless/menu` | Dropdown and nested context menus with safe hover zones | +| Popover | `@clerk/headless/popover` | Non-modal floating content triggered by click | +| Select | `@clerk/headless/select` | Dropdown select with typeahead and keyboard navigation | +| Tabs | `@clerk/headless/tabs` | Tab navigation with animated indicator | +| Tooltip | `@clerk/headless/tooltip` | Hover/focus tooltip with configurable delay and group support | + +Shared utilities are available at `@clerk/headless/utils` (includes `renderElement` and `mergeProps`). + +Each primitive has its own README in `src/primitives//` with full API docs, props tables, keyboard navigation, and data attributes. + +## Usage + +```tsx +import { Select } from '@clerk/headless/select'; + +; +``` + +All primitives follow the same compound component pattern. They emit zero styles — all visual styling is applied externally via `data-cl-*` attribute selectors. + +## Architecture + +- **Compound components** via `Object.assign` — each primitive is a single export with dot-accessed parts (`Select.Trigger`, `Select.Popup`, etc.) +- **`renderElement`** — every part uses this instead of returning JSX directly, enabling consumer `render` prop overrides and automatic state-to-data-attribute mapping +- **`data-cl-*` attributes** — structural (`data-cl-slot`), state (`data-cl-open`, `data-cl-selected`, `data-cl-active`), and animation lifecycle (`data-cl-starting-style`, `data-cl-ending-style`) +- **CSS-driven animations** — the transition system uses `data-cl-*` attributes and the Web Animations API (`getAnimations().finished`) so all timing lives in CSS +- **Floating UI** — positioning, interactions, focus management, dismiss handling, list navigation, and ARIA are all delegated to `@floating-ui/react` + +## Development + +```sh +pnpm dev # watch mode build +pnpm build # production build +pnpm test # run tests (vitest + playwright browser mode) +``` + +Tests run in a real Chromium browser via `@vitest/browser-playwright`, not jsdom. diff --git a/packages/headless/package.json b/packages/headless/package.json new file mode 100644 index 00000000000..ab81efd956f --- /dev/null +++ b/packages/headless/package.json @@ -0,0 +1,75 @@ +{ + "name": "@clerk/headless", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./select": { + "import": "./dist/primitives/select/index.js", + "types": "./dist/primitives/select/index.d.ts" + }, + "./menu": { + "import": "./dist/primitives/menu/index.js", + "types": "./dist/primitives/menu/index.d.ts" + }, + "./dialog": { + "import": "./dist/primitives/dialog/index.js", + "types": "./dist/primitives/dialog/index.d.ts" + }, + "./popover": { + "import": "./dist/primitives/popover/index.js", + "types": "./dist/primitives/popover/index.d.ts" + }, + "./tooltip": { + "import": "./dist/primitives/tooltip/index.js", + "types": "./dist/primitives/tooltip/index.d.ts" + }, + "./autocomplete": { + "import": "./dist/primitives/autocomplete/index.js", + "types": "./dist/primitives/autocomplete/index.d.ts" + }, + "./tabs": { + "import": "./dist/primitives/tabs/index.js", + "types": "./dist/primitives/tabs/index.d.ts" + }, + "./accordion": { + "import": "./dist/primitives/accordion/index.js", + "types": "./dist/primitives/accordion/index.d.ts" + }, + "./utils": { + "import": "./dist/utils/index.js", + "types": "./dist/utils/index.d.ts" + } + }, + "scripts": { + "build": "rm -rf dist && vite build", + "dev": "vite build --watch", + "test": "vitest" + }, + "dependencies": { + "@floating-ui/react": "catalog:repo" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", + "@vitest/browser": "4.1.4", + "@vitest/browser-playwright": "4.1.4", + "axe-core": "^4.11.3", + "playwright": "^1.59.1", + "react": "catalog:react", + "react-dom": "catalog:react", + "typescript": "catalog:repo", + "vite": "6.4.1", + "vite-plugin-dts": "^4.5.4", + "vitest": "4.1.4", + "vitest-axe": "^0.1.0" + }, + "peerDependencies": { + "react": "catalog:peer-react", + "react-dom": "catalog:peer-react" + } +} diff --git a/packages/headless/src/hooks/use-animations-finished.test.ts b/packages/headless/src/hooks/use-animations-finished.test.ts new file mode 100644 index 00000000000..6056f38dfdc --- /dev/null +++ b/packages/headless/src/hooks/use-animations-finished.test.ts @@ -0,0 +1,165 @@ +import { act, renderHook } from '@testing-library/react'; +import { createRef, type RefObject } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useAnimationsFinished } from './use-animations-finished'; + +function createMockElement( + animations: Array<{ finished: Promise }> = [], + attributes: Record = {}, +): HTMLElement { + const el = document.createElement('div'); + Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v)); + el.getAnimations = vi.fn(() => animations as unknown as Animation[]); + return el; +} + +describe('useAnimationsFinished', () => { + it('fires callback immediately when ref.current is null', () => { + const ref = createRef() as RefObject; + const { result } = renderHook(() => useAnimationsFinished(ref, false)); + + const callback = vi.fn(); + act(() => result.current(callback)); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('fires callback immediately when getAnimations is not supported', () => { + const ref = { current: document.createElement('div') } as RefObject; + // Don't add getAnimations + delete (ref.current as unknown as Record).getAnimations; + + const { result } = renderHook(() => useAnimationsFinished(ref, false)); + + const callback = vi.fn(); + act(() => result.current(callback)); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('fires callback immediately when no animations are running', () => { + const el = createMockElement([]); + const ref = { current: el } as RefObject; + + const { result } = renderHook(() => useAnimationsFinished(ref, false)); + + const callback = vi.fn(); + act(() => result.current(callback)); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('waits for all animations to finish before firing callback', async () => { + let resolveAnim!: () => void; + const animPromise = new Promise(r => { + resolveAnim = r; + }); + const el = createMockElement([{ finished: animPromise }]); + const ref = { current: el } as RefObject; + + const { result } = renderHook(() => useAnimationsFinished(ref, false)); + + const callback = vi.fn(); + act(() => result.current(callback)); + + expect(callback).not.toHaveBeenCalled(); + + // After animations finish, change getAnimations to return empty + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + await act(async () => resolveAnim()); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('aborts previous pending wait when called again', async () => { + let resolveFirst!: () => void; + const firstAnim = new Promise(r => { + resolveFirst = r; + }); + const el = createMockElement([{ finished: firstAnim }]); + const ref = { current: el } as RefObject; + + const { result } = renderHook(() => useAnimationsFinished(ref, false)); + + const firstCallback = vi.fn(); + act(() => result.current(firstCallback)); + + // Call again — should abort the first + const secondCallback = vi.fn(); + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + act(() => result.current(secondCallback)); + + expect(secondCallback).toHaveBeenCalledTimes(1); + + // Resolve first animation — its callback should NOT fire + await act(async () => resolveFirst()); + expect(firstCallback).not.toHaveBeenCalled(); + }); + + it('re-checks animations when one is cancelled', async () => { + let rejectAnim!: () => void; + const cancelledAnim = new Promise((_, reject) => { + rejectAnim = reject; + }); + const el = createMockElement([{ finished: cancelledAnim }]); + const ref = { current: el } as RefObject; + + const { result } = renderHook(() => useAnimationsFinished(ref, false)); + + const callback = vi.fn(); + act(() => result.current(callback)); + + expect(callback).not.toHaveBeenCalled(); + + // Cancel the animation — hook should re-check and find no new animations + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + await act(async () => { + rejectAnim(); + // Let microtask queue flush + await new Promise(r => setTimeout(r, 0)); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('waits for starting-style attribute removal when open=true', async () => { + const el = createMockElement([], { 'data-cl-starting-style': '' }); + const ref = { current: el } as RefObject; + + const { result } = renderHook(() => useAnimationsFinished(ref, true)); + + const callback = vi.fn(); + act(() => result.current(callback)); + + // Should not fire yet — waiting for attribute removal + expect(callback).not.toHaveBeenCalled(); + + // Remove the attribute — MutationObserver should fire + await act(async () => { + el.removeAttribute('data-cl-starting-style'); + // MutationObserver is async; wait a tick + await new Promise(r => setTimeout(r, 0)); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cleans up on unmount', () => { + let resolveAnim!: () => void; + const animPromise = new Promise(r => { + resolveAnim = r; + }); + const el = createMockElement([{ finished: animPromise }]); + const ref = { current: el } as RefObject; + + const { result, unmount } = renderHook(() => useAnimationsFinished(ref, false)); + + const callback = vi.fn(); + act(() => result.current(callback)); + + // Unmount should abort + unmount(); + + // Resolve animation — callback should not fire because abort was called + resolveAnim(); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/headless/src/hooks/use-animations-finished.ts b/packages/headless/src/hooks/use-animations-finished.ts new file mode 100644 index 00000000000..2d89510ea83 --- /dev/null +++ b/packages/headless/src/hooks/use-animations-finished.ts @@ -0,0 +1,91 @@ +'use client'; + +import { type RefObject, useCallback, useEffect, useRef } from 'react'; +import { flushSync } from 'react-dom'; + +/** + * Returns a function that waits for all CSS animations/transitions on the + * referenced element to finish, then invokes a callback. + * + * Uses the Web Animations API (`element.getAnimations()` + `animation.finished`) + * so we're duration-agnostic — CSS owns all timing. + * + * When `open` is true, waits for `[data-cl-starting-style]` to be removed + * before polling animations. This avoids a race where `getAnimations()` returns + * an empty array before the enter transition has been registered. + * + * Each call aborts any pending wait from a previous call, so rapid open/close + * toggles don't leak stale callbacks. + */ +export function useAnimationsFinished(ref: RefObject, open: boolean) { + const abortRef = useRef(null); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + return useCallback( + (callback: () => void) => { + const element = ref.current; + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + + if (!element || typeof element.getAnimations !== 'function') { + callback(); + return; + } + + const runCheck = () => { + if (signal.aborted) return; + const animations = element.getAnimations(); + if (animations.length === 0) { + // Called synchronously (from useEffect or MutationObserver) — + // plain callback is fine, React will batch the state update. + callback(); + return; + } + Promise.all(animations.map(a => a.finished)) + .then(() => { + if (signal.aborted) return; + // Called from a microtask — flushSync forces synchronous unmount + // so there's no flash of the element in its final animated state. + flushSync(callback); + }) + .catch(() => { + if (signal.aborted) return; + // An animation was cancelled. If new animations are running, wait + // for those instead; otherwise we're done. + const current = element.getAnimations(); + if (current.length > 0) { + runCheck(); + } else { + flushSync(callback); + } + }); + }; + + if (open && element.hasAttribute('data-cl-starting-style')) { + const observer = new MutationObserver(() => { + if (!element.hasAttribute('data-cl-starting-style')) { + observer.disconnect(); + runCheck(); + } + }); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-cl-starting-style'], + }); + signal.addEventListener('abort', () => observer.disconnect()); + return; + } + + runCheck(); + }, + [ref, open], + ); +} diff --git a/packages/headless/src/hooks/use-controllable-state.test.ts b/packages/headless/src/hooks/use-controllable-state.test.ts new file mode 100644 index 00000000000..7f1cabe3455 --- /dev/null +++ b/packages/headless/src/hooks/use-controllable-state.test.ts @@ -0,0 +1,74 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useControllableState } from './use-controllable-state'; + +describe('useControllableState', () => { + describe('uncontrolled mode', () => { + it('uses defaultValue when controlled is undefined', () => { + const { result } = renderHook(() => useControllableState(undefined, 'default')); + expect(result.current[0]).toBe('default'); + }); + + it('updates internal state on setValue', () => { + const { result } = renderHook(() => useControllableState(undefined, 'initial')); + + act(() => result.current[1]('updated')); + + expect(result.current[0]).toBe('updated'); + }); + + it('calls onChange when value changes', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => useControllableState(undefined, 'initial', onChange)); + + act(() => result.current[1]('new')); + + expect(onChange).toHaveBeenCalledWith('new'); + }); + }); + + describe('controlled mode', () => { + it('uses controlled value over default', () => { + const { result } = renderHook(() => useControllableState('controlled', 'default')); + expect(result.current[0]).toBe('controlled'); + }); + + it('does not update internal state when controlled', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => useControllableState('controlled', 'default', onChange)); + + act(() => result.current[1]('new')); + + // Value stays controlled + expect(result.current[0]).toBe('controlled'); + // But onChange still fires + expect(onChange).toHaveBeenCalledWith('new'); + }); + + it('reflects new controlled value on rerender', () => { + const { result, rerender } = renderHook(({ controlled }) => useControllableState(controlled, 'default'), { + initialProps: { controlled: 'a' as string | undefined }, + }); + + expect(result.current[0]).toBe('a'); + + rerender({ controlled: 'b' }); + + expect(result.current[0]).toBe('b'); + }); + }); + + describe('switching modes', () => { + it('switches from uncontrolled to controlled', () => { + const { result, rerender } = renderHook(({ controlled }) => useControllableState(controlled, 'default'), { + initialProps: { controlled: undefined as string | undefined }, + }); + + expect(result.current[0]).toBe('default'); + + rerender({ controlled: 'now-controlled' }); + + expect(result.current[0]).toBe('now-controlled'); + }); + }); +}); diff --git a/packages/headless/src/hooks/use-controllable-state.ts b/packages/headless/src/hooks/use-controllable-state.ts new file mode 100644 index 00000000000..aa5f06ad6db --- /dev/null +++ b/packages/headless/src/hooks/use-controllable-state.ts @@ -0,0 +1,28 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +/** + * Manages a value that can be either controlled (externally owned) or + * uncontrolled (internally owned). When `controlled` is `undefined`, the + * hook stores the value internally; otherwise it defers to the caller. + * + * `onChange` is always called on updates regardless of mode. + */ +export function useControllableState( + controlled: T | undefined, + defaultValue: T, + onChange?: (value: T) => void, +): [T, (value: T) => void] { + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const value = controlled !== undefined ? controlled : uncontrolled; + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on `controlled !== undefined` to avoid re-creating when the controlled value changes + const setValue = useCallback( + (next: T) => { + if (controlled === undefined) setUncontrolled(next); + onChange?.(next); + }, + [controlled !== undefined, onChange], + ); + return [value, setValue]; +} diff --git a/packages/headless/src/hooks/use-transition-status.test.ts b/packages/headless/src/hooks/use-transition-status.test.ts new file mode 100644 index 00000000000..980b83d1775 --- /dev/null +++ b/packages/headless/src/hooks/use-transition-status.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTransitionStatus } from './use-transition-status'; + +describe('useTransitionStatus', () => { + let rafCallbacks: Array; + let originalRaf: typeof requestAnimationFrame; + let originalCaf: typeof cancelAnimationFrame; + + beforeEach(() => { + rafCallbacks = []; + originalRaf = globalThis.requestAnimationFrame; + originalCaf = globalThis.cancelAnimationFrame; + globalThis.requestAnimationFrame = vi.fn(cb => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + globalThis.cancelAnimationFrame = vi.fn(id => { + rafCallbacks[id - 1] = () => {}; + }); + }); + + afterEach(() => { + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCaf; + }); + + function flushRaf() { + const cbs = [...rafCallbacks]; + rafCallbacks = []; + cbs.forEach(cb => cb(performance.now())); + } + + it('returns mounted=false and status=undefined when open=false', () => { + const { result } = renderHook(() => useTransitionStatus(false)); + expect(result.current.mounted).toBe(false); + expect(result.current.transitionStatus).toBeUndefined(); + }); + + it("returns mounted=true and status='starting' when open=true", () => { + const { result } = renderHook(() => useTransitionStatus(true)); + expect(result.current.mounted).toBe(true); + expect(result.current.transitionStatus).toBe('starting'); + }); + + it('synchronously sets mounted and starting when open flips true', () => { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + initialProps: { open: false }, + }); + expect(result.current.mounted).toBe(false); + + rerender({ open: true }); + + // Both must be set in the same render — no intermediate frame + expect(result.current.mounted).toBe(true); + expect(result.current.transitionStatus).toBe('starting'); + }); + + it('clears starting status after one rAF', () => { + const { result } = renderHook(() => useTransitionStatus(true)); + expect(result.current.transitionStatus).toBe('starting'); + + act(() => flushRaf()); + + expect(result.current.transitionStatus).toBeUndefined(); + }); + + it('sets ending status synchronously when open flips false', () => { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + initialProps: { open: true }, + }); + act(() => flushRaf()); // clear starting + + rerender({ open: false }); + + expect(result.current.transitionStatus).toBe('ending'); + expect(result.current.mounted).toBe(true); + }); + + it('stays mounted until setMounted(false) is called', () => { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + initialProps: { open: true }, + }); + act(() => flushRaf()); + + rerender({ open: false }); + expect(result.current.mounted).toBe(true); + + act(() => result.current.setMounted(false)); + expect(result.current.mounted).toBe(false); + }); + + it('clears transitionStatus when unmounted', () => { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + initialProps: { open: true }, + }); + act(() => flushRaf()); + + rerender({ open: false }); + expect(result.current.transitionStatus).toBe('ending'); + + act(() => result.current.setMounted(false)); + expect(result.current.transitionStatus).toBeUndefined(); + }); + + it('handles rapid open→close before rAF fires', () => { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + initialProps: { open: false }, + }); + + // Open + rerender({ open: true }); + expect(result.current.mounted).toBe(true); + expect(result.current.transitionStatus).toBe('starting'); + + // Close before rAF clears starting + rerender({ open: false }); + expect(result.current.mounted).toBe(true); + expect(result.current.transitionStatus).toBe('ending'); + + // The rAF from opening should be cancelled, not interfere + act(() => flushRaf()); + expect(result.current.transitionStatus).toBe('ending'); + }); +}); diff --git a/packages/headless/src/hooks/use-transition-status.ts b/packages/headless/src/hooks/use-transition-status.ts new file mode 100644 index 00000000000..dbf696f7129 --- /dev/null +++ b/packages/headless/src/hooks/use-transition-status.ts @@ -0,0 +1,50 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export type TransitionStatus = 'starting' | 'ending' | undefined; + +/** + * Core state machine for enter/exit animations. + * + * Tracks whether a component should be in the DOM (`mounted`) and which + * animation phase it is in (`transitionStatus`). + * + * - When `open` becomes true: synchronously sets `mounted=true` and + * `transitionStatus='starting'` during render so the first committed DOM + * frame carries `[data-starting-style]`. One animation frame later, clears + * the status so the CSS transition fires. + * - When `open` becomes false: synchronously sets `transitionStatus='ending'`. + * The element stays mounted until the caller explicitly calls `setMounted(false)` + * (typically after all CSS animations have finished). + */ +export function useTransitionStatus(open: boolean) { + const [mounted, setMounted] = useState(open); + const [transitionStatus, setTransitionStatus] = useState(open ? 'starting' : undefined); + + // Synchronous render-phase state updates. Running these during render (not in + // useEffect) guarantees the first committed DOM includes the right data + // attributes — critical for `[data-starting-style]` to be present on mount. + if (open && !mounted) { + setMounted(true); + setTransitionStatus('starting'); + } + if (!open && mounted && transitionStatus !== 'ending') { + setTransitionStatus('ending'); + } + if (!mounted && transitionStatus !== undefined) { + setTransitionStatus(undefined); + } + + // After the browser has painted the 'starting' frame, remove it so the CSS + // transition fires toward the element's resting style. + useEffect(() => { + if (!open) return; + const id = requestAnimationFrame(() => { + setTransitionStatus(undefined); + }); + return () => cancelAnimationFrame(id); + }, [open]); + + return { mounted, transitionStatus, setMounted }; +} diff --git a/packages/headless/src/hooks/use-transition.test.ts b/packages/headless/src/hooks/use-transition.test.ts new file mode 100644 index 00000000000..68b3c524fd6 --- /dev/null +++ b/packages/headless/src/hooks/use-transition.test.ts @@ -0,0 +1,202 @@ +import { act, renderHook } from '@testing-library/react'; +import type { RefObject } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTransition } from './use-transition'; + +describe('useTransition', () => { + let rafCallbacks: Array; + let originalRaf: typeof requestAnimationFrame; + let originalCaf: typeof cancelAnimationFrame; + + beforeEach(() => { + rafCallbacks = []; + originalRaf = globalThis.requestAnimationFrame; + originalCaf = globalThis.cancelAnimationFrame; + globalThis.requestAnimationFrame = vi.fn(cb => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + globalThis.cancelAnimationFrame = vi.fn(id => { + rafCallbacks[id - 1] = () => {}; + }); + }); + + afterEach(() => { + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCaf; + }); + + function flushRaf() { + const cbs = [...rafCallbacks]; + rafCallbacks = []; + cbs.forEach(cb => cb(performance.now())); + } + + function createElementRef(): RefObject { + const el = document.createElement('div'); + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + return { current: el }; + } + + function createAnimatingRef() { + let resolveAnim!: () => void; + const animPromise = new Promise(r => { + resolveAnim = r; + }); + const el = document.createElement('div'); + el.getAnimations = vi.fn(() => [{ finished: animPromise }] as unknown as Animation[]); + const ref = { current: el } as RefObject; + return { ref, el, resolveAnim }; + } + + it('returns mounted=false and empty transitionProps when closed', () => { + const ref = createElementRef(); + const { result } = renderHook(() => useTransition({ open: false, ref })); + + expect(result.current.mounted).toBe(false); + expect(result.current.transitionProps).toEqual({}); + }); + + it('returns mounted=true with starting-style props on open', () => { + const ref = createElementRef(); + const { result } = renderHook(() => useTransition({ open: true, ref })); + + expect(result.current.mounted).toBe(true); + expect(result.current.transitionProps).toEqual({ + 'data-cl-open': '', + 'data-cl-starting-style': '', + style: { transition: 'none' }, + }); + }); + + it('clears starting-style after first frame, keeps data-cl-open', () => { + const ref = createElementRef(); + const { result } = renderHook(() => useTransition({ open: true, ref })); + + act(() => flushRaf()); + + expect(result.current.transitionProps).toEqual({ + 'data-cl-open': '', + }); + }); + + it('sets closing props while animations are running', async () => { + const { ref, el, resolveAnim } = createAnimatingRef(); + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { + initialProps: { open: true }, + }); + act(() => flushRaf()); + + rerender({ open: false }); + + // Wait for effect to run + await act(async () => { + await new Promise(r => setTimeout(r, 0)); + }); + + expect(result.current.mounted).toBe(true); + expect(result.current.transitionProps).toEqual({ + 'data-cl-closed': '', + 'data-cl-ending-style': '', + }); + + // Cleanup: resolve to prevent hanging + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + await act(async () => { + resolveAnim(); + await new Promise(r => setTimeout(r, 0)); + }); + }); + + it('unmounts immediately when no animations are running', async () => { + const ref = createElementRef(); + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { + initialProps: { open: true }, + }); + act(() => flushRaf()); + + await act(async () => { + rerender({ open: false }); + await new Promise(r => setTimeout(r, 0)); + }); + + expect(result.current.mounted).toBe(false); + }); + + it('stays mounted while animations are running', async () => { + const { ref, el, resolveAnim } = createAnimatingRef(); + + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { + initialProps: { open: true }, + }); + act(() => flushRaf()); + + rerender({ open: false }); + + await act(async () => { + await new Promise(r => setTimeout(r, 0)); + }); + + expect(result.current.mounted).toBe(true); + + // Finish animation + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + await act(async () => { + resolveAnim(); + await new Promise(r => setTimeout(r, 0)); + }); + + expect(result.current.mounted).toBe(false); + }); + + it('handles rapid open→close — unmounts after animations finish', async () => { + const ref = createElementRef(); + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { + initialProps: { open: false }, + }); + + // Open + rerender({ open: true }); + expect(result.current.mounted).toBe(true); + + // Immediately close — no animations, so unmount happens on effect flush + await act(async () => { + rerender({ open: false }); + flushRaf(); + await new Promise(r => setTimeout(r, 0)); + }); + + expect(result.current.mounted).toBe(false); + }); + + it('handles rapid close→open by cancelling exit', async () => { + const { ref, el, resolveAnim } = createAnimatingRef(); + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { + initialProps: { open: true }, + }); + act(() => flushRaf()); + + // Close — sets ending + rerender({ open: false }); + + // Immediately reopen before animations finish — element never unmounted, + // so the state machine goes straight back to open. The ending status + // persists briefly until the next rAF clears it. + rerender({ open: true }); + + expect(result.current.mounted).toBe(true); + expect(result.current.transitionProps['data-cl-open']).toBe(''); + + // After rAF, ending-style is cleared and we're in stable open state + act(() => flushRaf()); + expect(result.current.transitionProps['data-cl-ending-style']).toBeUndefined(); + expect(result.current.transitionProps['data-cl-open']).toBe(''); + + // Cleanup + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + await act(async () => { + resolveAnim(); + await new Promise(r => setTimeout(r, 0)); + }); + }); +}); diff --git a/packages/headless/src/hooks/use-transition.ts b/packages/headless/src/hooks/use-transition.ts new file mode 100644 index 00000000000..a0cf0d67d08 --- /dev/null +++ b/packages/headless/src/hooks/use-transition.ts @@ -0,0 +1,68 @@ +'use client'; + +import { type CSSProperties, type RefObject, useEffect, useMemo } from 'react'; +import { useAnimationsFinished } from './use-animations-finished'; +import { type TransitionStatus, useTransitionStatus } from './use-transition-status'; + +export interface UseTransitionOptions { + open: boolean; + ref: RefObject; +} + +export interface TransitionProps { + 'data-cl-open'?: ''; + 'data-cl-closed'?: ''; + 'data-cl-starting-style'?: ''; + 'data-cl-ending-style'?: ''; + style?: CSSProperties; +} + +export interface UseTransitionReturn { + mounted: boolean; + transitionStatus: TransitionStatus; + transitionProps: TransitionProps; +} + +/** + * Enter/exit animation lifecycle hook. + * + * Returns: + * - `mounted`: whether the element should render. Gate your JSX on this. + * - `transitionProps`: spread onto the element. Exposes + * `data-cl-open` / `data-cl-closed` / `data-cl-starting-style` / + * `data-cl-ending-style` data attributes and an inline `transition: none` on + * the first mount frame. + * + * Consumers drive all animation via CSS — no durations in JS. Works with CSS + * transitions (via `[data-cl-starting-style]` / `[data-cl-ending-style]`) and + * CSS keyframe animations (via `[data-cl-open]` / `[data-cl-closed]`). + */ +export function useTransition({ open, ref }: UseTransitionOptions): UseTransitionReturn { + const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); + const runOnAnimationsFinished = useAnimationsFinished(ref, open); + + useEffect(() => { + if (transitionStatus !== 'ending') return; + runOnAnimationsFinished(() => { + setMounted(false); + }); + }, [transitionStatus, runOnAnimationsFinished, setMounted]); + + const transitionProps = useMemo(() => { + const props: TransitionProps = {}; + if (open) { + props['data-cl-open'] = ''; + } else if (mounted) { + props['data-cl-closed'] = ''; + } + if (transitionStatus === 'starting') { + props['data-cl-starting-style'] = ''; + props.style = { transition: 'none' }; + } else if (transitionStatus === 'ending') { + props['data-cl-ending-style'] = ''; + } + return props; + }, [open, mounted, transitionStatus]); + + return { mounted, transitionStatus, transitionProps }; +} diff --git a/packages/headless/src/primitives/accordion/README.md b/packages/headless/src/primitives/accordion/README.md new file mode 100644 index 00000000000..5254df32cc6 --- /dev/null +++ b/packages/headless/src/primitives/accordion/README.md @@ -0,0 +1,128 @@ +# Accordion + +A vertically stacked set of collapsible sections. Supports single or multiple open panels, keyboard navigation, and CSS-driven expand/collapse animations. + +## When to Use + +- FAQ sections, settings panels, or any UI where content should be shown/hidden in discrete sections. +- When you need accessible expand/collapse with proper ARIA attributes and keyboard support. +- Prefer Accordion over manual show/hide toggles — it handles focus management, ARIA, and animation lifecycle automatically. + +## Usage + +```tsx +import { Accordion } from '@/primitives/accordion'; + + + + + Section 1 + + Content for section 1 + + + + Section 2 + + Content for section 2 + +; +``` + +### Controlled + +```tsx +const [value, setValue] = useState(['item-1']); + + + {/* ... */} +; +``` + +## Parts + +| Part | Default Element | Description | +| ------------------- | --------------- | ---------------------------------- | +| `Accordion` | `
` | Root wrapper, provides context | +| `Accordion.Item` | `
` | Wraps a single collapsible section | +| `Accordion.Header` | `

` | Heading wrapper for the trigger | +| `Accordion.Trigger` | `