Skip to content

Commit c722955

Browse files
feat(headless): add package foundation with Dialog primitive (#8474)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent afa9a91 commit c722955

35 files changed

Lines changed: 3576 additions & 542 deletions

.changeset/dry-yaks-rats.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/headless/README.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# @clerk/headless
2+
3+
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.
4+
5+
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.
6+
7+
## Primitives
8+
9+
| Primitive | Import | Description |
10+
| ------------ | ------------------------------ | ------------------------------------------------------------- |
11+
| Accordion | `@clerk/headless/accordion` | Expandable content sections with single/multiple mode |
12+
| Autocomplete | `@clerk/headless/autocomplete` | Combobox input with filterable option list |
13+
| Dialog | `@clerk/headless/dialog` | Modal dialog with focus trapping and scroll lock |
14+
| Menu | `@clerk/headless/menu` | Dropdown and nested context menus with safe hover zones |
15+
| Popover | `@clerk/headless/popover` | Non-modal floating content triggered by click |
16+
| Select | `@clerk/headless/select` | Dropdown select with typeahead and keyboard navigation |
17+
| Tabs | `@clerk/headless/tabs` | Tab navigation with animated indicator |
18+
| Tooltip | `@clerk/headless/tooltip` | Hover/focus tooltip with configurable delay and group support |
19+
20+
Shared utilities are available at `@clerk/headless/utils` (includes `renderElement` and `mergeProps`).
21+
22+
Each primitive has its own README in `src/primitives/<name>/` with full API docs, props tables, keyboard navigation, and data attributes.
23+
24+
## Usage
25+
26+
```tsx
27+
import { Select } from '@clerk/headless/select';
28+
29+
<Select>
30+
<Select.Trigger>Choose...</Select.Trigger>
31+
<Select.Positioner>
32+
<Select.Popup>
33+
<Select.Option
34+
value='a'
35+
label='Option A'
36+
/>
37+
<Select.Option
38+
value='b'
39+
label='Option B'
40+
/>
41+
</Select.Popup>
42+
</Select.Positioner>
43+
</Select>;
44+
```
45+
46+
All primitives follow the same compound component pattern. They emit zero styles — all visual styling is applied externally via `data-cl-*` attribute selectors.
47+
48+
## Architecture
49+
50+
- **Compound components** — each primitive exports a namespace (e.g. `Select.Trigger`, `Select.Popup`) backed by per-part files so unused parts tree-shake out
51+
- **`renderElement`** — every part uses this instead of returning JSX directly, enabling consumer `render` prop overrides and automatic state-to-data-attribute mapping
52+
- **`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`)
53+
- **CSS-driven animations** — the transition system uses `data-cl-*` attributes and the Web Animations API (`getAnimations().finished`) so all timing lives in CSS
54+
- **Floating UI** — positioning, interactions, focus management, dismiss handling, list navigation, and ARIA are all delegated to `@floating-ui/react`
55+
56+
## Consuming from `@clerk/ui`
57+
58+
`@clerk/ui` uses `jsxImportSource: '@emotion/react'`, which automatically gives every component that accepts `className: string` a working `css` prop at runtime. Headless parts already declare `className` (via `ComponentProps<Tag>`), so **the emotion `css` prop works out of the box**:
59+
60+
```tsx
61+
import { Dialog } from '@clerk/headless/dialog';
62+
63+
<Dialog.Popup css={{ padding: 24, borderRadius: 8 }} />;
64+
```
65+
66+
For Clerk's theme-aware **`sx` prop**, each part must be wrapped with `makeCustomizable` (the HOC that resolves `descriptors`/`elementId` and forwards `css={sx}` down). Create a thin wrapper module in `@clerk/ui`:
67+
68+
```tsx
69+
// packages/ui/src/primitives/Dialog.tsx
70+
import { Dialog as HeadlessDialog } from '@clerk/headless/dialog';
71+
import type {
72+
DialogBackdropProps,
73+
DialogCloseProps,
74+
DialogDescriptionProps,
75+
DialogPopupProps,
76+
DialogPortalProps,
77+
DialogProps,
78+
DialogTitleProps,
79+
DialogTriggerProps,
80+
} from '@clerk/headless/dialog';
81+
import type { FunctionComponent } from 'react';
82+
import { makeCustomizable } from '../customizables/makeCustomizable';
83+
import type { ThemableCssProp } from '../styledSystem';
84+
85+
type Customizable<T> = T & { sx?: ThemableCssProp };
86+
87+
export const Dialog: {
88+
Root: FunctionComponent<DialogProps>;
89+
Trigger: FunctionComponent<Customizable<DialogTriggerProps>>;
90+
Portal: FunctionComponent<DialogPortalProps>;
91+
Backdrop: FunctionComponent<Customizable<DialogBackdropProps>>;
92+
Popup: FunctionComponent<Customizable<DialogPopupProps>>;
93+
Title: FunctionComponent<Customizable<DialogTitleProps>>;
94+
Description: FunctionComponent<Customizable<DialogDescriptionProps>>;
95+
Close: FunctionComponent<Customizable<DialogCloseProps>>;
96+
} = {
97+
Root: HeadlessDialog.Root,
98+
Trigger: makeCustomizable(HeadlessDialog.Trigger),
99+
Portal: HeadlessDialog.Portal,
100+
Backdrop: makeCustomizable(HeadlessDialog.Backdrop),
101+
Popup: makeCustomizable(HeadlessDialog.Popup),
102+
Title: makeCustomizable(HeadlessDialog.Title),
103+
Description: makeCustomizable(HeadlessDialog.Description),
104+
Close: makeCustomizable(HeadlessDialog.Close),
105+
};
106+
```
107+
108+
Consumers can then style with the theme:
109+
110+
```tsx
111+
<Dialog.Popup sx={t => ({ backgroundColor: t.colors.$colorBackground, padding: t.space.$6 })} />
112+
```
113+
114+
### Why the explicit type annotation is required
115+
116+
Without the annotation, `tsc` emits **TS2742**:
117+
118+
> The inferred type of `Dialog` cannot be named without a reference to `@clerk/headless/dist/utils/render-element`. This is likely not portable.
119+
120+
`makeCustomizable<P>` returns an internal `CustomizablePrimitive<P>` type. When TS rolls up `.d.ts`, it resolves `DialogTriggerProps = ComponentProps<'button'>` back to its source file (`headless/dist/utils/render-element`), which **isn't in the package `exports` map**. The explicit `FunctionComponent<Customizable<DialogXProps>>` annotation forces TS to reference the named `DialogXProps` type from `@clerk/headless/dialog` (a public entry) instead of expanding it.
121+
122+
This applies to **every** headless primitive consumed through `makeCustomizable` — Popover, Tooltip, Menu, Select, etc. Each gets its own wrapper module under `packages/ui/src/primitives/<Name>.tsx` following the pattern above.
123+
124+
### Pass-through parts
125+
126+
Parts that don't render a DOM element (e.g. `Root`, `Portal`) should **not** be wrapped — pass them through directly. `makeCustomizable` only adds value for parts that render an element with a `className`.
127+
128+
### Skipping the wrapper
129+
130+
If you only need one-off styling and don't want a wrapper module, headless's `render` prop is the escape hatch:
131+
132+
```tsx
133+
<HeadlessDialog.Popup render={props => <Box sx={{ ... }} {...props} />} />
134+
```
135+
136+
Trade-off: verbose at the call site and loses automatic `descriptors`/`elementId` plumbing. Prefer the wrapper for any primitive used more than once.
137+
138+
## Development
139+
140+
```sh
141+
pnpm dev # watch mode build
142+
pnpm build # production build
143+
pnpm test # run tests (vitest + playwright browser mode)
144+
```
145+
146+
Tests run in a real Chromium browser via `@vitest/browser-playwright`, not jsdom.

