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..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 new file mode 100644 index 00000000..5c5dccc4 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-spinbutton/components/SpinButton/useSpinButtonStyles.styles.ts @@ -0,0 +1,221 @@ +import * as React from 'react'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { tokens } from '@fluentui/tokens'; +import { capTokens } from '../../../tokens'; + +import { + bundleIcon, + ChevronDownFilled, + ChevronDownRegular, + ChevronUpFilled, + ChevronUpRegular, + iconFilledClassName, + iconRegularClassName, +} from '@fluentui/react-icons'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import type { SpinButtonState } from './SpinButton.types'; + +const stepperBoldSwap = { + [`& .${iconRegularClassName}`]: { display: 'none' }, + [`& .${iconFilledClassName}`]: { display: 'inline' }, +} as const; + +const ChevronUp = bundleIcon(ChevronUpFilled, ChevronUpRegular); +const ChevronDown = bundleIcon(ChevronDownFilled, ChevronDownRegular); + +const removeFluentUIStyleAdditions = { + '::before': { + display: 'none', + }, + '::after': { + display: 'none', + }, +}; + +const stepperButton = { + borderRadius: tokens.borderRadiusNone, + ':hover': { + ...stepperBoldSwap, + }, + ':active': { + ...stepperBoldSwap, + }, + ['&.fui-SpinButton__button_active']: { + ...stepperBoldSwap, + }, +}; + +const useRootStyles = makeStyles({ + root: { + minHeight: '16px', + overflow: 'hidden', + }, + disabled: { + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + }, + stepperButton: { + width: '34px', + height: '16px', + fontSize: '13px', + ...stepperButton, + }, + incrementButton: { + top: '-8px', // no other way + right: 0, // rtl/ltr issue + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalMNudge} ${tokens.spacingVerticalNone}`, + }, + decrementButton: { + bottom: 0, + right: 0, // rtl/ltr + padding: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalMNudge} ${tokens.spacingVerticalXS}`, + }, + stepperButtonSmall: { + width: '26px', + height: tokens.lineHeightBase100, + fontSize: '13px', + ...stepperButton, + }, + incrementButtonSmall: { + top: '-6px', // no other way + right: 0, // rtl/ltr issue + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalSNudge} ${tokens.spacingVerticalNone}`, + }, + decrementButtonSmall: { + bottom: 0, + right: 0, // rtl/ltr + padding: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalSNudge} ${tokens.spacingVerticalXXS}`, + }, + + stepperButtonLarge: { + width: '42px', + height: '22px', + fontSize: tokens.fontSizeBase400, + ...stepperButton, + }, + incrementButtonLarge: { + top: '-10px', // no other way + right: 0, // rtl/ltr issue + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM} ${tokens.spacingVerticalNone}`, + }, + decrementButtonLarge: { + 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, + }, + filled: { + backgroundColor: tokens.colorNeutralBackground1, + }, + 'filled-darker': { + ...shorthands.borderStyle('solid'), + ...shorthands.borderWidth(tokens.strokeWidthThin), + }, + 'filled-lighter': { + ...shorthands.borderStyle('solid'), + ...shorthands.borderWidth(tokens.strokeWidthThin), + }, +}); + +/** + * Apply CAP styling to the SpinButton slots based on the state. + * @alpha + */ +export const useSpinButtonStyles = ( + state: SpinButtonState +): SpinButtonState => { + const rootStyles = useRootStyles(); + const rootSizeStyles = useRootSizeStyles(); + const rootAppearanceStyles = useRootAppearanceStyles(); + + const { size, appearance } = state; + + state.root.className = mergeClasses( + state.root.className, + 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, + size === 'small' + ? rootStyles.stepperButtonSmall + : size === 'large' + ? rootStyles.stepperButtonLarge + : rootStyles.stepperButton, + size === 'small' + ? rootStyles.incrementButtonSmall + : size === 'large' + ? rootStyles.incrementButtonLarge + : rootStyles.incrementButton, + getSlotClassNameProp_unstable(state.incrementButton) + ); + } + + if (state.decrementButton) { + state.decrementButton.children = React.createElement(ChevronDown); + state.decrementButton.className = mergeClasses( + state.decrementButton.className, + size === 'small' + ? rootStyles.stepperButtonSmall + : size === 'large' + ? rootStyles.stepperButtonLarge + : rootStyles.stepperButton, + size === 'small' + ? rootStyles.decrementButtonSmall + : size === 'large' + ? rootStyles.decrementButtonLarge + : rootStyles.decrementButton, + getSlotClassNameProp_unstable(state.decrementButton) + ); + } + + 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'; 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..737b3a58 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Appearance.stories.tsx @@ -0,0 +1,64 @@ +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', + }, + + 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 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/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/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..a5418975 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Disabled.stories.tsx @@ -0,0 +1,64 @@ +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', + }, + + 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 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/Size.stories.tsx b/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx new file mode 100644 index 00000000..e981eefc --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/Size.stories.tsx @@ -0,0 +1,56 @@ +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'); + const largeId = useId('large-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..ab5b2ce1 --- /dev/null +++ b/packages/react-cap-theme/stories/SpinButton/index.stories.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { SpinButton } from '@fluentui/react-components'; + +const meta = { + title: 'Packages/react-cap-theme/Components/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'; +export { ContentBeforeAfter } from './ContentBeforeAfter.stories';