Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[useRenderer] Add public hook #1418

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3cfc3c5
[useRenderer] Add public hook
mnajdova Feb 5, 2025
9f32729
prettier
mnajdova Feb 5, 2025
1b705fa
update package.json exports
mnajdova Feb 5, 2025
5e4fbce
apply review changes
mnajdova Feb 6, 2025
b7c1d09
fix types
mnajdova Feb 6, 2025
8038e58
optional prop
mnajdova Feb 6, 2025
0051604
add tests
mnajdova Feb 6, 2025
29fd26a
add test, change API
mnajdova Feb 6, 2025
b87aadc
lint & fix example
mnajdova Feb 6, 2025
116a673
export more types, rename param
mnajdova Feb 7, 2025
d50077d
add docs page
mnajdova Feb 7, 2025
3c753fa
Aaron's review
mnajdova Feb 7, 2025
2baa3dd
prettier
mnajdova Feb 7, 2025
b94b8ce
Use render element instead of string as a default render value
mnajdova Feb 7, 2025
6a8d16f
move ref to props
mnajdova Feb 7, 2025
2a9621f
rename style hooks mapping
mnajdova Feb 7, 2025
c523e6f
fix type usage
mnajdova Feb 12, 2025
ec02061
add component & demo for comparison
mnajdova Feb 12, 2025
034b747
optional state
mnajdova Feb 12, 2025
f067410
remove test for now
mnajdova Feb 12, 2025
7dcdc5e
simplify demo
mnajdova Feb 12, 2025
15728ad
simlify hook demo
mnajdova Feb 12, 2025
56ff6fe
StateDataAttributes -> stateAttributesMap
mnajdova Feb 14, 2025
df8d773
improve state
mnajdova Feb 14, 2025
b813b3e
pointer cursor
mnajdova Feb 14, 2025
9515c77
Update packages/react/src/use-renderer/index.ts
mnajdova Feb 14, 2025
7103e08
improve docs page
mnajdova Feb 14, 2025
a113201
remove slot component
mnajdova Feb 14, 2025
be499fa
fix lint issue
mnajdova Feb 14, 2025
ed89bb1
remove non-sense styles
mnajdova Feb 14, 2025
a12e865
markdownlint
mnajdova Feb 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.Text {
cursor: pointer;
font-size: 0.875rem;
line-height: 1rem;
font-weight: 400;
color: var(--color-gray-900);
&[data-size='small'] {
font-size: 0.75rem;
}
&[data-size='large'] {
font-size: 1.25rem;
}
&[data-color='active'] {
color: var(--color-blue);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import { useRenderer, RenderProp } from '@base-ui-components/react/use-renderer';
import styles from './index.module.css';

type Size = 'small' | 'medium' | 'large';
type Color = 'default' | 'active';

type TextState = {
size: Size;
color: Color;
};

type TextProps = {
className: string | ((state: TextState) => string);
render?: RenderProp<TextState>;
onClick?: (event: React.MouseEvent<Element>) => void;
children: React.ReactNode;
size?: Size;
};

function Text(props: TextProps) {
const { className, render, size = 'medium', onClick, ...otherProps } = props;
const [color, setColor] = React.useState<Color>('default');

const onClickHandler = (event: React.MouseEvent<Element>) => {
setColor(color === 'default' ? 'active' : 'default');
onClick?.(event);
};

const state = React.useMemo(() => ({ size, color }), [size, color]);

const { renderElement } = useRenderer({
render: render ?? <p />,
state,
className,
props: {
...otherProps,
onClick: onClickHandler,
},
});

return renderElement();
}

export default function ExampleText() {
return (
<div>
<Text className={styles.Text} size="small">
Small size
</Text>
<Text className={styles.Text}>Medium size</Text>
<Text className={styles.Text} size="large">
Large size
</Text>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
'use client';
export { default as CssModules } from './css-modules';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.Text {
font-size: 0.875rem;
line-height: 1rem;
color: var(--color-gray-900);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import { useRenderer, RenderProp } from '@base-ui-components/react/use-renderer';
import styles from './index.module.css';

type TextProps = {
className?: string;
render?: RenderProp<Record<string, any>>;
children: React.ReactNode;
};

function Text(props: TextProps) {
const { render, ...otherProps } = props;

const { renderElement } = useRenderer({
render: render ?? <p />,
props: otherProps,
});

return renderElement();
}

export default function ExampleText() {
return (
<div>
<Text className={styles.Text}>Text component rendered as a paragraph tag</Text>
<Text className={styles.Text} render={<strong />}>
Text component rendered as a strong tag
</Text>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
'use client';
export { default as CssModules } from './css-modules';
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.Text {
cursor: pointer;
font-size: 0.875rem;
line-height: 1rem;
font-weight: 400;
color: var(--color-gray-900);
&[data-size-small] {
font-size: 0.75rem;
}
&[data-size-large] {
font-size: 1.25rem;
}
&[data-color-active] {
color: var(--color-blue);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client';
import * as React from 'react';
import { useRenderer, RenderProp } from '@base-ui-components/react/use-renderer';
import styles from './index.module.css';

type Size = 'small' | 'medium' | 'large';
type Color = 'default' | 'active';

type TextState = {
size: Size;
color: Color;
};

type TextProps = {
className: string | ((state: TextState) => string);
render?: RenderProp<TextState>;
onClick?: (event: React.MouseEvent<Element>) => void;
children: React.ReactNode;
size?: Size;
};

function Text(props: TextProps) {
const { className, render, size = 'medium', onClick, ...otherProps } = props;
const [color, setColor] = React.useState<Color>('default');

const onClickHandler = (event: React.MouseEvent<Element>) => {
setColor(color === 'default' ? 'active' : 'default');
onClick?.(event);
};

const state = React.useMemo(() => ({ size, color }), [size, color]);

const { renderElement } = useRenderer({
render: render ?? <p />,
state,
className,
stateAttributesMap: {
size: (value) => ({ [`data-size-${value}`]: '' }),
color: (value) => ({ [`data-color-${value}`]: '' }),
},
props: {
...otherProps,
onClick: onClickHandler,
},
});

return renderElement();
}

export default function ExampleText() {
return (
<div>
<Text className={styles.Text} size="small">
Small text
</Text>
<Text className={styles.Text}>Default text</Text>
<Text className={styles.Text} size="large">
Large text
</Text>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
'use client';
export { default as CssModules } from './css-modules';
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# useRenderer

<Subtitle>Utility for adding Base UI-like features to custom components.</Subtitle>
<Meta name="description" content="Utility for adding Base UI-like features to custom components." />

The `useRenderer` hook allows you to support the same features Base UI provides across all components so that you can also have a consistent experience in your custom components:

- A [render](/react/handbook/composition) prop to override the default rendered element.
- A [callback](/react/handbook/styling#css-classes) on the `className` prop that enables passing dynamically generated CSS classes based on the component's state.
- Adds [data-attributes](https://base-ui.com/react/handbook/styling#data-attributes) that map to the component's state that can be used as style hooks.

## API reference

### Input parameters

<PropsReferenceTable
data={{
className: {
type: 'string | ((state: State) => string)',
description:
"CSS class applied to the element, or a function that returns a class based on the component's state.",
},
render: {
type: 'RenderProp<State>',
description:
"Allows you to replace the component's HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a \`ReactElement\` or a function that returns the element to render.",
},
state: {
type: 'State',
description:
'The state of the component. It will be used as a parameter for the render and className callbacks.',
},
props: {
type: 'Record<string, unknown>',
description: 'Props to be spread on the rendered element.',
},
stateAttributesMap: {
type: 'StateDataAttributes<State>',
description: 'A mapping of state to style hooks.',
},
}}
/>

### Return value

The hook returns a function that when called returns the element that should be rendered.

## Usage

This is an example of a Text component that provides the support for the render prop.

<Demo path="./demos/render" />

In the following demo, the Text component provides more Base UI features, like adding data attributes and `className` callback where developers can have access to the internal state of the component.

<Demo path="./demos/data-attributes" />

Additionally, you can also customize how the data-attributes are generated, by providing the `stateAttributesMap` option.

<Demo path="./demos/state-attributes-map" />
4 changes: 4 additions & 0 deletions docs/src/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export const nav = [
label: 'Direction Provider',
href: '/react/utils/direction-provider',
},
{
label: 'useRenderer',
href: '/react/utils/use-renderer',
},
],
},
];
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"./tooltip": "./src/tooltip/index.ts",
"./unstable-no-ssr": "./src/unstable-no-ssr/index.ts",
"./unstable-use-media-query": "./src/unstable-use-media-query/index.ts",
"./use-renderer": "./src/use-renderer/index.ts",
"./utils": "./src/utils/index.ts"
},
"imports": {
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export * from './tabs';
export * from './toggle';
export * from './toggle-group';
export * from './tooltip';
export * from './use-renderer';
1 change: 1 addition & 0 deletions packages/react/src/use-renderer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useRenderer';
93 changes: 93 additions & 0 deletions packages/react/src/use-renderer/useRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as React from 'react';
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import { useRenderer } from '@base-ui-components/react/use-renderer';

describe('useRenderer', () => {
const { render } = createRenderer();

it('render props does not overwrite className in a render function when unspecified', async () => {
function TestComponent(props: {
render: useRenderer.Settings<any, Element>['render'];
className?: useRenderer.Settings<any, Element>['className'];
}) {
const { render: renderProp, className } = props;
const { renderElement } = useRenderer({
render: renderProp,
state: {},
className,
});
return renderElement();
}

const { container } = await render(
<TestComponent
render={(props: any, state: any) => <span className="my-span" {...props} {...state} />}
/>,
);

const element = container.firstElementChild;

expect(element).to.have.attribute('class', 'my-span');
});

it('includes data-attributes for all state members', async () => {
function TestComponent(props: {
render?: useRenderer.Settings<any, Element>['render'];
className?: useRenderer.Settings<any, Element>['className'];
size: 'small' | 'medium' | 'large';
weight: 'light' | 'regular' | 'bold';
}) {
const { render: renderProp, size, weight } = props;
const { renderElement } = useRenderer({
render: renderProp ?? 'span',
state: {
size,
weight,
},
});
return renderElement();
}

const { container } = await render(<TestComponent size="large" weight="bold" />);

const element = container.firstElementChild;

expect(element).to.have.attribute('data-size', 'large');
expect(element).to.have.attribute('data-weight', 'bold');
});

it('respects the customStyleHookMapping config if provided', async () => {
function TestComponent(props: {
render?: useRenderer.Settings<any, Element>['render'];
className?: useRenderer.Settings<any, Element>['className'];
size: 'small' | 'medium' | 'large';
weight: 'light' | 'regular' | 'bold';
}) {
const { render: renderProp, size, weight } = props;
const { renderElement } = useRenderer({
render: renderProp ?? 'span',
state: {
size,
weight,
},
stateAttributesMap: {
size(value) {
return { [`data-size${value}`]: '' };
},
weight() {
return null;
},
},
});
return renderElement();
}

const { container } = await render(<TestComponent size="large" weight="bold" />);

const element = container.firstElementChild;

expect(element).to.have.attribute('data-sizelarge', '');
expect(element).not.to.have.attribute('data-weight');
});
});
Loading