Add auto prop to useSurface()

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-01-05 11:52:22 +00:00
parent 497e0660ca
commit 2a9ce99a32
13 changed files with 380 additions and 28 deletions
+38 -1
View File
@@ -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<Surface> | undefined;
}
// @public
export const SurfaceProvider: ({
surface,
children,
}: SurfaceProviderProps) => JSX_2.Element;
// @public (undocumented)
export interface SurfaceProviderProps {
// (undocumented)
children: ReactNode;
// (undocumented)
surface: Responsive<Surface>;
}
// @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>;
surface?: Responsive<Surface>;
}
// @public
export function useTable<T = any>(
config?: UseTableConfig<T>,
@@ -401,3 +401,35 @@ export const SurfacesNested = meta.story({
</Flex>
),
});
export const SurfacesAutoIncrement = meta.story({
args: { px: '6', py: '4' },
render: args => (
<Flex direction="column">
<Box style={{ maxWidth: '600px' }} mb="4">
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).
</Box>
<Box {...args} surface="0">
Surface 0 (explicit)
<Box {...args} surface="auto" mt="4">
<Box mb="3">Surface auto (becomes 1)</Box>
<Button variant="secondary" onSurface="auto">
Button auto
</Button>
<Box {...args} surface="auto" mt="4">
Surface auto (becomes 2)
<Box {...args} surface="auto" mt="4">
Surface auto (becomes 3)
<Box {...args} surface="auto" mt="4">
Surface auto (stays 3 - capped)
</Box>
</Box>
</Box>
</Box>
</Box>
</Flex>
),
});
+13 -4
View File
@@ -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<HTMLDivElement, BoxProps>((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<HTMLDivElement, BoxProps>((props, ref) => {
...dataAttributes,
...rest,
},
surface ? (
<SurfaceProvider surface={surface}>{children}</SurfaceProvider>
resolvedSurface ? (
<SurfaceProvider surface={resolvedSurface}>{children}</SurfaceProvider>
) : (
children
),
@@ -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);
}
@@ -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({
</Flex>
),
});
export const OnSurfaceAuto = meta.story({
render: () => (
<Flex direction="column" gap="4">
<div style={{ maxWidth: '600px' }}>
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".
</div>
<Box surface="0" p="4">
<Text>Surface 0 container</Text>
<Flex gap="2" mt="2">
<Button variant="secondary">Default (inherits 0)</Button>
<Button variant="secondary" onSurface="auto">
Auto (inherits 0)
</Button>
<Button variant="secondary" onSurface="1">
Explicit 1
</Button>
</Flex>
</Box>
<Box surface="1" p="4">
<Text>Surface 1 container</Text>
<Flex gap="2" mt="2">
<Button variant="secondary">Default (inherits 1)</Button>
<Button variant="secondary" onSurface="auto">
Auto (inherits 1)
</Button>
<Button variant="secondary" onSurface="2">
Explicit 2
</Button>
</Flex>
</Box>
<Box surface="2" p="4">
<Text>Surface 2 container</Text>
<Flex gap="2" mt="2">
<Button variant="secondary">Default (inherits 2)</Button>
<Button variant="secondary" onSurface="auto">
Auto (inherits 2)
</Button>
<Button variant="secondary" onSurface="3">
Explicit 3
</Button>
</Flex>
</Box>
</Flex>
),
});
+1 -1
View File
@@ -54,7 +54,7 @@ export const Button = forwardRef(
ref={ref}
isPending={loading}
{...dataAttributes}
{...(surface ? { 'data-surface': surface } : {})}
{...(surface ? { 'data-on-surface': surface } : {})}
{...rest}
>
{({ isPending }) => (
@@ -279,3 +279,30 @@ export const Surfaces = meta.story({
</Flex>
),
});
export const SurfacesAutoIncrement = meta.story({
args: { px: '6', py: '4', gap: '4' },
render: args => (
<Flex direction="column">
<div style={{ maxWidth: '600px', marginBottom: '16px' }}>
Using surface="auto" automatically increments from the parent surface.
This allows components to be reusable without hardcoding surface levels.
</div>
<Flex {...args} surface="0" direction="column">
<div>Surface 0 (explicit)</div>
<Flex {...args} surface="auto" direction="column">
<div>Surface auto (becomes 1)</div>
<Flex {...args} surface="auto" direction="column">
<div>Surface auto (becomes 2)</div>
<Flex {...args} surface="auto" direction="column">
<div>Surface auto (becomes 3)</div>
<Flex {...args} surface="auto" direction="column">
<div>Surface auto (stays 3 - capped)</div>
</Flex>
</Flex>
</Flex>
</Flex>
</Flex>
</Flex>
),
});
+14 -4
View File
@@ -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<HTMLDivElement, FlexProps>((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<HTMLDivElement, FlexProps>((props, ref) => {
/>
);
return surface ? (
<SurfaceProvider surface={surface}>{content}</SurfaceProvider>
return resolvedSurface ? (
<SurfaceProvider surface={resolvedSurface}>{content}</SurfaceProvider>
) : (
content
);
@@ -182,3 +182,27 @@ export const Surfaces = meta.story({
</Flex>
),
});
export const SurfacesAutoIncrement = meta.story({
args: { px: '6', py: '4', columns: '2', gap: '4' },
render: args => (
<Flex direction="column">
<div style={{ maxWidth: '600px', marginBottom: '16px' }}>
Using surface="auto" automatically increments from the parent surface.
Each Grid.Item with auto will be one level above its Grid.Root parent.
</div>
<Grid.Root {...args} surface="0">
<Grid.Item>Surface 0 (Grid.Root)</Grid.Item>
<Grid.Item surface="auto">Surface auto (becomes 1)</Grid.Item>
<Grid.Item>
<Grid.Root {...args} surface="auto">
<Grid.Item>Nested: Surface auto (becomes 1)</Grid.Item>
<Grid.Item surface="auto">
Nested: Surface auto (becomes 2)
</Grid.Item>
</Grid.Root>
</Grid.Item>
</Grid.Root>
</Flex>
),
});
+27 -7
View File
@@ -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<HTMLDivElement, GridProps>((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<HTMLDivElement, GridProps>((props, ref) => {
/>
);
return surface ? (
<SurfaceProvider surface={surface}>{content}</SurfaceProvider>
return resolvedSurface ? (
<SurfaceProvider surface={resolvedSurface}>{content}</SurfaceProvider>
) : (
content
);
});
const GridItem = forwardRef<HTMLDivElement, GridItemProps>((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<HTMLDivElement, GridItemProps>((props, ref) => {
/>
);
return surface ? (
<SurfaceProvider surface={surface}>{content}</SurfaceProvider>
return resolvedSurface ? (
<SurfaceProvider surface={resolvedSurface}>{content}</SurfaceProvider>
) : (
content
);
+128 -3
View File
@@ -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<Surface>;
/**
* 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<Surface>;
}
@@ -37,10 +50,108 @@ const SurfaceContext = createContext<SurfaceContextValue>({
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<Surface> | undefined,
requestedSurface: Responsive<Surface> | undefined,
): Responsive<Surface> | 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<Surface> | undefined,
requestedSurface: Responsive<Surface> | undefined,
): Responsive<Surface> | 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,
};
};
+6
View File
@@ -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';
+13 -1
View File
@@ -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';