From 478d3dd1c5c457b2407239f333ac8b7d686efb6d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 24 Jul 2026 09:24:53 -0400 Subject: [PATCH 1/2] docs(repo): add headless primitives reference to mosaic skill Adds a how-to reference for @clerk/headless (consuming primitives, the useRender part-authoring contract, file layout, animation lifecycle, shared hooks/utils) and routes to it from the mosaic skill. Fixes stale renderElement references in the headless README (removed in favor of useRender). --- .changeset/mosaic-headless-reference-doc.md | 2 + .claude/skills/mosaic/SKILL.md | 1 + .claude/skills/mosaic/references/headless.md | 246 +++++++++++++++++++ packages/headless/README.md | 8 +- 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 .changeset/mosaic-headless-reference-doc.md create mode 100644 .claude/skills/mosaic/references/headless.md diff --git a/.changeset/mosaic-headless-reference-doc.md b/.changeset/mosaic-headless-reference-doc.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-headless-reference-doc.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/SKILL.md b/.claude/skills/mosaic/SKILL.md index 69770c695fb..fd0bb8db9dd 100644 --- a/.claude/skills/mosaic/SKILL.md +++ b/.claude/skills/mosaic/SKILL.md @@ -44,6 +44,7 @@ this skill is the _how-to_. | You are… | Read | | -------------------------------------------------------------------- | ------------------------------------------------------ | +| Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` | | Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` | | Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` | | Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | diff --git a/.claude/skills/mosaic/references/headless.md b/.claude/skills/mosaic/references/headless.md new file mode 100644 index 00000000000..2bc5e1d16cb --- /dev/null +++ b/.claude/skills/mosaic/references/headless.md @@ -0,0 +1,246 @@ +# Headless primitives + +`@clerk/headless` (`packages/headless/`) is the unstyled, accessible primitive +layer under Mosaic: Accordion, Autocomplete, Collapsible, Dialog, Drawer, +FileUpload, Menu, OTP, Popover, Select, Tabs, Tooltip. Every part emits **zero +styles** — positioning, keyboard nav, focus management, dismiss, and ARIA are +delegated to `@floating-ui/react`; all appearance is applied externally via +`data-*` selectors and consumer classNames. + +The package is `private: true` and consumed by `@clerk/ui`. It's a separate +package because `@clerk/ui` sets `jsxImportSource: '@emotion/react'`, which +conflicts with the standard `react-jsx` transform these primitives need. + +## Read this for the _what_ + +Per-primitive API docs (parts, props, keyboard, data attributes, ARIA) live +**next to the code** and are the source of truth: + +- **`packages/headless/src/primitives//README.md`** — one per primitive. +- **`packages/headless/README.md`** — package overview, the primitive table, and + the full **consuming-from-`@clerk/ui`** guide (the `makeCustomizable` wrapper, + the TS2742 annotation requirement, pass-through parts, the `render` escape + hatch). + +This file is the _how-to_ for the shared conventions — what every primitive has +in common, so you can author a new one or a new part without re-deriving the +pattern. + +## Consuming a primitive + +Every primitive is a compound component exported as a namespace. Import from the +subpath; render `Root` + parts: + +```tsx +import { Select } from '@clerk/headless/select'; + + + + + + + + + + +; +``` + +- **Each part accepts native props for its tag plus a `render` prop.** Unused + parts tree-shake out. +- **Style by className/`data-*`**, never by a slot attribute — the primitives + don't emit one. +- **`render` is the override escape hatch.** It takes a function + (`render={props => }`) **or an element** + (`render={}`) — the element is cloned with the part's computed props + and refs merged in. +- **From `@clerk/ui`**, wrap element-rendering parts with `makeCustomizable` to + get the theme-aware `sx` prop; pass-through parts (`Root`, `Portal`) are used + directly. See `packages/headless/README.md`. + +## Authoring a part: the useRender contract + +Every part that renders a DOM element calls **`useRender`** (from +`../../utils`) instead of returning JSX. This is the single mechanism behind +`render` overrides, state→`data-*` mapping, and ref merging. (It replaced the +old `renderElement` helper — `renderElement` no longer exists.) + +```tsx +'use client'; +import React from 'react'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { useSelectContext } from './select-context'; + +export type SelectTriggerProps = ComponentProps<'button'>; + +export const SelectTrigger = React.forwardRef( + function SelectTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useSelectContext(); + + const ownProps = { type: 'button' } satisfies DefaultProps<'button'>; + const defaultProps = { ...ownProps, ...getReferenceProps() }; + + return useRender({ + defaultTagName: 'button', + render, + state: { open }, + stateAttributesMapping: { + open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }), + }, + ref: [refs.setReference, ref], + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); +``` + +`useRender` params: + +| Param | Purpose | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | +| `defaultTagName` | Tag rendered when no `render` is given. | +| `render` | Consumer override: a render function **or** a React element (cloned with merged props). | +| `props` | Props to spread onto the element. Pass refs via `ref`, not here. | +| `ref` | A ref or **array** of refs; merged internally (`useMergeRefs`). E.g. `[refs.setX, ref]`. | +| `state` + `stateAttributesMapping` | Maps state values to `data-*` attrs (below). | +| `enabled` | When `false`, returns `null`. Positioners pass `enabled: mounted` to gate the floating DOM. | + +Rules that hold for **every** part: + +- **`'use client';`** at the top of every component file. +- **`React.forwardRef`** for any part that renders an element. +- **`const { render, ...otherProps } = props;`** — pull `render` out, spread the rest. +- **`ownProps satisfies DefaultProps`** for authored defaults. `DefaultProps` + is the tag's props widened to allow `data-*` keys — `satisfies` type-checks + every key against the real element without an `as` cast. +- **`mergeProps(internal, consumer)` — internal first, consumer second.** Event + handlers chain (internal fires, then consumer), `style` shallow-merges, + `className` concatenates, everything else the consumer overwrites. This lets + consumers extend behavior without breaking ARIA/handlers the primitive owns. + +### state → data-attribute mapping + +`stateAttributesMapping` maps each `state` key to a function returning a +`data-*` object or `null` (omit). Return the boolean-off branch as `null` for +presence attrs, or a second attribute for on/off pairs: + +```ts +// presence: attr only when true +selected: v => (v ? { 'data-selected': '' } : null), +disabled: v => (v ? { 'data-disabled': '' } : null), +// pair: data-open vs data-closed +open: v => (v ? { 'data-open': '' } : { 'data-closed': '' }), +``` + +Consumers then style off `[data-selected]`, `[data-open]`, etc. + +## File layout of a primitive + +Every `primitives//` folder follows the same shape: + +| File | Holds | +| ------------------- | ---------------------------------------------------------------------------------------------- | +| `-root.tsx` | Context provider; owns floating/interaction/transition state. Often wraps `FloatingTree`. | +| `-.tsx` | One file per part (`-trigger`, `-popup`, `-positioner`, …), each a `forwardRef` + `useRender`. | +| `-context.ts` | Context type + `createContext` + guard hook (below). | +| `parts.ts` | Re-exports each part under its short alias. | +| `index.ts` | Public entry: namespace + prop-type re-exports. | +| `.test.tsx` | Tests (real Chromium via vitest browser mode, not jsdom). | +| `README.md` | The primitive's API docs. | + +**Context + guard hook** — the pattern that makes "used outside Root" a clear error: + +```ts +export const SelectContext = createContext(null); + +export function useSelectContext() { + const ctx = useContext(SelectContext); + if (!ctx) throw new Error('Select compound components must be used within '); + return ctx; +} +``` + +**`parts.ts`** — alias each part; this is what the namespace spreads: + +```ts +export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger'; +export { type SelectOptionProps, SelectOption as Option } from './select-option'; +// … +``` + +**`index.ts`** — namespace + public prop types (the prop types must be +re-exported here or `@clerk/ui`'s `.d.ts` rollup hits TS2742): + +```ts +export * as Select from './parts'; +export type { SelectProps, SelectTriggerProps, SelectOptionProps /* … */ } from './parts'; +``` + +**`Portal`** parts take an optional `root` and gate on `mounted`: + +```tsx +export function SelectPortal(props: { + children: ReactNode; + root?: HTMLElement | RefObject | null; +}) { + const { mounted } = useSelectContext(); + if (!mounted) return null; + return {props.children}; +} +``` + +## Animation lifecycle (`data-*` driven) + +All enter/exit timing lives in **CSS**; the primitives only toggle `data-*` +attributes and drive unmount off the Web Animations API. Root spreads +`transitionProps` (from `useTransition`) onto the Popup; the lifecycle: + +- **Open** → synchronously `mounted=true`, status `'starting'`: first committed + frame carries `data-open` + `data-starting-style` + inline `transition: none` + (snapshot frame). One rAF later `data-starting-style` clears → CSS transitions + fire toward the resting style. +- **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied. + After all animations on the element finish (`useAnimationsFinished`), + `mounted` flips false and the element unmounts. + +Consumer CSS keys off these: `[data-starting-style] { opacity: 0 }`, +`[data-open] { animation: … }`, `[data-ending-style] { opacity: 0 }`. + +**Positioners** gate the floating layer on `mounted` via `useRender`'s +`enabled`, so the positioned DOM doesn't exist until the first frame: + +```tsx +const element = useRender({ defaultTagName: 'div', render, enabled: mounted, ref: [refs.setFloating, ref], props }); +if (!element) return null; +``` + +## Shared hooks (`@clerk/headless/hooks`) + +| Hook | Signature (abridged) | Purpose | +| ----------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `useControllableState` | `(controlled, defaultValue, onChange?) => [value, setValue]` | Dual-mode controlled/uncontrolled state; `onChange` fires either way. | +| `useTransition` | `({ open, ref }) => { mounted, transitionStatus, transitionProps }` | Enter/exit lifecycle; spread `transitionProps` onto the animated part. | +| `useTransitionStatus` | `(open) => { mounted, transitionStatus, setMounted }` | Lower-level phase machine (`'starting'` / `'ending'` / `undefined`). | +| `useAnimationsFinished` | `(ref, open) => (cb) => void` | Runs `cb` once all CSS animations finish; aborts on rapid toggles. | +| `useDataTable` | `(opts) => { rows, sorting, pagination, rowSelection, … }` | Table state (sort/filter/paginate/select), controlled or uncontrolled. | + +## Shared utils (`@clerk/headless/utils`) + +- **`useRender`, `mergeProps`, `ComponentProps`, `DefaultProps`, `RenderProp`** — the part-authoring primitives (above). +- **`cssVars({ sideOffset? }): Middleware`** — floating-ui middleware setting + `--cl-anchor-width/height`, `--cl-available-width/height`, `--cl-transform-origin` + on the floating element. Place it **after** `arrow()`. +- **`resetLayoutStyles(el): () => void`** — temporarily forces flex/grid + alignment to `initial` for accurate `scrollHeight`/`scrollWidth` measurement; + restores on next rAF. Call the returned cleanup from effect cleanup. + +## Testing + +Tests run in **real Chromium** (vitest browser mode), not jsdom, and include +`axe` accessibility assertions. `pnpm test` in `packages/headless`. See +`testing.md` for the Mosaic flow-layer testing model (a different concern — that +covers machines/controllers/views, not these primitives). diff --git a/packages/headless/README.md b/packages/headless/README.md index 3669443dd66..2f6b4e78f5d 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -19,7 +19,7 @@ This package is **internal** (`private: true`) and consumed by `@clerk/ui`. It e | 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`). +Shared utilities are available at `@clerk/headless/utils` (includes `useRender` and `mergeProps`). Each primitive has its own README in `src/primitives//` with full API docs, props tables, keyboard navigation, and data attributes. @@ -50,7 +50,7 @@ All primitives follow the same compound component pattern. They emit zero styles ## Architecture - **Compound components** — each primitive exports a namespace (e.g. `Select.Trigger`, `Select.Popup`) backed by per-part files so unused parts tree-shake out -- **`renderElement`** — every part uses this instead of returning JSX directly, enabling consumer `render` prop overrides and automatic state-to-data-attribute mapping +- **`useRender`** — every part calls this hook instead of returning JSX directly, enabling consumer `render` prop overrides (function or element) and automatic state-to-data-attribute mapping - **`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 - **CSS-driven animations** — the transition system uses `data-*` 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` @@ -117,9 +117,9 @@ Consumers can then style with the theme: Without the annotation, `tsc` emits **TS2742**: -> The inferred type of `Dialog` cannot be named without a reference to `@clerk/headless/dist/utils/render-element`. This is likely not portable. +> The inferred type of `Dialog` cannot be named without a reference to `@clerk/headless/dist/utils/use-render`. This is likely not portable. -`makeCustomizable

