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 & Slot component #1418

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
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
11 changes: 11 additions & 0 deletions docs/src/app/(private)/experiments/custom-components.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.Text {
font-size: 0.875rem;
line-height: 1.25rem;
color: #232323;
&[data-size='small'] {
font-size: 0.75rem;
}
&[data-size='large'] {
font-size: 2rem;
}
}
87 changes: 87 additions & 0 deletions docs/src/app/(private)/experiments/custom-components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client';
import * as React from 'react';
import { useRenderer, RenderProp } from '@base-ui-components/react/use-renderer';
import styles from './custom-components.module.css';

type Weight = 'light' | 'regular' | 'bold';
type Size = 'small' | 'medium' | 'large';

type TextState = {
weight: Weight;
size: Size;
excludedProp: boolean;
};

type TextProps = {
className: string | ((state: TextState) => string);
weight?: Weight;
render?: RenderProp<TextState>;
children: React.ReactNode;
style?: React.CSSProperties;
size?: Size;
excludedProp?: boolean;
};

const Text = React.forwardRef(
(props: TextProps, forwardedRef: React.ForwardedRef<HTMLElement>) => {
const {
className,
render,
style = {},
weight = 'regular',
size = 'medium',
// Example state prop that we exclude from the style hooks
excludedProp = true,
...otherProps
} = props;

const fontWeight = {
light: 300,
regular: 400,
bold: 700,
}[weight];

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

const { renderElement } = useRenderer({
render: render ?? 'p',
mnajdova marked this conversation as resolved.
Show resolved Hide resolved
state,
className,
ref: forwardedRef,
props: {
...otherProps,
style: {
...style,
fontWeight,
},
},
mnajdova marked this conversation as resolved.
Show resolved Hide resolved
styleHookMapping: {
excludedProp() {
return null;
},
},
Copy link
Contributor

@atomiks atomiks Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not the biggest fan of this prop name (styleHookMapping) - not sure if "style hook" is suitable for a public API, and Map instead of Mapping might be better. It's not clear it infers/uses the state object

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most clear name would be: stateToDataAttributesMap, to be very explicit, but it's a strange name 😅 @aarongarciah or @colmtuite do you have some suggestions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, "hook" has a strong different meaning in React world, so we can think of something else. stateAttributes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stateDataAttributes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, you could create any attribute (including class), so "dataAttributes" is not entirely correct. We just happen to use only data attributes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see! stateAttributes makes sense then.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to stateDataAttributes, I am still missing the mapping in it, but people can figure out how to define them by using the type. Maybe it's even worth adding demo about it on the page. What do you think?

});

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 in bold
</Text>
<Text className={styles.Text} render={<strong />} weight="bold">
Text in bold
</Text>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.Text {
font-size: 0.875rem;
line-height: 1rem;
color: var(--color-gray-900);
&[data-size='small'] {
font-size: 0.75rem;
}
&[data-size='large'] {
font-size: 1.25rem;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';
import * as React from 'react';
import { useRenderer, RenderProp } from '@base-ui-components/react/use-renderer';
import styles from './index.module.css';

type Weight = 'light' | 'regular' | 'bold';
type Size = 'small' | 'medium' | 'large';

type TextState = {
weight: Weight;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't really the state of the component but its props, so someone might be confused about the purpose of State and style hooks. Can we introduce something else that doesn't come from props? For example, a character counter?

size: Size;
};

type TextProps = {
className: string | ((state: TextState) => string);
weight?: Weight;
render?: RenderProp<TextState>;
children: React.ReactNode;
style?: React.CSSProperties;
size?: Size;
excludedProp?: boolean;
};

const Text = React.forwardRef(
(props: TextProps, forwardedRef: React.ForwardedRef<HTMLElement>) => {
const {
className,
render,
style = {},
weight = 'regular',
size = 'medium',
...otherProps
} = props;

const fontWeight = {
light: 300,
regular: 400,
bold: 700,
}[weight];

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

const { renderElement } = useRenderer({
render: render ?? 'p',
state,
className,
ref: forwardedRef,
props: {
...otherProps,
style: {
...style,
fontWeight,
},
},
});

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>
<Text className={styles.Text} weight="bold" render={<strong />}>
Bold text rendered in 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,60 @@
# useRenderer

<Subtitle>Utility for adding Base UI like features in a custom build components.</Subtitle>
<Meta
name="description"
content="Utility for adding Base UI like features in a custom build components."
mnajdova marked this conversation as resolved.
Show resolved Hide resolved
/>

Base UI provides few common features across all components:

- support for a [callback](/react/handbook/styling#css-classes) on the `className` prop where developers can generate classes based on the state of the components
- [render](/react/handbook/composition) prop that allows developers to override the default rendered element of the component
- adds data-attributes for the component's state that can be used as style hooks

The `useRenderer` hook allows you to add these feature on your custom built components, so that you can provide consistenc experience across your library.
mnajdova marked this conversation as resolved.
Show resolved Hide resolved

## API reference

### Input parameters

<PropsReferenceTable
data={{
className: {
type: 'string | ((state: State) => string)',
description:
'The class name to apply to the rendered element. Can be a string or a function that accepts the state and returns a string.',
mnajdova marked this conversation as resolved.
Show resolved Hide resolved
},
render: {
type: 'RenderProp<State>',
description: 'The render prop or React element to override the default element.',
mnajdova marked this conversation as resolved.
Show resolved Hide resolved
},
state: {
type: 'State',
description:
'The state of the component. It will be used as a parameter for the render and className callbacks.',
},
ref: {
type: 'React.Ref<RenderedElementType>',
description: 'The ref to apply to the rendered element.',
},
props: {
type: 'Record<string, unknown>',
description: 'Props to be spread on the rendered element.',
},
styleHookMapping: {
type: 'StyleHookMapping<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 usage for creating a Text component that generates data-attributes based on its state, and have the support for the `render` prop and the `className` callback.

<Demo path="./demos/usage" />
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';
2 changes: 2 additions & 0 deletions packages/react/src/use-renderer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useRenderer } from './useRenderer';
export * from './useRenderer';
Comment on lines +1 to +2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export * should be enough

Suggested change
export { useRenderer } from './useRenderer';
export * from './useRenderer';
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,
},
styleHookMapping: {
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