diff --git a/change/@fluentui-contrib-react-cap-theme-9ae06b97-135d-45f6-b610-30ca8ac5e4dc.json b/change/@fluentui-contrib-react-cap-theme-9ae06b97-135d-45f6-b610-30ca8ac5e4dc.json new file mode 100644 index 00000000..e68a03a4 --- /dev/null +++ b/change/@fluentui-contrib-react-cap-theme-9ae06b97-135d-45f6-b610-30ca8ac5e4dc.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Introduce CAP_STYLE_HOOKS_ROUNDED_CORNERS", + "packageName": "@fluentui-contrib/react-cap-theme", + "email": "6439050+davezuko@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-cap-theme/.storybook/docs.css b/packages/react-cap-theme/.storybook/docs.css new file mode 100644 index 00000000..189ea54d --- /dev/null +++ b/packages/react-cap-theme/.storybook/docs.css @@ -0,0 +1,11 @@ +/* + * Constrain the docs reading column to a comfortable width and center it. + * The shared docs CSS sets `.sbdocs-content` to max-width: 1200px; this + * package-scoped override narrows it and centers the column. The extra class in + * the selector raises specificity so it wins regardless of stylesheet order. + */ +#storybook-docs .sbdocs.sbdocs-content { + max-width: 800px; + margin-left: auto; + margin-right: auto; +} diff --git a/packages/react-cap-theme/.storybook/main.ts b/packages/react-cap-theme/.storybook/main.ts index 9f7d90b3..ddbdbe08 100644 --- a/packages/react-cap-theme/.storybook/main.ts +++ b/packages/react-cap-theme/.storybook/main.ts @@ -5,7 +5,10 @@ import rootConfig from '../../../.storybook/main'; const config: StorybookConfig = { ...rootConfig, addons: [...(rootConfig.addons ?? []), 'storybook-addon-pseudo-states'], - stories: ['../stories/**/index.stories.@(js|jsx|ts|tsx|mdx)'], + stories: [ + '../stories/**/index.stories.@(js|jsx|ts|tsx)', + '../stories/**/index.mdx', + ], }; export default config; diff --git a/packages/react-cap-theme/.storybook/preview.tsx b/packages/react-cap-theme/.storybook/preview.tsx index b864c707..022d05ab 100644 --- a/packages/react-cap-theme/.storybook/preview.tsx +++ b/packages/react-cap-theme/.storybook/preview.tsx @@ -1,29 +1,15 @@ import * as React from 'react'; import rootPreview from '../../../.storybook/preview'; -import { - FluentProvider, - Theme, - webLightTheme, -} from '@fluentui/react-components'; -import { CAP_STYLE_HOOKS } from '../src/index'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { CAP_STYLE_HOOKS, CAP_THEME_TOKENS } from '../src/index'; import type { JSXElement } from '@fluentui/react-utilities'; import type { Preview, StoryFn } from '@storybook/react'; -import { CAPTokens } from '../src/components/tokens/types'; -const capTheme: Record = { - ...webLightTheme, - borderRadius2XLarge: '12px', - borderRadius3XLarge: '16px', - borderRadius4XLarge: '24px', - colorNeutralStroke4: '#ebebeb', - colorNeutralStroke4Hover: '#e0e0e0', - colorNeutralStroke4Pressed: '#d6d6d6', - colorNeutralStroke4Selected: '#ebebeb', - colorNeutralForeground5: '#616161', - colorNeutralForeground5Hover: '#242424', - colorNeutralForeground5Pressed: '#242424', -}; +// Imported after `rootPreview` so this package's docs overrides win. +import './docs.css'; + +const capTheme = { ...webLightTheme, ...CAP_THEME_TOKENS }; const preview: Preview = { ...rootPreview, @@ -37,6 +23,26 @@ const preview: Preview = { ), ], + parameters: { + ...rootPreview.parameters, + options: { + storySort: { + order: [ + 'Packages', + [ + 'react-cap-theme', + [ + 'Getting Started', + 'Design Language', + ['Overview', 'Rounded Corners'], + 'Components', + '*', + ], + ], + ], + }, + }, + }, tags: ['autodocs'], }; diff --git a/packages/react-cap-theme/src/components/tokens/index.ts b/packages/react-cap-theme/src/components/tokens/index.ts index 223f4938..8636d558 100644 --- a/packages/react-cap-theme/src/components/tokens/index.ts +++ b/packages/react-cap-theme/src/components/tokens/index.ts @@ -1 +1 @@ -export { capTokens } from './tokens'; +export { capTokens, CAP_THEME_TOKENS } from './tokens'; diff --git a/packages/react-cap-theme/src/components/tokens/tokens.ts b/packages/react-cap-theme/src/components/tokens/tokens.ts index b611e997..ef297934 100644 --- a/packages/react-cap-theme/src/components/tokens/tokens.ts +++ b/packages/react-cap-theme/src/components/tokens/tokens.ts @@ -1,14 +1,40 @@ import { CAPTokens } from './types'; -export const capTokens: Record = { - borderRadius2XLarge: 'var(--borderRadius2XLarge)', - borderRadius3XLarge: 'var(--borderRadius3XLarge)', - borderRadius4XLarge: 'var(--borderRadius4XLarge)', - colorNeutralStroke4: 'var(--colorNeutralStroke4)', - colorNeutralStroke4Hover: 'var(--colorNeutralStroke4Hover)', - colorNeutralStroke4Pressed: 'var(--colorNeutralStroke4Pressed)', - colorNeutralStroke4Selected: 'var(--colorNeutralStroke4Selected)', - colorNeutralForeground5: 'var(--colorNeutralForeground5)', - colorNeutralForeground5Hover: 'var(--colorNeutralForeground5Hover)', - colorNeutralForeground5Pressed: 'var(--colorNeutralForeground5Pressed)', +/** + * Default values for the CAP-specific design tokens (the ones Fluent v9 doesn't + * ship yet). These defaults are baked into the CAP style hooks (see `capTokens`) + * as CSS-variable fallbacks, so consumers don't have to set them by default. + * + * Override a value by defining the matching theme token on your `FluentProvider` + * theme — that emits the CSS variable, which takes precedence over the fallback. + * To override several at once, spread these onto your base theme: + * + * ```tsx + * const capTheme = { ...webLightTheme, ...CAP_THEME_TOKENS }; + * ``` + */ +export const CAP_THEME_TOKENS: Record = { + borderRadius2XLarge: '12px', + borderRadius3XLarge: '16px', + borderRadius4XLarge: '24px', + colorNeutralStroke4: '#ebebeb', + colorNeutralStroke4Hover: '#e0e0e0', + colorNeutralStroke4Pressed: '#d6d6d6', + colorNeutralStroke4Selected: '#ebebeb', + colorNeutralForeground5: '#616161', + colorNeutralForeground5Hover: '#242424', + colorNeutralForeground5Pressed: '#242424', }; + +/** + * The CAP tokens as CSS-variable references for use inside style hooks. Each one + * carries its {@link CAP_THEME_TOKENS} default as a fallback, so the hooks work + * even when the consumer hasn't defined the token on their theme; defining it + * (e.g. via `CAP_THEME_TOKENS`) overrides the fallback. + */ +export const capTokens: Record = Object.fromEntries( + (Object.keys(CAP_THEME_TOKENS) as (keyof CAPTokens)[]).map((key) => [ + key, + `var(--${key}, ${CAP_THEME_TOKENS[key]})`, + ]) +) as Record; diff --git a/packages/react-cap-theme/src/facets/rounded-corners/index.ts b/packages/react-cap-theme/src/facets/rounded-corners/index.ts new file mode 100644 index 00000000..b43b40a6 --- /dev/null +++ b/packages/react-cap-theme/src/facets/rounded-corners/index.ts @@ -0,0 +1,881 @@ +import { FluentProviderProps } from '@fluentui/react-components'; +import { type GriffelStyle, makeStyles, mergeClasses } from '@griffel/react'; +import { + createCustomFocusIndicatorStyle, + createFocusOutlineStyle, +} from '@fluentui/react-tabster'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/tokens'; +import { capTokens } from '../../components/tokens'; + +import type { AccordionHeaderState } from '@fluentui/react-accordion'; +import type { AvatarState } from '@fluentui/react-avatar'; +import type { BadgeState } from '@fluentui/react-badge'; +import type { + ButtonState, + MenuButtonState, + SplitButtonState, + ToggleButtonState, +} from '../../components/react-button'; +import type { CompoundButtonState } from '@fluentui/react-button'; +import type { CardState, CardPreviewState } from '../../components/react-card'; +import type { CheckboxState } from '../../components/react-checkbox'; +import type { + ComboboxState, + DropdownState, +} from '../../components/react-combobox'; +import type { DialogSurfaceState } from '@fluentui/react-dialog'; +import type { OverlayDrawerState } from '@fluentui/react-drawer'; +import type { InlineDrawerState } from '../../components/react-drawer'; +import type { ImageState } from '@fluentui/react-image'; +import type { InputState } from '../../components/react-input'; +import { + menuItemClassNames, + type MenuItemState, + type MenuItemSwitchState, + type MenuPopoverState, + type MenuSplitGroupState, +} from '@fluentui/react-menu'; +import type { PopoverSurfaceState } from '@fluentui/react-popover'; +import type { SearchBoxState } from '../../components/react-search'; +import type { + TagState, + InteractionTagState, + InteractionTagPrimaryState, + InteractionTagSecondaryState, +} from '@fluentui/react-tags'; +import type { TabState } from '@fluentui/react-tabs'; +import type { ToolbarState } from '../../components/react-toolbar'; +import type { TooltipState } from '../../components/react-tooltip'; +import type { TeachingPopoverBodyState } from '@fluentui/react-teaching-popover'; + +/** + * Every border-radius value used by the CAP theme, in one place. + * + * `none`/`small`/`medium`/`large`/`xLarge`/`circular` are stock Fluent radii; + * `xxLarge`/`xxxLarge`/`xxxxLarge` are the CAP-specific radii (`capTokens`). + */ +export const CAP_BORDER_RADII = { + none: tokens.borderRadiusNone, + small: tokens.borderRadiusSmall, + medium: tokens.borderRadiusMedium, + large: tokens.borderRadiusLarge, + xLarge: tokens.borderRadiusXLarge, + circular: tokens.borderRadiusCircular, + xxLarge: capTokens.borderRadius2XLarge, + xxxLarge: capTokens.borderRadius3XLarge, + xxxxLarge: capTokens.borderRadius4XLarge, +} as const; + +const useButtonRadius = makeStyles({ + base: { borderRadius: CAP_BORDER_RADII.xxLarge }, + square: { borderRadius: CAP_BORDER_RADII.none }, + circular: { borderRadius: CAP_BORDER_RADII.circular }, + small: { borderRadius: CAP_BORDER_RADII.xLarge }, + focusBase: createCustomFocusIndicatorStyle({ + borderRadius: CAP_BORDER_RADII.xxLarge, + }) as unknown as GriffelStyle, + focusSmall: createCustomFocusIndicatorStyle({ + borderRadius: CAP_BORDER_RADII.xLarge, + }) as unknown as GriffelStyle, +}); + +/** Applies the base Button radius (root slot). Shared by the button family. */ +const applyButtonRadius = (state: ButtonState): void => { + const s = useButtonRadius(); + const { shape, size } = state; + state.root.className = mergeClasses( + state.root.className, + s.base, + s.focusBase, + size === 'small' && s.small, + (shape === 'square' || shape === 'circular') && s[shape], + size === 'small' && s.focusSmall, + getSlotClassNameProp_unstable(state.root) + ); +}; + +const useButtonRadiusStyles = (state: ButtonState): ButtonState => { + applyButtonRadius(state); + return state; +}; + +const useCompoundButtonIconRadius = makeStyles({ + small: { borderRadius: CAP_BORDER_RADII.medium }, + medium: { borderRadius: CAP_BORDER_RADII.xLarge }, + large: { borderRadius: CAP_BORDER_RADII.xLarge }, +}); + +const useCompoundButtonRadiusStyles = ( + state: CompoundButtonState +): CompoundButtonState => { + const iconRadius = useCompoundButtonIconRadius(); + applyButtonRadius(state as unknown as ButtonState); + if (state.icon) { + state.icon.className = mergeClasses( + state.icon.className, + iconRadius[state.size], + getSlotClassNameProp_unstable(state.icon) + ); + } + return state; +}; + +const useMenuButtonRadiusStyles = (state: MenuButtonState): MenuButtonState => { + applyButtonRadius(state as unknown as ButtonState); + return state; +}; + +const useToggleButtonRadiusStyles = ( + state: ToggleButtonState +): ToggleButtonState => { + applyButtonRadius(state as unknown as ButtonState); + return state; +}; + +// The split button's sub-buttons are Buttons, so they pick up the base Button +// radius via `useButtonStyles_unstable`. Here we only re-flatten the inner +// corners (which the enlarged base radius would otherwise round). +const useSplitButtonRadius = makeStyles({ + primary: { + borderTopRightRadius: CAP_BORDER_RADII.none, + borderBottomRightRadius: CAP_BORDER_RADII.none, + }, + primaryFocus: createCustomFocusIndicatorStyle({ + borderTopRightRadius: CAP_BORDER_RADII.none, + borderBottomRightRadius: CAP_BORDER_RADII.none, + }) as unknown as GriffelStyle, + menu: { + borderTopLeftRadius: CAP_BORDER_RADII.none, + borderBottomLeftRadius: CAP_BORDER_RADII.none, + }, + menuFocus: createCustomFocusIndicatorStyle({ + borderTopLeftRadius: CAP_BORDER_RADII.none, + borderBottomLeftRadius: CAP_BORDER_RADII.none, + }) as unknown as GriffelStyle, +}); + +const useSplitButtonRadiusStyles = ( + state: SplitButtonState +): SplitButtonState => { + const s = useSplitButtonRadius(); + if (state.primaryActionButton) { + state.primaryActionButton.className = mergeClasses( + state.primaryActionButton.className, + s.primary, + s.primaryFocus, + getSlotClassNameProp_unstable(state.primaryActionButton) + ); + } + if (state.menuButton) { + state.menuButton.className = mergeClasses( + state.menuButton.className, + s.menu, + s.menuFocus, + getSlotClassNameProp_unstable(state.menuButton) + ); + } + return state; +}; + +// Input/Combobox/Dropdown share the same radius behaviour: +// base 2XLarge, small XLarge, and a flat (none) radius for the underline +// appearance (both while editable and while disabled). +const useFieldRadius = makeStyles({ + base: { borderRadius: CAP_BORDER_RADII.xxLarge }, + small: { borderRadius: CAP_BORDER_RADII.xLarge }, + underline: { borderRadius: CAP_BORDER_RADII.none }, +}); + +// The radius is identical (`none`) whether the underline is editable or +// disabled, so only appearance + size are needed. +const applyFieldRadius = ( + root: { className?: string }, + appearance: string, + size: string +): void => { + const s = useFieldRadius(); + root.className = mergeClasses( + root.className, + s.base, + size === 'small' && s.small, + appearance === 'underline' && s.underline, + getSlotClassNameProp_unstable(root as never) + ); +}; + +const useInputRadiusStyles = (state: InputState): InputState => { + applyFieldRadius(state.root, state.appearance ?? 'outline', state.size); + return state; +}; + +const useComboboxRadiusStyles = (state: ComboboxState): ComboboxState => { + applyFieldRadius(state.root, state.appearance, state.size); + return state; +}; + +const useDropdownRadiusStyles = (state: DropdownState): DropdownState => { + applyFieldRadius(state.root, state.appearance, state.size); + return state; +}; + +const useSurfaceRadius = makeStyles({ + dialogSurface: { borderRadius: CAP_BORDER_RADII.xxxxLarge }, + popoverSurface: { borderRadius: CAP_BORDER_RADII.xxxxLarge }, + card: { borderRadius: CAP_BORDER_RADII.xxxxLarge }, + menuPopover: { borderRadius: CAP_BORDER_RADII.xxLarge }, +}); + +const useDialogSurfaceRadiusStyles = ( + state: DialogSurfaceState +): DialogSurfaceState => { + const s = useSurfaceRadius(); + state.root.className = mergeClasses( + state.root.className, + s.dialogSurface, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const usePopoverSurfaceRadiusStyles = ( + state: PopoverSurfaceState +): PopoverSurfaceState => { + const s = useSurfaceRadius(); + state.root.className = mergeClasses( + state.root.className, + s.popoverSurface, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useCardRadiusStyles = (state: CardState): CardState => { + const s = useSurfaceRadius(); + // The card's border/focus overlay (`::after`) uses `border-radius: inherit`, + // so overriding the root radius cascades to it. + state.root.className = mergeClasses( + state.root.className, + s.card, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useMenuPopoverRadiusStyles = ( + state: MenuPopoverState +): MenuPopoverState => { + const s = useSurfaceRadius(); + state.root.className = mergeClasses( + state.root.className, + s.menuPopover, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useCardPreviewRadius = makeStyles({ + contained: { borderRadius: CAP_BORDER_RADII.xxxLarge }, + logo: { borderRadius: CAP_BORDER_RADII.xLarge }, +}); + +const useCardPreviewRadiusStyles = ( + state: CardPreviewState +): CardPreviewState => { + const s = useCardPreviewRadius(); + state.root.className = mergeClasses( + state.root.className, + state.layout === 'contained' && s.contained, + getSlotClassNameProp_unstable(state.root) + ); + if (state.logo) { + state.logo.className = mergeClasses( + state.logo.className, + s.logo, + getSlotClassNameProp_unstable(state.logo) + ); + } + return state; +}; + +/* -------------------------------------------------------------------------- */ +/* Drawer (inline / overlay) */ +/* -------------------------------------------------------------------------- */ + +const drawerRadius = CAP_BORDER_RADII.xxxLarge; +const useDrawerRadius = makeStyles({ + start: { borderRadius: `0 ${drawerRadius} ${drawerRadius} 0` }, + end: { borderRadius: `${drawerRadius} 0 0 ${drawerRadius}` }, + bottom: { borderRadius: `${drawerRadius} ${drawerRadius} 0 0` }, +}); + +const useInlineDrawerRadiusStyles = ( + state: InlineDrawerState +): InlineDrawerState => { + const s = useDrawerRadius(); + state.root.className = mergeClasses( + state.root.className, + s[state.position as keyof typeof s], + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useOverlayDrawerRadiusStyles = ( + state: OverlayDrawerState +): OverlayDrawerState => { + const s = useDrawerRadius(); + state.root.className = mergeClasses( + state.root.className, + s[state.position as keyof typeof s], + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +/* -------------------------------------------------------------------------- */ +/* Focus-indicator-only radius (AccordionHeader, Checkbox, MenuItem) */ +/* -------------------------------------------------------------------------- */ + +const useAccordionHeaderRadius = makeStyles({ + focus: createFocusOutlineStyle({ + style: { outlineRadius: CAP_BORDER_RADII.xxLarge }, + }), + focusSmall: createFocusOutlineStyle({ + style: { outlineRadius: CAP_BORDER_RADII.xLarge }, + }), +}); + +const useAccordionHeaderRadiusStyles = ( + state: AccordionHeaderState +): AccordionHeaderState => { + const s = useAccordionHeaderRadius(); + state.button.className = mergeClasses( + state.button.className, + state.size === 'small' ? s.focusSmall : s.focus, + getSlotClassNameProp_unstable(state.button) + ); + return state; +}; + +const useCheckboxRadius = makeStyles({ + root: { + ...createFocusOutlineStyle({ + style: { outlineRadius: CAP_BORDER_RADII.xxLarge }, + selector: 'focus-within', + }), + }, +}); + +const useCheckboxRadiusStyles = (state: CheckboxState): CheckboxState => { + const s = useCheckboxRadius(); + state.root.className = mergeClasses( + state.root.className, + s.root, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useMenuItemRadius = makeStyles({ + root: { + borderRadius: CAP_BORDER_RADII.large, + ...createFocusOutlineStyle({ + style: { outlineRadius: CAP_BORDER_RADII.xLarge }, + }), + }, +}); + +const applyMenuItemRadius = (root: { className?: string }): void => { + const s = useMenuItemRadius(); + root.className = mergeClasses( + root.className, + s.root, + getSlotClassNameProp_unstable(root as never) + ); +}; + +const useMenuItemRadiusStyles = (state: MenuItemState): MenuItemState => { + applyMenuItemRadius(state.root); + return state; +}; + +const useMenuItemSwitchRadius = makeStyles({ + switchIndicator: { borderRadius: CAP_BORDER_RADII.circular }, +}); + +const useMenuItemSwitchRadiusStyles = ( + state: MenuItemSwitchState +): MenuItemSwitchState => { + const s = useMenuItemSwitchRadius(); + applyMenuItemRadius(state.root); + if (state.switchIndicator) { + state.switchIndicator.className = mergeClasses( + state.switchIndicator.className, + s.switchIndicator, + getSlotClassNameProp_unstable(state.switchIndicator) + ); + } + return state; +}; + +const useMenuSplitGroupRadius = makeStyles({ + root: { + [`& > .${menuItemClassNames.root}:nth-of-type(1)`]: { + borderBottomRightRadius: CAP_BORDER_RADII.large, + borderTopRightRadius: CAP_BORDER_RADII.large, + }, + [`& > .${menuItemClassNames.root}:nth-of-type(2)`]: { + borderTopLeftRadius: CAP_BORDER_RADII.large, + borderBottomLeftRadius: CAP_BORDER_RADII.large, + }, + }, +}); + +const useMenuSplitGroupRadiusStyles = ( + state: MenuSplitGroupState +): MenuSplitGroupState => { + const s = useMenuSplitGroupRadius(); + state.root.className = mergeClasses( + state.root.className, + s.root, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +/* -------------------------------------------------------------------------- */ +/* Tags */ +/* -------------------------------------------------------------------------- */ + +// Tag sets the visible radius on the element AND mirrors it on the focus ring. +const useTagRadius = makeStyles({ + medium: { + borderRadius: CAP_BORDER_RADII.xxLarge, + ...createCustomFocusIndicatorStyle({ + borderRadius: CAP_BORDER_RADII.xxLarge, + }), + }, + small: { + borderRadius: CAP_BORDER_RADII.xLarge, + ...createCustomFocusIndicatorStyle({ + borderRadius: CAP_BORDER_RADII.xLarge, + }), + }, + 'extra-small': { + borderRadius: CAP_BORDER_RADII.large, + ...createCustomFocusIndicatorStyle({ + borderRadius: CAP_BORDER_RADII.large, + }), + }, + circular: { + borderRadius: CAP_BORDER_RADII.circular, + ...createCustomFocusIndicatorStyle({ + borderRadius: CAP_BORDER_RADII.circular, + }), + }, +}); + +const useTagRadiusStyles = (state: TagState): TagState => { + const s = useTagRadius(); + const { shape, size } = state; + state.root.className = mergeClasses( + state.root.className, + s[size], + shape === 'circular' && s.circular, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +// InteractionTag root: same per-size/shape element radius as Tag (the +// Primary/Secondary parts inherit their visible corners from it). +const useInteractionTagRadius = makeStyles({ + medium: { borderRadius: CAP_BORDER_RADII.xxLarge }, + small: { borderRadius: CAP_BORDER_RADII.xLarge }, + 'extra-small': { borderRadius: CAP_BORDER_RADII.large }, + circular: { borderRadius: CAP_BORDER_RADII.circular }, +}); + +const useInteractionTagRadiusStyles = ( + state: InteractionTagState +): InteractionTagState => { + const s = useInteractionTagRadius(); + const { shape, size } = state; + state.root.className = mergeClasses( + state.root.className, + s[size], + shape === 'circular' && s.circular, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +// InteractionTagPrimary: round the outer (left) corners to the CAP tag radius. +// When paired with a secondary (dismiss) action, the inner (right) corners stay +// flat so the two halves meet flush; standalone, all four corners are rounded. +// Corners are set as explicit longhands (never the `border-radius` shorthand) so +// they reliably win over Fluent's own longhand corner styles — Griffel orders +// shorthands before longhands, so a shorthand here would lose to Fluent's. +const useInteractionTagPrimaryRadius = makeStyles({ + // Outer (left) corners + focus-ring radius, per size. + medium: { + borderTopLeftRadius: CAP_BORDER_RADII.xxLarge, + borderBottomLeftRadius: CAP_BORDER_RADII.xxLarge, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.xxLarge }, + }), + }, + small: { + borderTopLeftRadius: CAP_BORDER_RADII.xLarge, + borderBottomLeftRadius: CAP_BORDER_RADII.xLarge, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.xLarge }, + }), + }, + 'extra-small': { + borderTopLeftRadius: CAP_BORDER_RADII.large, + borderBottomLeftRadius: CAP_BORDER_RADII.large, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.large }, + }), + }, + circular: { + borderTopLeftRadius: CAP_BORDER_RADII.circular, + borderBottomLeftRadius: CAP_BORDER_RADII.circular, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.circular }, + }), + }, + // Inner (right) corners — applied only when there is no secondary action. + standaloneMedium: { + borderTopRightRadius: CAP_BORDER_RADII.xxLarge, + borderBottomRightRadius: CAP_BORDER_RADII.xxLarge, + }, + standaloneSmall: { + borderTopRightRadius: CAP_BORDER_RADII.xLarge, + borderBottomRightRadius: CAP_BORDER_RADII.xLarge, + }, + 'standaloneExtra-small': { + borderTopRightRadius: CAP_BORDER_RADII.large, + borderBottomRightRadius: CAP_BORDER_RADII.large, + }, + standaloneCircular: { + borderTopRightRadius: CAP_BORDER_RADII.circular, + borderBottomRightRadius: CAP_BORDER_RADII.circular, + }, +}); + +const standalonePrimaryRadiusKey = { + medium: 'standaloneMedium', + small: 'standaloneSmall', + 'extra-small': 'standaloneExtra-small', +} as const; + +const useInteractionTagPrimaryRadiusStyles = ( + state: InteractionTagPrimaryState +): InteractionTagPrimaryState => { + const s = useInteractionTagPrimaryRadius(); + const { shape, size, hasSecondaryAction } = state; + state.root.className = mergeClasses( + state.root.className, + s[size], + shape === 'circular' && s.circular, + !hasSecondaryAction && + (shape === 'circular' + ? s.standaloneCircular + : s[standalonePrimaryRadiusKey[size]]), + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +// InteractionTagSecondary: flat left corners, explicit per-size right corners, +// and a matching `::after` focus-ring radius. All corners are set as longhands +// (no `border-radius` shorthand) so they win over Fluent's longhand styles. +const useInteractionTagSecondaryRadius = makeStyles({ + base: { + borderTopLeftRadius: CAP_BORDER_RADII.none, + borderBottomLeftRadius: CAP_BORDER_RADII.none, + }, + medium: { + borderTopRightRadius: CAP_BORDER_RADII.xxLarge, + borderBottomRightRadius: CAP_BORDER_RADII.xxLarge, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.xxLarge }, + }), + }, + small: { + borderTopRightRadius: CAP_BORDER_RADII.xLarge, + borderBottomRightRadius: CAP_BORDER_RADII.xLarge, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.xLarge }, + }), + }, + 'extra-small': { + borderTopRightRadius: CAP_BORDER_RADII.large, + borderBottomRightRadius: CAP_BORDER_RADII.large, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.large }, + }), + }, + circular: { + borderTopRightRadius: CAP_BORDER_RADII.circular, + borderBottomRightRadius: CAP_BORDER_RADII.circular, + ...createCustomFocusIndicatorStyle({ + '::after': { borderRadius: CAP_BORDER_RADII.circular }, + }), + }, +}); + +const useInteractionTagSecondaryRadiusStyles = ( + state: InteractionTagSecondaryState +): InteractionTagSecondaryState => { + const s = useInteractionTagSecondaryRadius(); + const { shape, size } = state; + state.root.className = mergeClasses( + state.root.className, + s.base, + s[size], + shape === 'circular' && s.circular, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useImageRadius = makeStyles({ + rounded: { borderRadius: CAP_BORDER_RADII.xxLarge }, +}); + +const useImageRadiusStyles = (state: ImageState): ImageState => { + const s = useImageRadius(); + state.root.className = mergeClasses( + state.root.className, + state.shape === 'rounded' && s.rounded, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useBadgeRadius = makeStyles({ + 'rounded-tiny': { borderRadius: CAP_BORDER_RADII.small }, + 'rounded-extra-small': { borderRadius: CAP_BORDER_RADII.small }, + 'rounded-small': { borderRadius: CAP_BORDER_RADII.small }, + 'rounded-medium': { borderRadius: CAP_BORDER_RADII.medium }, + 'rounded-large': { borderRadius: CAP_BORDER_RADII.large }, + 'rounded-extra-large': { borderRadius: CAP_BORDER_RADII.xLarge }, + square: { borderRadius: CAP_BORDER_RADII.small }, +}); + +const useBadgeRadiusStyles = (state: BadgeState): BadgeState => { + const s = useBadgeRadius(); + const { shape, size } = state; + const shapeClassKey = shape === 'rounded' ? `${shape}-${size}` : shape; + state.root.className = mergeClasses( + state.root.className, + s[shapeClassKey as keyof typeof s], + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useAvatarRadius = makeStyles({ + medium: { borderRadius: CAP_BORDER_RADII.medium }, + large: { borderRadius: CAP_BORDER_RADII.large }, + xLarge: { borderRadius: CAP_BORDER_RADII.xLarge }, +}); + +const useAvatarRadiusStyles = (state: AvatarState): AvatarState => { + const s = useAvatarRadius(); + let squareRadius: string | undefined; + if (state.shape === 'square') { + switch (state.size) { + case 16: + squareRadius = s.medium; + break; + case 20: + case 24: + case 28: + squareRadius = s.large; + break; + default: + squareRadius = s.xLarge; + break; + } + } + state.root.className = mergeClasses( + state.root.className, + squareRadius, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useTabRadius = makeStyles({ + underline: createCustomFocusIndicatorStyle({ + borderRadius: CAP_BORDER_RADII.xxLarge, + }) as unknown as GriffelStyle, +}); + +const useTabRadiusStyles = (state: TabState): TabState => { + const s = useTabRadius(); + const isCircular = state.appearance?.includes('-circular'); + state.root.className = mergeClasses( + state.root.className, + !isCircular && s.underline, + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useToolbarRadius = makeStyles({ + base: { borderRadius: CAP_BORDER_RADII.xLarge }, + small: { borderRadius: CAP_BORDER_RADII.xLarge }, + medium: { borderRadius: CAP_BORDER_RADII.xxLarge }, + large: { borderRadius: CAP_BORDER_RADII.xxxLarge }, +}); + +const useToolbarRadiusStyles = (state: ToolbarState): ToolbarState => { + const s = useToolbarRadius(); + state.root.className = mergeClasses( + state.root.className, + s.base, + s[state.size], + getSlotClassNameProp_unstable(state.root) + ); + return state; +}; + +const useTooltipRadius = makeStyles({ + content: { borderRadius: CAP_BORDER_RADII.xLarge }, +}); + +const useTooltipRadiusStyles = (state: TooltipState): TooltipState => { + const s = useTooltipRadius(); + state.content.className = mergeClasses( + state.content.className, + s.content, + getSlotClassNameProp_unstable(state.content) + ); + return state; +}; + +const useTeachingPopoverBodyRadius = makeStyles({ + media: { borderRadius: CAP_BORDER_RADII.xLarge }, +}); + +const useTeachingPopoverBodyRadiusStyles = ( + state: TeachingPopoverBodyState +): TeachingPopoverBodyState => { + const s = useTeachingPopoverBodyRadius(); + if (state.media) { + state.media.className = mergeClasses( + state.media.className, + s.media, + getSlotClassNameProp_unstable(state.media) + ); + } + return state; +}; + +const useSearchBoxFilledInsetRadius = makeStyles({ + root: { borderRadius: CAP_BORDER_RADII.circular }, + separator: { borderRadius: CAP_BORDER_RADII.circular }, +}); + +const useSearchBoxRadiusStyles = (state: SearchBoxState): SearchBoxState => { + const s = useSearchBoxFilledInsetRadius(); + const appearance = state.appearance; + // SearchBox composes Input; treat 'filled-inset' as 'outline' for the base + // field radius, then override the root to circular for 'filled-inset'. + applyFieldRadius( + state.root, + appearance === 'filled-inset' ? 'outline' : appearance, + state.size + ); + if (appearance === 'filled-inset') { + state.root.className = mergeClasses( + state.root.className, + s.root, + getSlotClassNameProp_unstable(state.root) + ); + } + if (state.separator) { + state.separator.className = mergeClasses( + state.separator.className, + s.separator, + getSlotClassNameProp_unstable(state.separator) + ); + } + return state; +}; + +/** + * Rounded-corners-only variant of {@link CAP_STYLE_HOOKS}. Pass to + * `FluentProvider`'s `customStyleHooks_unstable` to apply CAP's rounded corners + * on top of an otherwise-stock Fluent theme. + */ +export const CAP_STYLE_HOOKS_ROUNDED_CORNERS: NonNullable< + FluentProviderProps['customStyleHooks_unstable'] +> = { + useAccordionHeaderStyles_unstable: (state) => + useAccordionHeaderRadiusStyles(state as AccordionHeaderState), + useAvatarStyles_unstable: (state) => + useAvatarRadiusStyles(state as AvatarState), + useBadgeStyles_unstable: (state) => useBadgeRadiusStyles(state as BadgeState), + useButtonStyles_unstable: (state) => + useButtonRadiusStyles(state as ButtonState), + useCompoundButtonStyles_unstable: (state) => + useCompoundButtonRadiusStyles(state as CompoundButtonState), + useMenuButtonStyles_unstable: (state) => + useMenuButtonRadiusStyles(state as MenuButtonState), + useToggleButtonStyles_unstable: (state) => + useToggleButtonRadiusStyles(state as ToggleButtonState), + useSplitButtonStyles_unstable: (state) => + useSplitButtonRadiusStyles(state as SplitButtonState), + useCardStyles_unstable: (state) => useCardRadiusStyles(state as CardState), + useCardPreviewStyles_unstable: (state) => + useCardPreviewRadiusStyles(state as CardPreviewState), + useCheckboxStyles_unstable: (state) => + useCheckboxRadiusStyles(state as CheckboxState), + useComboboxStyles_unstable: (state) => + useComboboxRadiusStyles(state as ComboboxState), + useDropdownStyles_unstable: (state) => + useDropdownRadiusStyles(state as DropdownState), + useDialogSurfaceStyles_unstable: (state) => + useDialogSurfaceRadiusStyles(state as DialogSurfaceState), + useInlineDrawerStyles_unstable: (state) => + useInlineDrawerRadiusStyles(state as InlineDrawerState), + useOverlayDrawerStyles_unstable: (state) => + useOverlayDrawerRadiusStyles(state as OverlayDrawerState), + useImageStyles_unstable: (state) => useImageRadiusStyles(state as ImageState), + useInputStyles_unstable: (state) => useInputRadiusStyles(state as InputState), + useMenuItemStyles_unstable: (state) => + useMenuItemRadiusStyles(state as MenuItemState), + useMenuItemSwitchStyles_unstable: (state) => + useMenuItemSwitchRadiusStyles(state as MenuItemSwitchState), + useMenuPopoverStyles_unstable: (state) => + useMenuPopoverRadiusStyles(state as MenuPopoverState), + useMenuSplitGroupStyles_unstable: (state) => + useMenuSplitGroupRadiusStyles(state as MenuSplitGroupState), + usePopoverSurfaceStyles_unstable: (state) => + usePopoverSurfaceRadiusStyles(state as PopoverSurfaceState), + useSearchBoxStyles_unstable: (state) => + useSearchBoxRadiusStyles(state as SearchBoxState), + useTagStyles_unstable: (state) => useTagRadiusStyles(state as TagState), + useInteractionTagStyles_unstable: (state) => + useInteractionTagRadiusStyles(state as InteractionTagState), + useInteractionTagPrimaryStyles_unstable: (state) => + useInteractionTagPrimaryRadiusStyles(state as InteractionTagPrimaryState), + useInteractionTagSecondaryStyles_unstable: (state) => + useInteractionTagSecondaryRadiusStyles( + state as InteractionTagSecondaryState + ), + useTabStyles_unstable: (state) => useTabRadiusStyles(state as TabState), + useToolbarStyles_unstable: (state) => + useToolbarRadiusStyles(state as ToolbarState), + useTooltipStyles_unstable: (state) => + useTooltipRadiusStyles(state as TooltipState), + useTeachingPopoverBodyStyles_unstable: (state) => + useTeachingPopoverBodyRadiusStyles(state as TeachingPopoverBodyState), +}; diff --git a/packages/react-cap-theme/src/index.ts b/packages/react-cap-theme/src/index.ts index 578b4ddd..904cc542 100644 --- a/packages/react-cap-theme/src/index.ts +++ b/packages/react-cap-theme/src/index.ts @@ -1,3 +1,8 @@ -// Export style hooks for CAP and Teams themes export { CAP_STYLE_HOOKS } from './capStyleHooks'; +export { + CAP_STYLE_HOOKS_ROUNDED_CORNERS, + CAP_BORDER_RADII, +} from './facets/rounded-corners'; export { TEAMS_STYLE_HOOKS } from './teams/index'; +export { mergeStyleHooks } from './mergeStyleHooks'; +export { CAP_THEME_TOKENS } from './components/tokens'; diff --git a/packages/react-cap-theme/src/mergeStyleHooks.ts b/packages/react-cap-theme/src/mergeStyleHooks.ts new file mode 100644 index 00000000..9eda9301 --- /dev/null +++ b/packages/react-cap-theme/src/mergeStyleHooks.ts @@ -0,0 +1,44 @@ +import { FluentProviderProps } from '@fluentui/react-components'; + +type StyleHooks = NonNullable; + +type StyleHook = (state: unknown) => unknown; + +/** + * Composes several `customStyleHooks_unstable` registries into one, so you can + * opt into individual facets of the CAP design language (e.g. rounded corners) + * and layer them together — or layer them on top of your own custom hooks. + * + * Registries are applied in array order. When more than one registry defines a + * hook for the same component, the hooks are *composed* (each runs in turn, + * receiving the state returned by the previous) rather than the later one + * replacing the earlier — which is what a plain `{ ...a, ...b }` (and Fluent's + * own provider nesting) would do. Because each hook appends its classes via + * `mergeClasses`, later registries win wherever two facets touch the same CSS + * property. + * + * @example + * ```tsx + * const styleHooks = mergeStyleHooks([ + * CAP_STYLE_HOOKS_ROUNDED_CORNERS, + * myCustomStyleHooks, + * ]); + * + * + * {/* ... *\/} + * + * ``` + */ +export function mergeStyleHooks(registries: StyleHooks[]): StyleHooks { + const merged: Record = {}; + + for (const registry of registries) { + for (const [key, hook] of Object.entries(registry)) { + const previous = merged[key]; + const next = hook as StyleHook; + merged[key] = previous ? (state) => next(previous(state)) : next; + } + } + + return merged as StyleHooks; +} diff --git a/packages/react-cap-theme/stories/Accordion/index.stories.tsx b/packages/react-cap-theme/stories/Accordion/index.stories.tsx index 0776f81b..2e6b0276 100644 --- a/packages/react-cap-theme/stories/Accordion/index.stories.tsx +++ b/packages/react-cap-theme/stories/Accordion/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Accordion } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Accordion', + title: 'Packages/react-cap-theme/Components/Accordion', component: Accordion, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Avatar/index.stories.tsx b/packages/react-cap-theme/stories/Avatar/index.stories.tsx index 4082a7a7..e9c592ef 100644 --- a/packages/react-cap-theme/stories/Avatar/index.stories.tsx +++ b/packages/react-cap-theme/stories/Avatar/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Avatar } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Avatar', + title: 'Packages/react-cap-theme/Components/Avatar', component: Avatar, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Badge/index.stories.tsx b/packages/react-cap-theme/stories/Badge/index.stories.tsx index f61256c6..7bf44698 100644 --- a/packages/react-cap-theme/stories/Badge/index.stories.tsx +++ b/packages/react-cap-theme/stories/Badge/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Badge } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Badge', + title: 'Packages/react-cap-theme/Components/Badge', component: Badge, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Button/index.stories.tsx b/packages/react-cap-theme/stories/Button/index.stories.tsx index 47316371..be159d04 100644 --- a/packages/react-cap-theme/stories/Button/index.stories.tsx +++ b/packages/react-cap-theme/stories/Button/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Button } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Button', + title: 'Packages/react-cap-theme/Components/Button', component: Button, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Carousel/index.stories.tsx b/packages/react-cap-theme/stories/Carousel/index.stories.tsx index 87a9c2cf..539246ed 100644 --- a/packages/react-cap-theme/stories/Carousel/index.stories.tsx +++ b/packages/react-cap-theme/stories/Carousel/index.stories.tsx @@ -8,7 +8,7 @@ import { } from '@fluentui/react-carousel'; const meta = { - title: 'Packages/react-cap-theme/Carousel', + title: 'Packages/react-cap-theme/Components/Carousel', component: Carousel, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Checkbox/index.stories.tsx b/packages/react-cap-theme/stories/Checkbox/index.stories.tsx index fceac64b..dba48073 100644 --- a/packages/react-cap-theme/stories/Checkbox/index.stories.tsx +++ b/packages/react-cap-theme/stories/Checkbox/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Checkbox } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Checkbox', + title: 'Packages/react-cap-theme/Components/Checkbox', component: Checkbox, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Dialog/index.stories.tsx b/packages/react-cap-theme/stories/Dialog/index.stories.tsx index e0336fa9..15e5a706 100644 --- a/packages/react-cap-theme/stories/Dialog/index.stories.tsx +++ b/packages/react-cap-theme/stories/Dialog/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Dialog } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Dialog', + title: 'Packages/react-cap-theme/Components/Dialog', component: Dialog, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Drawer/index.stories.tsx b/packages/react-cap-theme/stories/Drawer/index.stories.tsx index 00e44c7a..28b3cba4 100644 --- a/packages/react-cap-theme/stories/Drawer/index.stories.tsx +++ b/packages/react-cap-theme/stories/Drawer/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { DrawerBody } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Drawer', + title: 'Packages/react-cap-theme/Components/Drawer', component: DrawerBody, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Image/index.stories.tsx b/packages/react-cap-theme/stories/Image/index.stories.tsx index a83ae217..a2d803d6 100644 --- a/packages/react-cap-theme/stories/Image/index.stories.tsx +++ b/packages/react-cap-theme/stories/Image/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Image } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Image', + title: 'Packages/react-cap-theme/Components/Image', component: Image, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Link/index.stories.tsx b/packages/react-cap-theme/stories/Link/index.stories.tsx index 7536d13a..b42485ac 100644 --- a/packages/react-cap-theme/stories/Link/index.stories.tsx +++ b/packages/react-cap-theme/stories/Link/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Link } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Link', + title: 'Packages/react-cap-theme/Components/Link', component: Link, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Menu/index.stories.tsx b/packages/react-cap-theme/stories/Menu/index.stories.tsx index 1c1f8d94..04cc05c3 100644 --- a/packages/react-cap-theme/stories/Menu/index.stories.tsx +++ b/packages/react-cap-theme/stories/Menu/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Menu } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Menu', + title: 'Packages/react-cap-theme/Components/Menu', component: Menu, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Overview/index.mdx b/packages/react-cap-theme/stories/Overview/index.mdx new file mode 100644 index 00000000..65ba0212 --- /dev/null +++ b/packages/react-cap-theme/stories/Overview/index.mdx @@ -0,0 +1,45 @@ +import { Meta, Title } from '@storybook/addon-docs/blocks'; + + + +react-cap-theme + +`@fluentui-contrib/react-cap-theme` restyles Fluent UI v9 components to match the CAP design language. It's a collection of custom style hooks that you drop into a `FluentProvider`. Every Fluent component rendered inside that provider picks up the CAP look automatically, with no changes to your markup. Apply the entire look at once with `CAP_STYLE_HOOKS`, or opt into individual, [composable facets](?path=/docs/packages-react-cap-theme-design-language-overview--docs) of the design language (such as rounded corners) on their own. + +## Usage + +Install the package: + +```bash +npm install --save @fluentui-contrib/react-cap-theme +``` + +Wrap your app (or any subtree) in a `FluentProvider` and pass `CAP_STYLE_HOOKS` to `customStyleHooks_unstable`. Every Fluent component rendered inside picks up the CAP styling automatically. + +```tsx +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { CAP_STYLE_HOOKS } from '@fluentui-contrib/react-cap-theme'; + +export const App = () => ( + + {/* the rest of your app */} + +); +``` + +The CAP look-and-feel relies on a few design tokens that Fluent v9 doesn't ship yet. Their default values are built into the style hooks, so the example above works as-is — no extra theme setup required. To customize them, spread `CAP_THEME_TOKENS` (or your own values) onto your theme: + +```tsx +import { CAP_THEME_TOKENS } from '@fluentui-contrib/react-cap-theme'; + +const capTheme = { + ...webLightTheme, + ...CAP_THEME_TOKENS, + borderRadius2XLarge: '8px', // override an individual token +}; +``` + +Don't want the whole CAP look? The design language is also published as individual facets you can adopt on their own and combine together. See [Design Language](?path=/docs/packages-react-cap-theme-design-language-overview--docs) for the full list and how to use them. diff --git a/packages/react-cap-theme/stories/Popover/index.stories.tsx b/packages/react-cap-theme/stories/Popover/index.stories.tsx index d44aa436..069e2f19 100644 --- a/packages/react-cap-theme/stories/Popover/index.stories.tsx +++ b/packages/react-cap-theme/stories/Popover/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Popover } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Popover', + title: 'Packages/react-cap-theme/Components/Popover', component: Popover, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/Tooltip/index.stories.tsx b/packages/react-cap-theme/stories/Tooltip/index.stories.tsx index 279ee8f9..7f621284 100644 --- a/packages/react-cap-theme/stories/Tooltip/index.stories.tsx +++ b/packages/react-cap-theme/stories/Tooltip/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { Tooltip } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/Tooltip', + title: 'Packages/react-cap-theme/Components/Tooltip', component: Tooltip, } satisfies Meta; diff --git a/packages/react-cap-theme/stories/design-language/index.mdx b/packages/react-cap-theme/stories/design-language/index.mdx new file mode 100644 index 00000000..6ca05614 --- /dev/null +++ b/packages/react-cap-theme/stories/design-language/index.mdx @@ -0,0 +1,36 @@ +import { Meta, Title } from '@storybook/addon-docs/blocks'; + + + +Design Language + +The CAP design language is broken into **facets** — self-contained slices of the look that each restyle a single aspect of every component, such as their corner radius. Adopt a facet on its own to take part of the CAP look without committing to all of it, and combine the facets you want. Each facet is a `customStyleHooks_unstable` registry exported from the package. + +Today the design language ships one facet: + +- [**Rounded Corners**](?path=/docs/packages-react-cap-theme-design-language-rounded-corners--docs) (`CAP_STYLE_HOOKS_ROUNDED_CORNERS`) — rounds component corners to the CAP radius. + +## Combining facets + +Use `mergeStyleHooks` to combine facets — or layer them on top of your own custom hooks — into a single `customStyleHooks_unstable` map: + +```tsx +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { + mergeStyleHooks, + CAP_STYLE_HOOKS_ROUNDED_CORNERS, +} from '@fluentui-contrib/react-cap-theme'; + +const styleHooks = mergeStyleHooks([ + CAP_STYLE_HOOKS_ROUNDED_CORNERS, + // ...other CAP facets, or your own custom style hooks +]); + +export const App = () => ( + + {/* the rest of your app */} + +); +``` + +Each registry maps a component to a hook that _appends_ its styling. A plain `{ ...rounded, ...colors }` spread (which is also how nesting `FluentProvider`s behaves) is **last-one-wins**: for `Button`, only the colors hook survives and the rounded-corners hook is dropped entirely, so your buttons get the new colors but lose the rounded corners. `mergeStyleHooks` instead **composes** them: the merged `Button` hook runs both in array order, so the button gets both. Where two registries set the same CSS property the later one wins; where they touch different properties, both apply. diff --git a/packages/react-cap-theme/stories/design-language/rounded-corners/index.mdx b/packages/react-cap-theme/stories/design-language/rounded-corners/index.mdx new file mode 100644 index 00000000..70bd7f60 --- /dev/null +++ b/packages/react-cap-theme/stories/design-language/rounded-corners/index.mdx @@ -0,0 +1,34 @@ +import { Meta, Title, Stories } from '@storybook/addon-docs/blocks'; +import * as RoundedCornersStories from './index.stories'; + + + + + +Rounded Corners is a facet of the CAP design language. It softens Fluent UI v9 components by increasing their border radius — buttons, inputs, cards, menus, tags, and more — giving your app CAP's rounder, friendlier silhouette. Only the corner geometry changes: colors, spacing, typography, and layout stay exactly as Fluent ships them, so you can adopt CAP's shape without committing to the rest of the look. + +## Usage + +Pass `CAP_STYLE_HOOKS_ROUNDED_CORNERS` to your `FluentProvider`'s `customStyleHooks_unstable`. It works on a stock Fluent theme with no extra setup — the corner-radius values it uses fall back to sensible defaults when your theme doesn't define them. + +```tsx +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { CAP_STYLE_HOOKS_ROUNDED_CORNERS } from '@fluentui-contrib/react-cap-theme'; + +export const App = () => ( + <FluentProvider + theme={webLightTheme} + customStyleHooks_unstable={CAP_STYLE_HOOKS_ROUNDED_CORNERS} + > + {/* the rest of your app */} + </FluentProvider> +); +``` + +To combine it with other facets of the CAP design language — or with your own custom style hooks — compose them together with `mergeStyleHooks` (see [Design Language](?path=/docs/packages-react-cap-theme-design-language-overview--docs)). + +## Examples + +Each component below shows default Fluent v9 styling on the left and the rounded-corners variant on the right. Everything except the corner radii should look identical. + +<Stories includePrimary title={null} /> diff --git a/packages/react-cap-theme/stories/design-language/rounded-corners/index.stories.tsx b/packages/react-cap-theme/stories/design-language/rounded-corners/index.stories.tsx new file mode 100644 index 00000000..d54bea09 --- /dev/null +++ b/packages/react-cap-theme/stories/design-language/rounded-corners/index.stories.tsx @@ -0,0 +1,285 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { + Avatar, + Badge, + Button, + Card, + Checkbox, + Combobox, + CompoundButton, + Dropdown, + FluentProvider, + Image, + InteractionTag, + InteractionTagPrimary, + InteractionTagSecondary, + Input, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + Option, + SplitButton, + Tag, + TagGroup, + Text, + ToggleButton, + Toolbar, + ToolbarButton, + makeStyles, + tokens, + type FluentProviderProps, +} from '@fluentui/react-components'; +import { + CalendarRegular, + CalendarFilled, + bundleIcon, +} from '@fluentui/react-icons'; +import { + CAP_STYLE_HOOKS, + CAP_STYLE_HOOKS_ROUNDED_CORNERS, +} from '../../../src/index'; + +const CalendarIcon = bundleIcon(CalendarFilled, CalendarRegular); + +type StyleHooks = NonNullable<FluentProviderProps['customStyleHooks_unstable']>; + +// The global preview decorator wraps every story in a CAP-themed FluentProvider +// with the full CAP_STYLE_HOOKS, and nested providers *merge* their hooks with +// the ancestor's. To compare against stock Fluent we neutralize every CAP hook +// with an identity override; the rounded column then re-applies only the +// radius hooks on top of that neutral baseline. (Both columns still inherit the +// CAP theme tokens, so CAP's larger radii resolve in the right column.) +const neutralizedCapHooks = Object.fromEntries( + Object.keys(CAP_STYLE_HOOKS).map((key) => [key, (state: unknown) => state]) +) as StyleHooks; + +const roundedHooks: StyleHooks = { + ...neutralizedCapHooks, + ...CAP_STYLE_HOOKS_ROUNDED_CORNERS, +}; + +const sampleImage = + "data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='90'><rect width='140' height='90' fill='%230f6cbd'/></svg>"; + +const useStyles = makeStyles({ + compare: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: tokens.spacingHorizontalXXXL, + alignItems: 'start', + }, + cell: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + minWidth: 0, + }, + cellLabel: { + fontFamily: tokens.fontFamilyBase, + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground3, + }, + row: { + display: 'flex', + gap: tokens.spacingHorizontalM, + alignItems: 'center', + flexWrap: 'wrap', + }, +}); + +const Compare: React.FC<{ render: () => React.ReactNode }> = ({ render }) => { + const styles = useStyles(); + return ( + <div className={styles.compare}> + <div className={styles.cell}> + <span className={styles.cellLabel}>Fluent v9</span> + <FluentProvider customStyleHooks_unstable={neutralizedCapHooks}> + <div className={styles.row}>{render()}</div> + </FluentProvider> + </div> + <div className={styles.cell}> + <span className={styles.cellLabel}>Rounded</span> + <FluentProvider customStyleHooks_unstable={roundedHooks}> + <div className={styles.row}>{render()}</div> + </FluentProvider> + </div> + </div> + ); +}; + +type Story = (() => React.ReactElement) & { storyName?: string }; + +/** Builds a story that compares a single component default-vs-rounded. */ +const example = + (render: () => React.ReactNode): Story => + () => + <Compare render={render} />; + +export const ButtonExample = example(() => ( + <> + <Button>Default</Button> + <Button appearance="primary">Primary</Button> + <Button size="small">Small</Button> + <Button size="large">Large</Button> + <Button shape="circular">Circular</Button> + <Button shape="square">Square</Button> + <Button icon={<CalendarIcon />} title="Icon only" /> + </> +)); + +export const CompoundButtonExample = example(() => ( + <> + <CompoundButton secondaryContent="Secondary">Default</CompoundButton> + <CompoundButton appearance="primary" secondaryContent="Secondary"> + Primary + </CompoundButton> + </> +)); + +export const ToggleButtonExample = example(() => ( + <> + <ToggleButton>Off</ToggleButton> + <ToggleButton defaultChecked>On</ToggleButton> + </> +)); + +export const SplitButtonExample = example(() => ( + <Menu positioning="below-start"> + <MenuTrigger disableButtonEnhancement> + {(triggerProps) => ( + <SplitButton menuButton={triggerProps}>Split</SplitButton> + )} + </MenuTrigger> + <MenuPopover> + <MenuList> + <MenuItem>Item one</MenuItem> + <MenuItem>Item two</MenuItem> + </MenuList> + </MenuPopover> + </Menu> +)); + +export const InputExample = example(() => ( + <> + <Input placeholder="Outline" /> + <Input appearance="underline" placeholder="Underline" /> + <Input size="small" placeholder="Small" /> + </> +)); + +export const ComboboxExample = example(() => ( + <Combobox placeholder="Pick a color"> + <Option>Red</Option> + <Option>Green</Option> + <Option>Blue</Option> + </Combobox> +)); + +export const DropdownExample = example(() => ( + <Dropdown placeholder="Pick a number"> + <Option>One</Option> + <Option>Two</Option> + <Option>Three</Option> + </Dropdown> +)); + +export const CheckboxExample = example(() => ( + <> + <Checkbox label="Unchecked" /> + <Checkbox label="Checked" defaultChecked /> + </> +)); + +export const TagExample = example(() => ( + <TagGroup> + <Tag>Medium</Tag> + <Tag size="small">Small</Tag> + <Tag shape="circular">Circular</Tag> + </TagGroup> +)); + +export const InteractionTagExample = example(() => ( + <TagGroup> + <InteractionTag> + <InteractionTagPrimary hasSecondaryAction> + Interaction + </InteractionTagPrimary> + <InteractionTagSecondary aria-label="dismiss" /> + </InteractionTag> + </TagGroup> +)); + +export const MenuItemExample = example(() => ( + <MenuList style={{ minWidth: '180px' }}> + <MenuItem>New</MenuItem> + <MenuItem>Open</MenuItem> + <MenuItem>Save</MenuItem> + </MenuList> +)); + +export const AvatarExample = example(() => ( + <> + <Avatar shape="square" size={32} name="Cap Theme" /> + <Avatar shape="square" size={48} name="Cap Theme" /> + <Avatar shape="square" size={64} name="Cap Theme" /> + </> +)); + +export const BadgeExample = example(() => ( + <> + <Badge size="medium">Medium</Badge> + <Badge size="extra-large">Extra large</Badge> + <Badge shape="square" size="extra-large"> + Square + </Badge> + </> +)); + +export const ImageExample = example(() => ( + <Image src={sampleImage} shape="rounded" alt="" /> +)); + +export const ToolbarExample = example(() => ( + <Toolbar> + <ToolbarButton icon={<CalendarIcon />}>Action</ToolbarButton> + <ToolbarButton icon={<CalendarIcon />}>Action</ToolbarButton> + </Toolbar> +)); + +export const CardExample = example(() => ( + <Card style={{ maxWidth: '260px' }}> + <Text weight="semibold">Card</Text> + <Text>Surfaces use the largest CAP radius.</Text> + </Card> +)); + +ButtonExample.storyName = 'Button'; +CompoundButtonExample.storyName = 'CompoundButton'; +ToggleButtonExample.storyName = 'ToggleButton'; +SplitButtonExample.storyName = 'SplitButton'; +InputExample.storyName = 'Input'; +ComboboxExample.storyName = 'Combobox'; +DropdownExample.storyName = 'Dropdown'; +CheckboxExample.storyName = 'Checkbox'; +TagExample.storyName = 'Tag'; +InteractionTagExample.storyName = 'InteractionTag'; +MenuItemExample.storyName = 'MenuItem'; +AvatarExample.storyName = 'Avatar'; +BadgeExample.storyName = 'Badge'; +ImageExample.storyName = 'Image'; +ToolbarExample.storyName = 'Toolbar'; +CardExample.storyName = 'Card'; + +const meta = { + title: 'Packages/react-cap-theme/Design Language/Rounded Corners', + // Hide the per-component stories from the sidebar so this shows up as a + // single docs-only nav item (like "Getting Started"). The `!dev` tag removes + // them from the sidebar while they keep the inherited `docs` tag, so they + // still render in the "Components" section of the docs page. + tags: ['!dev'], +} satisfies Meta; + +export default meta;