diff --git a/README.md b/README.md index 264e3e35..975225df 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,7 @@ Useful repo commands: `@luke-ui/react` contains the public React package. -- Tokens: `packages/@luke-ui/react/src/tokens.ts`. -- Theme: `packages/@luke-ui/react/src/theme/`. +- Theme contract and compiler: `packages/@luke-ui/react/src/theme/`. - Styles: `packages/@luke-ui/react/src/styles/`. - Build output: `packages/@luke-ui/react/dist/stylesheet.css`. diff --git a/apps/docs/content/docs/components/feedback/loading-spinner.mdx b/apps/docs/content/docs/components/feedback/loading-spinner.mdx index 60a80bbc..d67101f9 100644 --- a/apps/docs/content/docs/components/feedback/loading-spinner.mdx +++ b/apps/docs/content/docs/components/feedback/loading-spinner.mdx @@ -38,7 +38,7 @@ Omit `value` for indeterminate progress. Pass `value` for determinate progress. ## Accessibility diff --git a/apps/docs/content/docs/components/layout/box.mdx b/apps/docs/content/docs/components/layout/box.mdx new file mode 100644 index 00000000..1a782945 --- /dev/null +++ b/apps/docs/content/docs/components/layout/box.mdx @@ -0,0 +1,37 @@ +--- +title: Box +description: Responsive layout container backed by Luke UI Sprinkles. +--- + +`Box` is a `div` by default. Use its responsive layout, spacing, sizing, positioning, overflow, +flex, and grid-child props to build layout without adding styling props to other components. + + + +Spacing props use `0` or the semantic space steps `100`, `200`, `300`, `400`, `600`, `800`, `1000`, +`1200`, and `1600`. Responsive objects use the `xsmall`, `small`, `medium`, `large`, `xlarge`, and +`xxlarge` breakpoints. + +## Custom div component + +Use `render` with a compatible custom `div` component, such as a motion or presentational wrapper. +Spread the provided props onto the actual `div` so the generated class, inline variables, ref, +accessibility attributes, and event handlers are preserved. The callback does not change Box's DOM +element contract to another element type. + +```tsx + }> + Account summary + +``` + +Semantic colour and typography are deliberately not Box props. Use component APIs or public semantic +variables from `@luke-ui/react/theme` for sanctioned custom styling. + +## Props + + diff --git a/apps/docs/content/docs/components/layout/meta.json b/apps/docs/content/docs/components/layout/meta.json new file mode 100644 index 00000000..9fae5c85 --- /dev/null +++ b/apps/docs/content/docs/components/layout/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Layout", + "pages": ["box"] +} diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json index e0920e66..1849f159 100644 --- a/apps/docs/content/docs/components/meta.json +++ b/apps/docs/content/docs/components/meta.json @@ -1,4 +1,4 @@ { "title": "Components", - "pages": ["actions", "feedback", "forms", "typography", "visuals", "primitives"] + "pages": ["actions", "feedback", "forms", "layout", "typography", "visuals", "primitives"] } diff --git a/apps/docs/src/examples/box/responsive-layout.tsx b/apps/docs/src/examples/box/responsive-layout.tsx new file mode 100644 index 00000000..d596f93d --- /dev/null +++ b/apps/docs/src/examples/box/responsive-layout.tsx @@ -0,0 +1,17 @@ +import { Box } from '@luke-ui/react/box'; +import { vars } from '@luke-ui/react/theme'; + +export default function ResponsiveLayout() { + return ( + + First item + Second item + + ); +} diff --git a/docs/STYLING.md b/docs/STYLING.md index 003c544b..08171fb0 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -8,9 +8,8 @@ stylesheet and apply its identity class to the same element. Neither step inject ## Structure -- `tokens.ts`: design token source. -- `styles/vars.css.ts`: theme variables. - `styles/reset.css.ts`: reset scoped to `.luke-ui-reset`. +- `styles/theme-root.css.ts`: base typography and text colour scoped to `.luke-ui-theme`. - `recipes/`: component recipes exported from `@luke-ui/react/recipes`. - `styles/`: public layout utilities exported from `@luke-ui/react/styles`. - `theme/contract.ts`: the semantic token tree and its `--luke-*` variable naming. @@ -114,8 +113,9 @@ 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. -Do not add a polymorphic `Box` component. React Aria Components' `render` prop covers the same need -without adding another component API. +`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. Do not add style props to every component. Component props should stay focused on component-specific variants and behaviour. @@ -129,8 +129,8 @@ import { createSprinkles } from '@luke-ui/react/styles'; const layout = createSprinkles({ display: 'flex', - gap: 'medium', - padding: 'large', + gap: '400', + padding: '600', }); return ( @@ -140,9 +140,10 @@ return ( ); ``` -Token-backed properties use the design-token scale, for example `padding: 'large'`. Enum-like -properties use CSS-native values, for example `display: 'flex'`. Numeric flex properties use string -values, for example `flexGrow: '1'`. +Spacing and gap properties use `0` or the semantic space steps `100`, `200`, `300`, `400`, `600`, +`800`, `1000`, `1200`, and `1600`. Margin also accepts `auto`. Enum-like properties use CSS-native +values, for example `display: 'flex'`. Sizing, inset, flex-basis, order, and grid-placement values +accept their CSS property values. ## Responsive values @@ -153,24 +154,12 @@ only overrides need to be specified. const responsive = createSprinkles({ display: 'flex', flexDirection: { xsmall: 'column', medium: 'row' }, - gap: { xsmall: 'small', medium: 'large' }, + gap: { xsmall: '300', medium: '600' }, }); ``` -## Pseudo-state conditions - -Use condition objects for `hover` and `focus-visible` states. - -```tsx -const interactive = createSprinkles({ - padding: 'medium', - backgroundColor: { - default: 'neutral', - hover: 'neutralHover', - focusVisible: 'input', - }, -}); -``` +The retained breakpoints are `xsmall` (base), `small` (640px), `medium` (768px), `large` (1024px), +`xlarge` (1280px), and `xxlarge` (1536px). ## React Aria `render` prop @@ -181,7 +170,7 @@ Aria Components' `render` prop. Use `mergeProps` from `@luke-ui/react/utils` so ```tsx import { mergeProps } from '@luke-ui/react/utils'; -const buttonBox = createSprinkles({ padding: 'medium' }); +const buttonBox = createSprinkles({ padding: '400' }); `, - ) + .map(([status, count]) => { + return ``; + }) .join(''); const namespaces = [...new Set(results.map((result) => getNamespace(result.id)))].sort(); const namespaceOptions = namespaces diff --git a/packages/@luke-ui/react/src/box/box.browser.test.tsx b/packages/@luke-ui/react/src/box/box.browser.test.tsx new file mode 100644 index 00000000..944c00a8 --- /dev/null +++ b/packages/@luke-ui/react/src/box/box.browser.test.tsx @@ -0,0 +1,79 @@ +import '../../dist/themes/machined-edge.css'; +import '../stylesheet.css.js'; +import { act, createRef } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/context'; +import { themeRootClassName } from '../theme/index.js'; +import { machinedEdgeThemeClassName } from '../themes/index.js'; +import { Box } from './index.js'; + +const mounted: Array<{ container: HTMLElement; root: Root }> = []; + +afterEach(async () => { + for (const { container, root } of mounted) { + act(() => root.unmount()); + container.remove(); + } + mounted.length = 0; + await page.viewport(1024, 800); +}); + +test('renders a responsive layout at the retained breakpoints', async () => { + const container = document.body.appendChild(document.createElement('div')); + container.className = `${themeRootClassName} ${machinedEdgeThemeClassName}`; + const root = createRoot(container); + mounted.push({ container, root }); + + act(() => { + root.render( + + First item + Second item + , + ); + }); + + const box = container.firstElementChild; + if (!(box instanceof HTMLElement)) throw new Error('Expected Box element.'); + + await page.viewport(640, 800); + expect(getComputedStyle(box).flexDirection).toBe('column'); + expect(getComputedStyle(box).gap).toBe('8px'); + + await page.viewport(768, 800); + expect(getComputedStyle(box).flexDirection).toBe('row'); + expect(getComputedStyle(box).gap).toBe('24px'); +}); + +test('forwards the ref through a custom rendered div', () => { + const container = document.body.appendChild(document.createElement('div')); + const root = createRoot(container); + const ref = createRef(); + mounted.push({ container, root }); + + act(() => { + root.render( +
} + > + Custom div + , + ); + }); + + const div = container.firstElementChild; + if (!(div instanceof HTMLDivElement)) throw new Error('Expected custom rendered div.'); + + expect(ref.current).toBe(div); + expect(div).toHaveAttribute('data-motion', 'enabled'); + expect(div).toHaveAttribute('id', 'custom-div'); + 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 new file mode 100644 index 00000000..791ef987 --- /dev/null +++ b/packages/@luke-ui/react/src/box/box.stories.tsx @@ -0,0 +1,62 @@ +import type { BoxProps } from '@luke-ui/react/box'; +import { Box } from '@luke-ui/react/box'; +import { vars } from '@luke-ui/react/theme'; +import type { ComponentPropsWithRef } from 'react'; +import { expect } from 'storybook/test'; +import preview from '../../.storybook/preview.js'; + +const meta = preview.meta({ + component: Box, + tags: ['layout'], + title: 'Layout/Box', +}); + +/** Use Box for responsive layout without attaching layout props to semantic components. */ +export const Default = meta.story({ + args: { + children: ( + <> + First item + Second item + + ), + display: 'flex', + flexDirection: { medium: 'row', xsmall: 'column' }, + gap: { medium: '600', xsmall: '200' }, + padding: { medium: '600', xsmall: '300' }, + style: { backgroundColor: vars.color.surface.recessed }, + } satisfies Partial, + play: async ({ canvas }) => { + const box = canvas.getByText('First item').parentElement; + if (!box) throw new Error('Expected Box parent.'); + + await expect(getComputedStyle(box).display).toBe('flex'); + await expect(getComputedStyle(box).flexDirection).toBe('row'); + await expect(getComputedStyle(box).gap).toBe('24px'); + }, +}); + +/** Use `render` with a compatible custom div while preserving generated and consumer props. */ +export const CustomDiv = meta.story({ + args: { + 'aria-label': 'Account summary', + children: 'Account summary content', + className: 'consumer-class', + id: 'account-summary', + padding: '400', + render: (domProps) => , + style: { backgroundColor: vars.color.surface.resting }, + } satisfies Partial, + play: async ({ canvas }) => { + const div = canvas.getByText('Account summary content'); + await expect(div).toHaveClass('consumer-class'); + await expect(div).toHaveAttribute('data-motion', 'enabled'); + await expect(div).toHaveAttribute('id', 'account-summary'); + await expect(getComputedStyle(div).padding).toBe('16px'); + await expect(getComputedStyle(div).backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); + }, +}); + +function MotionDiv(props: ComponentPropsWithRef<'div'>) { + return
; +} diff --git a/packages/@luke-ui/react/src/box/box.test.ts b/packages/@luke-ui/react/src/box/box.test.ts new file mode 100644 index 00000000..b7c46476 --- /dev/null +++ b/packages/@luke-ui/react/src/box/box.test.ts @@ -0,0 +1,28 @@ +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: { medium: 'flex', xsmall: 'block' }, + 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<{ + medium: 'flex'; + xsmall: 'block'; + }>(); +}); diff --git a/packages/@luke-ui/react/src/box/index.tsx b/packages/@luke-ui/react/src/box/index.tsx new file mode 100644 index 00000000..aaed1668 --- /dev/null +++ b/packages/@luke-ui/react/src/box/index.tsx @@ -0,0 +1,46 @@ +import type { ComponentPropsWithRef, JSX, ReactElement } from 'react'; +import type { SprinklesProps } from '../styles/index.js'; +import { createSprinkles } from '../styles/index.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import type { Prettify } from '../types/prettify.js'; +import { mergeProps } from '../utils/index.js'; + +type BoxRender = (props: ComponentPropsWithRef<'div'>, renderProps: undefined) => ReactElement; + +interface _BoxProps extends ComponentPropsWithRef<'div'>, SprinklesProps { + /** Renders a compatible custom `div` while carrying Box's DOM props and generated styles. */ + render?: BoxRender; +} + +/** + * Props for `Box`. Layout props accept responsive values keyed by Luke UI breakpoints. + * + * @tier atom + */ +export type BoxProps = Prettify<_BoxProps>; + +/** A layout container backed by Luke UI Sprinkles. */ +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 }); + + return render ? render(mergedDomProps, undefined) :
; +} + +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; + } + + return [sprinklesProps as SprinklesProps, elementProps as ComponentPropsWithRef<'div'>]; +} diff --git a/packages/@luke-ui/react/src/button/button.stories.tsx b/packages/@luke-ui/react/src/button/button.stories.tsx index 4f13f912..a498ab9b 100644 --- a/packages/@luke-ui/react/src/button/button.stories.tsx +++ b/packages/@luke-ui/react/src/button/button.stories.tsx @@ -4,7 +4,7 @@ import { Icon } from '@luke-ui/react/icon'; import type { CSSProperties } from 'react'; import { expect, fn, userEvent } from 'storybook/test'; import preview from '../../.storybook/preview.js'; -import { vars } from '../theme.css.js'; +import { vars } from '../theme/index.js'; const meta = preview.meta({ component: Button, @@ -43,7 +43,7 @@ const blockContainerStyle = { } as const satisfies CSSProperties; const truncationContainerStyle = { - borderColor: vars.border.default, + borderColor: vars.color.border.decorative, borderStyle: 'dashed', borderWidth: 1, inlineSize: '100%', diff --git a/packages/@luke-ui/react/src/button/button.visual.test.tsx b/packages/@luke-ui/react/src/button/button.visual.test.tsx index 10be97bb..3a70c8be 100644 --- a/packages/@luke-ui/react/src/button/button.visual.test.tsx +++ b/packages/@luke-ui/react/src/button/button.visual.test.tsx @@ -20,8 +20,8 @@ test('tones and appearances across sizes', async () => { const locator = renderVisual( {sizes.flatMap((size) => { - return tones.flatMap((tone) => - appearances.map((appearance) => ( + return tones.flatMap((tone) => { + return appearances.map((appearance) => ( - )), - ); + )); + }); })} , ); diff --git a/packages/@luke-ui/react/src/button/index.tsx b/packages/@luke-ui/react/src/button/index.tsx index 85714e15..01ea160d 100644 --- a/packages/@luke-ui/react/src/button/index.tsx +++ b/packages/@luke-ui/react/src/button/index.tsx @@ -3,7 +3,9 @@ import { LoadingSpinner } from '../loading-spinner/index.js'; import * as styles from '../recipes/button-composed.css.js'; import type * as primitiveStyles from '../recipes/button.css.js'; import { Text } from '../text/index.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { DocumentedPressProps } from '../types/documented-rac-props.js'; +import type { Prettify } from '../types/prettify.js'; import type { ButtonProps as PrimitiveButtonProps } from './primitive/index.js'; import { Button as PrimitiveButton } from './primitive/index.js'; @@ -47,16 +49,19 @@ interface ButtonStyleProps { tone?: PrimitiveButtonRecipeProps['tone']; } +type _ButtonOmit = DistributiveOmit< + PrimitiveButtonProps, + 'appearance' | 'isBlock' | 'isPending' | 'size' | 'tone' | keyof DocumentedPressProps +>; + +interface _ButtonProps extends _ButtonOmit, ButtonStyleProps, DocumentedPressProps {} + /** * Composed button with size, tone, appearance, pending, and block options. * * @tier composed */ -export interface ButtonProps - extends - Omit, - ButtonStyleProps, - DocumentedPressProps {} +export type ButtonProps = Prettify<_ButtonProps>; /** Composed button. Wraps children in a `Text` for ellipsis truncation. Shows a spinner when `isPending`. */ export function Button(props: ButtonProps): JSX.Element { diff --git a/packages/@luke-ui/react/src/button/primitive/index.tsx b/packages/@luke-ui/react/src/button/primitive/index.tsx index 0c08dd48..35e6aff2 100644 --- a/packages/@luke-ui/react/src/button/primitive/index.tsx +++ b/packages/@luke-ui/react/src/button/primitive/index.tsx @@ -5,6 +5,7 @@ import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import { IconSizeProvider } from '../../icon-size-context/index.js'; import * as styles from '../../recipes/button.css.js'; import { BUTTON_ICON_SIZE } from '../../sizing/button-sizing.js'; +import type { Prettify } from '../../types/prettify.js'; import { cx } from '../../utils/index.js'; interface ButtonRecipeProps extends NonNullable {} @@ -32,14 +33,15 @@ interface ButtonStyleProps { tone?: ButtonRecipeProps['tone']; } +interface _ButtonProps extends RacButtonProps, ButtonStyleProps {} + /** * Primitive button — a bare `