diff --git a/apps/docs/content/docs/components/layout/box/index.mdx b/apps/docs/content/docs/components/layout/box/index.mdx index 51fbc73a..36f5478d 100644 --- a/apps/docs/content/docs/components/layout/box/index.mdx +++ b/apps/docs/content/docs/components/layout/box/index.mdx @@ -1,15 +1,15 @@ --- title: Box -description: A div with responsive Luke UI layout properties. +description: A layout element with responsive Luke UI layout properties. source: packages/@luke-ui/react/src/box props: - name: BoxProps path: packages/@luke-ui/react/src/box/index.tsx --- -`Box` is a `div` that accepts Luke UI layout properties. It places content in a layout, adds token -spacing, constrains an element's size, or controls a flex or grid child. Use a normal `div` when no -supported layout property applies. +`Box` accepts Luke UI layout properties and renders a `div` by default. It places content in a +layout, adds token spacing, constrains an element's size, or controls a flex or grid child. Use a +normal `div` when no supported layout property applies. @@ -53,17 +53,27 @@ Padding, gap, and margin accept `0` and the semantic steps `100`, `200`, `300`, `1000`, `1200`, and `1600`. Margin also accepts `auto`. Sizing, inset, flex-basis, order, and grid-placement properties accept their CSS values. -## Render a custom div +## Choose the rendered element -Use `render` for a compatible custom `div`, such as a motion wrapper. Spread the supplied props on -the final element. It then receives Box's class name, inline style, ref, accessibility attributes, -and event handlers. `render` does not make Box polymorphic. It must return a `div`. +Set `elementType` when the layout needs a supported structural element. The prop does not change +Box's accepted DOM props. Box owns the element and its DOM attributes. -```tsx - }> - Account summary - -``` +The supported elements are `article`, `aside`, `dd`, `div`, `dl`, `dt`, `figcaption`, `figure`, +`footer`, `header`, `li`, `main`, `nav`, `ol`, `section`, `span`, and `ul`. + + + +## Own the rendered element + +Use `render` when the callback must own the element and its DOM attributes. The callback can return +an appropriate intrinsic element or custom component. It receives resolved `children`, `className`, +and `style` props, plus a callback `ref`. + +Put event, accessibility, and element-specific attributes on the element inside the callback. + +Do not combine `render` with `elementType`. Use `Link` or `Button` when you need their behaviour. + + ## Visual styles diff --git a/apps/docs/content/docs/composition.mdx b/apps/docs/content/docs/composition.mdx index 2460cbb7..1977f605 100644 --- a/apps/docs/content/docs/composition.mdx +++ b/apps/docs/content/docs/composition.mdx @@ -64,6 +64,21 @@ Return one DOM root. Custom wrappers must pass the supplied `ref` to that elemen The second `render` argument contains interaction state such as `isPressed`. +For `Box`, use `elementType` for a supported structural element. It does not change Box's accepted +DOM props. + + + +Use Box's `render` prop when the callback must own the element and its DOM attributes. Do not +combine `render` with `elementType`. Use `Link` and `Button` when you need their behaviour. + + + +Read the [Box documentation](/components/layout/box) for its full prop contract. + ## Style the new pattern Use public recipes from `@luke-ui/react/recipes` when the custom composition follows that diff --git a/apps/docs/src/examples/box/render-element.tsx b/apps/docs/src/examples/box/render-element.tsx new file mode 100644 index 00000000..0da2b016 --- /dev/null +++ b/apps/docs/src/examples/box/render-element.tsx @@ -0,0 +1,10 @@ +import { Box } from '@luke-ui/react/box'; + +export default () => { + return ( +
}> + Delivery details +

Your order will arrive within three business days.

