diff --git a/change/@fluentui-contrib-react-cap-theme-d1f6358d-469b-439d-ab8a-a6ab68c32fa5.json b/change/@fluentui-contrib-react-cap-theme-d1f6358d-469b-439d-ab8a-a6ab68c32fa5.json new file mode 100644 index 00000000..b017b884 --- /dev/null +++ b/change/@fluentui-contrib-react-cap-theme-d1f6358d-469b-439d-ab8a-a6ab68c32fa5.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "add react-card components", + "packageName": "@fluentui-contrib/react-cap-theme", + "email": "egianoglio@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-cap-theme/src/capStyleHooks.ts b/packages/react-cap-theme/src/capStyleHooks.ts index 1dfd0bab..f0f0c9e2 100644 --- a/packages/react-cap-theme/src/capStyleHooks.ts +++ b/packages/react-cap-theme/src/capStyleHooks.ts @@ -33,6 +33,18 @@ import type { ToggleButtonState, } from './components/react-button'; import type { CompoundButtonState } from '@fluentui/react-button'; +import { + useCardStyles, + useCardFooterStyles, + useCardHeaderStyles, + useCardPreviewStyles, +} from './components/react-card'; +import type { + CardState, + CardHeaderState, + CardPreviewState, +} from './components/react-card'; +import type { CardFooterState } from '@fluentui/react-components'; import { useCarouselStyles, useCarouselAutoplayButtonStyles, @@ -158,6 +170,18 @@ export const CAP_STYLE_HOOKS: NonNullable< useButtonStyles_unstable: (state) => { return useButtonStyles(state as ButtonState); }, + useCardStyles_unstable: (state) => { + return useCardStyles(state as CardState); + }, + useCardFooterStyles_unstable: (state) => { + return useCardFooterStyles(state as CardFooterState); + }, + useCardHeaderStyles_unstable: (state) => { + return useCardHeaderStyles(state as CardHeaderState); + }, + useCardPreviewStyles_unstable: (state) => { + return useCardPreviewStyles(state as CardPreviewState); + }, useCarouselAutoplayButtonStyles_unstable: (state) => { return useCarouselAutoplayButtonStyles( state as CarouselAutoplayButtonState diff --git a/packages/react-cap-theme/src/components/react-card/Card/Card.types.ts b/packages/react-cap-theme/src/components/react-card/Card/Card.types.ts new file mode 100644 index 00000000..22b27a3c --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/Card/Card.types.ts @@ -0,0 +1,57 @@ +import type { + CardContextValue as BaseContextValue, + CardProps as BaseProps, + CardState as BaseState, +} from '@fluentui/react-card'; + +/** + * SP specific data shared between card components + * @internal + */ +export type CardContextValueInternal = Pick< + CardState, + 'disabled' | 'orientation' | 'size' +>; + +/** + * Data shared between card components + * @internal + */ +export type CardContextValue = CardContextValueInternal & BaseContextValue; + +/** + * Combined Fluent and SP context values for Card and its underlying components. + * @internal + */ +export type CardContextValues = { + /** Fluent's context values from the Card component */ + base: BaseContextValue; + /** Local context values specific to this SP implementation */ + local: CardContextValueInternal; +}; + +/** + * Props for the Card component. + * @alpha + */ +export type CardProps = Omit & { + /** + * Sets the appearance of the card. + * + * `filled` + * The card will have a shadow, border and background color. + * + * `subtle` + * This appearance shows no background or shadow on rest. + * + * @default 'filled' + */ + appearance?: 'filled' | 'subtle'; +}; + +/** + * State for rendering the Card. + * @alpha + */ +export type CardState = Omit & + Required>; diff --git a/packages/react-cap-theme/src/components/react-card/Card/cssVariables.ts b/packages/react-cap-theme/src/components/react-card/Card/cssVariables.ts new file mode 100644 index 00000000..b0ddcb6e --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/Card/cssVariables.ts @@ -0,0 +1,11 @@ +import { cardCSSVars as fluentCardCSSVars } from '@fluentui/react-card'; + +// Declared in a separate file to avoid circular dependency. +/** + * CSS variable names used internally for uniform styling in Card. + * @internal + */ +export const cardCSSVars = { + ...fluentCardCSSVars, + cardChildMarginVar: '--cap-Card--child-margin', +}; diff --git a/packages/react-cap-theme/src/components/react-card/Card/useCardStyles.styles.ts b/packages/react-cap-theme/src/components/react-card/Card/useCardStyles.styles.ts new file mode 100644 index 00000000..edbb8d16 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/Card/useCardStyles.styles.ts @@ -0,0 +1,309 @@ +import * as React from 'react'; +import { + cardClassNames, + cardFooterClassNames, + cardPreviewClassNames, +} from '@fluentui/react-card'; +import { createCustomFocusIndicatorStyle } from '@fluentui/react-tabster'; +import { + type GriffelStyle, + makeStyles, + mergeClasses, + shorthands, +} from '@griffel/react'; +import { textClassNames } from '@fluentui/react-text'; +import { tokens } from '@fluentui/tokens'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { cardCSSVars } from './cssVariables'; +import type { CardProps, CardState } from './Card.types'; +import { capTokens } from '../../tokens/tokens'; + +// Negative margin styles so child can bleed into the Card's padding +const getInsetChildStyles = ( + orientation: NonNullable, + childClassName: string +): GriffelStyle => { + const styles = { + vertical: { + // First child + // After Tabster's hidden "Groupper" element, after hidden checkbox, after floatingAction + [`& > .${childClassName}:first-child`]: { + marginTop: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > [aria-hidden="true"]:first-child + .${childClassName}`]: { + marginTop: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > .${cardClassNames.checkbox} + .${childClassName}`]: { + marginTop: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > .${cardClassNames.floatingAction} + .${childClassName}`]: { + marginTop: `var(${cardCSSVars.cardChildMarginVar})`, + }, + // Last child + // Before Tabster's hidden "Groupper" element + [`& > .${childClassName}:last-child`]: { + marginBottom: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > .${childClassName}:has(+ [aria-hidden="true"]:last-child)`]: { + marginBottom: `var(${cardCSSVars.cardChildMarginVar})`, + }, + }, + horizontal: { + // First child + // // After Tabster's hidden "Groupper" element, after hidden checkbox, after floatingAction + [`& > .${childClassName}:first-child`]: { + marginLeft: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > [aria-hidden="true"]:first-child + .${childClassName}`]: { + marginLeft: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > .${cardClassNames.checkbox} + .${childClassName}`]: { + marginLeft: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > .${cardClassNames.floatingAction} + .${childClassName}`]: { + marginLeft: `var(${cardCSSVars.cardChildMarginVar})`, + }, + // Last child + // Before Tabster's hidden "Groupper" element + [`& > .${childClassName}:last-child`]: { + marginRight: `var(${cardCSSVars.cardChildMarginVar})`, + }, + [`& > .${childClassName}:has(+ [aria-hidden="true"]:last-child)`]: { + marginRight: `var(${cardCSSVars.cardChildMarginVar})`, + }, + }, + }; + return styles[orientation]; +}; + +const highContrastStyles: GriffelStyle = { + forcedColorAdjust: 'none', + backgroundColor: 'Highlight', + color: 'HighlightText', + + [`& .${cardPreviewClassNames.root}, & .${cardFooterClassNames.root}`]: { + forcedColorAdjust: 'auto', + }, +}; + +const focusOutlineStyle = { + '::after': { + ...shorthands.borderColor(tokens.colorStrokeFocus2), + boxShadow: ` + 0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus2} inset, + 0 0 0 ${tokens.strokeWidthThick} ${tokens.colorStrokeFocus1} inset + `, + + '@media (forced-colors: active)': { + ...shorthands.borderColor('Highlight'), + ...shorthands.borderWidth(tokens.strokeWidthThick), + }, + }, +}; + +const useStyles = makeStyles({ + root: { + [cardCSSVars.cardSizeVar]: tokens.spacingVerticalXL, + [cardCSSVars.cardChildMarginVar]: `calc((-1 * var(${cardCSSVars.cardSizeVar})) + ${tokens.spacingHorizontalM})`, // FIXME refactor to remove the `calc` + + display: 'flex', + position: 'relative', + overflow: 'hidden', + borderRadius: capTokens.borderRadius4XLarge, + boxShadow: tokens.shadow4, + boxSizing: 'border-box', + padding: `var(${cardCSSVars.cardSizeVar})`, + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + + '::after': { + position: 'absolute', + content: '""', + inset: 0, + pointerEvents: 'none', + border: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + borderRadius: 'inherit', + }, + }, + + vertical: { + flexDirection: 'column', + ...getInsetChildStyles('vertical', cardPreviewClassNames.root), + }, + + horizontal: { + flexDirection: 'row', + gap: tokens.spacingHorizontalXL, + ...getInsetChildStyles('horizontal', cardPreviewClassNames.root), + }, + + small: { + [cardCSSVars.cardSizeVar]: tokens.spacingVerticalL, + gap: tokens.spacingVerticalS, + }, + medium: { gap: tokens.spacingVerticalL }, + large: { + [cardCSSVars.cardSizeVar]: tokens.spacingVerticalXXXL, + gap: tokens.spacingVerticalL, + }, + + interactive: { + cursor: 'pointer', + + [`& .${textClassNames.root}`]: { color: 'currentColor' }, + + ':hover': { boxShadow: tokens.shadow8 }, + + ':active': { boxShadow: tokens.shadow4 }, + + '@media (forced-colors: active)': { + ':hover, :active': highContrastStyles, + '::after': shorthands.borderColor('Highlight'), + }, + }, + filled: { + /* Same as root */ + }, + ['filled-interactive']: { + ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }, + ':active': { backgroundColor: tokens.colorNeutralBackground1Pressed }, + }, + ['filled-selected']: { + backgroundColor: tokens.colorNeutralBackground1Selected, + }, + + subtle: { + backgroundColor: tokens.colorTransparentBackground, + boxShadow: 'none', + }, + ['subtle-interactive']: { + ':hover': { backgroundColor: tokens.colorSubtleBackgroundHover }, + ':active': { backgroundColor: tokens.colorSubtleBackgroundPressed }, + }, + ['subtle-selected']: { + backgroundColor: tokens.colorSubtleBackgroundSelected, + }, + + selected: { + '::after': shorthands.borderColor(tokens.colorNeutralStroke1), + '@media (forced-colors: active)': highContrastStyles, + }, + + focused: createCustomFocusIndicatorStyle(focusOutlineStyle), + selectableFocused: createCustomFocusIndicatorStyle(focusOutlineStyle, { + selector: 'focus-within', + }), + + floatingAction: { + position: 'absolute', + zIndex: tokens.zIndexContent, + top: 0, + right: 0, + marginTop: tokens.spacingVerticalXL, + marginRight: tokens.spacingHorizontalXL, + }, + + hiddenCheckbox: { + overflow: 'hidden', + width: '1px', + height: '1px', + position: 'absolute', + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }, + + disabled: { + cursor: 'not-allowed', + userSelect: 'none', + backgroundColor: tokens.colorNeutralBackgroundDisabled, + color: tokens.colorNeutralForegroundDisabled, + boxShadow: tokens.shadow2, + + '::before': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: `calc(${tokens.zIndexContent} + 1)`, + }, + '::after': shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + + ':hover': { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + boxShadow: tokens.shadow2, + '::after': shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + }, + ':active': { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + boxShadow: tokens.shadow2, + '::after': shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + }, + }, +}); + +/** + * Applies styling to the Card component based on its state. + * + * This includes styling that impacts the `CardPreview`. + * + * @param state - The state object for the Card component + * @returns The updated state object with the applied styles + * @alpha + */ +export const useCardStyles = (state: CardState): CardState => { + const styles = useStyles(); + const { + appearance, + disabled, + interactive, + orientation, + selected, + selectable, + selectFocused, + size, + } = state; + const isSelectableOrInteractive = !disabled && (interactive || selectable); + + const focusedStyles = React.useMemo(() => { + if (selectable) { + return selectFocused ? styles.selectableFocused : ''; + } + return styles.focused; + }, [selectFocused, selectable, styles.focused, styles.selectableFocused]); + + state.root.className = mergeClasses( + state.root.className, + cardClassNames.root, + styles.root, + styles[size], + styles[orientation], + styles[appearance], + isSelectableOrInteractive && styles.interactive, + isSelectableOrInteractive && styles[`${appearance}-interactive`], + selected && styles.selected, + selected && styles[`${appearance}-selected`], + disabled && styles.disabled, + focusedStyles, + getSlotClassNameProp_unstable(state.root) + ); + + if (state.floatingAction) { + state.floatingAction.className = mergeClasses( + state.floatingAction.className, + cardClassNames.floatingAction, + styles.floatingAction, + getSlotClassNameProp_unstable(state.floatingAction) + ); + } + + if (state.checkbox) { + state.checkbox.className = mergeClasses( + state.checkbox.className, + cardClassNames.checkbox, + styles.hiddenCheckbox, + getSlotClassNameProp_unstable(state.checkbox) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-card/CardFooter/useCardFooterStyles.styles.ts b/packages/react-cap-theme/src/components/react-card/CardFooter/useCardFooterStyles.styles.ts new file mode 100644 index 00000000..d35df0ee --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/CardFooter/useCardFooterStyles.styles.ts @@ -0,0 +1,46 @@ +import { type CardFooterState } from '@fluentui/react-card'; +import { makeStyles, mergeClasses } from '@griffel/react'; +import { tokens, typographyStyles } from '@fluentui/tokens'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; + +const useStyles = makeStyles({ + root: { + ...typographyStyles.caption1, + alignItems: 'center', + gap: tokens.spacingHorizontalSNudge, + }, + action: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalSNudge, + paddingLeft: tokens.spacingHorizontalM, + }, +}); + +/** + * Applies styling to the CardFooter component based on its state. + * @param state - The state object for the CardFooter component + * @returns The updated state object with applied styling + * @alpha + */ +export const useCardFooterStyles = ( + state: CardFooterState +): CardFooterState => { + const styles = useStyles(); + + state.root.className = mergeClasses( + state.root.className, + styles.root, + getSlotClassNameProp_unstable(state.root) + ); + + if (state.action) { + state.action.className = mergeClasses( + state.action.className, + styles.action, + getSlotClassNameProp_unstable(state.action) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-card/CardHeader/CardHeader.types.ts b/packages/react-cap-theme/src/components/react-card/CardHeader/CardHeader.types.ts new file mode 100644 index 00000000..627aa934 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/CardHeader/CardHeader.types.ts @@ -0,0 +1,8 @@ +import type { CardHeaderState as BaseState } from '@fluentui/react-card'; +import type { CardState } from '../Card/Card.types'; + +/** + * State used in rendering CardPreview. + * @alpha + */ +export type CardHeaderState = BaseState & Required>; diff --git a/packages/react-cap-theme/src/components/react-card/CardHeader/useCardHeaderStyles.styles.ts b/packages/react-cap-theme/src/components/react-card/CardHeader/useCardHeaderStyles.styles.ts new file mode 100644 index 00000000..430088a9 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/CardHeader/useCardHeaderStyles.styles.ts @@ -0,0 +1,74 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import { tokens, typographyStyles } from '@fluentui/tokens'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import type { CardHeaderState } from './CardHeader.types'; + +const useStyles = makeStyles({ + header: { + ...typographyStyles.subtitle1, + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXS, + }, + description: { + ...typographyStyles.body1, + display: 'flex', + alignItems: 'center', + color: tokens.colorNeutralForeground3, + gap: tokens.spacingHorizontalXS, + }, + action: { + display: 'flex', + alignItems: 'center', + }, + + alignToDescription: { alignSelf: 'end' }, + alignToHeader: { alignSelf: 'start' }, +}); + +const useDescriptionStyles = makeStyles({ + disabled: { color: 'inherit' }, +}); + +/** + * Applies styling to the CardHeader component based on its state. + * @param state - The state object for the CardHeader component + * @returns The updated state object with applied styling + * @alpha + */ +export const useCardHeaderStyles = ( + state: CardHeaderState +): CardHeaderState => { + const styles = useStyles(); + const descriptionStyles = useDescriptionStyles(); + const { disabled } = state; + + if (state.header) { + state.header.className = mergeClasses( + state.header.className, + styles.header, + state.description && styles.alignToDescription, + getSlotClassNameProp_unstable(state.header) + ); + } + + if (state.description) { + state.description.className = mergeClasses( + state.description.className, + styles.description, + state.header && styles.alignToHeader, + disabled && descriptionStyles.disabled, + getSlotClassNameProp_unstable(state.description) + ); + } + + if (state.action) { + state.action.className = mergeClasses( + state.action.className, + styles.action, + getSlotClassNameProp_unstable(state.action) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-card/CardPreview/CardPreview.types.ts b/packages/react-cap-theme/src/components/react-card/CardPreview/CardPreview.types.ts new file mode 100644 index 00000000..8b5405bf --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/CardPreview/CardPreview.types.ts @@ -0,0 +1,31 @@ +import type { + CardPreviewProps as BaseProps, + CardPreviewState as BaseState, +} from '@fluentui/react-card'; +import type { CardContextValue } from '../Card/Card.types'; + +/** + * CardPreview component props. + * @alpha + */ +export type CardPreviewProps = BaseProps & { + /** + * 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'; +}; + +/** + * State used in rendering CardPreview. + * @alpha + */ +export type CardPreviewState = BaseState & + Required< + Pick & + Pick + >; diff --git a/packages/react-cap-theme/src/components/react-card/CardPreview/useCardPreviewStyles.styles.ts b/packages/react-cap-theme/src/components/react-card/CardPreview/useCardPreviewStyles.styles.ts new file mode 100644 index 00000000..e710173d --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/CardPreview/useCardPreviewStyles.styles.ts @@ -0,0 +1,94 @@ +import { cardPreviewClassNames } from '@fluentui/react-card'; +import { makeStyles, mergeClasses } from '@griffel/react'; +import { tokens } from '@fluentui/tokens'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { capTokens } from '../../tokens/tokens'; +import { cardCSSVars } from '../Card/cssVariables'; +import type { CardPreviewState } from './CardPreview.types'; + +const useStyles = makeStyles({ + root: { + position: 'relative', + overflow: 'hidden', + + [`> :not(.${cardPreviewClassNames.logo})`]: { + display: 'block', + height: '100%', + width: '100%', + }, + }, + logo: { + position: 'absolute', + fontSize: tokens.fontSizeBase600, + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusXLarge, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalXS}`, + }, +}); + +const useLayoutStyles = makeStyles({ + full: { + [cardCSSVars.cardChildMarginVar]: `calc(-1 * var(${cardCSSVars.cardSizeVar}))`, + }, + contained: { borderRadius: capTokens.borderRadius3XLarge }, + vertical: { + marginLeft: `var(${cardCSSVars.cardChildMarginVar})`, + marginRight: `var(${cardCSSVars.cardChildMarginVar})`, + }, + horizontal: { + marginTop: `var(${cardCSSVars.cardChildMarginVar})`, + marginBottom: `var(${cardCSSVars.cardChildMarginVar})`, + }, +}); + +const useLogoFullStyles = makeStyles({ + small: { top: tokens.spacingVerticalL, left: tokens.spacingHorizontalL }, + medium: { top: tokens.spacingVerticalXL, left: tokens.spacingHorizontalXL }, + large: { + top: tokens.spacingVerticalXXXL, + left: tokens.spacingHorizontalXXXL, + }, +}); + +const useLogoContainedStyles = makeStyles({ + small: { top: tokens.spacingVerticalS, left: tokens.spacingHorizontalS }, + medium: { top: tokens.spacingVerticalS, left: tokens.spacingHorizontalS }, + large: { top: tokens.spacingVerticalXL, left: tokens.spacingHorizontalXL }, +}); + +/** + * Applies styling to the CardPreview component based on its state. + * @param state - The state object for the CardPreview component + * @returns The updated state object with applied styling. + * @alpha + */ +export const useCardPreviewStyles = ( + state: CardPreviewState +): CardPreviewState => { + const styles = useStyles(); + const layoutStyles = useLayoutStyles(); + const logoFullStyles = useLogoFullStyles(); + const logoContainedStyles = useLogoContainedStyles(); + const { layout, size, orientation } = state; + + state.root.className = mergeClasses( + state.root.className, + cardPreviewClassNames.root, + styles.root, + layoutStyles[layout], + layoutStyles[orientation], + getSlotClassNameProp_unstable(state.root) + ); + + if (state.logo) { + state.logo.className = mergeClasses( + state.logo.className, + cardPreviewClassNames.logo, + styles.logo, + layout === 'full' ? logoFullStyles[size] : logoContainedStyles[size], + getSlotClassNameProp_unstable(state.logo) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-card/index.ts b/packages/react-cap-theme/src/components/react-card/index.ts new file mode 100644 index 00000000..6d7b40e5 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-card/index.ts @@ -0,0 +1,10 @@ +export { useCardStyles } from './Card/useCardStyles.styles'; +export type { CardState } from './Card/Card.types'; + +export { useCardFooterStyles } from './CardFooter/useCardFooterStyles.styles'; + +export { useCardHeaderStyles } from './CardHeader/useCardHeaderStyles.styles'; +export type { CardHeaderState } from './CardHeader/CardHeader.types'; + +export { useCardPreviewStyles } from './CardPreview/useCardPreviewStyles.styles'; +export type { CardPreviewState } from './CardPreview/CardPreview.types';