packages/headless/package.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@clerk/headless",
3+
"version": "0.0.0",
4+
"private": true,
5+
"sideEffects": false,
6+
"type": "module",
7+
"exports": {
8+
"./dialog": {
9+
"import": "./dist/primitives/dialog/index.js",
10+
"types": "./dist/primitives/dialog/index.d.ts"
11+
},
12+
"./hooks": {
13+
"import": "./dist/hooks/index.js",
14+
"types": "./dist/hooks/index.d.ts"
15+
},
16+
"./utils": {
17+
"import": "./dist/utils/index.js",
18+
"types": "./dist/utils/index.d.ts"
19+
}
20+
},
21+
"scripts": {
22+
"build": "rm -rf dist && vite build",
23+
"dev": "vite build --watch",
24+
"lint": "eslint src",
25+
"test": "vitest"
26+
},
27+
"dependencies": {
28+
"@floating-ui/react": "catalog:repo"
29+
},
30+
"devDependencies": {
31+
"@testing-library/dom": "^10.4.1",
32+
"@testing-library/jest-dom": "^6.9.1",
33+
"@testing-library/react": "^16.3.2",
34+
"@testing-library/user-event": "^14.6.1",
35+
"@types/react": "catalog:react",
36+
"@types/react-dom": "catalog:react",
37+
"axe-core": "^4.11.3",
38+
"happy-dom": "^18.0.1",
39+
"react": "catalog:react",
40+
"react-dom": "catalog:react",
41+
"typescript": "catalog:repo",
42+
"vite": "6.4.1",
43+
"vite-plugin-dts": "^4.5.4",
44+
"vitest": "4.1.4",
45+
"vitest-axe": "^0.1.0"
46+
},
47+
"peerDependencies": {
48+
"react": "catalog:peer-react",
49+
"react-dom": "catalog:peer-react"
50+
}
51+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export { useAnimationsFinished } from './use-animations-finished';
2+
export { useControllableState } from './use-controllable-state';
3+
export {
4+
type TransitionProps,
5+
useTransition,
6+
type UseTransitionOptions,
7+
type UseTransitionReturn,
8+
} from './use-transition';
9+
export { type TransitionStatus, useTransitionStatus } from './use-transition-status';
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { createRef, type RefObject } from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { useAnimationsFinished } from './use-animations-finished';
6+
7+
function createMockElement(
8+
animations: Array<{ finished: Promise<void> }> = [],
9+
attributes: Record<string, string> = {},
10+
): HTMLElement {
11+
const el = document.createElement('div');
12+
Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v));
13+
el.getAnimations = vi.fn(() => animations as unknown as Animation[]);
14+
return el;
15+
}
16+
17+
describe('useAnimationsFinished', () => {
18+
it('fires callback immediately when ref.current is null', () => {
19+
const ref = createRef<HTMLElement>() as RefObject<HTMLElement | null>;
20+
const { result } = renderHook(() => useAnimationsFinished(ref, false));
21+
22+
const callback = vi.fn();
23+
act(() => result.current(callback));
24+
expect(callback).toHaveBeenCalledTimes(1);
25+
});
26+
27+
it('fires callback immediately when getAnimations is not supported', () => {
28+
const ref = { current: document.createElement('div') } as RefObject<HTMLElement | null>;
29+
// Don't add getAnimations
30+
delete (ref.current as unknown as Record<string, unknown>).getAnimations;
31+
32+
const { result } = renderHook(() => useAnimationsFinished(ref, false));
33+
34+
const callback = vi.fn();
35+
act(() => result.current(callback));
36+
expect(callback).toHaveBeenCalledTimes(1);
37+
});
38+
39+
it('fires callback immediately when no animations are running', () => {
40+
const el = createMockElement([]);
41+
const ref = { current: el } as RefObject<HTMLElement | null>;
42+
43+
const { result } = renderHook(() => useAnimationsFinished(ref, false));
44+
45+
const callback = vi.fn();
46+
act(() => result.current(callback));
47+
expect(callback).toHaveBeenCalledTimes(1);
48+
});
49+
50+
it('waits for all animations to finish before firing callback', async () => {
51+
let resolveAnim!: () => void;
52+
const animPromise = new Promise<void>(r => {
53+
resolveAnim = r;
54+
});
55+
const el = createMockElement([{ finished: animPromise }]);
56+
const ref = { current: el } as RefObject<HTMLElement | null>;
57+
58+
const { result } = renderHook(() => useAnimationsFinished(ref, false));
59+
60+
const callback = vi.fn();
61+
act(() => result.current(callback));
62+
63+
expect(callback).not.toHaveBeenCalled();
64+
65+
// After animations finish, change getAnimations to return empty
66+
el.getAnimations = vi.fn(() => [] as unknown as Animation[]);
67+
await act(() => resolveAnim());
68+
69+
expect(callback).toHaveBeenCalledTimes(1);
70+
});
71+
72+
it('aborts previous pending wait when called again', async () => {
73+
let resolveFirst!: () => void;
74+
const firstAnim = new Promise<void>(r => {
75+
resolveFirst = r;
76+
});
77+
const el = createMockElement([{ finished: firstAnim }]);
78+
const ref = { current: el } as RefObject<HTMLElement | null>;
79+
80+
const { result } = renderHook(() => useAnimationsFinished(ref, false));
81+
82+
const firstCallback = vi.fn();
83+
act(() => result.current(firstCallback));
84+
85+
// Call again — should abort the first
86+
const secondCallback = vi.fn();
87+
el.getAnimations = vi.fn(() => [] as unknown as Animation[]);
88+
act(() => result.current(secondCallback));
89+
90+
expect(secondCallback).toHaveBeenCalledTimes(1);
91+
92+
// Resolve first animation — its callback should NOT fire
93+
await act(() => resolveFirst());
94+
expect(firstCallback).not.toHaveBeenCalled();
95+
});
96+
97+
it('re-checks animations when one is cancelled', async () => {
98+
let rejectAnim!: () => void;
99+
const cancelledAnim = new Promise<void>((_, reject) => {
100+
rejectAnim = reject;
101+
});
102+
const el = createMockElement([{ finished: cancelledAnim }]);
103+
const ref = { current: el } as RefObject<HTMLElement | null>;
104+
105+
const { result } = renderHook(() => useAnimationsFinished(ref, false));
106+
107+
const callback = vi.fn();
108+
act(() => result.current(callback));
109+
110+
expect(callback).not.toHaveBeenCalled();
111+
112+
// Cancel the animation — hook should re-check and find no new animations
113+
el.getAnimations = vi.fn(() => [] as unknown as Animation[]);
114+
await act(async () => {
115+
rejectAnim();
116+
// Let microtask queue flush
117+
await new Promise(r => setTimeout(r, 0));
118+
});
119+
120+
expect(callback).toHaveBeenCalledTimes(1);
121+
});
122+
123+
it('waits for starting-style attribute removal when open=true', async () => {
124+
const el = createMockElement([], { 'data-cl-starting-style': '' });
125+
const ref = { current: el } as RefObject<HTMLElement | null>;
126+
127+
const { result } = renderHook(() => useAnimationsFinished(ref, true));
128+
129+
const callback = vi.fn();
130+
act(() => result.current(callback));
131+
132+
// Should not fire yet — waiting for attribute removal
133+
expect(callback).not.toHaveBeenCalled();
134+
135+
// Remove the attribute — MutationObserver should fire
136+
await act(async () => {
137+
el.removeAttribute('data-cl-starting-style');
138+
// MutationObserver is async; wait a tick
139+
await new Promise(r => setTimeout(r, 0));
140+
});
141+
142+
expect(callback).toHaveBeenCalledTimes(1);
143+
});
144+
145+
it('cleans up on unmount', () => {
146+
let resolveAnim!: () => void;
147+
const animPromise = new Promise<void>(r => {
148+
resolveAnim = r;
149+
});
150+
const el = createMockElement([{ finished: animPromise }]);
151+
const ref = { current: el } as RefObject<HTMLElement | null>;
152+
153+
const { result, unmount } = renderHook(() => useAnimationsFinished(ref, false));
154+
155+
const callback = vi.fn();
156+
act(() => result.current(callback));
157+
158+
// Unmount should abort
159+
unmount();
160+
161+
// Resolve animation — callback should not fire because abort was called
162+
resolveAnim();
163+
164+
expect(callback).not.toHaveBeenCalled();
165+
});
166+
});

0 commit comments

Comments
 (0)