From 03feb02e1f8a040d26caa24601ab1cc3c610ac11 Mon Sep 17 00:00:00 2001 From: Alexander Katrukhin Date: Mon, 8 Jun 2026 13:58:14 -0400 Subject: [PATCH 1/5] feat(react-cap-theme): add react-spinbutton component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CAP-themed SpinButton style hook layering CAP deltas on the Fluent base state: pill radius on the `outline` appearance (root and `::before` border), accessible stroke on all four borders (Fluent base only makes the bottom accessible), CAP field heights (36px medium / 28px small), and removal of the Fluent animated focus underline (`::after`) on `outline` only — other appearances keep the base focus underline. Registered via CAP_STYLE_HOOKS.useSpinButtonStyles_unstable, mirroring the react-input / react-search pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-1b496e4d-8f0b-4290-87e9-fc47f20f0b2d.json | 7 ++ packages/react-cap-theme/src/capStyleHooks.ts | 5 ++ .../components/SpinButton/SpinButton.types.ts | 7 ++ .../SpinButton/useSpinButtonStyles.styles.ts | 71 +++++++++++++++++++ .../src/components/react-spinbutton/index.ts | 2 + 5 files changed, 92 insertions(+) create mode 100644 change/@fluentui-contrib-react-cap-theme-1b496e4d-8f0b-4290-87e9-fc47f20f0b2d.json create mode 100644 packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts create mode 100644 packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts create mode 100644 packages/react-cap-theme/src/components/react-spinbutton/index.ts diff --git a/change/@fluentui-contrib-react-cap-theme-1b496e4d-8f0b-4290-87e9-fc47f20f0b2d.json b/change/@fluentui-contrib-react-cap-theme-1b496e4d-8f0b-4290-87e9-fc47f20f0b2d.json new file mode 100644 index 00000000..6524de61 --- /dev/null +++ b/change/@fluentui-contrib-react-cap-theme-1b496e4d-8f0b-4290-87e9-fc47f20f0b2d.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "add react-spinbutton component", + "packageName": "@fluentui-contrib/react-cap-theme", + "email": "Oleksandr.Katrukhin@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-cap-theme/src/capStyleHooks.ts b/packages/react-cap-theme/src/capStyleHooks.ts index 38aeb83b..53a3d5f7 100644 --- a/packages/react-cap-theme/src/capStyleHooks.ts +++ b/packages/react-cap-theme/src/capStyleHooks.ts @@ -136,6 +136,8 @@ import { usePopoverSurfaceStyles } from './components/react-popover'; import type { PopoverSurfaceState } from '@fluentui/react-popover'; import { useSearchBoxStyles } from './components/react-search'; import type { SearchBoxState } from './components/react-search'; +import { useSpinButtonStyles } from './components/react-spinbutton'; +import type { SpinButtonState } from './components/react-spinbutton'; import { useTagStyles, useInteractionTagStyles, @@ -319,6 +321,9 @@ export const CAP_STYLE_HOOKS: NonNullable< useSearchBoxStyles_unstable: (state) => { return useSearchBoxStyles(state as SearchBoxState); }, + useSpinButtonStyles_unstable: (state) => { + return useSpinButtonStyles(state as SpinButtonState); + }, useSplitButtonStyles_unstable: (state) => { return useSplitButtonStyles(state as SplitButtonState); }, diff --git a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts new file mode 100644 index 00000000..013bb964 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts @@ -0,0 +1,7 @@ +import type { SpinButtonState as BaseSpinButtonState } from '@fluentui/react-spinbutton'; + +/** + * State used in rendering the CAP SpinButton. + * @alpha + */ +export type SpinButtonState = BaseSpinButtonState; diff --git a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts new file mode 100644 index 00000000..185f58a4 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts @@ -0,0 +1,71 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/tokens'; +import { capTokens } from '../../../tokens'; +import type { SpinButtonState } from './SpinButton.types'; + +const useStyles = makeStyles({ + root: { + minHeight: '36px', + }, + small: { + minHeight: '28px', + }, + medium: {}, + // Pill radius on `outline`; border is painted on `root::before`, so radius is + // applied to both. The Fluent focus underline (`root::after`) is removed because + // it conflicts with the pill shape — outline focus is conveyed by the accessible + // border color in `outlineInteractive`. Other appearances keep the base underline. + outline: { + borderRadius: capTokens.borderRadius2XLarge, + '::before': { + borderRadius: capTokens.borderRadius2XLarge, + }, + '::after': { content: 'unset' }, + }, + outlineSmall: { + borderRadius: tokens.borderRadiusXLarge, + '::before': { + borderRadius: tokens.borderRadiusXLarge, + }, + }, + outlineInteractive: { + '::before': { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessible), + }, + ':hover::before': { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), + }, + // DO NOT add a space between the selectors! It changes the behavior of make-styles. + ':active,:focus-within': { + '::before': { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessiblePressed), + }, + }, + }, +}); + +/** + * Apply CAP styling to the SpinButton slots based on the state. + * @alpha + */ +export const useSpinButtonStyles = ( + state: SpinButtonState +): SpinButtonState => { + const styles = useStyles(); + const { appearance, size } = state; + const disabled = state.input.disabled; + const isOutline = appearance === 'outline'; + + state.root.className = mergeClasses( + state.root.className, + styles.root, + styles[size], + isOutline && styles.outline, + isOutline && size === 'small' && styles.outlineSmall, + isOutline && !disabled && styles.outlineInteractive, + getSlotClassNameProp_unstable(state.root) + ); + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-spinbutton/index.ts b/packages/react-cap-theme/src/components/react-spinbutton/index.ts new file mode 100644 index 00000000..4aa2f73a --- /dev/null +++ b/packages/react-cap-theme/src/components/react-spinbutton/index.ts @@ -0,0 +1,2 @@ +export { useSpinButtonStyles } from './components/SpinButton/useSpinButtonStyles.styles'; +export type { SpinButtonState } from './components/SpinButton/SpinButton.types'; From eb3f9ffae06f781863788ba6e4044e4d66e9417d Mon Sep 17 00:00:00 2001 From: Alexander Katrukhin Date: Mon, 8 Jun 2026 22:13:30 -0400 Subject: [PATCH 2/5] docs(react-cap-theme): add SpinButton storybook stories Default, Appearance, Size, and Disabled stories adapted from the Fluent react-spinbutton examples. Rendered under CAP_STYLE_HOOKS via the storybook decorator to visually verify the CAP outline overrides. --- .../stories/SpinButton/Appearance.stories.tsx | 70 +++++++++++++++++++ .../stories/SpinButton/Default.stories.tsx | 32 +++++++++ .../stories/SpinButton/Disabled.stories.tsx | 32 +++++++++ .../stories/SpinButton/Size.stories.tsx | 50 +++++++++++++ .../stories/SpinButton/index.stories.tsx | 15 ++++ 5 files changed, 199 insertions(+) create mode 100644 packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx create mode 100644 packages/react-cap-theme/stories/SpinButton/Default.stories.tsx create mode 100644 packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx create mode 100644 packages/react-cap-theme/stories/SpinButton/Size.stories.tsx create mode 100644 packages/react-cap-theme/stories/SpinButton/index.stories.tsx diff --git a/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx new file mode 100644 index 00000000..73dc5b35 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { + makeStyles, + mergeClasses, + tokens, + useId, + Label, + SpinButton, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + maxWidth: '500px', + }, + + field: { + display: 'grid', + gridRowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalMNudge, + padding: tokens.spacingHorizontalMNudge, + }, + + filledLighter: { + backgroundColor: tokens.colorNeutralBackgroundInverted, + '> label': { + color: tokens.colorNeutralForegroundInverted2, + }, + }, + filledDarker: { + backgroundColor: tokens.colorNeutralBackgroundInverted, + '> label': { + color: tokens.colorNeutralForegroundInverted2, + }, + }, +}); + +export const Appearance = () => { + const styles = useStyles(); + + const outlineId = useId('outline-id'); + const underlineId = useId('underline-id'); + const filledLighterId = useId('filledLighter-id'); + const filledDarkerId = useId('filledDarker-id'); + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +}; diff --git a/packages/react-cap-theme/stories/SpinButton/Default.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Default.stories.tsx new file mode 100644 index 00000000..10e288b8 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Default.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + makeStyles, + tokens, + useId, + Label, + SpinButton, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + maxWidth: '500px', + + '> label': { + marginBottom: tokens.spacingVerticalXXS, + }, + }, +}); + +export const Default = () => { + const styles = useStyles(); + const id = useId(); + + return ( +
+ + +
+ ); +}; diff --git a/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx new file mode 100644 index 00000000..4630c0d6 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + makeStyles, + tokens, + useId, + Label, + SpinButton, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + maxWidth: '500px', + + '> label': { + marginBottom: tokens.spacingVerticalXXS, + }, + }, +}); + +export const Disabled = () => { + const styles = useStyles(); + const id = useId(); + + return ( +
+ + +
+ ); +}; diff --git a/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx new file mode 100644 index 00000000..f3450ebf --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { + makeStyles, + tokens, + useId, + Label, + SpinButton, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + maxWidth: '500px', + + '> div': { + display: 'flex', + flexDirection: 'column', + marginTop: tokens.spacingHorizontalMNudge, + }, + + '> div:first-child': { + marginTop: '0px', + }, + + '> div label': { + marginBottom: tokens.spacingVerticalXXS, + }, + }, +}); + +export const Size = () => { + const styles = useStyles(); + const smallId = useId('small-id'); + const mediumId = useId('medium-id'); + + return ( +
+
+ + +
+ +
+ + +
+
+ ); +}; diff --git a/packages/react-cap-theme/stories/SpinButton/index.stories.tsx b/packages/react-cap-theme/stories/SpinButton/index.stories.tsx new file mode 100644 index 00000000..fa0ccd01 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/index.stories.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { SpinButton } from '@fluentui/react-components'; + +const meta = { + title: 'Packages/react-cap-theme/SpinButton', + component: SpinButton, +} satisfies Meta; + +export default meta; + +export { Default } from './Default.stories'; +export { Appearance } from './Appearance.stories'; +export { Size } from './Size.stories'; +export { Disabled } from './Disabled.stories'; From e2570d0f4c0c23cda05b2b9c8fbeb6cf1a18f676 Mon Sep 17 00:00:00 2001 From: Alexander Katrukhin Date: Mon, 15 Jun 2026 12:35:50 -0400 Subject: [PATCH 3/5] feat(react-cap-theme): polish SpinButton states + add small & medium state matrices Style fixes to match the Figma (CAP Web Toolkit) light-theme specs: - pressed/focus border uses brand stroke (colorCompoundBrandStroke, #0f6cbd) instead of the neutral accessible-pressed gray. - invalid no longer clobbered: outlineInteractive is skipped when invalid so the Fluent base red border (colorPaletteRedBorder2) shows; CAP adds the brand focus border for the invalid+focus case. - placeholder color now matches Figma: rest #616161 (colorNeutralForeground3), darkening to #242424 on hover/focus (base left it uncolored / too dark). - steppers polished per size (Figma): medium 34px col / 11px padding, small 26px col / 7px padding, outer corner matched to the pill radius (12/8px) so the hover background follows the curve. Stories: small + medium NoValue/WithValue state matrices covering rest, hover, pressed, error, disabled, read-only and focus, with hover/pressed/focus forced via storybook-addon-pseudo-states and a size-matched CAP brand leading circle. --- .../SpinButton/useSpinButtonStyles.styles.ts | 271 ++++++++++++++++- .../stories/SpinButton/Appearance.stories.tsx | 18 +- .../stories/SpinButton/Error.stories.tsx | 32 ++ .../stories/SpinButton/ReadOnly.stories.tsx | 32 ++ .../stories/SpinButton/Size.stories.tsx | 6 + .../stories/SpinButton/States.stories.tsx | 273 ++++++++++++++++++ .../stories/SpinButton/index.stories.tsx | 3 + 7 files changed, 604 insertions(+), 31 deletions(-) create mode 100644 packages/react-cap-theme/stories/SpinButton/Error.stories.tsx create mode 100644 packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx create mode 100644 packages/react-cap-theme/stories/SpinButton/States.stories.tsx diff --git a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts index 185f58a4..8e06cfb3 100644 --- a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts +++ b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts @@ -1,9 +1,27 @@ +import * as React from 'react'; import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { + bundleIcon, + ChevronUpFilled, + ChevronUpRegular, + ChevronDownFilled, + ChevronDownRegular, + iconFilledClassName, + iconRegularClassName, +} from '@fluentui/react-icons'; import { tokens } from '@fluentui/tokens'; import { capTokens } from '../../../tokens'; import type { SpinButtonState } from './SpinButton.types'; +const ChevronUp = bundleIcon(ChevronUpFilled, ChevronUpRegular); +const ChevronDown = bundleIcon(ChevronDownFilled, ChevronDownRegular); + +const stepperBoldSwap = { + [`& .${iconRegularClassName}`]: { display: 'none' }, + [`& .${iconFilledClassName}`]: { display: 'inline' }, +} as const; + const useStyles = makeStyles({ root: { minHeight: '36px', @@ -12,23 +30,56 @@ const useStyles = makeStyles({ minHeight: '28px', }, medium: {}, - // Pill radius on `outline`; border is painted on `root::before`, so radius is - // applied to both. The Fluent focus underline (`root::after`) is removed because - // it conflicts with the pill shape — outline focus is conveyed by the accessible - // border color in `outlineInteractive`. Other appearances keep the base underline. - outline: { + large: { + minHeight: '42px', + '& .fui-SpinButton__input': { + fontSize: '16px', + lineHeight: '22px', + }, + }, + rounded: { borderRadius: capTokens.borderRadius2XLarge, '::before': { borderRadius: capTokens.borderRadius2XLarge, }, - '::after': { content: 'unset' }, }, - outlineSmall: { + roundedSmall: { borderRadius: tokens.borderRadiusXLarge, '::before': { borderRadius: tokens.borderRadiusXLarge, }, }, + outline: { + '::after': { content: 'unset' }, + }, + outlineSteppers: { + gridTemplateColumns: '1fr 34px', + }, + outlineSteppersSmall: { + gridTemplateColumns: '1fr 26px', + }, + outlineSteppersLarge: { + gridTemplateColumns: '1fr 42px', + }, + outlinePlaceholder: { + '& .fui-SpinButton__input::placeholder': { + color: capTokens.colorNeutralForeground5, + opacity: 1, + fontWeight: 300, + }, + ':hover .fui-SpinButton__input::placeholder': { + color: capTokens.colorNeutralForeground5Hover, + }, + ':active .fui-SpinButton__input::placeholder': { + color: capTokens.colorNeutralForeground5Pressed, + }, + ':focus-within .fui-SpinButton__input::placeholder': { + color: capTokens.colorNeutralForeground5Pressed, + }, + '& .fui-SpinButton__input:disabled::placeholder': { + color: tokens.colorNeutralForegroundDisabled, + }, + }, outlineInteractive: { '::before': { ...shorthands.borderColor(tokens.colorNeutralStrokeAccessible), @@ -36,13 +87,139 @@ const useStyles = makeStyles({ ':hover::before': { ...shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), }, - // DO NOT add a space between the selectors! It changes the behavior of make-styles. - ':active,:focus-within': { - '::before': { - ...shorthands.borderColor(tokens.colorNeutralStrokeAccessiblePressed), + ':focus-within:not(:active)::before': { + ...shorthands.borderColor(tokens.colorCompoundBrandStroke), + }, + ':active::before': { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessiblePressed), + }, + }, + outlineInvalid: { + ':focus-within::before': { + ...shorthands.borderColor(tokens.colorCompoundBrandStroke), + }, + }, + stepperButton: { + width: '34px', + fontSize: '12px', + top: '1px', + color: capTokens.colorNeutralForeground5, + ':enabled': { + ':hover': { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Hover, + ...stepperBoldSwap, + }, + ':active': { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Pressed, + ...stepperBoldSwap, + }, + ['&.fui-SpinButton__button_active']: { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Pressed, + ...stepperBoldSwap, }, }, }, + incrementButton: { + paddingInline: '11px', + paddingTop: '5px', + paddingBottom: '1px', + }, + decrementButton: { + paddingInline: '11px', + paddingTop: '1px', + paddingBottom: '5px', + }, + incrementRounded: { + borderTopRightRadius: capTokens.borderRadius2XLarge, + borderBottomLeftRadius: tokens.borderRadiusMedium, + }, + decrementRounded: { + borderBottomRightRadius: capTokens.borderRadius2XLarge, + borderTopLeftRadius: tokens.borderRadiusMedium, + }, + stepperButtonSmall: { + width: '26px', + fontSize: '12px', + top: '1px', + color: capTokens.colorNeutralForeground5, + ':enabled': { + ':hover': { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Hover, + ...stepperBoldSwap, + }, + ':active': { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Pressed, + ...stepperBoldSwap, + }, + ['&.fui-SpinButton__button_active']: { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Pressed, + ...stepperBoldSwap, + }, + }, + }, + incrementButtonSmall: { + paddingInline: '7px', + paddingTop: tokens.spacingVerticalXXS, + paddingBottom: 0, + }, + decrementButtonSmall: { + paddingInline: '7px', + paddingTop: 0, + paddingBottom: tokens.spacingVerticalXXS, + }, + incrementRoundedSmall: { + borderTopRightRadius: tokens.borderRadiusXLarge, + }, + decrementRoundedSmall: { + borderBottomRightRadius: tokens.borderRadiusXLarge, + }, + stepperButtonLarge: { + width: '42px', + fontSize: '16px', + top: '2px', + color: capTokens.colorNeutralForeground5, + ':enabled': { + ':hover': { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Hover, + ...stepperBoldSwap, + }, + ':active': { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Pressed, + ...stepperBoldSwap, + }, + ['&.fui-SpinButton__button_active']: { + backgroundColor: 'transparent', + color: capTokens.colorNeutralForeground5Pressed, + ...stepperBoldSwap, + }, + }, + }, + incrementButtonLarge: { + paddingInline: '13px', + paddingTop: '5px', + paddingBottom: '1px', + }, + decrementButtonLarge: { + paddingInline: '13px', + paddingTop: '1px', + paddingBottom: '5px', + }, + incrementRoundedLarge: { + borderTopRightRadius: capTokens.borderRadius2XLarge, + borderBottomLeftRadius: tokens.borderRadiusMedium, + }, + decrementRoundedLarge: { + borderBottomRightRadius: capTokens.borderRadius2XLarge, + borderTopLeftRadius: tokens.borderRadiusMedium, + }, }); /** @@ -53,19 +230,83 @@ export const useSpinButtonStyles = ( state: SpinButtonState ): SpinButtonState => { const styles = useStyles(); - const { appearance, size } = state; + const { appearance } = state; + const size = state.size as 'small' | 'medium' | 'large'; const disabled = state.input.disabled; + const invalid = `${state.input['aria-invalid']}` === 'true'; const isOutline = appearance === 'outline'; + const isFilledDarker = appearance === 'filled-darker'; + const isFilledLighter = appearance === 'filled-lighter'; + const isOutlineLike = isOutline || isFilledDarker; + const isRounded = isOutline || isFilledDarker || isFilledLighter; + const isSmall = size === 'small'; + const bySize = (s: T, m: T, l: T): T => + size === 'small' ? s : size === 'large' ? l : m; state.root.className = mergeClasses( state.root.className, styles.root, - styles[size], + bySize(styles.small, styles.medium, styles.large), + isRounded && (isSmall ? styles.roundedSmall : styles.rounded), isOutline && styles.outline, - isOutline && size === 'small' && styles.outlineSmall, - isOutline && !disabled && styles.outlineInteractive, + styles.outlinePlaceholder, + bySize( + styles.outlineSteppersSmall, + styles.outlineSteppers, + styles.outlineSteppersLarge + ), + isOutline && !disabled && !invalid && styles.outlineInteractive, + isOutline && !disabled && invalid && styles.outlineInvalid, getSlotClassNameProp_unstable(state.root) ); + if (state.incrementButton) { + state.incrementButton.children = React.createElement(ChevronUp); + state.incrementButton.className = mergeClasses( + state.incrementButton.className, + bySize( + styles.stepperButtonSmall, + styles.stepperButton, + styles.stepperButtonLarge + ), + bySize( + styles.incrementButtonSmall, + styles.incrementButton, + styles.incrementButtonLarge + ), + isRounded && + bySize( + styles.incrementRoundedSmall, + styles.incrementRounded, + styles.incrementRoundedLarge + ), + getSlotClassNameProp_unstable(state.incrementButton) + ); + } + + if (state.decrementButton) { + state.decrementButton.children = React.createElement(ChevronDown); + state.decrementButton.className = mergeClasses( + state.decrementButton.className, + bySize( + styles.stepperButtonSmall, + styles.stepperButton, + styles.stepperButtonLarge + ), + bySize( + styles.decrementButtonSmall, + styles.decrementButton, + styles.decrementButtonLarge + ), + isRounded && + bySize( + styles.decrementRoundedSmall, + styles.decrementRounded, + styles.decrementRoundedLarge + ), + getSlotClassNameProp_unstable(state.decrementButton) + ); + } + return state; }; diff --git a/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx index 73dc5b35..abd03745 100644 --- a/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx +++ b/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { makeStyles, - mergeClasses, tokens, useId, Label, @@ -21,19 +20,6 @@ const useStyles = makeStyles({ marginTop: tokens.spacingVerticalMNudge, padding: tokens.spacingHorizontalMNudge, }, - - filledLighter: { - backgroundColor: tokens.colorNeutralBackgroundInverted, - '> label': { - color: tokens.colorNeutralForegroundInverted2, - }, - }, - filledDarker: { - backgroundColor: tokens.colorNeutralBackgroundInverted, - '> label': { - color: tokens.colorNeutralForegroundInverted2, - }, - }, }); export const Appearance = () => { @@ -56,12 +42,12 @@ export const Appearance = () => { -
+
-
+
diff --git a/packages/react-cap-theme/stories/SpinButton/Error.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Error.stories.tsx new file mode 100644 index 00000000..1835e439 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Error.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + makeStyles, + tokens, + useId, + Label, + SpinButton, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + maxWidth: '500px', + + '> label': { + marginBottom: tokens.spacingVerticalXXS, + }, + }, +}); + +export const Error = () => { + const styles = useStyles(); + const id = useId(); + + return ( +
+ + +
+ ); +}; diff --git a/packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx b/packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx new file mode 100644 index 00000000..858e6111 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + makeStyles, + tokens, + useId, + Label, + SpinButton, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + maxWidth: '500px', + + '> label': { + marginBottom: tokens.spacingVerticalXXS, + }, + }, +}); + +export const ReadOnly = () => { + const styles = useStyles(); + const id = useId(); + + return ( +
+ + +
+ ); +}; diff --git a/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx index f3450ebf..e981eefc 100644 --- a/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx +++ b/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx @@ -33,6 +33,7 @@ export const Size = () => { const styles = useStyles(); const smallId = useId('small-id'); const mediumId = useId('medium-id'); + const largeId = useId('large-id'); return (
@@ -45,6 +46,11 @@ export const Size = () => {
+ +
+ + +
); }; diff --git a/packages/react-cap-theme/stories/SpinButton/States.stories.tsx b/packages/react-cap-theme/stories/SpinButton/States.stories.tsx new file mode 100644 index 00000000..e6e7ff79 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/States.stories.tsx @@ -0,0 +1,273 @@ +import * as React from 'react'; +import { + makeStyles, + mergeClasses, + tokens, + SpinButton, + type SpinButtonProps, +} from '@fluentui/react-components'; +import { + bundleIcon, + Calendar16Filled, + CalendarLtr16Regular, + Calendar20Filled, + CalendarLtr20Regular, + Calendar24Filled, + CalendarLtr24Regular, + iconFilledClassName, + iconRegularClassName, +} from '@fluentui/react-icons'; +import { capTokens } from '../../src/components/tokens'; + +type Size = 'small' | 'medium' | 'large'; + +const CalendarSmall = bundleIcon(Calendar16Filled, CalendarLtr16Regular); +const CalendarMedium = bundleIcon(Calendar20Filled, CalendarLtr20Regular); +const CalendarLarge = bundleIcon(Calendar24Filled, CalendarLtr24Regular); + +const useStyles = makeStyles({ + grid: { + display: 'grid', + gridTemplateColumns: 'auto 280px', + alignItems: 'center', + rowGap: tokens.spacingVerticalM, + columnGap: tokens.spacingHorizontalXL, + maxWidth: '460px', + }, + rowLabel: { + color: tokens.colorNeutralForeground3, + fontFamily: tokens.fontFamilyBase, + fontSize: tokens.fontSizeBase200, + }, + wrap: { + position: 'relative', + display: 'inline-block', + width: '100%', + + [`& .${iconRegularClassName}`]: { color: capTokens.colorNeutralForeground5 }, + + [`& .fui-SpinButton:hover ~ .cap-sb-icon .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .fui-SpinButton:hover ~ .cap-sb-icon .${iconFilledClassName}`]: { + display: 'inline', + color: capTokens.colorNeutralForeground5Hover, + }, + + [`& .fui-SpinButton:active ~ .cap-sb-icon .${iconRegularClassName}, & .fui-SpinButton:focus-within ~ .cap-sb-icon .${iconRegularClassName}`]: + { + display: 'none', + }, + [`& .fui-SpinButton:active ~ .cap-sb-icon .${iconFilledClassName}, & .fui-SpinButton:focus-within ~ .cap-sb-icon .${iconFilledClassName}`]: + { + display: 'inline', + color: tokens.colorBrandForeground2, + }, + + [`& .cap-sb-icon-disabled .${iconRegularClassName}, & .cap-sb-icon-disabled .${iconFilledClassName}`]: + { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + wrapSmall: { + '& .fui-SpinButton__input': { + paddingLeft: '20px', + }, + }, + wrapMedium: { + '& .fui-SpinButton__input': { + paddingLeft: '26px', + }, + }, + wrapLarge: { + '& .fui-SpinButton__input': { + paddingLeft: '32px', + }, + }, + icon: { + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + display: 'inline-flex', + pointerEvents: 'none', + zIndex: 1, + }, + iconSmall: { + left: '8px', + }, + iconMedium: { + left: '10px', + }, + iconLarge: { + left: '10px', + }, + spin: { + width: '100%', + }, + all: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXXXL, + }, + sectionTitle: { + margin: 0, + marginBottom: tokens.spacingVerticalM, + fontFamily: tokens.fontFamilyBase, + fontSize: tokens.fontSizeBase400, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + }, + contrastPanel: { + backgroundColor: tokens.colorNeutralBackground3, + padding: tokens.spacingHorizontalL, + borderRadius: tokens.borderRadiusMedium, + }, +}); + +const bySize = (size: Size | undefined, s: T, m: T, l: T): T => + size === 'large' ? l : size === 'medium' ? m : s; + +const SpinButtonWithIcon = ( + props: Omit & { size?: Size } +) => { + const styles = useStyles(); + const { size } = props; + const Icon = bySize(size, CalendarSmall, CalendarMedium, CalendarLarge); + return ( +
+ + + + +
+ ); +}; + +type Row = { + key: string; + label: string; + props: SpinButtonProps; +}; + +const ROWS: Row[] = [ + { key: 'rest', label: 'Rest', props: {} }, + { key: 'hover', label: 'Hover', props: { className: 'cap-hover' } }, + { key: 'pressed', label: 'Pressed / Active', props: { className: 'cap-pressed' } }, + { key: 'error', label: 'Error', props: { 'aria-invalid': true } }, + { key: 'disabled', label: 'Disabled', props: { disabled: true } }, + { key: 'readonly', label: 'Read-only', props: { readOnly: true } }, + { key: 'focus', label: 'Focused', props: { className: 'cap-focus' } }, +]; + +type Appearance = NonNullable; + +const StatesMatrix = ({ + withValue, + size, + appearance, +}: { + withValue: boolean; + size: Size; + appearance: Appearance; +}) => { + const styles = useStyles(); + const valueProps: Omit = withValue + ? { appearance, defaultValue: 10, min: 0, max: 20 } + : { + appearance, + value: null, + displayValue: '', + placeholder: 'Placeholder text', + }; + + return ( +
+ {ROWS.map((row) => ( + + {row.label} + + + ))} +
+ ); +}; + +const pseudo = { + hover: '.cap-hover', + active: '.cap-pressed', + focusWithin: '.cap-focus', +}; + +const APPEARANCES: { key: Appearance; label: string }[] = [ + { key: 'outline', label: 'Outline' }, + { key: 'underline', label: 'Underline' }, + { key: 'filled-lighter', label: 'Filled Lighter' }, + { key: 'filled-darker', label: 'Filled Darker' }, +]; +const SIZES: Size[] = ['small', 'medium', 'large']; + +const SECTIONS = APPEARANCES.flatMap((appearance) => + SIZES.flatMap((size) => + [false, true].map((withValue) => ({ + appearance: appearance.key, + title: `${appearance.label} · ${size} · ${ + withValue ? 'with value' : 'placeholder' + }`, + size, + withValue, + })) + ) +); + +export const All = () => { + const styles = useStyles(); + return ( +
+ {SECTIONS.map((section) => ( +
+

{section.title}

+
+ +
+
+ ))} +
+ ); +}; +All.parameters = { + pseudo, + docs: { + description: { + story: + 'Every SpinButton permutation in one view: all four appearances ' + + '(outline, underline, filled-lighter, filled-darker) across both sizes, ' + + 'with and without a value, over all seven states. Hover, pressed and focus ' + + 'rows are forced via storybook-addon-pseudo-states.', + }, + }, +}; diff --git a/packages/react-cap-theme/stories/SpinButton/index.stories.tsx b/packages/react-cap-theme/stories/SpinButton/index.stories.tsx index fa0ccd01..48b4e97e 100644 --- a/packages/react-cap-theme/stories/SpinButton/index.stories.tsx +++ b/packages/react-cap-theme/stories/SpinButton/index.stories.tsx @@ -13,3 +13,6 @@ export { Default } from './Default.stories'; export { Appearance } from './Appearance.stories'; export { Size } from './Size.stories'; export { Disabled } from './Disabled.stories'; +export { Error } from './Error.stories'; +export { ReadOnly } from './ReadOnly.stories'; +export { All } from './States.stories'; From 4c6510d636d55a3696efd145810331456bce2c47 Mon Sep 17 00:00:00 2001 From: Alexander Katrukhin Date: Wed, 8 Jul 2026 12:52:53 -0400 Subject: [PATCH 4/5] refactor(react-cap-theme): streamline SpinButton styles and update storybook examples --- .../components/SpinButton/SpinButton.types.ts | 7 - .../SpinButton/useSpinButtonStyles.styles.ts | 379 +++++++----------- .../stories/SpinButton/Appearance.stories.tsx | 12 +- .../SpinButton/ContentBeforeAfter.stories.tsx | 89 ++++ .../stories/SpinButton/Disabled.stories.tsx | 44 +- .../stories/SpinButton/Error.stories.tsx | 32 -- .../stories/SpinButton/ReadOnly.stories.tsx | 32 -- .../stories/SpinButton/States.stories.tsx | 273 ------------- .../stories/SpinButton/index.stories.tsx | 6 +- 9 files changed, 283 insertions(+), 591 deletions(-) delete mode 100644 packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts create mode 100644 packages/react-cap-theme/stories/SpinButton/ContentBeforeAfter.stories.tsx delete mode 100644 packages/react-cap-theme/stories/SpinButton/Error.stories.tsx delete mode 100644 packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx delete mode 100644 packages/react-cap-theme/stories/SpinButton/States.stories.tsx diff --git a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts deleted file mode 100644 index 013bb964..00000000 --- a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SpinButtonState as BaseSpinButtonState } from '@fluentui/react-spinbutton'; - -/** - * State used in rendering the CAP SpinButton. - * @alpha - */ -export type SpinButtonState = BaseSpinButtonState; diff --git a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts index 8e06cfb3..28a31b25 100644 --- a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts +++ b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts @@ -1,224 +1,162 @@ import * as React from 'react'; import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; -import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/tokens'; +import { capTokens } from '../../../tokens'; + import { bundleIcon, - ChevronUpFilled, - ChevronUpRegular, ChevronDownFilled, ChevronDownRegular, + ChevronUpFilled, + ChevronUpRegular, iconFilledClassName, iconRegularClassName, } from '@fluentui/react-icons'; -import { tokens } from '@fluentui/tokens'; -import { capTokens } from '../../../tokens'; -import type { SpinButtonState } from './SpinButton.types'; - -const ChevronUp = bundleIcon(ChevronUpFilled, ChevronUpRegular); -const ChevronDown = bundleIcon(ChevronDownFilled, ChevronDownRegular); +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { SpinButtonState } from '@fluentui/react-components'; const stepperBoldSwap = { [`& .${iconRegularClassName}`]: { display: 'none' }, [`& .${iconFilledClassName}`]: { display: 'inline' }, } as const; -const useStyles = makeStyles({ - root: { - minHeight: '36px', - }, - small: { - minHeight: '28px', - }, - medium: {}, - large: { - minHeight: '42px', - '& .fui-SpinButton__input': { - fontSize: '16px', - lineHeight: '22px', - }, - }, - rounded: { - borderRadius: capTokens.borderRadius2XLarge, - '::before': { - borderRadius: capTokens.borderRadius2XLarge, - }, - }, - roundedSmall: { - borderRadius: tokens.borderRadiusXLarge, - '::before': { - borderRadius: tokens.borderRadiusXLarge, - }, - }, - outline: { - '::after': { content: 'unset' }, +const ChevronUp = bundleIcon(ChevronUpFilled, ChevronUpRegular); +const ChevronDown = bundleIcon(ChevronDownFilled, ChevronDownRegular); + +const removeFluentUIStyleAdditions = { + '::before': { + display: 'none', }, - outlineSteppers: { - gridTemplateColumns: '1fr 34px', + '::after': { + display: 'none', }, - outlineSteppersSmall: { - gridTemplateColumns: '1fr 26px', +}; + +const stepperButton = { + borderRadius: tokens.borderRadiusNone, + ':hover': { + ...stepperBoldSwap, }, - outlineSteppersLarge: { - gridTemplateColumns: '1fr 42px', + ':active': { + ...stepperBoldSwap, }, - outlinePlaceholder: { - '& .fui-SpinButton__input::placeholder': { - color: capTokens.colorNeutralForeground5, - opacity: 1, - fontWeight: 300, - }, - ':hover .fui-SpinButton__input::placeholder': { - color: capTokens.colorNeutralForeground5Hover, - }, - ':active .fui-SpinButton__input::placeholder': { - color: capTokens.colorNeutralForeground5Pressed, - }, - ':focus-within .fui-SpinButton__input::placeholder': { - color: capTokens.colorNeutralForeground5Pressed, - }, - '& .fui-SpinButton__input:disabled::placeholder': { - color: tokens.colorNeutralForegroundDisabled, - }, + ['&.fui-SpinButton__button_active']: { + ...stepperBoldSwap, }, - outlineInteractive: { - '::before': { - ...shorthands.borderColor(tokens.colorNeutralStrokeAccessible), - }, - ':hover::before': { - ...shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), - }, - ':focus-within:not(:active)::before': { - ...shorthands.borderColor(tokens.colorCompoundBrandStroke), - }, - ':active::before': { - ...shorthands.borderColor(tokens.colorNeutralStrokeAccessiblePressed), - }, +}; + +const useRootStyles = makeStyles({ + root: { + minHeight: '16px', + overflow: 'hidden', }, - outlineInvalid: { - ':focus-within::before': { - ...shorthands.borderColor(tokens.colorCompoundBrandStroke), - }, + disabled: { + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), }, stepperButton: { width: '34px', - fontSize: '12px', - top: '1px', - color: capTokens.colorNeutralForeground5, - ':enabled': { - ':hover': { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Hover, - ...stepperBoldSwap, - }, - ':active': { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Pressed, - ...stepperBoldSwap, - }, - ['&.fui-SpinButton__button_active']: { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Pressed, - ...stepperBoldSwap, - }, - }, + height: '16px', + fontSize: '13px', + ...stepperButton, }, incrementButton: { - paddingInline: '11px', - paddingTop: '5px', - paddingBottom: '1px', + top: '-8px', // no other way + right: 0, // rtl/ltr issue + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalMNudge} ${tokens.spacingVerticalNone}`, }, decrementButton: { - paddingInline: '11px', - paddingTop: '1px', - paddingBottom: '5px', - }, - incrementRounded: { - borderTopRightRadius: capTokens.borderRadius2XLarge, - borderBottomLeftRadius: tokens.borderRadiusMedium, - }, - decrementRounded: { - borderBottomRightRadius: capTokens.borderRadius2XLarge, - borderTopLeftRadius: tokens.borderRadiusMedium, + bottom: 0, + right: 0, // rtl/ltr + padding: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalMNudge} ${tokens.spacingVerticalXS}`, }, stepperButtonSmall: { width: '26px', - fontSize: '12px', - top: '1px', - color: capTokens.colorNeutralForeground5, - ':enabled': { - ':hover': { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Hover, - ...stepperBoldSwap, - }, - ':active': { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Pressed, - ...stepperBoldSwap, - }, - ['&.fui-SpinButton__button_active']: { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Pressed, - ...stepperBoldSwap, - }, - }, + height: tokens.lineHeightBase100, + fontSize: '13px', + ...stepperButton, }, incrementButtonSmall: { - paddingInline: '7px', - paddingTop: tokens.spacingVerticalXXS, - paddingBottom: 0, + top: '-6px', // no other way + right: 0, // rtl/ltr issue + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalSNudge} ${tokens.spacingVerticalNone}`, }, decrementButtonSmall: { - paddingInline: '7px', - paddingTop: 0, - paddingBottom: tokens.spacingVerticalXXS, - }, - incrementRoundedSmall: { - borderTopRightRadius: tokens.borderRadiusXLarge, - }, - decrementRoundedSmall: { - borderBottomRightRadius: tokens.borderRadiusXLarge, + bottom: 0, + right: 0, // rtl/ltr + padding: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalSNudge} ${tokens.spacingVerticalXXS}`, }, + stepperButtonLarge: { width: '42px', - fontSize: '16px', - top: '2px', - color: capTokens.colorNeutralForeground5, - ':enabled': { - ':hover': { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Hover, - ...stepperBoldSwap, - }, - ':active': { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Pressed, - ...stepperBoldSwap, - }, - ['&.fui-SpinButton__button_active']: { - backgroundColor: 'transparent', - color: capTokens.colorNeutralForeground5Pressed, - ...stepperBoldSwap, - }, - }, + height: '22px', + fontSize: tokens.fontSizeBase400, + ...stepperButton, }, incrementButtonLarge: { - paddingInline: '13px', - paddingTop: '5px', - paddingBottom: '1px', + top: '-10px', // no other way + right: 0, // rtl/ltr issue + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM} ${tokens.spacingVerticalNone}`, }, decrementButtonLarge: { - paddingInline: '13px', - paddingTop: '1px', - paddingBottom: '5px', + bottom: 0, + right: 0, // rtl/ltr + padding: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalM} ${tokens.spacingVerticalXS}`, + }, +}); + +const useRootSizeStyles = makeStyles({ + small: { + padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusXLarge, + ...removeFluentUIStyleAdditions, + }, + medium: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalMNudge}`, + borderRadius: capTokens.borderRadius2XLarge, + ...removeFluentUIStyleAdditions, + }, + large: { + minHeight: '22px', + padding: `${tokens.spacingVerticalMNudge} ${tokens.spacingHorizontalM}`, + borderRadius: capTokens.borderRadius2XLarge, + ...removeFluentUIStyleAdditions, + }, +}); + +const useRootAppearanceStyles = makeStyles({ + outline: { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessible), + ...shorthands.borderStyle('solid'), + ...shorthands.borderWidth(tokens.strokeWidthThin), + '::before': { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessible), + }, + ':hover': { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), + }, + ':focus-within:not(:active)': { + ...shorthands.borderColor(tokens.colorCompoundBrandStroke), + }, + ':active': { + ...shorthands.borderColor(tokens.colorNeutralStrokeAccessiblePressed), + }, + }, + underline: { + borderRadius: tokens.borderRadiusNone, + borderBottomWidth: tokens.strokeWidthThin, + borderBottomStyle: 'solid', + borderBottomColor: tokens.colorNeutralStrokeAccessible, }, - incrementRoundedLarge: { - borderTopRightRadius: capTokens.borderRadius2XLarge, - borderBottomLeftRadius: tokens.borderRadiusMedium, + filled: { + backgroundColor: tokens.colorNeutralBackground1, }, - decrementRoundedLarge: { - borderBottomRightRadius: capTokens.borderRadius2XLarge, - borderTopLeftRadius: tokens.borderRadiusMedium, + 'filled-darker': { + ...shorthands.borderStyle('solid'), + ...shorthands.borderWidth(tokens.strokeWidthThin), + }, + 'filled-lighter': { + ...shorthands.borderStyle('solid'), + ...shorthands.borderWidth(tokens.strokeWidthThin), }, }); @@ -229,57 +167,34 @@ const useStyles = makeStyles({ export const useSpinButtonStyles = ( state: SpinButtonState ): SpinButtonState => { - const styles = useStyles(); - const { appearance } = state; - const size = state.size as 'small' | 'medium' | 'large'; - const disabled = state.input.disabled; - const invalid = `${state.input['aria-invalid']}` === 'true'; - const isOutline = appearance === 'outline'; - const isFilledDarker = appearance === 'filled-darker'; - const isFilledLighter = appearance === 'filled-lighter'; - const isOutlineLike = isOutline || isFilledDarker; - const isRounded = isOutline || isFilledDarker || isFilledLighter; - const isSmall = size === 'small'; - const bySize = (s: T, m: T, l: T): T => - size === 'small' ? s : size === 'large' ? l : m; + const rootStyles = useRootStyles(); + const rootSizeStyles = useRootSizeStyles(); + const rootAppearanceStyles = useRootAppearanceStyles(); + + const { size, appearance } = state; state.root.className = mergeClasses( state.root.className, - styles.root, - bySize(styles.small, styles.medium, styles.large), - isRounded && (isSmall ? styles.roundedSmall : styles.rounded), - isOutline && styles.outline, - styles.outlinePlaceholder, - bySize( - styles.outlineSteppersSmall, - styles.outlineSteppers, - styles.outlineSteppersLarge - ), - isOutline && !disabled && !invalid && styles.outlineInteractive, - isOutline && !disabled && invalid && styles.outlineInvalid, - getSlotClassNameProp_unstable(state.root) + rootStyles.root, + rootSizeStyles[size], + rootAppearanceStyles[appearance], + state.input.disabled && rootStyles.disabled ); if (state.incrementButton) { state.incrementButton.children = React.createElement(ChevronUp); state.incrementButton.className = mergeClasses( state.incrementButton.className, - bySize( - styles.stepperButtonSmall, - styles.stepperButton, - styles.stepperButtonLarge - ), - bySize( - styles.incrementButtonSmall, - styles.incrementButton, - styles.incrementButtonLarge - ), - isRounded && - bySize( - styles.incrementRoundedSmall, - styles.incrementRounded, - styles.incrementRoundedLarge - ), + size === 'small' + ? rootStyles.stepperButtonSmall + : size === 'large' + ? rootStyles.stepperButtonLarge + : rootStyles.stepperButton, + size === 'small' + ? rootStyles.incrementButtonSmall + : size === 'large' + ? rootStyles.incrementButtonLarge + : rootStyles.incrementButton, getSlotClassNameProp_unstable(state.incrementButton) ); } @@ -288,22 +203,16 @@ export const useSpinButtonStyles = ( state.decrementButton.children = React.createElement(ChevronDown); state.decrementButton.className = mergeClasses( state.decrementButton.className, - bySize( - styles.stepperButtonSmall, - styles.stepperButton, - styles.stepperButtonLarge - ), - bySize( - styles.decrementButtonSmall, - styles.decrementButton, - styles.decrementButtonLarge - ), - isRounded && - bySize( - styles.decrementRoundedSmall, - styles.decrementRounded, - styles.decrementRoundedLarge - ), + size === 'small' + ? rootStyles.stepperButtonSmall + : size === 'large' + ? rootStyles.stepperButtonLarge + : rootStyles.stepperButton, + size === 'small' + ? rootStyles.decrementButtonSmall + : size === 'large' + ? rootStyles.decrementButtonLarge + : rootStyles.decrementButton, getSlotClassNameProp_unstable(state.decrementButton) ); } diff --git a/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx index abd03745..737b3a58 100644 --- a/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx +++ b/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx @@ -20,6 +20,14 @@ const useStyles = makeStyles({ marginTop: tokens.spacingVerticalMNudge, padding: tokens.spacingHorizontalMNudge, }, + fieldDark: { + display: 'grid', + gridRowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalMNudge, + padding: tokens.spacingHorizontalMNudge, + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, + }, }); export const Appearance = () => { @@ -42,12 +50,12 @@ export const Appearance = () => {
-
+
-
+
diff --git a/packages/react-cap-theme/stories/SpinButton/ContentBeforeAfter.stories.tsx b/packages/react-cap-theme/stories/SpinButton/ContentBeforeAfter.stories.tsx new file mode 100644 index 00000000..af4e9344 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/ContentBeforeAfter.stories.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { + makeStyles, + tokens, + useId, + Label, + SpinButton, + Input, + Text, + Body1, + Button, + ButtonProps, + mergeClasses, +} from '@fluentui/react-components'; +import { + PersonRegular, + MicRegular, + CalendarMonthFilled, + CalendarMonthRegular, + bundleIcon, +} from '@fluentui/react-icons'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +const useStyles = makeStyles({ + base: { + display: 'flex', + flexDirection: 'column', + maxWidth: '500px', + }, + + field: { + display: 'grid', + gridRowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalMNudge, + padding: tokens.spacingHorizontalMNudge, + }, + fieldDark: { + display: 'grid', + gridRowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalMNudge, + padding: tokens.spacingHorizontalMNudge, + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, + }, + customInput: { + '& .fui-Input': { + border: 'none', + padding: 0, + minHeight: '16px', + }, + }, +}); + +export const ContentBeforeAfter = () => { + const styles = useStyles(); + + const afterId = useId('content-after'); + + return ( +
+
+ + + ) => ( + } + id={afterId} + className={mergeClasses( + (props as any).className, + styles.customInput + )} + {...(props as any)} + /> + )) as unknown as undefined, + }} + /> + + An input with a button in the contentAfter slot. + +
+
+ ); +}; diff --git a/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx index 4630c0d6..a5418975 100644 --- a/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx +++ b/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx @@ -12,21 +12,53 @@ const useStyles = makeStyles({ display: 'flex', flexDirection: 'column', maxWidth: '500px', + }, - '> label': { - marginBottom: tokens.spacingVerticalXXS, - }, + field: { + display: 'grid', + gridRowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalMNudge, + padding: tokens.spacingHorizontalMNudge, + }, + fieldDark: { + display: 'grid', + gridRowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalMNudge, + padding: tokens.spacingHorizontalMNudge, + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, }, }); export const Disabled = () => { const styles = useStyles(); - const id = useId(); + + const outlineId = useId('outline-disabled-id'); + const underlineId = useId('underline-disabled-id'); + const filledLighterId = useId('filledLighter-disabled-id'); + const filledDarkerId = useId('filledDarker-disabled-id'); return (
- - +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
); }; diff --git a/packages/react-cap-theme/stories/SpinButton/Error.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Error.stories.tsx deleted file mode 100644 index 1835e439..00000000 --- a/packages/react-cap-theme/stories/SpinButton/Error.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import { - makeStyles, - tokens, - useId, - Label, - SpinButton, -} from '@fluentui/react-components'; - -const useStyles = makeStyles({ - base: { - display: 'flex', - flexDirection: 'column', - maxWidth: '500px', - - '> label': { - marginBottom: tokens.spacingVerticalXXS, - }, - }, -}); - -export const Error = () => { - const styles = useStyles(); - const id = useId(); - - return ( -
- - -
- ); -}; diff --git a/packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx b/packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx deleted file mode 100644 index 858e6111..00000000 --- a/packages/react-cap-theme/stories/SpinButton/ReadOnly.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import { - makeStyles, - tokens, - useId, - Label, - SpinButton, -} from '@fluentui/react-components'; - -const useStyles = makeStyles({ - base: { - display: 'flex', - flexDirection: 'column', - maxWidth: '500px', - - '> label': { - marginBottom: tokens.spacingVerticalXXS, - }, - }, -}); - -export const ReadOnly = () => { - const styles = useStyles(); - const id = useId(); - - return ( -
- - -
- ); -}; diff --git a/packages/react-cap-theme/stories/SpinButton/States.stories.tsx b/packages/react-cap-theme/stories/SpinButton/States.stories.tsx deleted file mode 100644 index e6e7ff79..00000000 --- a/packages/react-cap-theme/stories/SpinButton/States.stories.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import * as React from 'react'; -import { - makeStyles, - mergeClasses, - tokens, - SpinButton, - type SpinButtonProps, -} from '@fluentui/react-components'; -import { - bundleIcon, - Calendar16Filled, - CalendarLtr16Regular, - Calendar20Filled, - CalendarLtr20Regular, - Calendar24Filled, - CalendarLtr24Regular, - iconFilledClassName, - iconRegularClassName, -} from '@fluentui/react-icons'; -import { capTokens } from '../../src/components/tokens'; - -type Size = 'small' | 'medium' | 'large'; - -const CalendarSmall = bundleIcon(Calendar16Filled, CalendarLtr16Regular); -const CalendarMedium = bundleIcon(Calendar20Filled, CalendarLtr20Regular); -const CalendarLarge = bundleIcon(Calendar24Filled, CalendarLtr24Regular); - -const useStyles = makeStyles({ - grid: { - display: 'grid', - gridTemplateColumns: 'auto 280px', - alignItems: 'center', - rowGap: tokens.spacingVerticalM, - columnGap: tokens.spacingHorizontalXL, - maxWidth: '460px', - }, - rowLabel: { - color: tokens.colorNeutralForeground3, - fontFamily: tokens.fontFamilyBase, - fontSize: tokens.fontSizeBase200, - }, - wrap: { - position: 'relative', - display: 'inline-block', - width: '100%', - - [`& .${iconRegularClassName}`]: { color: capTokens.colorNeutralForeground5 }, - - [`& .fui-SpinButton:hover ~ .cap-sb-icon .${iconRegularClassName}`]: { - display: 'none', - }, - [`& .fui-SpinButton:hover ~ .cap-sb-icon .${iconFilledClassName}`]: { - display: 'inline', - color: capTokens.colorNeutralForeground5Hover, - }, - - [`& .fui-SpinButton:active ~ .cap-sb-icon .${iconRegularClassName}, & .fui-SpinButton:focus-within ~ .cap-sb-icon .${iconRegularClassName}`]: - { - display: 'none', - }, - [`& .fui-SpinButton:active ~ .cap-sb-icon .${iconFilledClassName}, & .fui-SpinButton:focus-within ~ .cap-sb-icon .${iconFilledClassName}`]: - { - display: 'inline', - color: tokens.colorBrandForeground2, - }, - - [`& .cap-sb-icon-disabled .${iconRegularClassName}, & .cap-sb-icon-disabled .${iconFilledClassName}`]: - { - color: tokens.colorNeutralForegroundDisabled, - }, - }, - wrapSmall: { - '& .fui-SpinButton__input': { - paddingLeft: '20px', - }, - }, - wrapMedium: { - '& .fui-SpinButton__input': { - paddingLeft: '26px', - }, - }, - wrapLarge: { - '& .fui-SpinButton__input': { - paddingLeft: '32px', - }, - }, - icon: { - position: 'absolute', - top: '50%', - transform: 'translateY(-50%)', - display: 'inline-flex', - pointerEvents: 'none', - zIndex: 1, - }, - iconSmall: { - left: '8px', - }, - iconMedium: { - left: '10px', - }, - iconLarge: { - left: '10px', - }, - spin: { - width: '100%', - }, - all: { - display: 'flex', - flexDirection: 'column', - rowGap: tokens.spacingVerticalXXXL, - }, - sectionTitle: { - margin: 0, - marginBottom: tokens.spacingVerticalM, - fontFamily: tokens.fontFamilyBase, - fontSize: tokens.fontSizeBase400, - fontWeight: tokens.fontWeightSemibold, - color: tokens.colorNeutralForeground1, - }, - contrastPanel: { - backgroundColor: tokens.colorNeutralBackground3, - padding: tokens.spacingHorizontalL, - borderRadius: tokens.borderRadiusMedium, - }, -}); - -const bySize = (size: Size | undefined, s: T, m: T, l: T): T => - size === 'large' ? l : size === 'medium' ? m : s; - -const SpinButtonWithIcon = ( - props: Omit & { size?: Size } -) => { - const styles = useStyles(); - const { size } = props; - const Icon = bySize(size, CalendarSmall, CalendarMedium, CalendarLarge); - return ( -
- - - - -
- ); -}; - -type Row = { - key: string; - label: string; - props: SpinButtonProps; -}; - -const ROWS: Row[] = [ - { key: 'rest', label: 'Rest', props: {} }, - { key: 'hover', label: 'Hover', props: { className: 'cap-hover' } }, - { key: 'pressed', label: 'Pressed / Active', props: { className: 'cap-pressed' } }, - { key: 'error', label: 'Error', props: { 'aria-invalid': true } }, - { key: 'disabled', label: 'Disabled', props: { disabled: true } }, - { key: 'readonly', label: 'Read-only', props: { readOnly: true } }, - { key: 'focus', label: 'Focused', props: { className: 'cap-focus' } }, -]; - -type Appearance = NonNullable; - -const StatesMatrix = ({ - withValue, - size, - appearance, -}: { - withValue: boolean; - size: Size; - appearance: Appearance; -}) => { - const styles = useStyles(); - const valueProps: Omit = withValue - ? { appearance, defaultValue: 10, min: 0, max: 20 } - : { - appearance, - value: null, - displayValue: '', - placeholder: 'Placeholder text', - }; - - return ( -
- {ROWS.map((row) => ( - - {row.label} - - - ))} -
- ); -}; - -const pseudo = { - hover: '.cap-hover', - active: '.cap-pressed', - focusWithin: '.cap-focus', -}; - -const APPEARANCES: { key: Appearance; label: string }[] = [ - { key: 'outline', label: 'Outline' }, - { key: 'underline', label: 'Underline' }, - { key: 'filled-lighter', label: 'Filled Lighter' }, - { key: 'filled-darker', label: 'Filled Darker' }, -]; -const SIZES: Size[] = ['small', 'medium', 'large']; - -const SECTIONS = APPEARANCES.flatMap((appearance) => - SIZES.flatMap((size) => - [false, true].map((withValue) => ({ - appearance: appearance.key, - title: `${appearance.label} · ${size} · ${ - withValue ? 'with value' : 'placeholder' - }`, - size, - withValue, - })) - ) -); - -export const All = () => { - const styles = useStyles(); - return ( -
- {SECTIONS.map((section) => ( -
-

{section.title}

-
- -
-
- ))} -
- ); -}; -All.parameters = { - pseudo, - docs: { - description: { - story: - 'Every SpinButton permutation in one view: all four appearances ' + - '(outline, underline, filled-lighter, filled-darker) across both sizes, ' + - 'with and without a value, over all seven states. Hover, pressed and focus ' + - 'rows are forced via storybook-addon-pseudo-states.', - }, - }, -}; diff --git a/packages/react-cap-theme/stories/SpinButton/index.stories.tsx b/packages/react-cap-theme/stories/SpinButton/index.stories.tsx index 48b4e97e..ab5b2ce1 100644 --- a/packages/react-cap-theme/stories/SpinButton/index.stories.tsx +++ b/packages/react-cap-theme/stories/SpinButton/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { SpinButton } from '@fluentui/react-components'; const meta = { - title: 'Packages/react-cap-theme/SpinButton', + title: 'Packages/react-cap-theme/Components/SpinButton', component: SpinButton, } satisfies Meta; @@ -13,6 +13,4 @@ export { Default } from './Default.stories'; export { Appearance } from './Appearance.stories'; export { Size } from './Size.stories'; export { Disabled } from './Disabled.stories'; -export { Error } from './Error.stories'; -export { ReadOnly } from './ReadOnly.stories'; -export { All } from './States.stories'; +export { ContentBeforeAfter } from './ContentBeforeAfter.stories'; From 9696353630e6d67acf7512c7d2eb3673f2787dca Mon Sep 17 00:00:00 2001 From: Alexander Katrukhin Date: Thu, 9 Jul 2026 13:09:12 -0400 Subject: [PATCH 5/5] fix(react-cap-theme): restore SpinButton state type to fix type-check The SpinButton refactor deleted SpinButton.types.ts and re-pointed the styles hook at @fluentui/react-components' SpinButtonState (size only 'small' | 'medium'). This broke type-check: index.ts imported the deleted module and the 'large' size branches no longer type-checked. Restore SpinButton.types.ts re-exporting the base state with size widened to include the CAP-only 'large' value, and source it from the styles hook. Base state is imported from @fluentui/react-components (already a peer dep) to satisfy @nx/dependency-checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/SpinButton/SpinButton.types.ts | 12 ++++++++++++ .../SpinButton/useSpinButtonStyles.styles.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts diff --git a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts new file mode 100644 index 00000000..911d81ad --- /dev/null +++ b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/SpinButton.types.ts @@ -0,0 +1,12 @@ +import type { SpinButtonState as BaseSpinButtonState } from '@fluentui/react-components'; + +/** + * State used in rendering the CAP SpinButton. + * + * CAP additionally supports a `'large'` size, which the base SpinButton does not + * expose, so `size` is widened accordingly. + * @alpha + */ +export type SpinButtonState = Omit & { + size: BaseSpinButtonState['size'] | 'large'; +}; diff --git a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts index 28a31b25..5c5dccc4 100644 --- a/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts +++ b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts @@ -13,7 +13,7 @@ import { iconRegularClassName, } from '@fluentui/react-icons'; import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; -import { SpinButtonState } from '@fluentui/react-components'; +import type { SpinButtonState } from './SpinButton.types'; const stepperBoldSwap = { [`& .${iconRegularClassName}`]: { display: 'none' },