From 306906f0b9a66b882ef0acd1ce71f03aab1e957c Mon Sep 17 00:00:00 2001 From: EnricoGianoglio Date: Mon, 15 Jun 2026 17:52:29 +0200 Subject: [PATCH] feat(react-card): add `layout` prop to CardPreview and expose Card orientation/size on context --- .../react-card/library/etc/react-card.api.md | 12 ++++++---- .../library/src/components/Card/Card.types.ts | 4 ++-- .../src/components/Card/CardContext.ts | 2 ++ .../src/components/Card/cardCSSVars.ts | 12 ++++++++++ .../components/Card/useCardContextValue.ts | 4 ++-- .../components/Card/useCardStyles.styles.ts | 23 +++++++++++-------- .../CardPreview/CardPreview.types.ts | 19 +++++++++++++-- .../components/CardPreview/useCardPreview.ts | 10 +++++--- .../useCardPreviewStyles.styles.ts | 20 +++++++++++++++- 9 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 packages/react-components/react-card/library/src/components/Card/cardCSSVars.ts diff --git a/packages/react-components/react-card/library/etc/react-card.api.md b/packages/react-components/react-card/library/etc/react-card.api.md index 8ce6e799d47fef..fbbeb50c77321f 100644 --- a/packages/react-components/react-card/library/etc/react-card.api.md +++ b/packages/react-components/react-card/library/etc/react-card.api.md @@ -27,20 +27,20 @@ export type CardBaseState = Omit; // @public -export interface CardContextValue { - // (undocumented) +export type CardContextValue = { selectableA11yProps: { referenceId?: string; setReferenceId: (referenceId: string) => void; referenceLabel?: string; setReferenceLabel: (referenceLabel: string) => void; }; -} +} & Required>; // @public export const cardCSSVars: { cardSizeVar: string; cardBorderRadiusVar: string; + cardChildMarginVar: string; }; // @public @@ -115,7 +115,9 @@ export type CardPreviewBaseState = CardPreviewState; export const cardPreviewClassNames: SlotClassNames; // @public -export type CardPreviewProps = ComponentProps; +export type CardPreviewProps = ComponentProps & { + layout?: 'full' | 'contained'; +}; // @public export type CardPreviewSlots = { @@ -124,7 +126,7 @@ export type CardPreviewSlots = { }; // @public -export type CardPreviewState = ComponentState; +export type CardPreviewState = ComponentState & Required & Pick>; // @public export type CardProps = ComponentProps & { diff --git a/packages/react-components/react-card/library/src/components/Card/Card.types.ts b/packages/react-components/react-card/library/src/components/Card/Card.types.ts index 3285b0a6812788..1a484e9ec677cd 100644 --- a/packages/react-components/react-card/library/src/components/Card/Card.types.ts +++ b/packages/react-components/react-card/library/src/components/Card/Card.types.ts @@ -18,14 +18,14 @@ export type CardOnSelectData = { /** * Data shared between card components */ -export interface CardContextValue { +export type CardContextValue = { selectableA11yProps: { referenceId?: string; setReferenceId: (referenceId: string) => void; referenceLabel?: string; setReferenceLabel: (referenceLabel: string) => void; }; -} +} & Required>; /** * Slots available in the Card component. diff --git a/packages/react-components/react-card/library/src/components/Card/CardContext.ts b/packages/react-components/react-card/library/src/components/Card/CardContext.ts index 1c5f048b74d912..1ff99bea35d70e 100644 --- a/packages/react-components/react-card/library/src/components/Card/CardContext.ts +++ b/packages/react-components/react-card/library/src/components/Card/CardContext.ts @@ -19,6 +19,8 @@ export const cardContextDefaultValue: CardContextValue = { /* Noop */ }, }, + orientation: 'vertical', + size: 'medium', }; /** diff --git a/packages/react-components/react-card/library/src/components/Card/cardCSSVars.ts b/packages/react-components/react-card/library/src/components/Card/cardCSSVars.ts new file mode 100644 index 00000000000000..170bea777b0f4e --- /dev/null +++ b/packages/react-components/react-card/library/src/components/Card/cardCSSVars.ts @@ -0,0 +1,12 @@ +/** + * CSS variable names used internally for uniform styling in Card. + * + * Extracted into a dedicated module so descendants (e.g. CardPreview styles) + * can reference them without creating a circular import via `useCardStyles.styles.ts`, + * which itself imports class names from descendant components. + */ +export const cardCSSVars = { + cardSizeVar: '--fui-Card--size', + cardBorderRadiusVar: '--fui-Card--border-radius', + cardChildMarginVar: '--fui-Card--child-margin', +}; diff --git a/packages/react-components/react-card/library/src/components/Card/useCardContextValue.ts b/packages/react-components/react-card/library/src/components/Card/useCardContextValue.ts index 89aeb2254537a3..be231bbf3a7907 100644 --- a/packages/react-components/react-card/library/src/components/Card/useCardContextValue.ts +++ b/packages/react-components/react-card/library/src/components/Card/useCardContextValue.ts @@ -1,5 +1,5 @@ import type { CardContextValue, CardState } from './Card.types'; -export function useCardContextValue({ selectableA11yProps }: CardState): CardContextValue { - return { selectableA11yProps }; +export function useCardContextValue({ selectableA11yProps, orientation, size }: CardState): CardContextValue { + return { selectableA11yProps, orientation, size }; } diff --git a/packages/react-components/react-card/library/src/components/Card/useCardStyles.styles.ts b/packages/react-components/react-card/library/src/components/Card/useCardStyles.styles.ts index bd8ac71b185f57..a22d57d5903a44 100644 --- a/packages/react-components/react-card/library/src/components/Card/useCardStyles.styles.ts +++ b/packages/react-components/react-card/library/src/components/Card/useCardStyles.styles.ts @@ -29,6 +29,7 @@ export const cardClassNames: SlotClassNames = { export const cardCSSVars = { cardSizeVar: '--fui-Card--size', cardBorderRadiusVar: '--fui-Card--border-radius', + cardChildMarginVar: '--fui-Card--child-margin', }; const focusOutlineStyle: Partial = { @@ -107,21 +108,23 @@ const useCardStyles = makeStyles({ alignItems: 'center', // Remove vertical padding to keep CardPreview content flush with Card's borders. + // The margin is driven by `cardChildMarginVar` (set by CardPreview's `layout` styles); + // the fallback preserves the legacy bleed-to-edge behavior when the var isn't set. [`> .${cardPreviewClassNames.root}`]: { - marginTop: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, - marginBottom: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, + marginTop: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, + marginBottom: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, }, // Due to Tabster's "Groupper" focus functionality, hidden elements are injected before and after Card's content. // As such, the code below targets a CardPreview, when it's the first element. // Since this is on horizontal cards, the left padding is removed to keep the content flush with the border. [`> :not([aria-hidden="true"]).${cardPreviewClassNames.root}:first-of-type`]: { - marginLeft: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, + marginLeft: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, }, // Due to Tabster's "Groupper" focus functionality, hidden elements are injected before and after Card's content. // As such, the code below targets a CardPreview, when it's the last element. // Since this is on horizontal cards, the right padding is removed to keep the content flush with the border. [`> :not([aria-hidden="true"]).${cardPreviewClassNames.root}:last-of-type`]: { - marginRight: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, + marginRight: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, }, // If the last child is a CardHeader or CardFooter, allow it to grow to fill the available space. @@ -133,26 +136,28 @@ const useCardStyles = makeStyles({ flexDirection: 'column', // Remove lateral padding to keep CardPreview content flush with Card's borders. + // The margin is driven by `cardChildMarginVar` (set by CardPreview's `layout` styles); + // the fallback preserves the legacy bleed-to-edge behavior when the var isn't set. [`> .${cardPreviewClassNames.root}`]: { - marginLeft: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, - marginRight: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, + marginLeft: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, + marginRight: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, }, // Due to Tabster's "Groupper" focus functionality, hidden elements are injected before and after Card's content. // As such, the code below targets a CardPreview, when it's the first element. // Since this is on vertical cards, the top padding is removed to keep the content flush with the border. [`> :not([aria-hidden="true"]).${cardPreviewClassNames.root}:first-of-type`]: { - marginTop: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, + marginTop: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, }, [`> .${cardClassNames.floatingAction} + .${cardPreviewClassNames.root}`]: { - marginTop: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, + marginTop: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, }, // Due to Tabster's "Groupper" focus functionality, hidden elements are injected before and after Card's content. // As such, the code below targets a CardPreview, when it's the first element. // Since this is on vertical cards, the bottom padding is removed to keep the content flush with the border. [`> :not([aria-hidden="true"]).${cardPreviewClassNames.root}:last-of-type`]: { - marginBottom: `calc(var(${cardCSSVars.cardSizeVar}) * -1)`, + marginBottom: `var(${cardCSSVars.cardChildMarginVar}, calc(var(${cardCSSVars.cardSizeVar}) * -1))`, }, }, diff --git a/packages/react-components/react-card/library/src/components/CardPreview/CardPreview.types.ts b/packages/react-components/react-card/library/src/components/CardPreview/CardPreview.types.ts index 58a2c3f8d8048d..b3edbfa72a708b 100644 --- a/packages/react-components/react-card/library/src/components/CardPreview/CardPreview.types.ts +++ b/packages/react-components/react-card/library/src/components/CardPreview/CardPreview.types.ts @@ -1,4 +1,5 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { CardContextValue } from '../Card/Card.types'; /** * Slots available in the Card component. @@ -18,7 +19,17 @@ export type CardPreviewSlots = { /** * CardPreview component props. */ -export type CardPreviewProps = ComponentProps; +export type CardPreviewProps = ComponentProps & { + /** + * Layout of the content. + * + * - `full` (default): Pushes out to align with the edges of the Card. + * - `contained`: Content stays within the Card's spacing. + * + * @default 'full' + */ + layout?: 'full' | 'contained'; +}; /** * CardPreview base props (same as CardPreviewProps since CardPreview has no design props) @@ -27,8 +38,12 @@ export type CardPreviewBaseProps = CardPreviewProps; /** * State used in rendering CardPreview. + * + * `orientation` and `size` are inherited from the parent Card via context so descendant + * slots (and external theme libraries) can react to them without re-reading context. */ -export type CardPreviewState = ComponentState; +export type CardPreviewState = ComponentState & + Required & Pick>; /** * CardPreview base state (same as CardPreviewState since CardPreview has no design props) diff --git a/packages/react-components/react-card/library/src/components/CardPreview/useCardPreview.ts b/packages/react-components/react-card/library/src/components/CardPreview/useCardPreview.ts index 38ffba47748ad5..f7672f5a0b484b 100644 --- a/packages/react-components/react-card/library/src/components/CardPreview/useCardPreview.ts +++ b/packages/react-components/react-card/library/src/components/CardPreview/useCardPreview.ts @@ -35,10 +35,12 @@ export const useCardPreviewBase_unstable = ( props: CardPreviewBaseProps, ref: React.Ref, ): CardPreviewBaseState => { - const { logo } = props; + const { logo, layout = 'full', ...rest } = props; const { selectableA11yProps: { referenceLabel, referenceId, setReferenceLabel, setReferenceId }, + orientation, + size, } = useCardContext_unstable(); // FIXME: // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` @@ -73,12 +75,14 @@ export const useCardPreviewBase_unstable = ( root: 'div', logo: 'div', }, - + layout, + orientation, + size, root: slot.always( // eslint-disable-next-line react-hooks/refs getIntrinsicElementProps('div', { ref: previewRef, - ...props, + ...rest, }), { elementType: 'div' }, ), diff --git a/packages/react-components/react-card/library/src/components/CardPreview/useCardPreviewStyles.styles.ts b/packages/react-components/react-card/library/src/components/CardPreview/useCardPreviewStyles.styles.ts index e50dcbfe8a7ba1..7e54f64be013dd 100644 --- a/packages/react-components/react-card/library/src/components/CardPreview/useCardPreviewStyles.styles.ts +++ b/packages/react-components/react-card/library/src/components/CardPreview/useCardPreviewStyles.styles.ts @@ -1,7 +1,9 @@ 'use client'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/react-theme'; import { makeStyles, mergeClasses } from '@griffel/react'; +import { cardCSSVars } from '../Card/cardCSSVars'; import type { CardPreviewSlots, CardPreviewState } from './CardPreview.types'; /** @@ -32,13 +34,29 @@ const useStyles = makeStyles({ }, }); +const useLayoutStyles = makeStyles({ + full: { + [cardCSSVars.cardChildMarginVar]: `calc(-1 * var(${cardCSSVars.cardSizeVar}))`, + }, + contained: { + [cardCSSVars.cardChildMarginVar]: '0', + borderRadius: tokens.borderRadiusXLarge, + }, +}); + /** * Apply styling to the CardPreview slots based on the state. */ export const useCardPreviewStyles_unstable = (state: CardPreviewState): CardPreviewState => { const styles = useStyles(); + const layoutStyles = useLayoutStyles(); // eslint-disable-next-line react-hooks/immutability - state.root.className = mergeClasses(cardPreviewClassNames.root, styles.root, state.root.className); + state.root.className = mergeClasses( + cardPreviewClassNames.root, + styles.root, + layoutStyles[state.layout], + state.root.className, + ); if (state.logo) { // eslint-disable-next-line react-hooks/immutability