Skip to content

Commit 2466247

Browse files
docs(repo): add headless primitives reference to mosaic skill (#9239)
1 parent dadebc2 commit 2466247

4 files changed

Lines changed: 254 additions & 4 deletions

File tree

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

.claude/skills/mosaic/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ this skill is the _how-to_.
4444

4545
| You are… | Read |
4646
| -------------------------------------------------------------------- | ------------------------------------------------------ |
47+
| Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` |
4748
| Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` |
4849
| Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` |
4950
| Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` |
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# Headless primitives
2+
3+
`@clerk/headless` (`packages/headless/`) is the unstyled, accessible primitive
4+
layer under Mosaic: Accordion, Autocomplete, Collapsible, Dialog, Drawer,
5+
FileUpload, Menu, OTP, Popover, Select, Tabs, Tooltip. Every part emits **zero
6+
styles** — positioning, keyboard nav, focus management, dismiss, and ARIA are
7+
delegated to `@floating-ui/react`; all appearance is applied externally via
8+
`data-*` selectors and consumer classNames.
9+
10+
The package is `private: true` and consumed by `@clerk/ui`. It's a separate
11+
package because `@clerk/ui` sets `jsxImportSource: '@emotion/react'`, which
12+
conflicts with the standard `react-jsx` transform these primitives need.
13+
14+
## Read this for the _what_
15+
16+
Per-primitive API docs (parts, props, keyboard, data attributes, ARIA) live
17+
**next to the code** and are the source of truth:
18+
19+
- **`packages/headless/src/primitives/<name>/README.md`** — one per primitive.
20+
- **`packages/headless/README.md`** — package overview, the primitive table, and
21+
the full **consuming-from-`@clerk/ui`** guide (the `makeCustomizable` wrapper,
22+
the TS2742 annotation requirement, pass-through parts, the `render` escape
23+
hatch).
24+
25+
This file is the _how-to_ for the shared conventions — what every primitive has
26+
in common, so you can author a new one or a new part without re-deriving the
27+
pattern.
28+
29+
## Consuming a primitive
30+
31+
Every primitive is a compound component exported as a namespace. Import from the
32+
subpath; render `Root` + parts:
33+
34+
```tsx
35+
import { Select } from '@clerk/headless/select';
36+
37+
<Select.Root>
38+
<Select.Trigger>
39+
<Select.Value placeholder='Choose…' />
40+
</Select.Trigger>
41+
<Select.Positioner>
42+
<Select.Popup>
43+
<Select.Option
44+
value='a'
45+
label='A'
46+
/>
47+
</Select.Popup>
48+
</Select.Positioner>
49+
</Select.Root>;
50+
```
51+
52+
- **Each element-rendering part accepts native props for its tag plus a `render`
53+
prop.** Pass-through parts (`Root`, `Portal`) render no element of their own
54+
and have their own APIs instead. Unused parts tree-shake out.
55+
- **Style by className/`data-*`**, never by a slot attribute — the primitives
56+
don't emit one.
57+
- **`render` is the override escape hatch.** It takes a function
58+
(`render={props => <X {...props} />}`) **or an element**
59+
(`render={<Link />}`) — the element is cloned with the part's computed props
60+
and refs merged in.
61+
- **From `@clerk/ui`**, wrap element-rendering parts with `makeCustomizable` to
62+
get the theme-aware `sx` prop; pass-through parts (`Root`, `Portal`) are used
63+
directly. See `packages/headless/README.md`.
64+
65+
## Authoring a part: the useRender contract
66+
67+
Every part that renders a DOM element calls **`useRender`** (from
68+
`../../utils`) instead of returning JSX. This is the single mechanism behind
69+
`render` overrides, state→`data-*` mapping, and ref merging. (It replaced the
70+
old `renderElement` helper — `renderElement` no longer exists.)
71+
72+
```tsx
73+
'use client';
74+
import React from 'react';
75+
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
76+
import { useSelectContext } from './select-context';
77+
78+
export type SelectTriggerProps = ComponentProps<'button'>;
79+
80+
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
81+
function SelectTrigger(props, ref) {
82+
const { render, ...otherProps } = props;
83+
const { open, refs, getReferenceProps } = useSelectContext();
84+
85+
const ownProps = { type: 'button' } satisfies DefaultProps<'button'>;
86+
const defaultProps = { ...ownProps, ...getReferenceProps() };
87+
88+
return useRender({
89+
defaultTagName: 'button',
90+
render,
91+
state: { open },
92+
stateAttributesMapping: {
93+
open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }),
94+
},
95+
ref: [refs.setReference, ref],
96+
props: mergeProps<'button'>(defaultProps, otherProps),
97+
});
98+
},
99+
);
100+
```
101+
102+
`useRender` params:
103+
104+
| Param | Purpose |
105+
| ---------------------------------- | ------------------------------------------------------------------------------------------- |
106+
| `defaultTagName` | Tag rendered when no `render` is given. |
107+
| `render` | Consumer override: a render function **or** a React element (cloned with merged props). |
108+
| `props` | Props to spread onto the element. Pass refs via `ref`, not here. |
109+
| `ref` | A ref or **array** of refs; merged internally (`useMergeRefs`). E.g. `[refs.setX, ref]`. |
110+
| `state` + `stateAttributesMapping` | Maps state values to `data-*` attrs (below). |
111+
| `enabled` | When `false`, returns `null`. Positioners pass `enabled: mounted` to gate the floating DOM. |
112+
113+
Rules that hold for **every** part:
114+
115+
- **`'use client';`** at the top of every component file.
116+
- **`React.forwardRef`** for any part that renders an element.
117+
- **`const { render, ...otherProps } = props;`** — pull `render` out, spread the rest.
118+
- **`ownProps satisfies DefaultProps<Tag>`** for authored defaults. `DefaultProps`
119+
is the tag's props widened to allow `data-*` keys — `satisfies` type-checks
120+
every key against the real element without an `as` cast.
121+
- **`mergeProps(internal, consumer)` — internal first, consumer second.** Event
122+
handlers chain (internal fires, then consumer), `style` shallow-merges,
123+
`className` concatenates, everything else the consumer overwrites. This lets
124+
consumers extend behavior without breaking ARIA/handlers the primitive owns.
125+
126+
### state → data-attribute mapping
127+
128+
`stateAttributesMapping` maps each `state` key to a function returning a
129+
`data-*` object or `null` (omit). Return the boolean-off branch as `null` for
130+
presence attrs, or a second attribute for on/off pairs:
131+
132+
```ts
133+
// presence: attr only when true
134+
selected: v => (v ? { 'data-selected': '' } : null),
135+
disabled: v => (v ? { 'data-disabled': '' } : null),
136+
// pair: data-open vs data-closed
137+
open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }),
138+
```
139+
140+
Consumers then style off `[data-selected]`, `[data-open]`, etc.
141+
142+
## File layout of a primitive
143+
144+
Every `primitives/<name>/` folder follows the same shape:
145+
146+
| File | Holds |
147+
| ------------------- | ---------------------------------------------------------------------------------------------- |
148+
| `<name>-root.tsx` | Context provider; owns floating/interaction/transition state. Often wraps `FloatingTree`. |
149+
| `<name>-<part>.tsx` | One file per part (`-trigger`, `-popup`, `-positioner`, …), each a `forwardRef` + `useRender`. |
150+
| `<name>-context.ts` | Context type + `createContext` + guard hook (below). |
151+
| `parts.ts` | Re-exports each part under its short alias. |
152+
| `index.ts` | Public entry: namespace + prop-type re-exports. |
153+
| `<name>.test.tsx` | Tests (real Chromium via vitest browser mode, not jsdom). |
154+
| `README.md` | The primitive's API docs. |
155+
156+
**Context + guard hook** — the pattern that makes "used outside Root" a clear error:
157+
158+
```ts
159+
export const SelectContext = createContext<SelectContextValue | null>(null);
160+
161+
export function useSelectContext() {
162+
const ctx = useContext(SelectContext);
163+
if (!ctx) throw new Error('Select compound components must be used within <Select.Root>');
164+
return ctx;
165+
}
166+
```
167+
168+
**`parts.ts`** — alias each part; this is what the namespace spreads:
169+
170+
```ts
171+
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
172+
export { type SelectOptionProps, SelectOption as Option } from './select-option';
173+
//
174+
```
175+
176+
**`index.ts`** — namespace + public prop types (the prop types must be
177+
re-exported here or `@clerk/ui`'s `.d.ts` rollup hits TS2742):
178+
179+
```ts
180+
export * as Select from './parts';
181+
export type { SelectProps, SelectTriggerProps, SelectOptionProps /**/ } from './parts';
182+
```
183+
184+
**`Portal`** parts take an optional `root` and gate on `mounted`:
185+
186+
```tsx
187+
export function SelectPortal(props: {
188+
children: ReactNode;
189+
root?: HTMLElement | RefObject<HTMLElement | null> | null;
190+
}) {
191+
const { mounted } = useSelectContext();
192+
if (!mounted) return null;
193+
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
194+
}
195+
```
196+
197+
## Animation lifecycle (`data-*` driven)
198+
199+
All enter/exit timing lives in **CSS**; the primitives only toggle `data-*`
200+
attributes and drive unmount off the Web Animations API. Root spreads
201+
`transitionProps` (from `useTransition`) onto the Popup; the lifecycle:
202+
203+
- **Open** → synchronously `mounted=true`, status `'starting'`: first committed
204+
frame carries `data-open` + `data-starting-style` + inline `transition: none`
205+
(snapshot frame). One rAF later `data-starting-style` clears → CSS transitions
206+
fire toward the resting style.
207+
- **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied.
208+
After all animations on the element finish (`useAnimationsFinished`),
209+
`mounted` flips false and the element unmounts.
210+
211+
Consumer CSS keys off these: `[data-starting-style] { opacity: 0 }`,
212+
`[data-open] { animation: … }`, `[data-ending-style] { opacity: 0 }`.
213+
214+
**Positioners** gate the floating layer on `mounted` via `useRender`'s
215+
`enabled`, so the positioned DOM doesn't exist until the first frame:
216+
217+
```tsx
218+
const element = useRender({ defaultTagName: 'div', render, enabled: mounted, ref: [refs.setFloating, ref], props });
219+
if (!element) return null;
220+
```
221+
222+
## Shared hooks (`@clerk/headless/hooks`)
223+
224+
| Hook | Signature (abridged) | Purpose |
225+
| ----------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------- |
226+
| `useControllableState` | `(controlled, defaultValue, onChange?) => [value, setValue]` | Dual-mode controlled/uncontrolled state; `onChange` fires either way. |
227+
| `useTransition` | `({ open, ref }) => { mounted, transitionStatus, transitionProps }` | Enter/exit lifecycle; spread `transitionProps` onto the animated part. |
228+
| `useTransitionStatus` | `(open) => { mounted, transitionStatus, setMounted }` | Lower-level phase machine (`'starting'` / `'ending'` / `undefined`). |
229+
| `useAnimationsFinished` | `(ref, open) => (cb) => void` | Runs `cb` once all CSS animations finish; aborts on rapid toggles. |
230+
| `useDataTable` | `(opts) => { rows, sorting, pagination, rowSelection, … }` | Table state (sort/filter/paginate/select), controlled or uncontrolled. |
231+
232+
## Shared utils (`@clerk/headless/utils`)
233+
234+
- **`useRender`, `mergeProps`, `ComponentProps<Tag>`, `DefaultProps<Tag>`, `RenderProp`** — the part-authoring primitives (above).
235+
- **`cssVars({ sideOffset? }): Middleware`** — floating-ui middleware setting
236+
`--cl-anchor-width/height`, `--cl-available-width/height`, `--cl-transform-origin`
237+
on the floating element. Place it **after** `arrow()`.
238+
- **`resetLayoutStyles(el): () => void`** — temporarily forces flex/grid
239+
alignment to `initial` for accurate `scrollHeight`/`scrollWidth` measurement;
240+
restores on next rAF. Call the returned cleanup from effect cleanup.
241+
242+
## Testing
243+
244+
Tests run in **real Chromium** (vitest browser mode), not jsdom, and include
245+
`axe` accessibility assertions. `pnpm test` in `packages/headless`. See
246+
`testing.md` for the Mosaic flow-layer testing model (a different concern — that
247+
covers machines/controllers/views, not these primitives).

packages/headless/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This package is **internal** (`private: true`) and consumed by `@clerk/ui`. It e
1919
| Tabs | `@clerk/headless/tabs` | Tab navigation with animated indicator |
2020
| Tooltip | `@clerk/headless/tooltip` | Hover/focus tooltip with configurable delay and group support |
2121

22-
Shared utilities are available at `@clerk/headless/utils` (includes `renderElement` and `mergeProps`).
22+
Shared utilities are available at `@clerk/headless/utils` (includes `useRender` and `mergeProps`).
2323

2424
Each primitive has its own README in `src/primitives/<name>/` with full API docs, props tables, keyboard navigation, and data attributes.
2525

@@ -50,7 +50,7 @@ All primitives follow the same compound component pattern. They emit zero styles
5050
## Architecture
5151

5252
- **Compound components** — each primitive exports a namespace (e.g. `Select.Trigger`, `Select.Popup`) backed by per-part files so unused parts tree-shake out
53-
- **`renderElement`** — every part uses this instead of returning JSX directly, enabling consumer `render` prop overrides and automatic state-to-data-attribute mapping
53+
- **`useRender`** — every part that renders a DOM element calls this hook instead of returning JSX directly, enabling consumer `render` prop overrides (function or element) and automatic state-to-data-attribute mapping
5454
- **`data-*` attributes** — state (`data-open`, `data-selected`, `data-active`) and animation lifecycle (`data-starting-style`, `data-ending-style`); parts are targeted by consumer-supplied classNames, not by an emitted slot attribute
5555
- **CSS-driven animations** — the transition system uses `data-*` attributes and the Web Animations API (`getAnimations().finished`) so all timing lives in CSS
5656
- **Floating UI** — positioning, interactions, focus management, dismiss handling, list navigation, and ARIA are all delegated to `@floating-ui/react`
@@ -117,9 +117,9 @@ Consumers can then style with the theme:
117117

118118
Without the annotation, `tsc` emits **TS2742**:
119119

120-
> The inferred type of `Dialog` cannot be named without a reference to `@clerk/headless/dist/utils/render-element`. This is likely not portable.
120+
> The inferred type of `Dialog` cannot be named without a reference to `@clerk/headless/dist/utils/use-render`. This is likely not portable.
121121
122-
`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.
122+
`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/use-render`), 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.
123123

124124
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.
125125

0 commit comments

Comments
 (0)