Refactor Bg providers

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-10 16:55:04 +00:00
parent fa4678d895
commit b113fb9359
23 changed files with 260 additions and 258 deletions
+27 -19
View File
@@ -249,7 +249,9 @@ export const BoxDefinition: {
readonly classNames: {
readonly root: 'bui-Box';
};
readonly bg: 'container';
readonly bg: {
readonly provider: true;
};
readonly propDefs: {
readonly as: {
readonly default: 'div';
@@ -290,7 +292,7 @@ export const BoxDefinition: {
// @public (undocumented)
export type BoxOwnProps = {
as?: keyof JSX.IntrinsicElements;
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
children?: ReactNode;
className?: string;
style?: CSSProperties;
@@ -335,7 +337,9 @@ export const ButtonDefinition: {
readonly content: 'bui-ButtonContent';
readonly spinner: 'bui-ButtonSpinner';
};
readonly bg: 'leaf';
readonly bg: {
readonly consumer: true;
};
readonly propDefs: {
readonly size: {
readonly dataAttribute: true;
@@ -374,7 +378,9 @@ export const ButtonIconDefinition: {
readonly content: 'bui-ButtonIconContent';
readonly spinner: 'bui-ButtonIconSpinner';
};
readonly bg: 'leaf';
readonly bg: {
readonly consumer: true;
};
readonly propDefs: {
readonly size: {
readonly dataAttribute: true;
@@ -422,7 +428,9 @@ export const ButtonLinkDefinition: {
readonly root: 'bui-ButtonLink';
readonly content: 'bui-ButtonLinkContent';
};
readonly bg: 'leaf';
readonly bg: {
readonly consumer: true;
};
readonly propDefs: {
readonly size: {
readonly dataAttribute: true;
@@ -517,11 +525,12 @@ export const CardDefinition: {
readonly classNames: {
readonly root: 'bui-Card';
};
readonly bg: 'container';
readonly bg: {
readonly provider: true;
readonly consumer: true;
readonly defaultBg: 'neutral-auto';
};
readonly propDefs: {
readonly bg: {
readonly dataAttribute: true;
};
readonly children: {};
readonly className: {};
};
@@ -589,7 +598,6 @@ export interface CardHeaderProps
// @public (undocumented)
export type CardOwnProps = {
bg?: Responsive<ContainerBg>;
children?: ReactNode;
className?: string;
};
@@ -966,7 +974,7 @@ export interface FlexProps extends SpaceProps {
// (undocumented)
align?: Responsive<'start' | 'center' | 'end' | 'baseline' | 'stretch'>;
// (undocumented)
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
// (undocumented)
children?: React.ReactNode;
// (undocumented)
@@ -1048,7 +1056,7 @@ export const GridItemDefinition: {
// @public (undocumented)
export interface GridItemProps {
// (undocumented)
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
// (undocumented)
children?: React.ReactNode;
// (undocumented)
@@ -1068,7 +1076,7 @@ export interface GridItemProps {
// @public (undocumented)
export interface GridProps extends SpaceProps {
// (undocumented)
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
// (undocumented)
children?: React.ReactNode;
// (undocumented)
@@ -1521,6 +1529,9 @@ export interface PopoverProps extends Omit<PopoverProps_2, 'children'> {
hideArrow?: boolean;
}
// @public
export type ProviderBg = ContainerBg | 'neutral-auto';
// @public (undocumented)
export interface QueryOptions<TFilter> {
// (undocumented)
@@ -2174,13 +2185,10 @@ export const TooltipTrigger: (
) => JSX_2.Element;
// @public
export const useBg: (options?: UseBgOptions) => BgContextValue;
export function useBgConsumer(): BgContextValue;
// @public (undocumented)
export interface UseBgOptions {
bg?: Responsive<ContainerBg>;
mode: 'container' | 'leaf';
}
// @public
export function useBgProvider(bg?: Responsive<ProviderBg>): BgContextValue;
// @public (undocumented)
export const useBreakpoint: () => {
+8 -25
View File
@@ -379,31 +379,14 @@ export const BackgroundColors = meta.story({
export const NestedNeutralColors = meta.story({
args: { px: '6', py: '4' },
render: args => (
<Flex direction="column" gap="6">
<Flex direction="column" gap="2">
<div>With bg="neutral-1" nested boxes and buttons auto-increment</div>
<Box {...args} bg="neutral-1">
<Button variant="secondary">Button (neutral-2)</Button>
<Box {...args} mt="4">
<Button variant="secondary">Button (neutral-3)</Button>
<Box {...args} mt="4">
<Button variant="secondary">Button (neutral-4)</Button>
</Box>
</Box>
<Box {...args} bg="neutral-1">
<Button variant="secondary">Button (on neutral-1)</Button>
<Box {...args} bg="neutral-2" mt="4">
<Button variant="secondary">Button (on neutral-2)</Button>
<Box {...args} bg="neutral-3" mt="4">
<Button variant="secondary">Button (on neutral-3)</Button>
</Box>
</Flex>
<Flex direction="column" gap="2">
<div>Without bg no context, no auto-increment</div>
<Box {...args}>
<Button variant="secondary">Button (default)</Button>
<Box {...args} mt="4">
<Button variant="secondary">Button (default)</Button>
<Box {...args} mt="4">
<Button variant="secondary">Button (default)</Button>
</Box>
</Box>
</Box>
</Flex>
</Flex>
</Box>
</Box>
),
});
+1 -1
View File
@@ -27,7 +27,7 @@ export const BoxDefinition = defineComponent<BoxOwnProps>()({
classNames: {
root: 'bui-Box',
},
bg: 'container',
bg: { provider: true },
propDefs: {
as: { default: 'div' },
bg: { dataAttribute: true },
+2 -2
View File
@@ -15,12 +15,12 @@
*/
import type { ReactNode, CSSProperties } from 'react';
import type { Responsive, ContainerBg, SpaceProps } from '../../types';
import type { Responsive, ProviderBg, SpaceProps } from '../../types';
/** @public */
export type BoxOwnProps = {
as?: keyof JSX.IntrinsicElements;
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
children?: ReactNode;
className?: string;
style?: CSSProperties;
@@ -29,7 +29,7 @@ export const ButtonDefinition = defineComponent<ButtonOwnProps>()({
content: 'bui-ButtonContent',
spinner: 'bui-ButtonSpinner',
},
bg: 'leaf',
bg: { consumer: true },
propDefs: {
size: { dataAttribute: true, default: 'small' },
variant: { dataAttribute: true, default: 'primary' },
@@ -29,7 +29,7 @@ export const ButtonIconDefinition = defineComponent<ButtonIconOwnProps>()({
content: 'bui-ButtonIconContent',
spinner: 'bui-ButtonIconSpinner',
},
bg: 'leaf',
bg: { consumer: true },
propDefs: {
size: { dataAttribute: true, default: 'small' },
variant: { dataAttribute: true, default: 'primary' },
@@ -28,7 +28,7 @@ export const ButtonLinkDefinition = defineComponent<ButtonLinkOwnProps>()({
root: 'bui-ButtonLink',
content: 'bui-ButtonLinkContent',
},
bg: 'leaf',
bg: { consumer: true },
propDefs: {
size: { dataAttribute: true, default: 'small' },
variant: { dataAttribute: true, default: 'primary' },
@@ -30,28 +30,16 @@
width: 100%;
}
.bui-Card[data-bg='neutral-1'] {
background-color: var(--bui-bg-neutral-1);
}
.bui-Card[data-bg='neutral-2'] {
.bui-Card[data-on-bg='neutral-1'] {
background-color: var(--bui-bg-neutral-2);
}
.bui-Card[data-bg='neutral-3'] {
.bui-Card[data-on-bg='neutral-2'] {
background-color: var(--bui-bg-neutral-3);
}
.bui-Card[data-bg='danger'] {
background-color: var(--bui-bg-danger);
}
.bui-Card[data-bg='warning'] {
background-color: var(--bui-bg-warning);
}
.bui-Card[data-bg='success'] {
background-color: var(--bui-bg-success);
.bui-Card[data-on-bg='neutral-3'] {
background-color: var(--bui-bg-neutral-4);
}
.bui-CardBody {
@@ -132,41 +132,27 @@ export const Backgrounds = meta.story({
render: args => (
<Flex align="start" style={{ flexWrap: 'wrap' }} gap="4">
<Card {...args} style={{ width: '200px' }}>
<CardHeader>Default</CardHeader>
<CardBody>No bg prop</CardBody>
</Card>
<Card {...args} bg="neutral-1" style={{ width: '200px' }}>
<CardHeader>Neutral 1</CardHeader>
<CardBody>Explicit neutral-1</CardBody>
</Card>
<Card {...args} bg="neutral-2" style={{ width: '200px' }}>
<CardHeader>Neutral 2</CardHeader>
<CardBody>Explicit neutral-2</CardBody>
</Card>
<Card {...args} bg="neutral-3" style={{ width: '200px' }}>
<CardHeader>Neutral 3</CardHeader>
<CardBody>Explicit neutral-3</CardBody>
</Card>
<Card
{...args}
bg={{ initial: 'neutral-1', sm: 'neutral-2' }}
style={{ width: '200px' }}
>
<CardHeader>Responsive</CardHeader>
<CardBody>Neutral 1 → 2</CardBody>
</Card>
<Card {...args} bg="danger" style={{ width: '200px' }}>
<CardHeader>Danger</CardHeader>
<CardBody>Bg danger</CardBody>
</Card>
<Card {...args} bg="warning" style={{ width: '200px' }}>
<CardHeader>Warning</CardHeader>
<CardBody>Bg warning</CardBody>
</Card>
<Card {...args} bg="success" style={{ width: '200px' }}>
<CardHeader>Success</CardHeader>
<CardBody>Bg success</CardBody>
<CardHeader>No parent</CardHeader>
<CardBody>Defaults to neutral-1</CardBody>
</Card>
<Box bg="neutral-1" p="4" style={{ borderRadius: '8px' }}>
<Card {...args} style={{ width: '200px' }}>
<CardHeader>On neutral-1</CardHeader>
<CardBody>Auto-increments to neutral-2</CardBody>
</Card>
</Box>
<Box bg="neutral-2" p="4" style={{ borderRadius: '8px' }}>
<Card {...args} style={{ width: '200px' }}>
<CardHeader>On neutral-2</CardHeader>
<CardBody>Auto-increments to neutral-3</CardBody>
</Card>
</Box>
<Box bg="neutral-3" p="4" style={{ borderRadius: '8px' }}>
<Card {...args} style={{ width: '200px' }}>
<CardHeader>On neutral-3</CardHeader>
<CardBody>Steps up to neutral-4</CardBody>
</Card>
</Box>
</Flex>
),
});
@@ -175,22 +161,25 @@ export const BgNested = meta.story({
render: args => (
<Flex direction="column">
<Box style={{ maxWidth: '600px' }} mb="4">
In this test, we are nesting cards to ensure that the correct background
is applied to each element. Buttons automatically inherit the bg context
and increment their neutral level.
Nested cards auto-increment their neutral level. Buttons inherit the
parent card's bg via data-on-bg.
</Box>
<Card {...args} bg="neutral-1" style={{ width: '500px' }}>
<CardHeader>Neutral 1</CardHeader>
<Card {...args} style={{ width: '500px' }}>
<CardHeader>Card (visual: neutral-1, provides: neutral-1)</CardHeader>
<CardBody>
<Button variant="secondary">Button</Button>
<Card {...args} bg="neutral-2" style={{ marginTop: '16px' }}>
<CardHeader>Neutral 2</CardHeader>
<Button variant="secondary">Button (on neutral-1)</Button>
<Card {...args} style={{ marginTop: '16px' }}>
<CardHeader>
Card (visual: neutral-2, provides: neutral-2)
</CardHeader>
<CardBody>
<Button variant="secondary">Button</Button>
<Button variant="secondary">Button (on neutral-2)</Button>
<Card {...args} style={{ marginTop: '16px' }}>
<CardHeader>Auto-incremented</CardHeader>
<CardHeader>
Card (visual: neutral-4, provides: neutral-3)
</CardHeader>
<CardBody>
<Button variant="secondary">Button</Button>
<Button variant="secondary">Button (on neutral-3)</Button>
</CardBody>
</Card>
</CardBody>
@@ -201,27 +190,56 @@ export const BgNested = meta.story({
),
});
export const BgAutoIncrement = meta.story({
export const BgOnProviders = meta.story({
render: args => (
<Flex align="start" style={{ flexWrap: 'wrap' }} gap="4">
<Card {...args} style={{ width: '200px' }}>
<CardHeader>No provider</CardHeader>
<CardBody>Card defaults to neutral-1</CardBody>
</Card>
<Box bg="neutral-1" p="4" style={{ borderRadius: '8px' }}>
<Card {...args} style={{ width: '200px' }}>
<CardHeader>On neutral-1</CardHeader>
<CardBody>Card auto → neutral-2</CardBody>
<CardBody>Card auto-increments to neutral-2</CardBody>
</Card>
</Box>
<Box bg="neutral-2" p="4" style={{ borderRadius: '8px' }}>
<Card {...args} style={{ width: '200px' }}>
<CardHeader>On neutral-2</CardHeader>
<CardBody>Card auto → neutral-3</CardBody>
<CardBody>Card auto-increments to neutral-3</CardBody>
</Card>
</Box>
<Box bg="neutral-3" p="4" style={{ borderRadius: '8px' }}>
<Card {...args} style={{ width: '200px' }}>
<CardHeader>On neutral-3</CardHeader>
<CardBody>Card auto neutral-4</CardBody>
<CardBody>Card visually at neutral-4</CardBody>
</Card>
</Box>
</Flex>
),
});
export const CustomCardWithBox = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Box style={{ maxWidth: '600px' }}>
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.
</Box>
<Box
bg="neutral-auto"
p="4"
style={{ borderRadius: '8px', width: '300px' }}
>
<Button variant="secondary" style={{ marginTop: '8px' }}>
Button (on neutral-1)
</Button>
</Box>
<Card style={{ width: '300px' }}>
<CardHeader>Header</CardHeader>
<CardBody>Body</CardBody>
<CardFooter>Footer</CardFooter>
</Card>
</Flex>
),
});
@@ -32,9 +32,8 @@ export const CardDefinition = defineComponent<CardOwnProps>()({
classNames: {
root: 'bui-Card',
},
bg: 'container',
bg: { provider: true, consumer: true, defaultBg: 'neutral-auto' },
propDefs: {
bg: { dataAttribute: true },
children: {},
className: {},
},
-2
View File
@@ -15,11 +15,9 @@
*/
import type { ReactNode } from 'react';
import type { Responsive, ContainerBg } from '../../types';
/** @public */
export type CardOwnProps = {
bg?: Responsive<ContainerBg>;
children?: ReactNode;
className?: string;
};
@@ -277,7 +277,7 @@ export const Backgrounds = meta.story({
),
});
export const BgAutoIncrement = meta.story({
export const BgNeutralAuto = meta.story({
args: { px: '6', py: '4', gap: '4' },
render: args => (
<Flex direction="column">
+2 -6
View File
@@ -20,15 +20,11 @@ import clsx from 'clsx';
import { useStyles } from '../../hooks/useStyles';
import { FlexDefinition } from './definition';
import styles from './Flex.module.css';
import { BgProvider, useBg } from '../../hooks/useBg';
import { BgProvider, useBgProvider } from '../../hooks/useBg';
/** @public */
export const Flex = forwardRef<HTMLDivElement, FlexProps>((props, ref) => {
// Only establish bg context when an explicit bg prop is provided.
// Flex is a layout primitive — it should be transparent to the bg system by default.
const { bg: resolvedBg } = useBg(
props.bg !== undefined ? { mode: 'container', bg: props.bg } : undefined,
);
const { bg: resolvedBg } = useBgProvider(props.bg);
const { classNames, dataAttributes, utilityClasses, style, cleanedProps } =
useStyles(FlexDefinition, {
+2 -2
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Responsive, Space, SpaceProps, ContainerBg } 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;
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
}
@@ -177,7 +177,7 @@ export const Backgrounds = meta.story({
),
});
export const BgAutoIncrement = meta.story({
export const BgNeutralAuto = meta.story({
args: { px: '6', py: '4', columns: '2', gap: '4' },
render: args => (
<Flex direction="column">
+3 -11
View File
@@ -20,14 +20,10 @@ import type { GridItemProps, GridProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { GridDefinition, GridItemDefinition } from './definition';
import styles from './Grid.module.css';
import { BgProvider, useBg } from '../../hooks/useBg';
import { BgProvider, useBgProvider } from '../../hooks/useBg';
const GridRoot = forwardRef<HTMLDivElement, GridProps>((props, ref) => {
// Only establish bg context when an explicit bg prop is provided.
// Grid is a layout primitive — it should be transparent to the bg system by default.
const { bg: resolvedBg } = useBg(
props.bg !== undefined ? { mode: 'container', bg: props.bg } : undefined,
);
const { bg: resolvedBg } = useBgProvider(props.bg);
const { classNames, dataAttributes, utilityClasses, style, cleanedProps } =
useStyles(GridDefinition, {
@@ -62,11 +58,7 @@ const GridRoot = forwardRef<HTMLDivElement, GridProps>((props, ref) => {
});
const GridItem = forwardRef<HTMLDivElement, GridItemProps>((props, ref) => {
// Only establish bg context when an explicit bg prop is provided.
// GridItem is a layout slot — it should be transparent to the bg system by default.
const { bg: resolvedBg } = useBg(
props.bg !== undefined ? { mode: 'container', bg: props.bg } : undefined,
);
const { bg: resolvedBg } = useBgProvider(props.bg);
const { classNames, dataAttributes, utilityClasses, style, cleanedProps } =
useStyles(GridItemDefinition, {
+3 -3
View File
@@ -19,7 +19,7 @@ import type {
SpaceProps,
Responsive,
Columns,
ContainerBg,
ProviderBg,
} from '../../types';
/** @public */
@@ -29,7 +29,7 @@ export interface GridProps extends SpaceProps {
columns?: Responsive<Columns>;
gap?: Responsive<Space>;
style?: React.CSSProperties;
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
}
/** @public */
@@ -41,5 +41,5 @@ export interface GridItemProps {
colStart?: Responsive<Columns>;
rowSpan?: Responsive<Columns>;
style?: React.CSSProperties;
bg?: Responsive<ContainerBg>;
bg?: Responsive<ProviderBg>;
}
+36 -76
View File
@@ -19,7 +19,7 @@ import {
createVersionedContext,
createVersionedValueMap,
} from '@backstage/version-bridge';
import { ContainerBg, Responsive } from '../types';
import { ContainerBg, ProviderBg, Responsive } from '../types';
import { useBreakpoint } from './useBreakpoint';
import { resolveResponsiveValue } from './useDefinition/helpers';
@@ -34,26 +34,6 @@ export interface BgProviderProps {
children: ReactNode;
}
/** @public */
export interface UseBgOptions {
/**
* The bg mode of the component.
*
* - `'container'` for components like Box, Card, Flex that establish bg context.
* If `bg` prop is provided, uses that value. Otherwise auto-increments from parent,
* capping at `neutral-3`.
* - `'leaf'` for components like Button that consume bg context.
* Returns the parent context bg unchanged (no increment). The leaf component's CSS
* handles the visual step-up. The `bg` prop is ignored.
*/
mode: 'container' | 'leaf';
/**
* The explicit bg value from the component's prop.
* Only used in container mode leaf mode ignores this.
*/
bg?: Responsive<ContainerBg>;
}
const BgContext = createVersionedContext<{
1: BgContextValue;
}>('bg-context');
@@ -62,15 +42,13 @@ const BgContext = createVersionedContext<{
* 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 leaf component CSS and is never
* set on containers.
* The 'neutral-4' level is reserved for consumer component CSS and is
* never set on providers.
*
* @internal
*/
function incrementNeutralBg(
bg: ContainerBg | undefined,
): ContainerBg | undefined {
if (!bg) return undefined;
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
@@ -78,32 +56,6 @@ function incrementNeutralBg(
return bg;
}
/**
* Resolves the bg for a container component.
*
* Uses the explicit `bg` prop if provided. Otherwise auto-increments from
* the parent context, capping at `neutral-3`. Returns undefined when there
* is no prop and no parent context.
*
* @internal
*/
function resolveContainerBg(
context: BgContextValue,
propBg: ContainerBg | undefined,
): BgContextValue {
// Explicit bg prop takes priority
if (propBg !== undefined) {
return { bg: propBg };
}
// No explicit bg: auto-increment from context if available
if (context.bg === undefined) {
return { bg: undefined };
}
return { bg: incrementNeutralBg(context.bg) };
}
/**
* Provider component that establishes the bg context for child components.
*
@@ -118,36 +70,44 @@ export const BgProvider = ({ bg, children }: BgProviderProps) => {
};
/**
* Hook to access and resolve the current bg context.
* Hook for consumer components (e.g. Button) to read the parent bg context.
*
* - **Container mode** uses explicit `bg` if provided, otherwise auto-increments
* from parent context. Caps at `neutral-3`.
* - **Leaf mode** returns the parent context bg unchanged. No prop needed.
* - **No options** returns the raw context value without resolution.
* 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`).
*
* @param options - Configuration for bg resolution
* @public
*/
export const useBg = (options?: UseBgOptions): BgContextValue => {
const { breakpoint } = useBreakpoint();
export function useBgConsumer(): BgContextValue {
const value = useContext(BgContext)?.atVersion(1);
const context = value ?? { bg: undefined };
return value ?? { bg: undefined };
}
if (!options) {
return context;
/**
* Hook for provider components (e.g. Box, Card) to resolve and provide bg context.
*
* - `bg` is `undefined` -- transparent, no context change, returns `{ bg: undefined }`
* - `bg` is a `ContainerBg` value -- uses that value directly
* - `bg` is `'neutral-auto'` -- increments from the parent context, capping at `neutral-3`
*
* The caller is responsible for wrapping children with `BgProvider` when the
* resolved bg is defined.
*
* @public
*/
export function useBgProvider(bg?: Responsive<ProviderBg>): BgContextValue {
const { breakpoint } = useBreakpoint();
const context = useBgConsumer();
if (bg === undefined) {
return { bg: undefined };
}
// Leaf mode: return the parent context bg unchanged.
// The leaf component's CSS handles the visual step-up.
if (options.mode === 'leaf') {
return context;
const resolved = resolveResponsiveValue(bg, breakpoint);
if (resolved === 'neutral-auto') {
return { bg: incrementNeutralBg(context.bg) };
}
// Resolve responsive prop value to a scalar for the current breakpoint
const propBg =
options.bg !== undefined
? resolveResponsiveValue(options.bg, breakpoint)
: undefined;
return resolveContainerBg(context, propBg);
};
return { bg: resolved };
}
+29 -10
View File
@@ -27,6 +27,21 @@ export interface PropDefConfig<T> {
export type UtilityPropKey = keyof typeof utilityClassMap;
/**
* Configuration for how a component participates in the bg system.
*
* - `provider` calls `useBgProvider`, sets `data-bg`, wraps children in `BgProvider`
* - `consumer` calls `useBgConsumer`, sets `data-on-bg`
* - `defaultBg` default bg value when no `bg` prop is provided (e.g. `'neutral-auto'`)
*
* A component can be provider-only, consumer-only, or both.
*/
export interface BgConfig {
provider?: boolean;
consumer?: boolean;
defaultBg?: string;
}
export interface ComponentConfig<
P extends Record<string, any>,
S extends Record<string, string>,
@@ -36,19 +51,22 @@ export interface ComponentConfig<
propDefs: { [K in keyof P]: PropDefConfig<P[K]> };
// readonly for compatibility with const inference from factory
utilityProps?: readonly UtilityPropKey[];
bg?: 'container' | 'leaf';
bg?: BgConfig;
}
/**
* Type constraint that validates bg props are present in the props type.
* - If bg is 'container', P must include 'bg'
* - If bg is 'leaf', no prop constraint (fully automatic)
* - Provider-only components must include 'bg' in their props
* - Provider+consumer components (e.g. Card) don't need a bg prop (they auto-increment)
* - Consumer-only components don't need a bg prop
*/
export type BgPropsConstraint<P, BgMode> = BgMode extends 'container'
? 'bg' extends keyof P
export type BgPropsConstraint<P, Bg> = Bg extends { provider: true }
? Bg extends { consumer: true }
? {} // provider+consumer: bg prop is optional (auto-increment via defaultBg)
: 'bg' extends keyof P
? {}
: {
__error: 'Bg container components must include bg in props type.';
__error: 'Bg provider components must include bg in props type.';
}
: {};
@@ -75,10 +93,11 @@ type ResolvedOwnProps<
[K in keyof PropDefs & keyof P]: ResolvePropType<P[K], PropDefs[K]>;
};
type ChildrenProps<BgMode extends 'container' | 'leaf' | undefined> =
BgMode extends 'container'
? { bgChildren: ReactNode; children?: never }
: { children: ReactNode; bgChildren?: never };
type ChildrenProps<Bg extends BgConfig | undefined> = Bg extends {
provider: true;
}
? { bgChildren: ReactNode; children?: never }
: { children: ReactNode; bgChildren?: never };
type DataAttributeKeys<PropDefs> = {
[K in keyof PropDefs]: PropDefs[K] extends { dataAttribute: true }
@@ -17,7 +17,7 @@
import { ReactNode } from 'react';
import clsx from 'clsx';
import { useBreakpoint } from '../useBreakpoint';
import { useBg, BgProvider } from '../useBg';
import { useBgProvider, useBgConsumer, BgProvider } from '../useBg';
import { resolveResponsiveValue, processUtilityProps } from './helpers';
import type {
ComponentConfig,
@@ -36,9 +36,17 @@ export function useDefinition<
): UseDefinitionResult<D, P> {
const { breakpoint } = useBreakpoint();
const { bg: resolvedBg } = useBg(
definition.bg ? { mode: definition.bg, bg: props.bg } : undefined,
);
// Resolve the effective bg value: use the bg prop if provided,
// otherwise fall back to the defaultBg from the bg config
const effectiveBg = definition.bg?.provider
? props.bg ?? definition.bg.defaultBg
: undefined;
// Provider: resolve bg and provide context for children
const providerBg = useBgProvider(effectiveBg);
// Consumer: read parent context bg
const consumerBg = useBgConsumer();
const ownPropKeys = new Set(Object.keys(definition.propDefs));
const utilityPropKeys = new Set(definition.utilityProps ?? []);
@@ -65,6 +73,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);
@@ -72,11 +83,18 @@ export function useDefinition<
}
}
// Set the bg data attribute from the resolved bg value
// Containers use data-bg, leaf components use data-on-bg
if (definition.bg && resolvedBg !== undefined) {
const attrName = definition.bg === 'leaf' ? 'data-on-bg' : 'data-bg';
dataAttributes[attrName] = String(resolvedBg);
// Provider-only: set data-bg (provider+consumer components use data-on-bg instead)
if (
definition.bg?.provider &&
!definition.bg?.consumer &&
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<UtilityKeys<D>>(
@@ -101,9 +119,9 @@ export function useDefinition<
let children: ReactNode | undefined;
let bgChildren: ReactNode | undefined;
if (definition.bg === 'container') {
bgChildren = resolvedBg ? (
<BgProvider bg={resolvedBg}>{props.children}</BgProvider>
if (definition.bg?.provider) {
bgChildren = providerBg.bg ? (
<BgProvider bg={providerBg.bg}>{props.children}</BgProvider>
) : (
props.children
);
@@ -115,7 +133,7 @@ export function useDefinition<
ownProps: {
classes,
...ownPropsResolved,
...(definition.bg === 'container' ? { bgChildren } : { children }),
...(definition.bg?.provider ? { bgChildren } : { children }),
},
restProps,
dataAttributes,
+2 -6
View File
@@ -64,9 +64,5 @@ export * from './types';
// Hooks
export { useBreakpoint } from './hooks/useBreakpoint';
export { useBg, BgProvider } from './hooks/useBg';
export type {
BgContextValue,
BgProviderProps,
UseBgOptions,
} from './hooks/useBg';
export { useBgProvider, useBgConsumer, BgProvider } from './hooks/useBg';
export type { BgContextValue, BgProviderProps } from './hooks/useBg';
+10
View File
@@ -199,3 +199,13 @@ export type ContainerBg =
| 'danger'
| 'warning'
| 'success';
/**
* 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';