From 3445cc32402c35fbd427d58b4e639972caafa0f5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 17:33:35 -0400 Subject: [PATCH] feat(headless): add useRender hook alongside renderElement Introduces a base-ui-style useRender hook: a render prop that accepts a React element (render={}) or a function, with internal ref merging and state-to-data-attribute mapping. renderElement stays exported for the phased migration. Migrates Mosaic Box, Heading, Text and the dialog trigger/popup primitives onto the hook. --- .changeset/mosaic-use-render.md | 2 + .../src/primitives/dialog/dialog-popup.tsx | 37 ++-- .../src/primitives/dialog/dialog-trigger.tsx | 17 +- packages/headless/src/utils/index.ts | 1 + .../headless/src/utils/render-element.tsx | 2 +- .../headless/src/utils/use-render.test.tsx | 189 ++++++++++++++++++ packages/headless/src/utils/use-render.tsx | 126 ++++++++++++ packages/ui/src/mosaic/components/heading.tsx | 24 ++- packages/ui/src/mosaic/components/text.tsx | 22 +- packages/ui/src/mosaic/primitives/box.tsx | 6 +- 10 files changed, 382 insertions(+), 44 deletions(-) create mode 100644 .changeset/mosaic-use-render.md create mode 100644 packages/headless/src/utils/use-render.test.tsx create mode 100644 packages/headless/src/utils/use-render.tsx diff --git a/.changeset/mosaic-use-render.md b/.changeset/mosaic-use-render.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-use-render.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 45f68b9ee84..6e11828a734 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -1,9 +1,9 @@ 'use client'; -import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react'; +import { FloatingFocusManager } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; /** Props for {@link DialogPopup}. */ @@ -15,35 +15,36 @@ export const DialogPopup = React.forwardRef(fu const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, mounted, transitionProps } = useDialogContext(); - // floating-ui types `setFloating` as a method signature, but at runtime it's - // a stable callback that doesn't use `this`, so the unbound-method check is a - // false positive here. - // eslint-disable-next-line @typescript-eslint/unbound-method - const combinedRef = useMergeRefs([popupRef, refs.setFloating, ref]); - - if (!mounted) { - return null; - } - const ownProps = { - ref: combinedRef, 'aria-labelledby': labelId, 'aria-describedby': descriptionId, } satisfies DefaultProps<'div'>; const defaultProps = { ...ownProps, ...getFloatingProps(), ...transitionProps }; + const element = useRender({ + defaultTagName: 'div', + render, + enabled: mounted, + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [popupRef, refs.setFloating, ref], + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + if (!element) { + return null; + } + return ( - {renderElement({ - defaultTagName: 'div', - render, - props: mergeProps<'div'>(defaultProps, otherProps), - })} + {element} ); }); diff --git a/packages/headless/src/primitives/dialog/dialog-trigger.tsx b/packages/headless/src/primitives/dialog/dialog-trigger.tsx index 168e4bb1b51..33cc82b4263 100644 --- a/packages/headless/src/primitives/dialog/dialog-trigger.tsx +++ b/packages/headless/src/primitives/dialog/dialog-trigger.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; /** Props for {@link DialogTrigger}. */ @@ -15,24 +14,22 @@ export const DialogTrigger = React.forwardRef; const defaultProps = { ...ownProps, ...getReferenceProps() }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [refs.setReference, ref], state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index a0fc997f94a..eae4ccedc32 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1,3 +1,4 @@ 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'; diff --git a/packages/headless/src/utils/render-element.tsx b/packages/headless/src/utils/render-element.tsx index 8d2b56ecb8c..170dc75d588 100644 --- a/packages/headless/src/utils/render-element.tsx +++ b/packages/headless/src/utils/render-element.tsx @@ -34,7 +34,7 @@ export type DefaultProps = React. /** * Maps state keys to functions that return data-attribute objects (or null). */ -type StateAttributesMapping = { +export type StateAttributesMapping = { [K in keyof S]?: (value: S[K]) => Record | null; }; diff --git a/packages/headless/src/utils/use-render.test.tsx b/packages/headless/src/utils/use-render.test.tsx new file mode 100644 index 00000000000..6589b95c7b8 --- /dev/null +++ b/packages/headless/src/utils/use-render.test.tsx @@ -0,0 +1,189 @@ +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'; + +afterEach(() => { + cleanup(); +}); + +describe('useRender', () => { + it('renders the default tag when no render prop', () => { + function C() { + return useRender({ defaultTagName: 'span', props: { 'data-testid': 'test', children: 'hello' } }); + } + render(); + + const el = screen.getByTestId('test'); + expect(el.tagName).toBe('SPAN'); + expect(el).toHaveTextContent('hello'); + }); + + it('renders via a render function', () => { + function C() { + return useRender({ + defaultTagName: 'div', + render: props =>
, + props: { 'data-testid': 'test', children: 'content' }, + }); + } + render(); + + expect(screen.getByTestId('test').tagName).toBe('ARTICLE'); + }); + + it('renders via a render element (clones it)', () => { + function C() { + return useRender({ + defaultTagName: 'div', + render:
, + props: { 'data-testid': 'test', children: 'content' }, + }); + } + render(); + + const el = screen.getByTestId('test'); + expect(el.tagName).toBe('ARTICLE'); + expect(el).toHaveAttribute('data-variant', 'x'); + expect(el).toHaveTextContent('content'); + }); + + it('returns null when enabled is false', () => { + const { result } = renderHook(() => + useRender({ defaultTagName: 'div', enabled: false, props: { children: 'hidden' } }), + ); + expect(result.current).toBeNull(); + }); + + it('applies state attributes via stateAttributesMapping', () => { + function C() { + return useRender({ + defaultTagName: 'button', + state: { open: true }, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: { 'data-testid': 'test' }, + }); + } + render(); + + const el = screen.getByTestId('test'); + expect(el).toHaveAttribute('data-cl-open', ''); + expect(el).not.toHaveAttribute('data-cl-closed'); + }); + + it('merges a single ref onto the rendered element', () => { + const ref = React.createRef(); + function C() { + return useRender({ defaultTagName: 'div', ref, props: { 'data-testid': 'test' } }); + } + render(); + + expect(ref.current).toBe(screen.getByTestId('test')); + }); + + it('merges an array of refs onto the rendered element', () => { + const refA = React.createRef(); + const refB = vi.fn(); + function C() { + return useRender({ defaultTagName: 'div', ref: [refA, refB], props: { 'data-testid': 'test' } }); + } + render(); + + const el = screen.getByTestId('test'); + expect(refA.current).toBe(el); + expect(refB).toHaveBeenCalledWith(el); + }); + + it("merges a render element's own ref with the params ref", () => { + const paramRef = React.createRef(); + const elementRef = React.createRef(); + function C() { + return useRender({ + defaultTagName: 'div', + ref: paramRef, + render: ( +
+ ), + }); + } + render(); + + const el = screen.getByTestId('test'); + expect(paramRef.current).toBe(el); + expect(elementRef.current).toBe(el); + }); + + it('concatenates className when cloning a render element', () => { + function C() { + return useRender({ + defaultTagName: 'div', + render: ( +
+ ), + props: { className: 'from-part' }, + }); + } + render(); + + expect(screen.getByTestId('test')).toHaveClass('from-part', 'from-element'); + }); + + it('chains event handlers when cloning a render element', () => { + const partHandler = vi.fn(); + const elementHandler = vi.fn(); + function C() { + return useRender({ + defaultTagName: 'button', + render: ( +