diff --git a/.changeset/curvy-worlds-change.md b/.changeset/curvy-worlds-change.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/curvy-worlds-change.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts
index eae4ccedc32..47abab801d6 100644
--- a/packages/headless/src/utils/index.ts
+++ b/packages/headless/src/utils/index.ts
@@ -1,4 +1,3 @@
export { cssVars } from './css-vars';
export { resetLayoutStyles } from './reset-layout-styles';
-export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, renderElement } from './render-element';
-export { useRender } from './use-render';
+export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, useRender } from './use-render';
diff --git a/packages/headless/src/utils/render-element.test.tsx b/packages/headless/src/utils/render-element.test.tsx
deleted file mode 100644
index eea05a55b85..00000000000
--- a/packages/headless/src/utils/render-element.test.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { cleanup, render, screen } from '@testing-library/react';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { mergeProps, renderElement } from './render-element';
-
-afterEach(() => {
- cleanup();
-});
-
-describe('mergeProps', () => {
- it('merges two plain objects', () => {
- const result = mergeProps<'div'>({ id: 'a', 'data-x': '1' }, { 'data-y': '2' });
- expect(result).toEqual({ id: 'a', 'data-x': '1', 'data-y': '2' });
- });
-
- it('second argument overrides first for plain props', () => {
- const result = mergeProps<'div'>({ id: 'a' }, { id: 'b' });
- expect(result.id).toBe('b');
- });
-
- it('chains event handlers', () => {
- const first = vi.fn();
- const second = vi.fn();
- const result = mergeProps<'button'>({ onClick: first }, { onClick: second });
-
- (result.onClick as (...args: unknown[]) => void)();
-
- expect(first).toHaveBeenCalledTimes(1);
- expect(second).toHaveBeenCalledTimes(1);
- });
-
- it('shallow-merges style objects', () => {
- const result = mergeProps<'div'>(
- { style: { color: 'red', fontSize: 14 } },
- { style: { color: 'blue', padding: 8 } },
- );
- expect(result.style).toEqual({
- color: 'blue',
- fontSize: 14,
- padding: 8,
- });
- });
-
- it('concatenates className', () => {
- const result = mergeProps<'div'>({ className: 'base' }, { className: 'extra' });
- expect(result.className).toBe('base extra');
- });
-
- it('does not chain non-event-handler functions', () => {
- const ref1 = vi.fn();
- const ref2 = vi.fn();
- const result = mergeProps<'div'>({ ref: ref1 }, { ref: ref2 });
- // ref is not an event handler (doesn't match /^on[A-Z]/), so it's overwritten
- expect(result.ref).toBe(ref2);
- });
-});
-
-describe('renderElement', () => {
- it('renders default tag when no render prop', () => {
- const element = renderElement({
- defaultTagName: 'span',
- props: { 'data-testid': 'test', children: 'hello' },
- });
-
- render(element);
-
- const el = screen.getByTestId('test');
- expect(el.tagName).toBe('SPAN');
- expect(el).toHaveTextContent('hello');
- });
-
- it('renders via render function', () => {
- const element = renderElement({
- defaultTagName: 'div',
- render: props => ,
- props: { 'data-testid': 'test', children: 'content' },
- });
-
- render(element);
-
- const el = screen.getByTestId('test');
- expect(el.tagName).toBe('ARTICLE');
- });
-
- it('returns null when enabled is false', () => {
- const element = renderElement({
- defaultTagName: 'div',
- enabled: false,
- props: { children: 'hidden' },
- });
-
- expect(element).toBeNull();
- });
-
- it('renders when enabled is true', () => {
- const element = renderElement({
- defaultTagName: 'div',
- enabled: true,
- props: { 'data-testid': 'test', children: 'visible' },
- });
-
- render(element);
-
- expect(screen.getByTestId('test')).toBeInTheDocument();
- });
-
- it('applies state attributes via stateAttributesMapping', () => {
- const element = renderElement({
- defaultTagName: 'button',
- state: { open: true },
- stateAttributesMapping: {
- open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
- },
- props: { 'data-testid': 'test' },
- });
-
- render(element);
-
- const el = screen.getByTestId('test');
- expect(el).toHaveAttribute('data-cl-open', '');
- expect(el).not.toHaveAttribute('data-cl-closed');
- });
-
- it('applies false state attributes via stateAttributesMapping', () => {
- const element = renderElement({
- defaultTagName: 'button',
- state: { open: false },
- stateAttributesMapping: {
- open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
- },
- props: { 'data-testid': 'test' },
- });
-
- render(element);
-
- const el = screen.getByTestId('test');
- expect(el).toHaveAttribute('data-cl-closed', '');
- expect(el).not.toHaveAttribute('data-cl-open');
- });
-
- it('skips null return from stateAttributesMapping', () => {
- const element = renderElement({
- defaultTagName: 'div',
- state: { selected: false },
- stateAttributesMapping: {
- selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
- },
- props: { 'data-testid': 'test' },
- });
-
- render(element);
-
- const el = screen.getByTestId('test');
- expect(el).not.toHaveAttribute('data-cl-selected');
- });
-
- it('merges multiple state attribute mappings', () => {
- const element = renderElement({
- defaultTagName: 'div',
- state: { selected: true, active: true, disabled: false },
- stateAttributesMapping: {
- selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
- active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
- disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
- },
- props: { 'data-testid': 'test' },
- });
-
- render(element);
-
- const el = screen.getByTestId('test');
- expect(el).toHaveAttribute('data-cl-selected', '');
- expect(el).toHaveAttribute('data-cl-active', '');
- expect(el).not.toHaveAttribute('data-cl-disabled');
- });
-
- it('state attributes override props', () => {
- const element = renderElement({
- defaultTagName: 'div',
- state: { open: true },
- stateAttributesMapping: {
- open: () => ({ 'data-cl-open': '' }),
- },
- props: { 'data-testid': 'test', 'data-cl-open': 'should-be-overridden' },
- });
-
- render(element);
-
- const el = screen.getByTestId('test');
- expect(el).toHaveAttribute('data-cl-open', '');
- });
-
- it('passes computed props to render function', () => {
- const renderFn = vi.fn(props => );
-
- renderElement({
- defaultTagName: 'div',
- render: renderFn,
- state: { open: true },
- stateAttributesMapping: {
- open: (v: boolean) => (v ? { 'data-cl-open': '' } : null),
- },
- props: { id: 'test' },
- });
-
- expect(renderFn).toHaveBeenCalledWith(
- expect.objectContaining({
- id: 'test',
- 'data-cl-open': '',
- }),
- );
- });
-});
diff --git a/packages/headless/src/utils/render-element.tsx b/packages/headless/src/utils/render-element.tsx
deleted file mode 100644
index 170dc75d588..00000000000
--- a/packages/headless/src/utils/render-element.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import * as React from 'react';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-/**
- * A render prop: a function that receives computed HTML props and returns a JSX element.
- */
-export type RenderProp> = (props: Props) => React.ReactElement;
-
-/**
- * Props accepted by any primitive part. Extends the native props for `Tag`
- * and adds the optional `render` escape hatch, narrowed to that tag's props.
- */
-export type ComponentProps = React.ComponentPropsWithRef & {
- render?: RenderProp>;
-};
-
-/**
- * The props a primitive part applies to its own rendered element. Extends the
- * native props for `Tag` and additionally permits internal `data-*` attributes
- * (e.g. `data-cl-slot`), which `@types/react` intentionally omits from its
- * element prop types.
- *
- * Use with `satisfies` to type-check authored default props — this validates
- * every key against the real element props while still allowing our `data-*`
- * attributes, instead of laundering the whole object past the checker with an
- * `as` assertion.
- */
-export type DefaultProps = React.ComponentPropsWithRef &
- Record<`data-${string}`, string>;
-
-/**
- * Maps state keys to functions that return data-attribute objects (or null).
- */
-export type StateAttributesMapping = {
- [K in keyof S]?: (value: S[K]) => Record | null;
-};
-
-// ---------------------------------------------------------------------------
-// mergeProps
-// ---------------------------------------------------------------------------
-
-type PropsInput =
- | React.ComponentPropsWithRef
- | Record;
-
-/**
- * Merges two props objects. Event handlers are chained (both fire),
- * `style` is shallow-merged, `className` is concatenated, and
- * everything else is overwritten by the second argument.
- */
-export function mergeProps(
- a: PropsInput,
- b: PropsInput,
-): Record;
-export function mergeProps(a: Record, b: Record): Record {
- const merged: Record = { ...a };
-
- for (const key of Object.keys(b)) {
- const bVal = b[key];
-
- if (
- key.charCodeAt(0) === 111 /* o */ &&
- key.charCodeAt(1) === 110 /* n */ &&
- key.charCodeAt(2) >= 65 /* A */ &&
- key.charCodeAt(2) <= 90 /* Z */ &&
- typeof a[key] === 'function' &&
- typeof bVal === 'function'
- ) {
- // Chain event handlers — internal fires first, consumer fires second
- const aHandler = a[key] as (...args: unknown[]) => void;
- const bHandler = bVal as (...args: unknown[]) => void;
- merged[key] = (...args: unknown[]) => {
- aHandler(...args);
- bHandler(...args);
- };
- } else if (key === 'style' && a.style && bVal) {
- merged.style = { ...(a.style as object), ...(bVal as object) };
- } else if (key === 'className' && typeof a.className === 'string' && typeof bVal === 'string') {
- merged.className = `${a.className} ${bVal}`;
- } else {
- merged[key] = bVal;
- }
- }
-
- return merged;
-}
-
-// ---------------------------------------------------------------------------
-// renderElement
-// ---------------------------------------------------------------------------
-
-interface RenderElementParamsBase<
- Tag extends keyof React.JSX.IntrinsicElements,
- State extends Record = Record,
-> {
- /** Fallback HTML tag when `render` is not provided. */
- defaultTagName: Tag;
- /** Render prop from the consumer, narrowed to the element's native props. */
- render?: RenderProp>;
- /** State object. Keys are mapped to data attributes via `stateAttributesMapping`. */
- state?: State;
- /** Custom mapping from state keys to data-attribute objects. */
- stateAttributesMapping?: StateAttributesMapping;
- /** Merged props to spread onto the element. */
- props?: Record;
-}
-
-interface RenderElementParamsConditional<
- Tag extends keyof React.JSX.IntrinsicElements,
- State extends Record = Record,
-> extends RenderElementParamsBase {
- /** When false, returns null (used by Positioners gated on `mounted`). */
- enabled: boolean;
-}
-
-interface RenderElementParamsAlways<
- Tag extends keyof React.JSX.IntrinsicElements,
- State extends Record = Record,
-> extends RenderElementParamsBase {
- enabled?: never;
-}
-
-/**
- * Renders a primitive part element with render-prop support, conditional
- * rendering, and state-to-data-attribute mapping.
- *
- * When `enabled` is omitted the element is always rendered (returns ReactElement).
- * When `enabled` is passed the element may be skipped (returns ReactElement | null).
- */
-export function renderElement<
- Tag extends keyof React.JSX.IntrinsicElements,
- State extends Record = Record,
->(params: RenderElementParamsAlways): React.ReactElement;
-export function renderElement<
- Tag extends keyof React.JSX.IntrinsicElements,
- State extends Record = Record,
->(params: RenderElementParamsConditional): React.ReactElement | null;
-export function renderElement<
- Tag extends keyof React.JSX.IntrinsicElements,
- State extends Record = Record,
->(params: RenderElementParamsBase & { enabled?: boolean }): React.ReactElement | null {
- const { defaultTagName, render, enabled = true, state, stateAttributesMapping, props } = params;
-
- if (!enabled) {
- return null;
- }
-
- // Build data attributes from state
- let dataAttrs: Record = {};
- if (state && stateAttributesMapping) {
- for (const key of Object.keys(stateAttributesMapping) as Array) {
- const mapper = stateAttributesMapping[key];
- if (mapper) {
- const attrs = mapper(state[key]);
- if (attrs) {
- dataAttrs = { ...dataAttrs, ...attrs };
- }
- }
- }
- }
-
- const computedProps = { ...props, ...dataAttrs };
-
- if (render) {
- return render(computedProps as React.ComponentPropsWithRef);
- }
-
- return React.createElement(defaultTagName, computedProps);
-}
diff --git a/packages/headless/src/utils/render-element.test-d.ts b/packages/headless/src/utils/use-render.test-d.ts
similarity index 83%
rename from packages/headless/src/utils/render-element.test-d.ts
rename to packages/headless/src/utils/use-render.test-d.ts
index 5fc7e09df2c..5da4b4a387d 100644
--- a/packages/headless/src/utils/render-element.test-d.ts
+++ b/packages/headless/src/utils/use-render.test-d.ts
@@ -1,9 +1,9 @@
import type React from 'react';
import { describe, expectTypeOf, test } from 'vitest';
-import type { ComponentProps } from './render-element';
+import type { ComponentProps } from './use-render';
-describe('render-element', () => {
+describe('use-render', () => {
test('render prop arg is narrowed to the element tag props, not the generic HTMLAttributes', () => {
type Props = ComponentProps<'button'>;
type RenderArg = NonNullable extends (props: infer P) => React.ReactElement ? P : never;
diff --git a/packages/headless/src/utils/use-render.test.tsx b/packages/headless/src/utils/use-render.test.tsx
index 6589b95c7b8..a26c3cdb0cb 100644
--- a/packages/headless/src/utils/use-render.test.tsx
+++ b/packages/headless/src/utils/use-render.test.tsx
@@ -2,12 +2,60 @@ import { cleanup, render, renderHook, screen } from '@testing-library/react';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { useRender } from './use-render';
+import { mergeProps, useRender } from './use-render';
afterEach(() => {
cleanup();
});
+describe('mergeProps', () => {
+ it('merges two plain objects', () => {
+ const result = mergeProps<'div'>({ id: 'a', 'data-x': '1' }, { 'data-y': '2' });
+ expect(result).toEqual({ id: 'a', 'data-x': '1', 'data-y': '2' });
+ });
+
+ it('second argument overrides first for plain props', () => {
+ const result = mergeProps<'div'>({ id: 'a' }, { id: 'b' });
+ expect(result.id).toBe('b');
+ });
+
+ it('chains event handlers', () => {
+ const first = vi.fn();
+ const second = vi.fn();
+ const result = mergeProps<'button'>({ onClick: first }, { onClick: second });
+
+ (result.onClick as (...args: unknown[]) => void)();
+
+ expect(first).toHaveBeenCalledTimes(1);
+ expect(second).toHaveBeenCalledTimes(1);
+ });
+
+ it('shallow-merges style objects', () => {
+ const result = mergeProps<'div'>(
+ { style: { color: 'red', fontSize: 14 } },
+ { style: { color: 'blue', padding: 8 } },
+ );
+ expect(result.style).toEqual({
+ color: 'blue',
+ fontSize: 14,
+ padding: 8,
+ });
+ });
+
+ it('concatenates className', () => {
+ const result = mergeProps<'div'>({ className: 'base' }, { className: 'extra' });
+ expect(result.className).toBe('base extra');
+ });
+
+ it('does not chain non-event-handler functions', () => {
+ const ref1 = vi.fn();
+ const ref2 = vi.fn();
+ const result = mergeProps<'div'>({ ref: ref1 }, { ref: ref2 });
+ // ref is not an event handler (doesn't match /^on[A-Z]/), so it's overwritten
+ expect(result.ref).toBe(ref2);
+ });
+});
+
describe('useRender', () => {
it('renders the default tag when no render prop', () => {
function C() {
diff --git a/packages/headless/src/utils/use-render.tsx b/packages/headless/src/utils/use-render.tsx
index 32c4093f3ff..bf1197e7ee4 100644
--- a/packages/headless/src/utils/use-render.tsx
+++ b/packages/headless/src/utils/use-render.tsx
@@ -1,8 +1,97 @@
import { useMergeRefs } from '@floating-ui/react';
import * as React from 'react';
-import type { DefaultProps, RenderProp, StateAttributesMapping } from './render-element';
-import { mergeProps } from './render-element';
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/**
+ * A render prop: a function that receives computed HTML props and returns a JSX element.
+ */
+export type RenderProp> = (props: Props) => React.ReactElement;
+
+/**
+ * Props accepted by any primitive part. Extends the native props for `Tag`
+ * and adds the optional `render` escape hatch, narrowed to that tag's props.
+ */
+export type ComponentProps = React.ComponentPropsWithRef & {
+ render?: RenderProp>;
+};
+
+/**
+ * The props a primitive part applies to its own rendered element. Extends the
+ * native props for `Tag` and additionally permits internal `data-*` attributes
+ * (e.g. `data-cl-slot`), which `@types/react` intentionally omits from its
+ * element prop types.
+ *
+ * Use with `satisfies` to type-check authored default props — this validates
+ * every key against the real element props while still allowing our `data-*`
+ * attributes, instead of laundering the whole object past the checker with an
+ * `as` assertion.
+ */
+export type DefaultProps = React.ComponentPropsWithRef &
+ Record<`data-${string}`, string>;
+
+/**
+ * Maps state keys to functions that return data-attribute objects (or null).
+ */
+export type StateAttributesMapping = {
+ [K in keyof S]?: (value: S[K]) => Record | null;
+};
+
+// ---------------------------------------------------------------------------
+// mergeProps
+// ---------------------------------------------------------------------------
+
+type PropsInput =
+ | React.ComponentPropsWithRef
+ | Record;
+
+/**
+ * Merges two props objects. Event handlers are chained (both fire),
+ * `style` is shallow-merged, `className` is concatenated, and
+ * everything else is overwritten by the second argument.
+ */
+export function mergeProps(
+ a: PropsInput,
+ b: PropsInput,
+): Record;
+export function mergeProps(a: Record, b: Record): Record {
+ const merged: Record = { ...a };
+
+ for (const key of Object.keys(b)) {
+ const bVal = b[key];
+
+ if (
+ key.charCodeAt(0) === 111 /* o */ &&
+ key.charCodeAt(1) === 110 /* n */ &&
+ key.charCodeAt(2) >= 65 /* A */ &&
+ key.charCodeAt(2) <= 90 /* Z */ &&
+ typeof a[key] === 'function' &&
+ typeof bVal === 'function'
+ ) {
+ // Chain event handlers — internal fires first, consumer fires second
+ const aHandler = a[key] as (...args: unknown[]) => void;
+ const bHandler = bVal as (...args: unknown[]) => void;
+ merged[key] = (...args: unknown[]) => {
+ aHandler(...args);
+ bHandler(...args);
+ };
+ } else if (key === 'style' && a.style && bVal) {
+ merged.style = { ...(a.style as object), ...(bVal as object) };
+ } else if (key === 'className' && typeof a.className === 'string' && typeof bVal === 'string') {
+ merged.className = `${a.className} ${bVal}`;
+ } else {
+ merged[key] = bVal;
+ }
+ }
+
+ return merged;
+}
+
+// ---------------------------------------------------------------------------
+// useRender
+// ---------------------------------------------------------------------------
/**
* A `render` prop: a render function, or a React element to clone with the
@@ -62,9 +151,8 @@ interface UseRenderParamsAlways<
/**
* Renders a primitive part with render-prop support, conditional rendering, and
- * state-to-data-attribute mapping. Unlike `renderElement`, `render` accepts a
- * React element (cloned with merged props) as well as a function, and refs are
- * merged internally via `ref`.
+ * state-to-data-attribute mapping. `render` accepts a React element (cloned with
+ * merged props) as well as a function, and refs are merged internally via `ref`.
*
* Hook: call it unconditionally. When `enabled` is omitted the element is always
* rendered (returns ReactElement); when passed it may be skipped (returns null).
@@ -108,7 +196,7 @@ export function useRender<
if (typeof render === 'function') {
// SAFETY: computedProps is the tag's props widened with data-* attrs; the render
- // function is declared to receive this tag's props. Matches renderElement above.
+ // function is declared to receive this tag's props.
return render({ ...computedProps, ref: mergedRef } as React.ComponentPropsWithRef);
}