` returns an internal `CustomizablePrimitive

` 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>` annotation forces TS to reference the named `DialogXProps` type from `@clerk/headless/dialog` (a public entry) instead of expanding it. +`makeCustomizable

` returns an internal `CustomizablePrimitive

` 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>` annotation forces TS to reference the named `DialogXProps` type from `@clerk/headless/dialog` (a public entry) instead of expanding it. 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/.tsx` following the pattern above. From 1308099895565c0c87a83ef671de129f18c27bd1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 24 Jul 2026 09:36:29 -0400 Subject: [PATCH 2/2] docs(repo): scope useRender wording to element-rendering parts --- .claude/skills/mosaic/references/headless.md | 5 +++-- packages/headless/README.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.claude/skills/mosaic/references/headless.md b/.claude/skills/mosaic/references/headless.md index 2bc5e1d16cb..5fde047a15a 100644 --- a/.claude/skills/mosaic/references/headless.md +++ b/.claude/skills/mosaic/references/headless.md @@ -49,8 +49,9 @@ import { Select } from '@clerk/headless/select'; ; ``` -- **Each part accepts native props for its tag plus a `render` prop.** Unused - parts tree-shake out. +- **Each element-rendering part accepts native props for its tag plus a `render` + prop.** Pass-through parts (`Root`, `Portal`) render no element of their own + and have their own APIs instead. Unused parts tree-shake out. - **Style by className/`data-*`**, never by a slot attribute — the primitives don't emit one. - **`render` is the override escape hatch.** It takes a function diff --git a/packages/headless/README.md b/packages/headless/README.md index 2f6b4e78f5d..871728e6336 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -50,7 +50,7 @@ All primitives follow the same compound component pattern. They emit zero styles ## Architecture - **Compound components** — each primitive exports a namespace (e.g. `Select.Trigger`, `Select.Popup`) backed by per-part files so unused parts tree-shake out -- **`useRender`** — every part calls this hook instead of returning JSX directly, enabling consumer `render` prop overrides (function or element) and automatic state-to-data-attribute mapping +- **`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 - **`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 - **CSS-driven animations** — the transition system uses `data-*` 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`