Skip to content

Commit 68c042b

Browse files
committed
[useRender] Add defaultTagName parameter (mui#2527)
1 parent 4406285 commit 68c042b

6 files changed

Lines changed: 76 additions & 14 deletions

File tree

docs/reference/generated/use-render.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"parameters": {
55
"render": {
66
"type": "useRender.RenderProp<Record<string, unknown>>",
7-
"required": true,
87
"description": "The React element or a function that returns one to override the default element."
98
},
109
"ref": {
@@ -33,6 +32,12 @@
3332
"default": "true",
3433
"description": "If `false`, the hook will skip most of its internal logic and return `null`.\nThis is useful for rendering a component conditionally.",
3534
"detailedType": "boolean | undefined | undefined"
35+
},
36+
"defaultTagName": {
37+
"type": "'symbol' | 'object' | 'a' | 'abbr' | 'address' | 'area' | 'article' | 'aside' | 'audio' | 'b' | 'base' | 'bdi' | 'bdo' | 'big' | 'blockquote' | 'body' | 'br' | 'button' | 'canvas' | 'caption' | 'center' | 'cite' | 'code' | 'col' | 'colgroup' | 'data' | 'datalist' | 'dd' | 'del' | 'details' | 'dfn' | 'dialog' | 'div' | 'dl' | 'dt' | 'em' | 'embed' | 'fieldset' | 'figcaption' | 'figure' | 'footer' | 'form' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'head' | 'header' | 'hgroup' | 'hr' | 'html' | 'i' | 'iframe' | 'img' | 'input' | 'ins' | 'kbd' | 'keygen' | 'label' | 'legend' | 'li' | 'link' | 'main' | 'map' | 'mark' | 'menu' | 'menuitem' | 'meta' | 'meter' | 'nav' | 'noindex' | 'noscript' | 'ol' | 'optgroup' | 'option' | 'output' | 'p' | 'param' | 'picture' | 'pre' | 'progress' | 'q' | 'rp' | 'rt' | 'ruby' | 's' | 'samp' | 'search' | 'slot' | 'script' | 'section' | 'select' | 'small' | 'source' | 'span' | 'strong' | 'style' | 'sub' | 'summary' | 'sup' | 'table' | 'template' | 'tbody' | 'td' | 'textarea' | 'tfoot' | 'th' | 'thead' | 'time' | 'title' | 'tr' | 'track' | 'u' | 'ul' | 'var' | 'video' | 'wbr' | 'webview' | 'svg' | 'animate' | 'animateMotion' | 'animateTransform' | 'circle' | 'clipPath' | 'defs' | 'desc' | 'ellipse' | 'feBlend' | 'feColorMatrix' | 'feComponentTransfer' | 'feComposite' | 'feConvolveMatrix' | 'feDiffuseLighting' | 'feDisplacementMap' | 'feDistantLight' | 'feDropShadow' | 'feFlood' | 'feFuncA' | 'feFuncB' | 'feFuncG' | 'feFuncR' | 'feGaussianBlur' | 'feImage' | 'feMerge' | 'feMergeNode' | 'feMorphology' | 'feOffset' | 'fePointLight' | 'feSpecularLighting' | 'feSpotLight' | 'feTile' | 'feTurbulence' | 'filter' | 'foreignObject' | 'g' | 'image' | 'line' | 'linearGradient' | 'marker' | 'mask' | 'metadata' | 'mpath' | 'path' | 'pattern' | 'polygon' | 'polyline' | 'radialGradient' | 'rect' | 'set' | 'stop' | 'switch' | 'text' | 'textPath' | 'tspan' | 'use' | 'view'",
38+
"default": "'div'",
39+
"description": "The default tag name to use for the rendered element when `render` is not provided.",
40+
"detailedType": "| 'symbol'\n| 'object'\n| 'a'\n| 'abbr'\n| 'address'\n| 'area'\n| 'article'\n| 'aside'\n| 'audio'\n| 'b'\n| 'base'\n| 'bdi'\n| 'bdo'\n| 'big'\n| 'blockquote'\n| 'body'\n| 'br'\n| 'button'\n| 'canvas'\n| 'caption'\n| 'center'\n| 'cite'\n| 'code'\n| 'col'\n| 'colgroup'\n| 'data'\n| 'datalist'\n| 'dd'\n| 'del'\n| 'details'\n| 'dfn'\n| 'dialog'\n| 'div'\n| 'dl'\n| 'dt'\n| 'em'\n| 'embed'\n| 'fieldset'\n| 'figcaption'\n| 'figure'\n| 'footer'\n| 'form'\n| 'h1'\n| 'h2'\n| 'h3'\n| 'h4'\n| 'h5'\n| 'h6'\n| 'head'\n| 'header'\n| 'hgroup'\n| 'hr'\n| 'html'\n| 'i'\n| 'iframe'\n| 'img'\n| 'input'\n| 'ins'\n| 'kbd'\n| 'keygen'\n| 'label'\n| 'legend'\n| 'li'\n| 'link'\n| 'main'\n| 'map'\n| 'mark'\n| 'menu'\n| 'menuitem'\n| 'meta'\n| 'meter'\n| 'nav'\n| 'noindex'\n| 'noscript'\n| 'ol'\n| 'optgroup'\n| 'option'\n| 'output'\n| 'p'\n| 'param'\n| 'picture'\n| 'pre'\n| 'progress'\n| 'q'\n| 'rp'\n| 'rt'\n| 'ruby'\n| 's'\n| 'samp'\n| 'search'\n| 'slot'\n| 'script'\n| 'section'\n| 'select'\n| 'small'\n| 'source'\n| 'span'\n| 'strong'\n| 'style'\n| 'sub'\n| 'summary'\n| 'sup'\n| 'table'\n| 'template'\n| 'tbody'\n| 'td'\n| 'textarea'\n| 'tfoot'\n| 'th'\n| 'thead'\n| 'time'\n| 'title'\n| 'tr'\n| 'track'\n| 'u'\n| 'ul'\n| 'var'\n| 'video'\n| 'wbr'\n| 'webview'\n| 'svg'\n| 'animate'\n| 'animateMotion'\n| 'animateTransform'\n| 'circle'\n| 'clipPath'\n| 'defs'\n| 'desc'\n| 'ellipse'\n| 'feBlend'\n| 'feColorMatrix'\n| 'feComponentTransfer'\n| 'feComposite'\n| 'feConvolveMatrix'\n| 'feDiffuseLighting'\n| 'feDisplacementMap'\n| 'feDistantLight'\n| 'feDropShadow'\n| 'feFlood'\n| 'feFuncA'\n| 'feFuncB'\n| 'feFuncG'\n| 'feFuncR'\n| 'feGaussianBlur'\n| 'feImage'\n| 'feMerge'\n| 'feMergeNode'\n| 'feMorphology'\n| 'feOffset'\n| 'fePointLight'\n| 'feSpecularLighting'\n| 'feSpotLight'\n| 'feTile'\n| 'feTurbulence'\n| 'filter'\n| 'foreignObject'\n| 'g'\n| 'image'\n| 'line'\n| 'linearGradient'\n| 'marker'\n| 'mask'\n| 'metadata'\n| 'mpath'\n| 'path'\n| 'pattern'\n| 'polygon'\n| 'polyline'\n| 'radialGradient'\n| 'rect'\n| 'set'\n| 'stop'\n| 'switch'\n| 'text'\n| 'textPath'\n| 'tspan'\n| 'use'\n| 'view'\n| undefined"
3641
}
3742
},
3843
"returnValue": "ReactElement | null"

docs/src/app/(public)/(content)/react/utils/use-render/demos/render-callback/css-modules/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface CounterState {
1111
interface CounterProps extends useRender.ComponentProps<'button', CounterState> {}
1212

1313
function Counter(props: CounterProps) {
14-
const { render = <button />, ...otherProps } = props;
14+
const { render, ...otherProps } = props;
1515

1616
const [count, setCount] = React.useState(0);
1717
const odd = count % 2 === 1;
@@ -32,6 +32,7 @@ function Counter(props: CounterProps) {
3232
};
3333

3434
const element = useRender({
35+
defaultTagName: 'button',
3536
render,
3637
state,
3738
props: mergeProps<'button'>(defaultProps, otherProps),

docs/src/app/(public)/(content)/react/utils/use-render/demos/render/css-modules/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import styles from './index.module.css';
77
interface TextProps extends useRender.ComponentProps<'p'> {}
88

99
function Text(props: TextProps) {
10-
const { render = <p />, ...otherProps } = props;
10+
const { render, ...otherProps } = props;
1111

1212
const element = useRender({
13+
defaultTagName: 'p',
1314
render,
1415
props: mergeProps<'p'>({ className: styles.Text }, otherProps),
1516
});

docs/src/app/(public)/(content)/react/utils/use-render/page.mdx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ The `useRender` hook lets you build custom components that provide a `render` pr
3131
description:
3232
'The state of the component. It will be used as a parameter for the render callback.',
3333
},
34+
defaultTagName: {
35+
type: 'keyof React.JSX.IntrinsicElements',
36+
description:
37+
'The default tag name to use for the rendered element when `render` is not provided.',
38+
},
3439
}}
3540
/>
3641

@@ -97,11 +102,12 @@ When building custom components, you often need to control a ref internally whil
97102

98103
In React 19, `React.forwardRef()` is not needed when building primitive components, as the external ref prop is already contained inside `props`. Your internal ref can be passed to `ref` to be merged with `props.ref`:
99104

100-
```tsx title="React 19" {5} "internalRef"
101-
function Text({ render = <p />, ...props }: TextProps) {
105+
```tsx title="React 19" {6} "internalRef"
106+
function Text({ render, ...props }: TextProps) {
102107
const internalRef = React.useRef<HTMLElement | null>(null);
103108

104109
const element = useRender({
110+
defaultTagName: 'p',
105111
ref: internalRef,
106112
props,
107113
render,
@@ -115,14 +121,15 @@ In older versions of React, you need to use `React.forwardRef()` and add the for
115121

116122
The [examples](#examples) above assume React 19, and should be modified to use `React.forwardRef()` to support React 18 and 17.
117123

118-
```tsx title="React 18 and 17" {8} "forwardedRef" "internalRef"
124+
```tsx title="React 18 and 17" {9} "forwardedRef" "internalRef"
119125
const Text = React.forwardRef(function Text(
120-
{ render = <p />, ...props }: TextProps,
126+
{ render, ...props }: TextProps,
121127
forwardedRef: React.ForwardedRef<HTMLElement>,
122128
) {
123129
const internalRef = React.useRef<HTMLElement | null>(null);
124130

125131
const element = useRender({
132+
defaultTagName: 'p',
126133
ref: [forwardedRef, internalRef],
127134
props,
128135
render,
@@ -142,14 +149,15 @@ To type props, there are two interfaces:
142149
```tsx title="Typing props" {1,4}
143150
interface ButtonProps extends useRender.ComponentProps<'button'> {}
144151

145-
function Button({ render = <button />, ...props }: ButtonProps) {
152+
function Button({ render, ...props }: ButtonProps) {
146153
const defaultProps: useRender.ElementProps<'button'> = {
147154
className: styles.Button,
148155
type: 'button',
149156
children: 'Click me',
150157
};
151158

152159
const element = useRender({
160+
defaultTagName: 'button',
153161
render,
154162
props: mergeProps<'button'>(defaultProps, props),
155163
});
@@ -183,10 +191,14 @@ In Base UI, `useRender` lets you implement a `render` prop. The example below is
183191
```jsx title="Base UI render prop"
184192
import { useRender } from '@base-ui-components/react/use-render';
185193

186-
function Button({ render = <button />, ...props }) {
187-
return useRender({ render, props });
194+
function Button({ render, ...props }) {
195+
return useRender({
196+
defaultTagName: 'button',
197+
render,
198+
props,
199+
});
188200
}
189201

190202
// Usage
191-
<Button render={<a href="/contact">Contact</a>} />;
203+
<Button render={<a href="/contact" />}>Contact</Button>;
192204
```

packages/react/src/use-render/useRender.test.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import { expect } from 'chai';
3-
import { createRenderer } from '@mui/internal-test-utils';
43
import { useRender } from '@base-ui-components/react/use-render';
4+
import { createRenderer } from '#test-utils';
55

66
describe('useRender', () => {
77
const { render } = createRenderer();
@@ -70,6 +70,44 @@ describe('useRender', () => {
7070
});
7171
});
7272

73+
describe('param: defaultTagName', () => {
74+
it('renders the element with the default tag with no render prop', async () => {
75+
function TestComponent({
76+
defaultTagName,
77+
}: {
78+
defaultTagName: keyof React.JSX.IntrinsicElements;
79+
}) {
80+
return useRender({ defaultTagName });
81+
}
82+
83+
const { container, setProps } = await render(<TestComponent defaultTagName="div" />);
84+
expect(container.firstElementChild).to.have.property('tagName', 'DIV');
85+
86+
await setProps({ defaultTagName: 'span' });
87+
expect(container.firstElementChild).to.have.property('tagName', 'SPAN');
88+
});
89+
90+
it('is overwritten by the render prop', async () => {
91+
function TestComponent({
92+
render: renderProp,
93+
defaultTagName,
94+
}: {
95+
render: useRender.Parameters<{}, Element, undefined>['render'];
96+
defaultTagName: keyof React.JSX.IntrinsicElements;
97+
}) {
98+
return useRender({ render: renderProp, defaultTagName });
99+
}
100+
101+
const { container, setProps } = await render(
102+
<TestComponent defaultTagName="div" render={<span />} />,
103+
);
104+
expect(container.firstElementChild).to.have.property('tagName', 'SPAN');
105+
106+
await setProps({ defaultTagName: 'a' });
107+
expect(container.firstElementChild).to.have.property('tagName', 'SPAN');
108+
});
109+
});
110+
73111
describe('state to data attributes', () => {
74112
it('converts state to data attributes automatically', async () => {
75113
function TestComponent() {

packages/react/src/use-render/useRender.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function useRender<
2222

2323
renderParams.customStyleHookMapping = renderParams.stateAttributesMapping;
2424

25-
return useRenderElement(undefined, renderParams, renderParams);
25+
return useRenderElement(renderParams.defaultTagName, renderParams, renderParams);
2626
}
2727

2828
export namespace useRender {
@@ -57,7 +57,7 @@ export namespace useRender {
5757
/**
5858
* The React element or a function that returns one to override the default element.
5959
*/
60-
render: RenderProp<State>;
60+
render?: RenderProp<State>;
6161
/**
6262
* The ref to apply to the rendered element.
6363
*/
@@ -86,6 +86,11 @@ export namespace useRender {
8686
* @default true
8787
*/
8888
enabled?: Enabled;
89+
/**
90+
* The default tag name to use for the rendered element when `render` is not provided.
91+
* @default 'div'
92+
*/
93+
defaultTagName?: keyof React.JSX.IntrinsicElements;
8994
}
9095

9196
export type ReturnValue<Enabled extends boolean | undefined> = Enabled extends false

0 commit comments

Comments
 (0)