diff --git a/.changeset/alert-remove-surface.md b/.changeset/alert-remove-surface.md new file mode 100644 index 0000000000..fcdd9303e5 --- /dev/null +++ b/.changeset/alert-remove-surface.md @@ -0,0 +1,14 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Alert no longer accepts a `surface` prop + +The Alert component's background is now driven entirely by its `status` prop. The `surface` prop has been removed. + +```diff +- ++ +``` + +**Affected components:** Alert diff --git a/.changeset/clean-bags-occur.md b/.changeset/clean-bags-occur.md new file mode 100644 index 0000000000..ea4ae25aa6 --- /dev/null +++ b/.changeset/clean-bags-occur.md @@ -0,0 +1,94 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Replaced `Surface` / `onSurface` system with new provider/consumer background system + +The old `Surface` type (`'0'`–`'3'`, `'auto'`) and its associated props (`surface`, `onSurface`) have been replaced by a provider/consumer `bg` architecture. + +**Types:** + +- `ContainerBg` — `'neutral-1'` | `'neutral-2'` | `'neutral-3'` | `'danger'` | `'warning'` | `'success'` +- `ProviderBg` — `ContainerBg | 'neutral-auto'` + +Consumer components (e.g. Button) inherit the parent's `bg` via `data-on-bg`, and CSS handles the visual step-up. See "Neutral level capping" below for details on how levels are bounded. + +**Hooks:** + +- `useBgProvider(bg?)` — for provider components. Returns `{ bg: undefined }` when no `bg` is given (transparent). Supports `'neutral-auto'` to auto-increment from the parent context. +- `useBgConsumer()` — for consumer components. Returns the parent container's `bg` unchanged. + +**Component roles:** + +- **Provider-only** (Box, Flex, Grid): set `data-bg`, wrap children in `BgProvider`. **Transparent by default** — they do _not_ auto-increment; pass `bg="neutral-auto"` explicitly if you want automatic neutral stepping. +- **Consumer-only** (Button, ButtonIcon, ButtonLink): set `data-on-bg`, inherit the parent container's `bg` unchanged. +- **Provider + Consumer** (Card): sets both `data-bg` and `data-on-bg`, wraps children. Card passes `bg="neutral-auto"` to its inner Box, so it auto-increments from the parent context. + +**Neutral level capping:** + +Provider components cap at `neutral-3`. There is no `neutral-4` prop value. The `neutral-4` level exists only in consumer component CSS — for example, a Button sitting on a `neutral-3` surface uses `neutral-4` tokens internally via `data-on-bg`. + +**Migration:** + +Rename the `surface` prop to `bg` on provider components and update values: + +```diff +- ++ + +- ++ + +- ++ + +- ++ +``` + +Remove `onSurface` from consumer components — they now always inherit from the parent container: + +```diff +- - - - - - + + + + + + - - ), -}); - -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 23ed5b9303..fb87be9ae1 100644 --- a/packages/ui/src/components/Box/Box.tsx +++ b/packages/ui/src/components/Box/Box.tsx @@ -25,7 +25,7 @@ export const Box = forwardRef((props, ref) => { BoxDefinition, props, ); - const { classes, as, surfaceChildren } = ownProps; + const { classes, as, childrenWithBgProvider } = ownProps; return createElement( as, @@ -36,7 +36,7 @@ export const Box = forwardRef((props, ref) => { ...dataAttributes, ...restProps, }, - surfaceChildren, + childrenWithBgProvider, ); }); diff --git a/packages/ui/src/components/Box/definition.ts b/packages/ui/src/components/Box/definition.ts index f202291175..3837dc8ea4 100644 --- a/packages/ui/src/components/Box/definition.ts +++ b/packages/ui/src/components/Box/definition.ts @@ -27,10 +27,10 @@ export const BoxDefinition = defineComponent()({ classNames: { root: 'bui-Box', }, - surface: 'container', + bg: 'provider', propDefs: { as: { default: 'div' }, - surface: { dataAttribute: true }, + bg: { dataAttribute: true }, children: {}, className: {}, style: {}, diff --git a/packages/ui/src/components/Box/types.ts b/packages/ui/src/components/Box/types.ts index 09b45429c3..f7641d2f27 100644 --- a/packages/ui/src/components/Box/types.ts +++ b/packages/ui/src/components/Box/types.ts @@ -15,12 +15,12 @@ */ import type { ReactNode, CSSProperties } from 'react'; -import type { Responsive, Surface, SpaceProps } from '../../types'; +import type { Responsive, ProviderBg, SpaceProps } from '../../types'; /** @public */ export type BoxOwnProps = { as?: keyof JSX.IntrinsicElements; - surface?: Responsive; + bg?: Responsive; children?: ReactNode; className?: string; style?: CSSProperties; diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index 9b6fbbd09d..322a65279d 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -118,19 +118,19 @@ --bg-active: var(--bui-bg-neutral-1-pressed); --fg: var(--bui-fg-primary); - &[data-on-surface='1'] { + &[data-on-bg='neutral-1'] { --bg: var(--bui-bg-neutral-2); --bg-hover: var(--bui-bg-neutral-2-hover); --bg-active: var(--bui-bg-neutral-2-pressed); } - &[data-on-surface='2'] { + &[data-on-bg='neutral-2'] { --bg: var(--bui-bg-neutral-3); --bg-hover: var(--bui-bg-neutral-3-hover); --bg-active: var(--bui-bg-neutral-3-pressed); } - &[data-on-surface='3'] { + &[data-on-bg='neutral-3'] { --bg: var(--bui-bg-neutral-4); --bg-hover: var(--bui-bg-neutral-4-hover); --bg-active: var(--bui-bg-neutral-4-pressed); @@ -182,17 +182,17 @@ --bg-active: var(--bui-bg-neutral-1-pressed); --fg: var(--bui-fg-primary); - &[data-on-surface='1'] { + &[data-on-bg='neutral-1'] { --bg-hover: var(--bui-bg-neutral-2-hover); --bg-active: var(--bui-bg-neutral-2-pressed); } - &[data-on-surface='2'] { + &[data-on-bg='neutral-2'] { --bg-hover: var(--bui-bg-neutral-3-hover); --bg-active: var(--bui-bg-neutral-3-pressed); } - &[data-on-surface='3'] { + &[data-on-bg='neutral-3'] { --bg-hover: var(--bui-bg-neutral-4-hover); --bg-active: var(--bui-bg-neutral-4-pressed); } diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index b5eb885930..5e5df2073b 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -81,32 +81,9 @@ export const Variants = meta.story({ - - Neutral 0 - - - - - - - - - Neutral 1 - + @@ -129,63 +106,49 @@ export const Variants = meta.story({ Neutral 2 - - - - - - - - - - + + + + + + + + Neutral 3 - - - - - - - - - - - - + + + + + + + + ), @@ -246,8 +209,8 @@ export const Destructive = meta.story({ - On Surface 1 - + On Neutral 1 + @@ -462,49 +425,33 @@ export const LoadingVariants = meta.story({ ), }); -export const OnSurfaceAuto = meta.story({ +export const AutoBg = 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". + Buttons automatically detect their parent bg context and increment the + neutral level by 1. No prop is needed on the button -- it's fully + automatic.
- - Surface 0 container + + Neutral 1 container - - - + + - - Surface 1 container + + Neutral 2 container - - - + + - - Surface 2 container + + Neutral 3 container - - - + +
diff --git a/packages/ui/src/components/Button/definition.ts b/packages/ui/src/components/Button/definition.ts index 65c4e82f53..d15ceabd77 100644 --- a/packages/ui/src/components/Button/definition.ts +++ b/packages/ui/src/components/Button/definition.ts @@ -29,7 +29,7 @@ export const ButtonDefinition = defineComponent()({ content: 'bui-ButtonContent', spinner: 'bui-ButtonSpinner', }, - surface: 'leaf', + bg: 'consumer', propDefs: { size: { dataAttribute: true, default: 'small' }, variant: { dataAttribute: true, default: 'primary' }, @@ -37,7 +37,6 @@ export const ButtonDefinition = defineComponent()({ loading: { dataAttribute: true }, iconStart: {}, iconEnd: {}, - onSurface: {}, children: {}, className: {}, style: {}, diff --git a/packages/ui/src/components/Button/types.ts b/packages/ui/src/components/Button/types.ts index c50cd5c8fc..a18d9e9164 100644 --- a/packages/ui/src/components/Button/types.ts +++ b/packages/ui/src/components/Button/types.ts @@ -16,10 +16,10 @@ import type { ReactElement, ReactNode, CSSProperties } from 'react'; import type { ButtonProps as RAButtonProps } from 'react-aria-components'; -import type { LeafSurfaceProps, Responsive } from '../../types'; +import type { Responsive } from '../../types'; /** @public */ -export type ButtonOwnProps = LeafSurfaceProps & { +export type ButtonOwnProps = { size?: Responsive<'small' | 'medium'>; variant?: Responsive<'primary' | 'secondary' | 'tertiary'>; destructive?: boolean; diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css index 19a74db3bf..128e958594 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css @@ -85,19 +85,19 @@ --bg-active: var(--bui-bg-neutral-1-pressed); --fg: var(--bui-fg-primary); - &[data-on-surface='1'] { + &[data-on-bg='neutral-1'] { --bg: var(--bui-bg-neutral-2); --bg-hover: var(--bui-bg-neutral-2-hover); --bg-active: var(--bui-bg-neutral-2-pressed); } - &[data-on-surface='2'] { + &[data-on-bg='neutral-2'] { --bg: var(--bui-bg-neutral-3); --bg-hover: var(--bui-bg-neutral-3-hover); --bg-active: var(--bui-bg-neutral-3-pressed); } - &[data-on-surface='3'] { + &[data-on-bg='neutral-3'] { --bg: var(--bui-bg-neutral-4); --bg-hover: var(--bui-bg-neutral-4-hover); --bg-active: var(--bui-bg-neutral-4-pressed); @@ -122,17 +122,17 @@ --bg-active: var(--bui-bg-neutral-1-pressed); --fg: var(--bui-fg-primary); - &[data-on-surface='1'] { + &[data-on-bg='neutral-1'] { --bg-hover: var(--bui-bg-neutral-2-hover); --bg-active: var(--bui-bg-neutral-2-pressed); } - &[data-on-surface='2'] { + &[data-on-bg='neutral-2'] { --bg-hover: var(--bui-bg-neutral-3-hover); --bg-active: var(--bui-bg-neutral-3-pressed); } - &[data-on-surface='3'] { + &[data-on-bg='neutral-3'] { --bg-hover: var(--bui-bg-neutral-4-hover); --bg-active: var(--bui-bg-neutral-4-pressed); } diff --git a/packages/ui/src/components/ButtonIcon/definition.ts b/packages/ui/src/components/ButtonIcon/definition.ts index 3befeef7c1..76f6be68ce 100644 --- a/packages/ui/src/components/ButtonIcon/definition.ts +++ b/packages/ui/src/components/ButtonIcon/definition.ts @@ -29,13 +29,12 @@ export const ButtonIconDefinition = defineComponent()({ content: 'bui-ButtonIconContent', spinner: 'bui-ButtonIconSpinner', }, - surface: 'leaf', + bg: 'consumer', propDefs: { size: { dataAttribute: true, default: 'small' }, variant: { dataAttribute: true, default: 'primary' }, loading: { dataAttribute: true }, icon: {}, - onSurface: {}, className: {}, style: {}, }, diff --git a/packages/ui/src/components/ButtonIcon/types.ts b/packages/ui/src/components/ButtonIcon/types.ts index 750e0db5e9..92618d5ff6 100644 --- a/packages/ui/src/components/ButtonIcon/types.ts +++ b/packages/ui/src/components/ButtonIcon/types.ts @@ -16,10 +16,10 @@ import type { ReactElement, CSSProperties } from 'react'; import type { ButtonProps as RAButtonProps } from 'react-aria-components'; -import type { LeafSurfaceProps, Responsive } from '../../types'; +import type { Responsive } from '../../types'; /** @public */ -export type ButtonIconOwnProps = LeafSurfaceProps & { +export type ButtonIconOwnProps = { size?: Responsive<'small' | 'medium'>; variant?: Responsive<'primary' | 'secondary' | 'tertiary'>; icon?: ReactElement; diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.module.css b/packages/ui/src/components/ButtonLink/ButtonLink.module.css index 1184a160b7..74a3fe6ba6 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.module.css +++ b/packages/ui/src/components/ButtonLink/ButtonLink.module.css @@ -79,19 +79,19 @@ --bg-active: var(--bui-bg-neutral-1-pressed); --fg: var(--bui-fg-primary); - &[data-on-surface='1'] { + &[data-on-bg='neutral-1'] { --bg: var(--bui-bg-neutral-2); --bg-hover: var(--bui-bg-neutral-2-hover); --bg-active: var(--bui-bg-neutral-2-pressed); } - &[data-on-surface='2'] { + &[data-on-bg='neutral-2'] { --bg: var(--bui-bg-neutral-3); --bg-hover: var(--bui-bg-neutral-3-hover); --bg-active: var(--bui-bg-neutral-3-pressed); } - &[data-on-surface='3'] { + &[data-on-bg='neutral-3'] { --bg: var(--bui-bg-neutral-4); --bg-hover: var(--bui-bg-neutral-4-hover); --bg-active: var(--bui-bg-neutral-4-pressed); @@ -115,17 +115,17 @@ --bg-active: var(--bui-bg-neutral-1-pressed); --fg: var(--bui-fg-primary); - &[data-on-surface='1'] { + &[data-on-bg='neutral-1'] { --bg-hover: var(--bui-bg-neutral-2-hover); --bg-active: var(--bui-bg-neutral-2-pressed); } - &[data-on-surface='2'] { + &[data-on-bg='neutral-2'] { --bg-hover: var(--bui-bg-neutral-3-hover); --bg-active: var(--bui-bg-neutral-3-pressed); } - &[data-on-surface='3'] { + &[data-on-bg='neutral-3'] { --bg-hover: var(--bui-bg-neutral-4-hover); --bg-active: var(--bui-bg-neutral-4-pressed); } diff --git a/packages/ui/src/components/ButtonLink/definition.ts b/packages/ui/src/components/ButtonLink/definition.ts index e6a80e09b3..c86cb026b5 100644 --- a/packages/ui/src/components/ButtonLink/definition.ts +++ b/packages/ui/src/components/ButtonLink/definition.ts @@ -28,13 +28,12 @@ export const ButtonLinkDefinition = defineComponent()({ root: 'bui-ButtonLink', content: 'bui-ButtonLinkContent', }, - surface: 'leaf', + bg: 'consumer', propDefs: { size: { dataAttribute: true, default: 'small' }, variant: { dataAttribute: true, default: 'primary' }, iconStart: {}, iconEnd: {}, - onSurface: {}, children: {}, className: {}, style: {}, diff --git a/packages/ui/src/components/ButtonLink/types.ts b/packages/ui/src/components/ButtonLink/types.ts index 0379d72adb..0f01c3cf40 100644 --- a/packages/ui/src/components/ButtonLink/types.ts +++ b/packages/ui/src/components/ButtonLink/types.ts @@ -16,10 +16,10 @@ import type { ReactElement, ReactNode, CSSProperties } from 'react'; import type { LinkProps as RALinkProps } from 'react-aria-components'; -import type { LeafSurfaceProps, Responsive } from '../../types'; +import type { Responsive } from '../../types'; /** @public */ -export type ButtonLinkOwnProps = LeafSurfaceProps & { +export type ButtonLinkOwnProps = { size?: Responsive<'small' | 'medium'>; variant?: Responsive<'primary' | 'secondary' | 'tertiary'>; iconStart?: ReactElement; diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index d97b0a6083..445873f6ba 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -21,11 +21,9 @@ display: flex; flex-direction: column; gap: var(--bui-space-3); - background-color: var(--bui-bg-neutral-1); border-radius: var(--bui-radius-3); padding-block: var(--bui-space-3); color: var(--bui-fg-primary); - border: 1px solid var(--bui-border); overflow: hidden; min-height: 0; width: 100%; diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index dccf3ca555..3957b7beff 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -16,6 +16,9 @@ import preview from '../../../../../.storybook/preview'; import { Card, CardHeader, CardBody, CardFooter } from './Card'; import { Text } from '../..'; +import { Flex } from '../Flex'; +import { Box } from '../Box'; +import { Button } from '../Button'; const meta = preview.meta({ title: 'Backstage UI/Card', @@ -124,3 +127,119 @@ export const WithListRow = meta.story({ ), }); + +export const Backgrounds = meta.story({ + render: args => ( + + + No parent + Defaults to neutral-1 + + + + On neutral-1 + Auto-increments to neutral-2 + + + + + On neutral-2 + Auto-increments to neutral-3 + + + + + On neutral-3 + Steps up to neutral-4 + + + + ), +}); + +export const BgNested = meta.story({ + render: args => ( + + + Nested cards auto-increment their neutral level. Buttons inherit the + parent card's bg via data-on-bg. + + + Card (visual: neutral-1, provides: neutral-1) + + + + + Card (visual: neutral-2, provides: neutral-2) + + + + + + Card (visual: neutral-4, provides: neutral-3) + + + + + + + + + + + ), +}); + +export const BgOnProviders = meta.story({ + render: args => ( + + + No provider + Card defaults to neutral-1 + + + + On neutral-1 + Card auto-increments to neutral-2 + + + + + On neutral-2 + Card auto-increments to neutral-3 + + + + + On neutral-3 + Card visually at neutral-4 + + + + ), +}); + +export const CustomCardWithBox = meta.story({ + render: () => ( + + + A custom card built with Box. Use Box with an explicit bg prop to create + a card-like container that participates in the bg system as a provider. + + + + + + Header + Body + Footer + + + ), +}); diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index a3f4995588..e3e4781eac 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -15,16 +15,20 @@ */ import { forwardRef } from 'react'; -import clsx from 'clsx'; -import { useStyles } from '../../hooks/useStyles'; -import { CardDefinition } from './definition'; +import { useDefinition } from '../../hooks/useDefinition'; +import { + CardDefinition, + CardHeaderDefinition, + CardBodyDefinition, + CardFooterDefinition, +} from './definition'; import type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps, } from './types'; -import styles from './Card.module.css'; +import { Box } from '../Box/Box'; /** * Card component. @@ -32,18 +36,27 @@ import styles from './Card.module.css'; * @public */ export const Card = forwardRef((props, ref) => { - const { classNames, cleanedProps } = useStyles(CardDefinition, props); - const { className, ...rest } = cleanedProps; + const { ownProps, restProps, dataAttributes } = useDefinition( + CardDefinition, + props, + ); + const { classes, children } = ownProps; return ( -
+ className={classes.root} + {...dataAttributes} + {...restProps} + > + {children} + ); }); +Card.displayName = 'Card'; + /** * CardHeader component. * @@ -51,23 +64,19 @@ export const Card = forwardRef((props, ref) => { */ export const CardHeader = forwardRef( (props, ref) => { - const { classNames, cleanedProps } = useStyles(CardDefinition, props); - const { className, ...rest } = cleanedProps; + const { ownProps, restProps } = useDefinition(CardHeaderDefinition, props); + const { classes, children } = ownProps; return ( -
+
+ {children} +
); }, ); +CardHeader.displayName = 'CardHeader'; + /** * CardBody component. * @@ -75,19 +84,19 @@ export const CardHeader = forwardRef( */ export const CardBody = forwardRef( (props, ref) => { - const { classNames, cleanedProps } = useStyles(CardDefinition, props); - const { className, ...rest } = cleanedProps; + const { ownProps, restProps } = useDefinition(CardBodyDefinition, props); + const { classes, children } = ownProps; return ( -
+
+ {children} +
); }, ); +CardBody.displayName = 'CardBody'; + /** * CardFooter component. * @@ -95,19 +104,15 @@ export const CardBody = forwardRef( */ export const CardFooter = forwardRef( (props, ref) => { - const { classNames, cleanedProps } = useStyles(CardDefinition, props); - const { className, ...rest } = cleanedProps; + const { ownProps, restProps } = useDefinition(CardFooterDefinition, props); + const { classes, children } = ownProps; return ( -
+
+ {children} +
); }, ); + +CardFooter.displayName = 'CardFooter'; diff --git a/packages/ui/src/components/Card/definition.ts b/packages/ui/src/components/Card/definition.ts index cd0c5f634b..6493d01839 100644 --- a/packages/ui/src/components/Card/definition.ts +++ b/packages/ui/src/components/Card/definition.ts @@ -14,17 +14,71 @@ * limitations under the License. */ -import type { ComponentDefinition } from '../../types'; +import { defineComponent } from '../../hooks/useDefinition'; +import type { + CardOwnProps, + CardHeaderOwnProps, + CardBodyOwnProps, + CardFooterOwnProps, +} from './types'; +import styles from './Card.module.css'; /** * Component definition for Card * @public */ -export const CardDefinition = { +export const CardDefinition = defineComponent()({ + styles, classNames: { root: 'bui-Card', - header: 'bui-CardHeader', - body: 'bui-CardBody', - footer: 'bui-CardFooter', }, -} as const satisfies ComponentDefinition; + propDefs: { + children: {}, + className: {}, + }, +}); + +/** + * Component definition for CardHeader + * @public + */ +export const CardHeaderDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-CardHeader', + }, + propDefs: { + children: {}, + className: {}, + }, +}); + +/** + * Component definition for CardBody + * @public + */ +export const CardBodyDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-CardBody', + }, + propDefs: { + children: {}, + className: {}, + }, +}); + +/** + * Component definition for CardFooter + * @public + */ +export const CardFooterDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-CardFooter', + }, + propDefs: { + children: {}, + className: {}, + }, +}); diff --git a/packages/ui/src/components/Card/index.ts b/packages/ui/src/components/Card/index.ts index dc7dbd4ead..f5596a717a 100644 --- a/packages/ui/src/components/Card/index.ts +++ b/packages/ui/src/components/Card/index.ts @@ -15,11 +15,10 @@ */ export { Card, CardHeader, CardBody, CardFooter } from './Card'; -export { CardDefinition } from './definition'; - -export type { - CardProps, - CardHeaderProps, - CardBodyProps, - CardFooterProps, -} from './types'; +export { + CardDefinition, + CardHeaderDefinition, + CardBodyDefinition, + CardFooterDefinition, +} from './definition'; +export type * from './types'; diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts index 8b3419f6a2..e31b2cfda2 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -14,38 +14,64 @@ * limitations under the License. */ +import type { ReactNode } from 'react'; + +/** @public */ +export type CardOwnProps = { + children?: ReactNode; + className?: string; +}; + /** * Props for the Card component. * * @public */ -export interface CardProps extends React.HTMLAttributes { - children?: React.ReactNode; -} +export interface CardProps + extends CardOwnProps, + React.HTMLAttributes {} + +/** @public */ +export type CardHeaderOwnProps = { + children?: ReactNode; + className?: string; +}; /** * Props for the CardHeader component. * * @public */ -export interface CardHeaderProps extends React.HTMLAttributes { - children?: React.ReactNode; -} +export interface CardHeaderProps + extends CardHeaderOwnProps, + React.HTMLAttributes {} + +/** @public */ +export type CardBodyOwnProps = { + children?: ReactNode; + className?: string; +}; /** * Props for the CardBody component. * * @public */ -export interface CardBodyProps extends React.HTMLAttributes { - children?: React.ReactNode; -} +export interface CardBodyProps + extends CardBodyOwnProps, + React.HTMLAttributes {} + +/** @public */ +export type CardFooterOwnProps = { + children?: ReactNode; + className?: string; +}; /** * Props for the CardFooter component. * * @public */ -export interface CardFooterProps extends React.HTMLAttributes { - children?: React.ReactNode; -} +export interface CardFooterProps + extends CardFooterOwnProps, + React.HTMLAttributes {} diff --git a/packages/ui/src/components/Flex/Flex.module.css b/packages/ui/src/components/Flex/Flex.module.css index 5923fa160e..499326b9ea 100644 --- a/packages/ui/src/components/Flex/Flex.module.css +++ b/packages/ui/src/components/Flex/Flex.module.css @@ -24,31 +24,27 @@ min-width: 0; } - .bui-Flex[data-surface='0'] { - background-color: var(--bui-bg-neutral-0); - } - - .bui-Flex[data-surface='1'] { + .bui-Flex[data-bg='neutral-1'] { background-color: var(--bui-bg-neutral-1); } - .bui-Flex[data-surface='2'] { + .bui-Flex[data-bg='neutral-2'] { background-color: var(--bui-bg-neutral-2); } - .bui-Flex[data-surface='3'] { + .bui-Flex[data-bg='neutral-3'] { background-color: var(--bui-bg-neutral-3); } - .bui-Flex[data-surface='danger'] { + .bui-Flex[data-bg='danger'] { background-color: var(--bui-bg-danger); } - .bui-Flex[data-surface='warning'] { + .bui-Flex[data-bg='warning'] { background-color: var(--bui-bg-warning); } - .bui-Flex[data-surface='success'] { + .bui-Flex[data-bg='success'] { background-color: var(--bui-bg-success); } } diff --git a/packages/ui/src/components/Flex/Flex.stories.tsx b/packages/ui/src/components/Flex/Flex.stories.tsx index 998d97bc0a..8b3b7af220 100644 --- a/packages/ui/src/components/Flex/Flex.stories.tsx +++ b/packages/ui/src/components/Flex/Flex.stories.tsx @@ -244,7 +244,7 @@ export const WithTextTruncate = meta.story({ ), }); -export const Surfaces = meta.story({ +export const Backgrounds = meta.story({ args: { px: '6', py: '4', @@ -252,54 +252,46 @@ export const Surfaces = meta.story({ render: args => ( Default - - Surface 0 + + Neutral 1 - - Surface 1 + + Neutral 2 - - Surface 2 + + Neutral 3 - - Surface 3 + + Responsive Bg - - Responsive Surface + + Danger - - Surface Danger + + Warning - - Surface Warning - - - Surface Success + + Success ), }); -export const SurfacesAutoIncrement = meta.story({ +export const BgNeutralAuto = 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. + Using bg="neutral-auto" on Flex auto-increments from the parent context. + The first Flex defaults to neutral-1 (no parent), then each nested Flex + increments by one, capping at neutral-3.
- -
Surface 0 (explicit)
- -
Surface auto (becomes 1)
- -
Surface auto (becomes 2)
- -
Surface auto (becomes 3)
- -
Surface auto (stays 3 - capped)
-
-
+ +
Neutral 1 (auto, no parent)
+ +
Neutral 2 (auto-incremented)
+ +
Neutral 3 (auto-incremented, capped)
diff --git a/packages/ui/src/components/Flex/Flex.tsx b/packages/ui/src/components/Flex/Flex.tsx index 3d29408b2b..a798a88012 100644 --- a/packages/ui/src/components/Flex/Flex.tsx +++ b/packages/ui/src/components/Flex/Flex.tsx @@ -20,24 +20,20 @@ import clsx from 'clsx'; import { useStyles } from '../../hooks/useStyles'; import { FlexDefinition } from './definition'; import styles from './Flex.module.css'; -import { SurfaceProvider, useSurface } from '../../hooks/useSurface'; +import { BgProvider, useBgProvider } from '../../hooks/useBg'; /** @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 { bg: resolvedBg } = useBgProvider(props.bg); const { classNames, dataAttributes, utilityClasses, style, cleanedProps } = useStyles(FlexDefinition, { gap: '4', ...props, - surface: resolvedSurface, // Use resolved surface for data attribute + bg: resolvedBg, // Use resolved bg for data attribute }); - const { className, surface, ...rest } = cleanedProps; + const { className, bg, ...rest } = cleanedProps; const content = (
((props, ref) => { /> ); - return resolvedSurface ? ( - {content} + return resolvedBg ? ( + {content} ) : ( content ); diff --git a/packages/ui/src/components/Flex/definition.ts b/packages/ui/src/components/Flex/definition.ts index 3f747e153b..d0452d6b2c 100644 --- a/packages/ui/src/components/Flex/definition.ts +++ b/packages/ui/src/components/Flex/definition.ts @@ -45,6 +45,13 @@ export const FlexDefinition = { 'direction', ], dataAttributes: { - surface: ['0', '1', '2', '3', 'danger', 'warning', 'success'] as const, + bg: [ + 'neutral-1', + 'neutral-2', + 'neutral-3', + 'danger', + 'warning', + 'success', + ] as const, }, } as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/Flex/types.ts b/packages/ui/src/components/Flex/types.ts index 33bc476f93..1f04a4818d 100644 --- a/packages/ui/src/components/Flex/types.ts +++ b/packages/ui/src/components/Flex/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Responsive, Space, SpaceProps, Surface } from '../../types'; +import type { Responsive, Space, SpaceProps, ProviderBg } from '../../types'; /** @public */ export interface FlexProps extends SpaceProps { @@ -25,5 +25,5 @@ export interface FlexProps extends SpaceProps { direction?: Responsive<'row' | 'column' | 'row-reverse' | 'column-reverse'>; className?: string; style?: React.CSSProperties; - surface?: Responsive; + bg?: Responsive; } diff --git a/packages/ui/src/components/Grid/Grid.module.css b/packages/ui/src/components/Grid/Grid.module.css index aa281b8695..8fdaf15c3b 100644 --- a/packages/ui/src/components/Grid/Grid.module.css +++ b/packages/ui/src/components/Grid/Grid.module.css @@ -21,38 +21,33 @@ display: grid; } - .bui-Grid[data-surface='0'], - .bui-GridItem[data-surface='0'] { - background-color: var(--bui-bg-neutral-0); - } - - .bui-Grid[data-surface='1'], - .bui-GridItem[data-surface='1'] { + .bui-Grid[data-bg='neutral-1'], + .bui-GridItem[data-bg='neutral-1'] { background-color: var(--bui-bg-neutral-1); } - .bui-Grid[data-surface='2'], - .bui-GridItem[data-surface='2'] { + .bui-Grid[data-bg='neutral-2'], + .bui-GridItem[data-bg='neutral-2'] { background-color: var(--bui-bg-neutral-2); } - .bui-Grid[data-surface='3'], - .bui-GridItem[data-surface='3'] { + .bui-Grid[data-bg='neutral-3'], + .bui-GridItem[data-bg='neutral-3'] { background-color: var(--bui-bg-neutral-3); } - .bui-Grid[data-surface='danger'], - .bui-GridItem[data-surface='danger'] { + .bui-Grid[data-bg='danger'], + .bui-GridItem[data-bg='danger'] { background-color: var(--bui-bg-danger); } - .bui-Grid[data-surface='warning'], - .bui-GridItem[data-surface='warning'] { + .bui-Grid[data-bg='warning'], + .bui-GridItem[data-bg='warning'] { background-color: var(--bui-bg-warning); } - .bui-Grid[data-surface='success'], - .bui-GridItem[data-surface='success'] { + .bui-Grid[data-bg='success'], + .bui-GridItem[data-bg='success'] { background-color: var(--bui-bg-success); } } diff --git a/packages/ui/src/components/Grid/Grid.stories.tsx b/packages/ui/src/components/Grid/Grid.stories.tsx index b3524444fd..d5b83d257d 100644 --- a/packages/ui/src/components/Grid/Grid.stories.tsx +++ b/packages/ui/src/components/Grid/Grid.stories.tsx @@ -106,78 +106,70 @@ export const RowAndColumns = meta.story({ ), }); -export const Surfaces = meta.story({ +export const Backgrounds = meta.story({ args: { px: '6', py: '4' }, render: args => ( - - Surface 0 + + Neutral 1 - - Surface 1 + + Neutral 2 - - Surface 2 + + Neutral 3 - - Surface 3 + + Responsive Bg - - Responsive Surface + + Danger - - Surface Danger + + Warning - - Surface Warning - - - Surface Success + + Success - - Surface 0 + + Neutral 1 - - Surface 1 + + Neutral 2 - - Surface 2 - - - - - Surface 3 + + Neutral 3 - Responsive Surface + Responsive Bg - - Surface Danger + + Danger - - Surface Warning + + Warning - - Surface Success + + Success @@ -185,23 +177,21 @@ export const Surfaces = meta.story({ ), }); -export const SurfacesAutoIncrement = meta.story({ +export const BgNeutralAuto = 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. + Grid is a layout primitive and is transparent to the bg system by + default. Only an explicit bg prop establishes a new bg level. Nested + grids without a bg prop inherit the parent context unchanged.
- - Surface 0 (Grid.Root) - Surface auto (becomes 1) + + Neutral 1 (Grid.Root) - - Nested: Surface auto (becomes 1) - - Nested: Surface auto (becomes 2) - + + Nested: neutral-2 (explicit) + Nested: neutral-2 (explicit) diff --git a/packages/ui/src/components/Grid/Grid.tsx b/packages/ui/src/components/Grid/Grid.tsx index 52dd22167e..d6e4cc2437 100644 --- a/packages/ui/src/components/Grid/Grid.tsx +++ b/packages/ui/src/components/Grid/Grid.tsx @@ -20,24 +20,20 @@ import type { GridItemProps, GridProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import { GridDefinition, GridItemDefinition } from './definition'; import styles from './Grid.module.css'; -import { SurfaceProvider, useSurface } from '../../hooks/useSurface'; +import { BgProvider, useBgProvider } from '../../hooks/useBg'; 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 { bg: resolvedBg } = useBgProvider(props.bg); const { classNames, dataAttributes, utilityClasses, style, cleanedProps } = useStyles(GridDefinition, { columns: 'auto', gap: '4', ...props, - surface: resolvedSurface, // Use resolved surface for data attribute + bg: resolvedBg, // Use resolved bg for data attribute }); - const { className, surface, ...rest } = cleanedProps; + const { className, bg, ...rest } = cleanedProps; const content = (
((props, ref) => { /> ); - return resolvedSurface ? ( - {content} + return resolvedBg ? ( + {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 { bg: resolvedBg } = useBgProvider(props.bg); const { classNames, dataAttributes, utilityClasses, style, cleanedProps } = useStyles(GridItemDefinition, { ...props, - surface: resolvedSurface, // Use resolved surface for data attribute + bg: resolvedBg, // Use resolved bg for data attribute }); - const { className, surface, ...rest } = cleanedProps; + const { className, bg, ...rest } = cleanedProps; const content = (
((props, ref) => { /> ); - return resolvedSurface ? ( - {content} + return resolvedBg ? ( + {content} ) : ( content ); diff --git a/packages/ui/src/components/Grid/definition.ts b/packages/ui/src/components/Grid/definition.ts index a6c5c582c7..c3acb59274 100644 --- a/packages/ui/src/components/Grid/definition.ts +++ b/packages/ui/src/components/Grid/definition.ts @@ -43,7 +43,14 @@ export const GridDefinition = { 'py', ], dataAttributes: { - surface: ['0', '1', '2', '3', 'danger', 'warning', 'success'] as const, + bg: [ + 'neutral-1', + 'neutral-2', + 'neutral-3', + 'danger', + 'warning', + 'success', + ] as const, }, } as const satisfies ComponentDefinition; @@ -57,6 +64,13 @@ export const GridItemDefinition = { }, utilityProps: ['colSpan', 'colEnd', 'colStart', 'rowSpan'], dataAttributes: { - surface: ['0', '1', '2', '3', 'danger', 'warning', 'success'] as const, + bg: [ + 'neutral-1', + 'neutral-2', + 'neutral-3', + 'danger', + 'warning', + 'success', + ] as const, }, } as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/Grid/types.ts b/packages/ui/src/components/Grid/types.ts index f5ce9ca9cb..4b33c657f3 100644 --- a/packages/ui/src/components/Grid/types.ts +++ b/packages/ui/src/components/Grid/types.ts @@ -19,7 +19,7 @@ import type { SpaceProps, Responsive, Columns, - Surface, + ProviderBg, } from '../../types'; /** @public */ @@ -29,7 +29,7 @@ export interface GridProps extends SpaceProps { columns?: Responsive; gap?: Responsive; style?: React.CSSProperties; - surface?: Responsive; + bg?: Responsive; } /** @public */ @@ -41,5 +41,5 @@ export interface GridItemProps { colStart?: Responsive; rowSpan?: Responsive; style?: React.CSSProperties; - surface?: Responsive; + bg?: Responsive; } diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx index 14183e6ede..a89bbe3862 100644 --- a/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx +++ b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx @@ -44,7 +44,7 @@ export const Default = meta.story({ }, }); -export const Surfaces = meta.story({ +export const Backgrounds = meta.story({ args: { children: 'Toggle', }, @@ -64,26 +64,20 @@ export const Surfaces = meta.story({ - On Surface 0 - + On Neutral 1 + Toggle - On Surface 1 - + On Neutral 2 + Toggle - On Surface 2 - - Toggle - - - - On Surface 3 - + On Neutral 3 + Toggle diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.tsx index 9c96304a80..d783681778 100644 --- a/packages/ui/src/components/ToggleButton/ToggleButton.tsx +++ b/packages/ui/src/components/ToggleButton/ToggleButton.tsx @@ -21,7 +21,6 @@ import type { ToggleButtonProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import { ToggleButtonDefinition } from './definition'; import styles from './ToggleButton.module.css'; -import { useSurface } from '../../hooks/useSurface'; /** @public */ export const ToggleButton = forwardRef( @@ -34,17 +33,13 @@ export const ToggleButton = forwardRef( }, ); - const { children, className, iconStart, iconEnd, onSurface, ...rest } = - cleanedProps; - - const { surface } = useSurface({ onSurface }); + const { children, className, iconStart, iconEnd, ...rest } = cleanedProps; return ( {renderProps => { diff --git a/packages/ui/src/components/ToggleButton/types.ts b/packages/ui/src/components/ToggleButton/types.ts index de70bd3c29..2e5c5755f2 100644 --- a/packages/ui/src/components/ToggleButton/types.ts +++ b/packages/ui/src/components/ToggleButton/types.ts @@ -17,7 +17,6 @@ import type { Breakpoint } from '../..'; import type { ReactElement } from 'react'; import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-components'; -import type { Responsive, Surface } from '../../types'; /** * Properties for {@link ToggleButton} @@ -28,6 +27,4 @@ export interface ToggleButtonProps extends AriaToggleButtonProps { size?: 'small' | 'medium' | Partial>; iconStart?: ReactElement; iconEnd?: ReactElement; - /** Surface the toggle button is placed on. Defaults to context surface if available */ - onSurface?: Responsive; } diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx index 30e53f0669..617cd0c0fa 100644 --- a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx @@ -71,7 +71,7 @@ export const MultipleSelection = meta.story({ ), }); -export const Surfaces = meta.story({ +export const Backgrounds = meta.story({ args: { selectionMode: 'single', defaultSelectedKeys: ['option1'], @@ -99,8 +99,8 @@ export const Surfaces = meta.story({ - On Surface 0 - + On Neutral 1 + - On Surface 1 - + On Neutral 2 + - On Surface 2 - - - Option 1 - Option 2 - Option 3 - - - - - On Surface 3 - + On Neutral 3 + ('bg-context'); + +/** + * Increments a neutral bg level by one, capping at 'neutral-3'. + * Intent backgrounds (danger, warning, success) pass through unchanged. + * + * The 'neutral-4' level is reserved for consumer component CSS and is + * never set on providers. + * + * @internal + */ +function incrementNeutralBg(bg: ContainerBg | undefined): ContainerBg { + if (!bg) return 'neutral-1'; + if (bg === 'neutral-1') return 'neutral-2'; + if (bg === 'neutral-2') return 'neutral-3'; + if (bg === 'neutral-3') return 'neutral-3'; // capped at neutral-3 + // Intent values pass through unchanged + return bg; +} + +/** + * Provider component that establishes the bg context for child components. + * + * @public + */ +export const BgProvider = ({ bg, children }: BgProviderProps) => { + return ( + + {children} + + ); +}; + +/** + * Hook for consumer components (e.g. Button) to read the parent bg context. + * + * Returns the parent container's bg unchanged. The consumer component's CSS + * handles the visual step-up (e.g. on a neutral-1 surface, the consumer + * uses neutral-2 tokens via `data-on-bg`). + * + * @public + */ +export function useBgConsumer(): BgContextValue { + const value = useContext(BgContext)?.atVersion(1); + return value ?? { bg: undefined }; +} + +/** + * Hook for provider components (e.g. Box, Card) to resolve and provide bg context. + * + * **Resolution rules:** + * + * - `bg` is `undefined` -- transparent, no context change, returns `{ bg: undefined }`. + * This is the default for Box, Flex, and Grid (they do **not** auto-increment). + * - `bg` is a `ContainerBg` value -- uses that value directly (e.g. `'neutral-1'`). + * - `bg` is `'neutral-auto'` -- increments the neutral level from the parent context, + * capping at `neutral-3`. Only components that explicitly pass `'neutral-auto'` + * (e.g. Card) will auto-increment; it is never implicit. + * + * **Capping:** + * + * Provider components cap at `neutral-3`. The `neutral-4` level is **not** a valid + * prop value -- it exists only in consumer component CSS (e.g. a Button on a + * `neutral-3` surface renders with `neutral-4` tokens via `data-on-bg`). + * + * The caller is responsible for wrapping children with `BgProvider` when the + * resolved bg is defined. + * + * @public + */ +export function useBgProvider(bg?: Responsive): BgContextValue { + const { breakpoint } = useBreakpoint(); + const context = useBgConsumer(); + + if (bg === undefined) { + return { bg: undefined }; + } + + const resolved = resolveResponsiveValue(bg, breakpoint); + + if (resolved === 'neutral-auto') { + return { bg: incrementNeutralBg(context.bg) }; + } + + return { bg: resolved }; +} diff --git a/packages/ui/src/hooks/useDefinition/defineComponent.ts b/packages/ui/src/hooks/useDefinition/defineComponent.ts index 5e2a0139a7..6dbfb5742b 100644 --- a/packages/ui/src/hooks/useDefinition/defineComponent.ts +++ b/packages/ui/src/hooks/useDefinition/defineComponent.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import type { ComponentConfig, SurfacePropsConstraint } from './types'; +import type { ComponentConfig, BgPropsConstraint } from './types'; export function defineComponent

>() { return < const S extends Record, const C extends ComponentConfig, >( - config: C & SurfacePropsConstraint, + config: C & BgPropsConstraint, ): C => config; } diff --git a/packages/ui/src/hooks/useDefinition/types.ts b/packages/ui/src/hooks/useDefinition/types.ts index 3cef56148e..91ebdb8ef5 100644 --- a/packages/ui/src/hooks/useDefinition/types.ts +++ b/packages/ui/src/hooks/useDefinition/types.ts @@ -36,25 +36,25 @@ export interface ComponentConfig< propDefs: { [K in keyof P]: PropDefConfig }; // readonly for compatibility with const inference from factory utilityProps?: readonly UtilityPropKey[]; - surface?: 'container' | 'leaf'; + /** + * How this component participates in the bg system. + * + * - `'provider'` — calls `useBgProvider`, sets `data-bg`, wraps children in `BgProvider` + * - `'consumer'` — calls `useBgConsumer`, sets `data-on-bg` + */ + bg?: 'provider' | 'consumer'; } /** - * Type constraint that validates surface props are present in the props type. - * - If surface is 'leaf', P must include 'onSurface' - * - If surface is 'container', P must include 'surface' + * Type constraint that validates bg props are present in the props type. + * - Provider components must include 'bg' in their props + * - Consumer components don't need a bg prop */ -export type SurfacePropsConstraint = Surface extends 'leaf' - ? 'onSurface' extends keyof P +export type BgPropsConstraint = Bg extends 'provider' + ? 'bg' extends keyof P ? {} : { - __error: 'Leaf components must include onSurface in props type. Extend LeafProps.'; - } - : Surface extends 'container' - ? 'surface' extends keyof P - ? {} - : { - __error: 'Container components must include surface in props type. Extend ContainerProps.'; + __error: 'Bg provider components must include bg in props type.'; } : {}; @@ -81,12 +81,10 @@ type ResolvedOwnProps< [K in keyof PropDefs & keyof P]: ResolvePropType; }; -type ChildrenProps = - Surface extends 'container' - ? { surfaceChildren: ReactNode; children?: never } - : Surface extends 'leaf' - ? { children: ReactNode; surfaceChildren?: never } - : { children: ReactNode }; +type ChildrenProps = + Bg extends 'provider' + ? { childrenWithBgProvider: ReactNode; children?: never } + : { children: ReactNode; childrenWithBgProvider?: never }; type DataAttributeKeys = { [K in keyof PropDefs]: PropDefs[K] extends { dataAttribute: true } @@ -98,7 +96,7 @@ type DataAttributes = { [K in DataAttributeKeys as `data-${Lowercase< string & K >}`]?: string; -} & { 'data-on-surface'?: string }; +} & { 'data-bg'?: string; 'data-on-bg'?: string }; export type UtilityKeys> = D['utilityProps'] extends ReadonlyArray ? K : never; @@ -127,7 +125,7 @@ export interface UseDefinitionResult< ownProps: { classes: Record; } & ResolvedOwnProps & - ChildrenProps; + ChildrenProps; // Rest props excludes both propDefs keys AND utility prop keys restProps: keyof Omit> extends never diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.tsx index 0de2eb8358..d1fe9af96c 100644 --- a/packages/ui/src/hooks/useDefinition/useDefinition.tsx +++ b/packages/ui/src/hooks/useDefinition/useDefinition.tsx @@ -17,7 +17,7 @@ import { ReactNode } from 'react'; import clsx from 'clsx'; import { useBreakpoint } from '../useBreakpoint'; -import { useSurface, SurfaceProvider, UseSurfaceOptions } from '../useSurface'; +import { useBgProvider, useBgConsumer, BgProvider } from '../useBg'; import { resolveResponsiveValue, processUtilityProps } from './helpers'; import type { ComponentConfig, @@ -36,14 +36,13 @@ export function useDefinition< ): UseDefinitionResult { const { breakpoint } = useBreakpoint(); - const surfaceOptions: UseSurfaceOptions | undefined = - definition.surface === 'container' - ? { surface: props.surface } - : definition.surface === 'leaf' - ? { onSurface: props.onSurface } - : undefined; + // Provider: resolve bg and provide context for children + const providerBg = useBgProvider( + definition.bg === 'provider' ? props.bg : undefined, + ); - const { surface: resolvedSurface } = useSurface(surfaceOptions); + // Consumer: read parent context bg + const consumerBg = useBgConsumer(); const ownPropKeys = new Set(Object.keys(definition.propDefs)); const utilityPropKeys = new Set(definition.utilityProps ?? []); @@ -70,6 +69,9 @@ export function useDefinition< if (finalValue !== undefined) { ownPropsResolved[key] = finalValue; + // Skip data-bg for bg prop when the provider path handles it + if (key === 'bg' && definition.bg === 'provider') continue; + if ((config as any).dataAttribute) { // eslint-disable-next-line no-restricted-syntax dataAttributes[`data-${key.toLowerCase()}`] = String(finalValue); @@ -77,16 +79,14 @@ export function useDefinition< } } - // Add data-on-surface for leaf components - if (definition.surface === 'leaf' && resolvedSurface !== undefined) { - // Handle responsive surface values - for data attributes, use the resolved string - const surfaceValue = - typeof resolvedSurface === 'object' - ? resolveResponsiveValue(resolvedSurface as any, breakpoint) - : resolvedSurface; - if (surfaceValue !== undefined) { - dataAttributes['data-on-surface'] = String(surfaceValue); - } + // Provider: set data-bg from the resolved provider bg + if (definition.bg === 'provider' && providerBg.bg !== undefined) { + dataAttributes['data-bg'] = String(providerBg.bg); + } + + // Consumer: set data-on-bg from the parent context + if (definition.bg === 'consumer' && consumerBg.bg !== undefined) { + dataAttributes['data-on-bg'] = String(consumerBg.bg); } const { utilityClasses, utilityStyle } = processUtilityProps>( @@ -109,13 +109,11 @@ export function useDefinition< } let children: ReactNode | undefined; - let surfaceChildren: ReactNode | undefined; + let childrenWithBgProvider: ReactNode | undefined; - if (definition.surface === 'container') { - surfaceChildren = resolvedSurface ? ( - - {props.children} - + if (definition.bg === 'provider') { + childrenWithBgProvider = providerBg.bg ? ( + {props.children} ) : ( props.children ); @@ -127,8 +125,8 @@ export function useDefinition< ownProps: { classes, ...ownPropsResolved, - ...(definition.surface === 'container' - ? { surfaceChildren } + ...(definition.bg === 'provider' + ? { childrenWithBgProvider } : { children }), }, restProps, diff --git a/packages/ui/src/hooks/useSurface.tsx b/packages/ui/src/hooks/useSurface.tsx deleted file mode 100644 index a088fb3b63..0000000000 --- a/packages/ui/src/hooks/useSurface.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useContext, ReactNode } from 'react'; -import { - createVersionedContext, - createVersionedValueMap, -} from '@backstage/version-bridge'; -import { Surface, Responsive } from '../types'; - -/** @public */ -export interface SurfaceContextValue { - surface: Responsive | undefined; -} - -/** @public */ -export interface SurfaceProviderProps { - surface: Responsive; - children: ReactNode; -} - -/** @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; -} - -const SurfaceContext = createVersionedContext<{ - 1: SurfaceContextValue; -}>('surface-context'); - -/** - * 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 - * @internal - */ -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. - * - * @internal - */ -export const SurfaceProvider = ({ - surface, - children, -}: SurfaceProviderProps) => { - return ( - - {children} - - ); -}; - -/** - * Hook to access the current surface context. - * Returns the current surface level, or undefined if no provider is present. - * - * 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 - * @internal - */ -export const useSurface = ( - options?: UseSurfaceOptions, -): SurfaceContextValue => { - const value = useContext(SurfaceContext)?.atVersion(1); - const context = value ?? { surface: undefined }; - - // 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: resolvedSurface, - }; -}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4ce616d5f1..c9f2a3763c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -64,3 +64,5 @@ export * from './types'; // Hooks export { useBreakpoint } from './hooks/useBreakpoint'; +export { useBgProvider, useBgConsumer, BgProvider } from './hooks/useBg'; +export type { BgContextValue, BgProviderProps } from './hooks/useBg'; diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 933e1d8aa9..d3dfd996aa 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -182,29 +182,30 @@ export interface ComponentDefinition { } /** - * Surface type + * Background type for the neutral bg system. * - * Supports absolute levels ('0'-'3'), intent surfaces ('danger', 'warning', 'success'), - * and 'auto' which increments from the parent surface context. + * Supports neutral levels ('neutral-1' through 'neutral-3') and + * intent backgrounds ('danger', 'warning', 'success'). + * + * The 'neutral-4' level is not exposed as a prop value -- it is reserved + * for leaf component CSS (e.g. Button on a 'neutral-3' surface). * * @public */ -export type Surface = - | '0' - | '1' - | '2' - | '3' +export type ContainerBg = + | 'neutral-1' + | 'neutral-2' + | 'neutral-3' | 'danger' | 'warning' - | 'success' - | 'auto'; + | 'success'; -/** @public */ -export interface LeafSurfaceProps { - onSurface?: Responsive; -} - -/** @public */ -export interface ContainerSurfaceProps { - surface?: Responsive; -} +/** + * Background values accepted by provider components. + * + * Includes all `ContainerBg` values plus `'neutral-auto'` which + * automatically increments the neutral level from the parent context. + * + * @public + */ +export type ProviderBg = ContainerBg | 'neutral-auto';