diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index eeae948048..f11f9f88df 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1283,7 +1283,35 @@ export const SubmenuTrigger: (props: SubmenuTriggerProps) => JSX_2.Element; export interface SubmenuTriggerProps extends SubmenuTriggerProps_2 {} // @public -export type Surface = '0' | '1' | '2' | '3' | 'danger' | 'warning' | 'success'; +export type Surface = + | '0' + | '1' + | '2' + | '3' + | 'danger' + | 'warning' + | 'success' + | 'auto'; + +// @public (undocumented) +export interface SurfaceContextValue { + // (undocumented) + surface: Responsive | undefined; +} + +// @public +export const SurfaceProvider: ({ + surface, + children, +}: SurfaceProviderProps) => JSX_2.Element; + +// @public (undocumented) +export interface SurfaceProviderProps { + // (undocumented) + children: ReactNode; + // (undocumented) + surface: Responsive; +} // @public (undocumented) export const Switch: ForwardRefExoticComponent< @@ -1591,6 +1619,15 @@ export const useBreakpoint: () => { down: (key: Breakpoint) => boolean; }; +// @public +export const useSurface: (options?: UseSurfaceOptions) => SurfaceContextValue; + +// @public (undocumented) +export interface UseSurfaceOptions { + onSurface?: Responsive; + surface?: Responsive; +} + // @public export function useTable( config?: UseTableConfig, diff --git a/packages/ui/src/components/Box/Box.stories.tsx b/packages/ui/src/components/Box/Box.stories.tsx index 4d64b5c560..db0c05a24b 100644 --- a/packages/ui/src/components/Box/Box.stories.tsx +++ b/packages/ui/src/components/Box/Box.stories.tsx @@ -401,3 +401,35 @@ export const SurfacesNested = meta.story({ ), }); + +export const SurfacesAutoIncrement = meta.story({ + args: { px: '6', py: '4' }, + render: args => ( + + + Using surface="auto" automatically increments from the parent surface + level. This makes components more reusable as they don't need to know + their absolute surface level. Notice how each nested Box with + surface="auto" automatically increments: 0 → 1 → 2 → 3 (capped at 3). + + + Surface 0 (explicit) + + Surface auto (becomes 1) + + + Surface auto (becomes 2) + + Surface auto (becomes 3) + + Surface auto (stays 3 - capped) + + + + + + + ), +}); diff --git a/packages/ui/src/components/Box/Box.tsx b/packages/ui/src/components/Box/Box.tsx index 4864a27a0e..49e6fae201 100644 --- a/packages/ui/src/components/Box/Box.tsx +++ b/packages/ui/src/components/Box/Box.tsx @@ -20,12 +20,21 @@ import clsx from 'clsx'; import { useStyles } from '../../hooks/useStyles'; import styles from './Box.module.css'; import { BoxDefinition } from './definition'; -import { SurfaceProvider } from '../../hooks/useSurface'; +import { SurfaceProvider, useSurface } from '../../hooks/useSurface'; /** @public */ export const Box = forwardRef((props, ref) => { + // Resolve the surface this Box creates for its children + // Using 'surface' parameter = container behavior (auto increments) + const { surface: resolvedSurface } = useSurface({ + surface: props.surface, + }); + const { classNames, dataAttributes, utilityClasses, style, cleanedProps } = - useStyles(BoxDefinition, props); + useStyles(BoxDefinition, { + ...props, + surface: resolvedSurface, // Use resolved surface for data attribute + }); const { as = 'div', children, className, surface, ...rest } = cleanedProps; @@ -43,8 +52,8 @@ export const Box = forwardRef((props, ref) => { ...dataAttributes, ...rest, }, - surface ? ( - {children} + resolvedSurface ? ( + {children} ) : ( children ), diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index a68799ab7b..7c154b869e 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -80,7 +80,7 @@ background-color: var(--bui-bg-neutral-on-surface-0-pressed); } - &[data-surface='1'] { + &[data-on-surface='1'] { background-color: var(--bui-bg-neutral-on-surface-1); &:hover { @@ -92,7 +92,7 @@ } } - &[data-surface='2'] { + &[data-on-surface='2'] { background-color: var(--bui-bg-neutral-on-surface-2); &:hover { @@ -104,7 +104,7 @@ } } - &[data-surface='3'] { + &[data-on-surface='3'] { background-color: var(--bui-bg-neutral-on-surface-3); &:hover { @@ -133,7 +133,7 @@ background-color: transparent; color: var(--bui-fg-primary); - &[data-surface='0'] { + &[data-on-surface='0'] { &:hover { background-color: var(--bui-bg-neutral-on-surface-0-hover); } @@ -143,7 +143,7 @@ } } - &[data-surface='1'] { + &[data-on-surface='1'] { &:hover { background-color: var(--bui-bg-neutral-on-surface-1-hover); } @@ -153,7 +153,7 @@ } } - &[data-surface='2'] { + &[data-on-surface='2'] { &:hover { background-color: var(--bui-bg-neutral-on-surface-2-hover); } @@ -163,7 +163,7 @@ } } - &[data-surface='3'] { + &[data-on-surface='3'] { &:hover { background-color: var(--bui-bg-neutral-on-surface-3-hover); } diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 0cc2fb2be0..3ddc937314 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -16,6 +16,7 @@ import preview from '../../../../../.storybook/preview'; import { Button } from './Button'; import { Flex } from '../Flex'; +import { Box } from '../Box'; import { Text } from '../Text'; import { RiArrowRightSLine, RiCloudLine } from '@remixicon/react'; import { useState } from 'react'; @@ -347,3 +348,52 @@ export const LoadingVariants = meta.story({ ), }); + +export const OnSurfaceAuto = meta.story({ + render: () => ( + +
+ Using onSurface="auto" on buttons inherits their container's surface + level, making them reusable. This is equivalent to not specifying + onSurface. To override, use explicit surface values like onSurface="0" + or onSurface="2". +
+ + Surface 0 container + + + + + + + + Surface 1 container + + + + + + + + Surface 2 container + + + + + + +
+ ), +}); diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index b23bf3b197..2922d409bb 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -54,7 +54,7 @@ export const Button = forwardRef( ref={ref} isPending={loading} {...dataAttributes} - {...(surface ? { 'data-surface': surface } : {})} + {...(surface ? { 'data-on-surface': surface } : {})} {...rest} > {({ isPending }) => ( diff --git a/packages/ui/src/components/Flex/Flex.stories.tsx b/packages/ui/src/components/Flex/Flex.stories.tsx index 64c5dd0239..998d97bc0a 100644 --- a/packages/ui/src/components/Flex/Flex.stories.tsx +++ b/packages/ui/src/components/Flex/Flex.stories.tsx @@ -279,3 +279,30 @@ export const Surfaces = meta.story({ ), }); + +export const SurfacesAutoIncrement = meta.story({ + args: { px: '6', py: '4', gap: '4' }, + render: args => ( + +
+ Using surface="auto" automatically increments from the parent surface. + This allows components to be reusable without hardcoding surface levels. +
+ +
Surface 0 (explicit)
+ +
Surface auto (becomes 1)
+ +
Surface auto (becomes 2)
+ +
Surface auto (becomes 3)
+ +
Surface auto (stays 3 - capped)
+
+
+
+
+
+
+ ), +}); diff --git a/packages/ui/src/components/Flex/Flex.tsx b/packages/ui/src/components/Flex/Flex.tsx index 6ad248d3a4..3d29408b2b 100644 --- a/packages/ui/src/components/Flex/Flex.tsx +++ b/packages/ui/src/components/Flex/Flex.tsx @@ -20,12 +20,22 @@ import clsx from 'clsx'; import { useStyles } from '../../hooks/useStyles'; import { FlexDefinition } from './definition'; import styles from './Flex.module.css'; -import { SurfaceProvider } from '../../hooks/useSurface'; +import { SurfaceProvider, useSurface } from '../../hooks/useSurface'; /** @public */ export const Flex = forwardRef((props, ref) => { + // Resolve the surface this Flex creates for its children + // Using 'surface' parameter = container behavior (auto increments) + const { surface: resolvedSurface } = useSurface({ + surface: props.surface, + }); + const { classNames, dataAttributes, utilityClasses, style, cleanedProps } = - useStyles(FlexDefinition, { gap: '4', ...props }); + useStyles(FlexDefinition, { + gap: '4', + ...props, + surface: resolvedSurface, // Use resolved surface for data attribute + }); const { className, surface, ...rest } = cleanedProps; @@ -44,8 +54,8 @@ export const Flex = forwardRef((props, ref) => { /> ); - return surface ? ( - {content} + return resolvedSurface ? ( + {content} ) : ( content ); diff --git a/packages/ui/src/components/Grid/Grid.stories.tsx b/packages/ui/src/components/Grid/Grid.stories.tsx index c445b2ae0b..c75f4b7d1c 100644 --- a/packages/ui/src/components/Grid/Grid.stories.tsx +++ b/packages/ui/src/components/Grid/Grid.stories.tsx @@ -182,3 +182,27 @@ export const Surfaces = meta.story({ ), }); + +export const SurfacesAutoIncrement = meta.story({ + args: { px: '6', py: '4', columns: '2', gap: '4' }, + render: args => ( + +
+ Using surface="auto" automatically increments from the parent surface. + Each Grid.Item with auto will be one level above its Grid.Root parent. +
+ + Surface 0 (Grid.Root) + Surface auto (becomes 1) + + + Nested: Surface auto (becomes 1) + + Nested: Surface auto (becomes 2) + + + + +
+ ), +}); diff --git a/packages/ui/src/components/Grid/Grid.tsx b/packages/ui/src/components/Grid/Grid.tsx index b2fc401ca8..52dd22167e 100644 --- a/packages/ui/src/components/Grid/Grid.tsx +++ b/packages/ui/src/components/Grid/Grid.tsx @@ -20,11 +20,22 @@ import type { GridItemProps, GridProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import { GridDefinition, GridItemDefinition } from './definition'; import styles from './Grid.module.css'; -import { SurfaceProvider } from '../../hooks/useSurface'; +import { SurfaceProvider, useSurface } from '../../hooks/useSurface'; const GridRoot = forwardRef((props, ref) => { + // Resolve the surface this Grid creates for its children + // Using 'surface' parameter = container behavior (auto increments) + const { surface: resolvedSurface } = useSurface({ + surface: props.surface, + }); + const { classNames, dataAttributes, utilityClasses, style, cleanedProps } = - useStyles(GridDefinition, { columns: 'auto', gap: '4', ...props }); + useStyles(GridDefinition, { + columns: 'auto', + gap: '4', + ...props, + surface: resolvedSurface, // Use resolved surface for data attribute + }); const { className, surface, ...rest } = cleanedProps; @@ -43,16 +54,25 @@ const GridRoot = forwardRef((props, ref) => { /> ); - return surface ? ( - {content} + return resolvedSurface ? ( + {content} ) : ( content ); }); const GridItem = forwardRef((props, ref) => { + // Resolve the surface this GridItem creates for its children + // Using 'surface' parameter = container behavior (auto increments) + const { surface: resolvedSurface } = useSurface({ + surface: props.surface, + }); + const { classNames, dataAttributes, utilityClasses, style, cleanedProps } = - useStyles(GridItemDefinition, props); + useStyles(GridItemDefinition, { + ...props, + surface: resolvedSurface, // Use resolved surface for data attribute + }); const { className, surface, ...rest } = cleanedProps; @@ -71,8 +91,8 @@ const GridItem = forwardRef((props, ref) => { /> ); - return surface ? ( - {content} + return resolvedSurface ? ( + {content} ) : ( content ); diff --git a/packages/ui/src/hooks/useSurface.tsx b/packages/ui/src/hooks/useSurface.tsx index 4a2bacfd35..c5c5e4d862 100644 --- a/packages/ui/src/hooks/useSurface.tsx +++ b/packages/ui/src/hooks/useSurface.tsx @@ -30,6 +30,19 @@ export interface SurfaceProviderProps { /** @public */ export interface UseSurfaceOptions { + /** + * The surface value this component CREATES for its children (container behavior). + * When 'auto', increments from parent surface. + * + * Use this for components like Box, Flex, Grid that establish surface context. + */ + surface?: Responsive; + /** + * The surface value this component is ON for styling (leaf behavior). + * When 'auto', inherits from current surface. + * + * Use this for leaf components like Button that consume surface for styling. + */ onSurface?: Responsive; } @@ -37,10 +50,108 @@ const SurfaceContext = createContext({ surface: undefined, }); +/** + * Increments a surface level by one, capping at '3'. + * Intent surfaces (danger, warning, success) remain unchanged. + * + * @internal + */ +function incrementSurface(surface: Surface | undefined): Surface { + if (!surface) return '0'; // no context = root level + if (surface === '0') return '1'; + if (surface === '1') return '2'; + if (surface === '2' || surface === '3') return '3'; // cap at max + // Intent surfaces remain unchanged + if (surface === 'danger') return 'danger'; + if (surface === 'warning') return 'warning'; + if (surface === 'success') return 'success'; + // 'auto' should not appear here, but handle it defensively + if (surface === 'auto') return '1'; + return surface; +} + +/** + * Resolves a surface value for containers (SurfaceProvider). + * When 'auto' is used, increments from the parent surface. + * For responsive surfaces (objects), returns them as-is without resolution. + * + * @param contextSurface - The surface from context + * @param requestedSurface - The requested surface value (may be 'auto') + * @returns The resolved surface value + * @public + */ +export function resolveSurfaceForProvider( + contextSurface: Responsive | undefined, + requestedSurface: Responsive | undefined, +): Responsive | undefined { + if (!requestedSurface) { + return contextSurface; + } + + // If requestedSurface is a responsive object (breakpoint-based), return as-is + if (typeof requestedSurface === 'object') { + return requestedSurface; + } + + // If contextSurface is a responsive object, we can't auto-increment from it + // Return the requested surface as-is or default to '0' for auto + if (typeof contextSurface === 'object') { + if (requestedSurface === 'auto') { + return '0'; // fallback to root when context is responsive + } + return requestedSurface; + } + + // For containers, 'auto' means increment to create a new elevated context + if (requestedSurface === 'auto') { + return incrementSurface(contextSurface); + } + + return requestedSurface; +} + +/** + * Resolves a surface value for leaf components (useSurface hook). + * When 'auto' is used, inherits the current surface (doesn't increment). + * For responsive surfaces (objects), returns them as-is without resolution. + * + * @param contextSurface - The surface from context + * @param requestedSurface - The requested surface value (may be 'auto') + * @returns The resolved surface value + * @internal + */ +function resolveSurfaceForConsumer( + contextSurface: Responsive | undefined, + requestedSurface: Responsive | undefined, +): Responsive | undefined { + if (!requestedSurface) { + return contextSurface; + } + + // If requestedSurface is a responsive object (breakpoint-based), return as-is + if (typeof requestedSurface === 'object') { + return requestedSurface; + } + + // For leaf components, 'auto' means inherit the current surface + if (requestedSurface === 'auto') { + // If context is responsive, fallback to '0' + if (typeof contextSurface === 'object') { + return '0'; + } + return contextSurface; + } + + return requestedSurface; +} + /** * Provider component that establishes the surface context for child components. * This allows components to adapt their styling based on their background surface. * + * Note: The surface value should already be resolved before passing to this provider. + * Container components should use useSurface with the surface parameter. + * * @public */ export const SurfaceProvider = ({ @@ -58,15 +169,29 @@ export const SurfaceProvider = ({ * Hook to access the current surface context. * Returns the current surface level, or undefined if no provider is present. * - * @param options - Optional configuration - * @param options.onSurface - Override the context surface with a specific surface value + * The parameter name determines the behavior: + * - `surface`: Container behavior - 'auto' increments from parent + * - `onSurface`: Leaf behavior - 'auto' inherits from parent + * + * @param options - Optional configuration for surface resolution * @public */ export const useSurface = ( options?: UseSurfaceOptions, ): SurfaceContextValue => { const context = useContext(SurfaceContext); + + // Infer behavior from which parameter is provided + // 'surface' = provider behavior (increment) + // 'onSurface' = consumer behavior (inherit) + const isProvider = options?.surface !== undefined; + const requestedSurface = options?.surface ?? options?.onSurface; + + const resolvedSurface = isProvider + ? resolveSurfaceForProvider(context.surface, requestedSurface) + : resolveSurfaceForConsumer(context.surface, requestedSurface); + return { - surface: options?.onSurface || context.surface, + surface: resolvedSurface, }; }; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 03ae3245e3..d8a1c55f12 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -59,3 +59,9 @@ export * from './types'; // Hooks export { useBreakpoint } from './hooks/useBreakpoint'; +export { useSurface, SurfaceProvider } from './hooks/useSurface'; +export type { + SurfaceContextValue, + SurfaceProviderProps, + UseSurfaceOptions, +} from './hooks/useSurface'; diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 8630dd1f44..3b7c359dc5 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -176,6 +176,18 @@ export interface ComponentDefinition { /** * Surface type + * + * Supports absolute levels ('0'-'3'), intent surfaces ('danger', 'warning', 'success'), + * and 'auto' which increments from the parent surface context. + * * @public */ -export type Surface = '0' | '1' | '2' | '3' | 'danger' | 'warning' | 'success'; +export type Surface = + | '0' + | '1' + | '2' + | '3' + | 'danger' + | 'warning' + | 'success' + | 'auto';