+ + ); +}; diff --git a/apps/docs/src/examples/box/semantic-element.tsx b/apps/docs/src/examples/box/semantic-element.tsx new file mode 100644 index 00000000..76756cfe --- /dev/null +++ b/apps/docs/src/examples/box/semantic-element.tsx @@ -0,0 +1,12 @@ +import { Box } from '@luke-ui/react/box'; +import { Heading } from '@luke-ui/react/heading'; +import { Text } from '@luke-ui/react/text'; + +export default () => { + return ( + + Account settings + Manage your profile and sign-in details. + + ); +}; diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 917c205e..557c7a90 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -48,6 +48,20 @@ Short version: Components wrap `react-aria-components` and use `composeRenderProps` for styling. +### Element choice + +Use `elementType` to change the semantic element without changing the accepted DOM props. Accept +only the elements a component is designed to render. + +Use a dedicated component for element-specific behaviour or props. Use `Link` for links and `Button` +for buttons. + +Use `render` when its callback must own the element and its DOM attributes. Pass the component's +documented resolved props to that element. Keep `render` and `elementType` mutually exclusive. + +Do not add generic polymorphic props, `as`, or `asChild` without a demonstrated need. Apply this +rule to public component APIs, not internal prop handling. + See [COMPONENTS.md](COMPONENTS.md) for component tiers, package paths, and generator rules. ## Styling diff --git a/docs/STYLING.md b/docs/STYLING.md index 0befd6fe..0c5efbda 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -291,9 +291,8 @@ bundle smaller as the token scale grows. The tradeoff is that some values are applied through inline `style`, which raises specificity. That is acceptable because styling utilities are already the highest-priority escape hatch. -`Box` from `@luke-ui/react/box` applies the same utilities to a `div`. Its `render` prop can use a -compatible custom `div` component while preserving the generated class, style, ref, and DOM props. -It does not provide `as` or `asChild` polymorphism. +`Box` from `@luke-ui/react/box` applies these utilities. See the +[Box documentation](/components/layout/box) for its element and render contracts. Do not add style props to every component. Component props should stay focused on component-specific variants and behaviour. diff --git a/packages/@luke-ui/react/src/box/box.browser.test.tsx b/packages/@luke-ui/react/src/box/box.browser.test.tsx index bcd63aee..eb8f0c79 100644 --- a/packages/@luke-ui/react/src/box/box.browser.test.tsx +++ b/packages/@luke-ui/react/src/box/box.browser.test.tsx @@ -1,4 +1,3 @@ -import { createRef } from 'react'; import type { ComponentProps } from 'react'; import { afterEach, expect, test } from 'vite-plus/test'; import { page } from 'vite-plus/test/context'; @@ -46,23 +45,52 @@ test('renders a responsive layout at the retained breakpoints', async () => { expect(getComputedStyle(box).gap).toBe('24px'); }); -test('forwards the ref through a custom rendered div', () => { - const ref = createRef(); - const { locator } = render( +test('renders semantic and consumer-owned elements with resolved props', () => { + const semanticResult = render( + + Account summary content + , + ); + const section = semanticResult.locator.getByRole('region', { name: 'Account summary' }); + + expect(section.element().tagName).toBe('SECTION'); + expect(getComputedStyle(section.element()).display).toBe('flex'); + expect(getComputedStyle(section.element()).padding).toBe('16px'); + + let refElement: HTMLElement | null = null; + let receivedAriaLabel = false; + const customResult = render(
} + aria-label="Ignored Box label" + className="consumer-class" + ref={(element) => { + refElement = element; + }} + render={(resolvedProps) => { + receivedAriaLabel = Object.hasOwn(resolvedProps, 'aria-label'); + return ( +
+ ); + }} + style={{ display: 'grid' }} > Custom div , ); - - const div = locator.element().firstElementChild; + const div = customResult.locator.getByText('Custom div').element(); if (!(div instanceof HTMLDivElement)) throw new Error('Expected custom rendered div.'); - expect(ref.current).toBe(div); + expect(refElement).toBe(div); expect(div).toHaveAttribute('data-motion', 'enabled'); expect(div).toHaveAttribute('id', 'custom-div'); + expect(div).toHaveAttribute('aria-label', 'Custom layout'); + expect(receivedAriaLabel).toBe(false); + expect(div).toHaveClass('consumer-class'); + expect(div.style.display).toBe('grid'); expect(div).toHaveTextContent('Custom div'); }); diff --git a/packages/@luke-ui/react/src/box/box.stories.tsx b/packages/@luke-ui/react/src/box/box.stories.tsx index d44b74f6..bf4bde0d 100644 --- a/packages/@luke-ui/react/src/box/box.stories.tsx +++ b/packages/@luke-ui/react/src/box/box.stories.tsx @@ -27,14 +27,26 @@ export const Default = meta.story({ } satisfies Partial, }); -export const CustomDiv = meta.story({ +export const Section = meta.story({ args: { 'aria-label': 'Account summary', + children: 'Account summary content', + display: 'flex', + elementType: 'section', + padding: '400', + style: { backgroundColor: vars.color.surface.recessed }, + } satisfies Partial, +}); + +export const CustomDiv = meta.story({ + args: { children: 'Account summary content', className: 'consumer-class', - id: 'account-summary', padding: '400', - render: (domProps) => , + ref: (element) => element?.setAttribute('data-box-ref', 'received'), + render: (resolvedProps) => ( + + ), style: { backgroundColor: vars.color.surface.recessed }, } satisfies Partial, }); diff --git a/packages/@luke-ui/react/src/box/box.test.ts b/packages/@luke-ui/react/src/box/box.test.ts deleted file mode 100644 index 4ed1e895..00000000 --- a/packages/@luke-ui/react/src/box/box.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createElement, createRef } from 'react'; -import { expectTypeOf, test } from 'vite-plus/test'; -import type { BoxProps } from './index.js'; - -const boxProps = { - 'aria-label': 'Account summary', - display: { initial: 'block', medium: 'flex' }, - id: 'account-summary', - onClick: () => undefined, - padding: '400', - ref: createRef(), - render: (domProps, renderProps) => { - expectTypeOf(renderProps).toEqualTypeOf(); - - return createElement('div', domProps); - }, -} satisfies BoxProps; - -// Type assertions are compile-time only. -// oxlint-disable-next-line vitest/expect-expect -test('preserves native DOM, render, and responsive layout props', () => { - expectTypeOf(boxProps).toExtend(); - expectTypeOf(boxProps.render).toExtend(); - expectTypeOf(boxProps.display).toEqualTypeOf<{ - initial: 'block'; - medium: 'flex'; - }>(); -}); diff --git a/packages/@luke-ui/react/src/box/index.tsx b/packages/@luke-ui/react/src/box/index.tsx index 1bcb01dc..7314bcec 100644 --- a/packages/@luke-ui/react/src/box/index.tsx +++ b/packages/@luke-ui/react/src/box/index.tsx @@ -1,45 +1,105 @@ -import type { ComponentPropsWithRef, JSX } from 'react'; +import type { HTMLAttributes, JSX, ReactElement, Ref, RefCallback } from 'react'; import type { SprinklesProps } from '../styles/utilities.css.js'; import { createSprinkles } from '../styles/utilities.css.js'; import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { Prettify } from '../types/prettify.js'; -import type { RenderProp } from '../types/render-prop.js'; import { mergeProps } from '../utils/index.js'; -interface _BoxProps extends ComponentPropsWithRef<'div'>, SprinklesProps { - /** Renders a compatible custom `div` while carrying Box's DOM props and generated styles. */ - render?: RenderProp<'div'>; -} - /** * Props for `Box`. Layout props accept responsive values keyed by Luke UI breakpoints. * + * `elementType` and `render` are mutually exclusive ways to choose the rendered element. + * * @tier atom */ -export type BoxProps = Prettify<_BoxProps>; +export type BoxProps = Prettify<_BoxElementProps | _BoxRenderProps>; -/** A layout container backed by Luke UI Sprinkles. */ +/** Applies layout properties to a supported structural element or an element returned by `render`. */ export function Box(props: BoxProps): JSX.Element { - const { className, render, style, ...restProps } = props; - const [sprinklesProps, elementProps] = splitProps(restProps); - const domProps = mergeProps(elementProps, createSprinkles(sprinklesProps)); - const mergedDomProps = mergeProps(domProps, { className, style }); + const { children, className, elementType = 'div', ref, render, style, ...restProps } = props; + const callbackRef: RefCallback = (element) => { + if (typeof ref === 'function') return ref(element); + if (ref) ref.current = element; + }; + + if (render) { + const renderProps = mergeProps(createSprinkles(retainSprinklesProps(restProps)), { + children, + className, + style, + }); + + // The render owner must receive Box's ref with its presentation props. + // oxlint-disable-next-line react-hooks-js/refs + return render({ ...renderProps, ref: callbackRef }); + } - return render ? render(mergedDomProps, undefined) :
; + const Element = elementType; + const domProps = mergeProps(createSprinkles(restProps), { children, className, style }); + return ; } -function splitProps( - props: DistributiveOmit, -): [SprinklesProps, ComponentPropsWithRef<'div'>] { - const sprinklesProps: Record = {}; - const elementProps: Record = {}; - - for (const [property, value] of Object.entries(props)) { - const target = createSprinkles.properties.has(property as keyof SprinklesProps) - ? sprinklesProps - : elementProps; - target[property] = value; +type BoxElementType = keyof Pick< + JSX.IntrinsicElements, + | 'article' + | 'aside' + | 'dd' + | 'div' + | 'dl' + | 'dt' + | 'figcaption' + | 'figure' + | 'footer' + | 'header' + | 'li' + | 'main' + | 'nav' + | 'ol' + | 'section' + | 'span' + | 'ul' +>; + +interface _BoxElementProps extends HTMLAttributes, SprinklesProps { + /** + * Chooses a supported structural element. + * @default div + */ + elementType?: BoxElementType; + /** Ref to the rendered element. */ + ref?: Ref; + render?: never; +} + +interface _BoxPresentationProps + extends Pick, 'children' | 'className' | 'style'>, SprinklesProps { + ref?: Ref; +} + +type _BoxResolvedRenderOmit = DistributiveOmit<_BoxPresentationProps, 'ref' | keyof SprinklesProps>; + +interface _BoxResolvedRenderProps extends _BoxResolvedRenderOmit { + ref: RefCallback; +} + +type BoxResolvedRenderProps = Prettify<_BoxResolvedRenderProps>; + +interface _BoxRenderProps extends _BoxPresentationProps { + elementType?: never; + /** Passes Box's content and presentation props to a caller-owned element. */ + render: (props: BoxResolvedRenderProps) => ReactElement; +} + +const sprinklesProperties: ReadonlySet = createSprinkles.properties; + +function retainSprinklesProps(props: Props): Props { + const sprinklesProps = { ...props }; + + for (const key of Reflect.ownKeys(sprinklesProps)) { + if (!sprinklesProperties.has(key)) { + Reflect.deleteProperty(sprinklesProps, key); + } } - return [sprinklesProps as SprinklesProps, elementProps as ComponentPropsWithRef<'div'>]; + return sprinklesProps; } diff --git a/packages/@luke-ui/react/src/types/render-prop.ts b/packages/@luke-ui/react/src/types/render-prop.ts deleted file mode 100644 index ceadd29c..00000000 --- a/packages/@luke-ui/react/src/types/render-prop.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ComponentPropsWithRef, JSX, ReactElement } from 'react'; - -/** - * Render-delegation prop shared across Luke UI. Mirrors react-aria-components' `render` prop: - * receive the component's resolved DOM props (and optional render state) and return the element to - * render, so a caller can swap the underlying element while keeping the component's props and styles. - * - * `react-aria-components` does not publicly export its equivalent render-function type, so this is the - * canonical Luke UI definition. Reuse it instead of hand-rolling a new render prop per component. - */ -export type RenderProp< - ElementType extends keyof JSX.IntrinsicElements = 'div', - RenderState = undefined, -> = (props: ComponentPropsWithRef, renderState: RenderState) => ReactElement;