diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/css-modules/index.module.css new file mode 100644 index 0000000000..da17fc411a --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/css-modules/index.module.css @@ -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); + } +} diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/css-modules/index.tsx new file mode 100644 index 0000000000..cc20bfc564 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/css-modules/index.tsx @@ -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; + onClick?: (event: React.MouseEvent) => void; + children: React.ReactNode; + size?: Size; +}; + +function Text(props: TextProps) { + const { className, render, size = 'medium', onClick, ...otherProps } = props; + const [color, setColor] = React.useState('default'); + + const onClickHandler = (event: React.MouseEvent) => { + setColor(color === 'default' ? 'active' : 'default'); + onClick?.(event); + }; + + const state = React.useMemo(() => ({ size, color }), [size, color]); + + const { renderElement } = useRenderer({ + render: render ??

, + state, + className, + props: { + ...otherProps, + onClick: onClickHandler, + }, + }); + + return renderElement(); +} + +export default function ExampleText() { + return ( +

+ + Small size + + Medium size + + Large size + +
+ ); +} diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/index.ts b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/index.ts new file mode 100644 index 0000000000..94264ab85d --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/data-attributes/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default as CssModules } from './css-modules'; diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/css-modules/index.module.css new file mode 100644 index 0000000000..a9f3bad2d4 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/css-modules/index.module.css @@ -0,0 +1,5 @@ +.Text { + font-size: 0.875rem; + line-height: 1rem; + color: var(--color-gray-900); +} diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/css-modules/index.tsx new file mode 100644 index 0000000000..0f7001dc59 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/css-modules/index.tsx @@ -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>; + children: React.ReactNode; +}; + +function Text(props: TextProps) { + const { render, ...otherProps } = props; + + const { renderElement } = useRenderer({ + render: render ??

, + props: otherProps, + }); + + return renderElement(); +} + +export default function ExampleText() { + return ( +

+ Text component rendered as a paragraph tag + }> + Text component rendered as a strong tag + +
+ ); +} diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/index.ts b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/index.ts new file mode 100644 index 0000000000..94264ab85d --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/render/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default as CssModules } from './css-modules'; diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/css-modules/index.module.css new file mode 100644 index 0000000000..c4786ca244 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/css-modules/index.module.css @@ -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); + } +} diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/css-modules/index.tsx new file mode 100644 index 0000000000..2c8c7708ab --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/css-modules/index.tsx @@ -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; + onClick?: (event: React.MouseEvent) => void; + children: React.ReactNode; + size?: Size; +}; + +function Text(props: TextProps) { + const { className, render, size = 'medium', onClick, ...otherProps } = props; + const [color, setColor] = React.useState('default'); + + const onClickHandler = (event: React.MouseEvent) => { + setColor(color === 'default' ? 'active' : 'default'); + onClick?.(event); + }; + + const state = React.useMemo(() => ({ size, color }), [size, color]); + + const { renderElement } = useRenderer({ + render: render ??

, + state, + className, + stateAttributesMap: { + size: (value) => ({ [`data-size-${value}`]: '' }), + color: (value) => ({ [`data-color-${value}`]: '' }), + }, + props: { + ...otherProps, + onClick: onClickHandler, + }, + }); + + return renderElement(); +} + +export default function ExampleText() { + return ( +

+ + Small text + + Default text + + Large text + +
+ ); +} diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/index.ts b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/index.ts new file mode 100644 index 0000000000..94264ab85d --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/demos/state-attributes-map/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default as CssModules } from './css-modules'; diff --git a/docs/src/app/(public)/(content)/react/utils/use-renderer/page.mdx b/docs/src/app/(public)/(content)/react/utils/use-renderer/page.mdx new file mode 100644 index 0000000000..b723241090 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/utils/use-renderer/page.mdx @@ -0,0 +1,60 @@ +# useRenderer + +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 + + string)', + description: + "CSS class applied to the element, or a function that returns a class based on the component's state.", + }, + render: { + type: 'RenderProp', + 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', + description: 'Props to be spread on the rendered element.', + }, + stateAttributesMap: { + type: 'StateDataAttributes', + 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. + + + +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. + + + +Additionally, you can also customize how the data-attributes are generated, by providing the `stateAttributesMap` option. + + diff --git a/docs/src/nav.ts b/docs/src/nav.ts index a90dec4fe1..d912011860 100644 --- a/docs/src/nav.ts +++ b/docs/src/nav.ts @@ -149,6 +149,10 @@ export const nav = [ label: 'Direction Provider', href: '/react/utils/direction-provider', }, + { + label: 'useRenderer', + href: '/react/utils/use-renderer', + }, ], }, ]; diff --git a/packages/react/package.json b/packages/react/package.json index b1eb7a428b..e99fe9bd98 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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": { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 35abfbeda7..deabba51ee 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -25,3 +25,4 @@ export * from './tabs'; export * from './toggle'; export * from './toggle-group'; export * from './tooltip'; +export * from './use-renderer'; diff --git a/packages/react/src/use-renderer/index.ts b/packages/react/src/use-renderer/index.ts new file mode 100644 index 0000000000..97d7757640 --- /dev/null +++ b/packages/react/src/use-renderer/index.ts @@ -0,0 +1 @@ +export * from './useRenderer'; diff --git a/packages/react/src/use-renderer/useRenderer.test.tsx b/packages/react/src/use-renderer/useRenderer.test.tsx new file mode 100644 index 0000000000..8ed89767e1 --- /dev/null +++ b/packages/react/src/use-renderer/useRenderer.test.tsx @@ -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['render']; + className?: useRenderer.Settings['className']; + }) { + const { render: renderProp, className } = props; + const { renderElement } = useRenderer({ + render: renderProp, + state: {}, + className, + }); + return renderElement(); + } + + const { container } = await render( + } + />, + ); + + 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['render']; + className?: useRenderer.Settings['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(); + + 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['render']; + className?: useRenderer.Settings['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(); + + const element = container.firstElementChild; + + expect(element).to.have.attribute('data-sizelarge', ''); + expect(element).not.to.have.attribute('data-weight'); + }); +}); diff --git a/packages/react/src/use-renderer/useRenderer.ts b/packages/react/src/use-renderer/useRenderer.ts new file mode 100644 index 0000000000..f0c5be5f6a --- /dev/null +++ b/packages/react/src/use-renderer/useRenderer.ts @@ -0,0 +1,60 @@ +import * as React from 'react'; +import type { ComponentRenderFn } from '../utils/types'; +import { useComponentRenderer } from '../utils/useComponentRenderer'; +import { defaultRenderFunctions } from '../utils/defaultRenderFunctions'; +import { CustomStyleHookMapping as StateDataAttributes } from '../utils/getStyleHookProps'; + +/** + * Returns a function that renders a Base UI component. + */ +function useRenderer, RenderedElementType extends Element>( + settings: useRenderer.Settings, +) { + const { className, render, state = {}, props, stateAttributesMap } = settings; + const { ref, ...extraProps } = props ?? {}; + + return useComponentRenderer({ + className, + render, + state: state as State, + ref: ref as React.Ref, + extraProps, + propGetter: (x) => x, + customStyleHookMapping: stateAttributesMap, + }); +} + +type RenderProp = + | ComponentRenderFn, State> + | React.ReactElement> + | keyof typeof defaultRenderFunctions; + +namespace useRenderer { + export interface Settings { + /** + * The class name to apply to the rendered element. + * Can be a string or a function that accepts the state and returns a string. + */ + className?: string | ((state: State) => string); + /** + * The render prop or React element to override the default element. + */ + render: RenderProp; + /** + * The state of the component. It will be used as a parameter for the render and className callbacks. + */ + state?: State; + /** + * Props to be spread on the rendered element. + */ + props?: Record & { ref?: React.Ref }; + /** + * A mapping of state to data attributes. + */ + stateAttributesMap?: StateDataAttributes; + } +} + +export type { ComponentRenderFn, StateDataAttributes, RenderProp }; + +export { useRenderer };