diff --git a/apps/vr-tests/src/stories/ReactButton.stories.tsx b/apps/vr-tests/src/stories/ReactButton.stories.tsx index 7e3c30eed9cac9..f7c6e8890634fc 100644 --- a/apps/vr-tests/src/stories/ReactButton.stories.tsx +++ b/apps/vr-tests/src/stories/ReactButton.stories.tsx @@ -1,7 +1,7 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; import Screener from 'screener-storybook/src/screener'; -import { Button, ButtonProps } from '@fluentui/react-button'; +import { Button, ButtonProps, CompoundButton } from '@fluentui/react-button'; import { FluentProviderDecorator, FabricDecorator } from '../utilities/index'; @@ -51,7 +51,7 @@ const AppearanceExample = (props: ButtonProps) => ( > ); -storiesOf('React Button', module) +storiesOf('react-button Button', module) .addDecorator(FabricDecorator) .addDecorator(FluentProviderDecorator) .addDecorator(story => ( @@ -93,3 +93,66 @@ storiesOf('React Button', module) > )); + +storiesOf('react-button CompoundButton', module) + .addDecorator(FabricDecorator) + .addDecorator(FluentProviderDecorator) + .addDecorator(story => ( + + {story()} + + )) + .addStory('Default', () => ( + Hello, world + )) + .addStory('Primary', () => ( + + Hello, world + + )) + .addStory('Disabled', () => ( + + Hello, world + + )) + .addStory('Primary Disabled', () => ( + + Hello, world + + )) + .addStory('With icon before content', () => ( + + Hello, world + + )) + .addStory('With icon after content', () => ( + + Hello, world + + )) + .addStory('Size small', () => ( + + Hello, world + + )) + .addStory('Size large', () => ( + + Hello, world + + )) + .addStory('Icon only', () => ( + + Hello, world + + )); diff --git a/change/@fluentui-react-button-7ead62e3-2881-40ba-8b5f-6dc38469b69d.json b/change/@fluentui-react-button-7ead62e3-2881-40ba-8b5f-6dc38469b69d.json new file mode 100644 index 00000000000000..cfdac94a0ccfc8 --- /dev/null +++ b/change/@fluentui-react-button-7ead62e3-2881-40ba-8b5f-6dc38469b69d.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "CompoundButton: Re-introducing CompoundButton using latest version of makeStyles.", + "packageName": "@fluentui/react-button", + "email": "Humberto.Morimoto@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-examples-69121500-1d87-4c68-995b-458d2e8b4ded.json b/change/@fluentui-react-examples-69121500-1d87-4c68-995b-458d2e8b4ded.json new file mode 100644 index 00000000000000..af81e02ac999e6 --- /dev/null +++ b/change/@fluentui-react-examples-69121500-1d87-4c68-995b-458d2e8b4ded.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "CompoundButton: Re-introducing CompoundButton using latest version of makeStyles.", + "packageName": "@fluentui/react-examples", + "email": "Humberto.Morimoto@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-button/etc/react-button.api.md b/packages/react-button/etc/react-button.api.md index 68f4ca2dda398a..7c9aeb31e49493 100644 --- a/packages/react-button/etc/react-button.api.md +++ b/packages/react-button/etc/react-button.api.md @@ -53,29 +53,34 @@ export type ButtonStyleSelectors = { // @public (undocumented) export type ButtonTokens = { height: string; + maxWidth: string; + minWidth: string; paddingX: string; paddingY: string; - minWidth: string; - maxWidth: string; fontSize: string; fontWeight: number; lineHeight: string; - iconWidth: string; + iconFontSize: string; iconHeight: string; iconSpacing: string; - color: string; - content2Color: string; + iconWidth: string; background: string; - backgroundHover: string; - backgroundPressed: string; - backgroundActive: string; + color: string; borderColor: string; - borderColorHover: string; - borderColorActive: string; - borderWidth: string; borderRadius: string; + borderWidth: string; shadow: string; - shadowPressed: string; + hovered: Partial<{ + background: string; + borderColor: string; + color: string; + }>; + pressed: Partial<{ + background: string; + borderColor: string; + color: string; + shadow: string; + }>; }; // @public (undocumented) @@ -86,9 +91,51 @@ export type ButtonVariantTokens = { [variant in ButtonVariants]: Partial; }; +// @public +export const CompoundButton: React.ForwardRefExoticComponent>; + +// @public (undocumented) +export interface CompoundButtonProps extends ButtonProps { + contentContainer?: ShorthandProps>; + secondaryContent?: ShorthandProps>; +} + +// @public +export const compoundButtonShorthandProps: string[]; + +// @public (undocumented) +export interface CompoundButtonState extends Omit, ButtonState { + // (undocumented) + contentContainer?: ObjectShorthandProps>; + // (undocumented) + secondaryContent?: ObjectShorthandProps>; +} + +// @public (undocumented) +export type CompoundButtonStyleSelectors = ButtonStyleSelectors; + +// Warning: (ae-forgotten-export) The symbol "CompoundButtonBaseTokens" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type CompoundButtonTokens = ButtonTokens & CompoundButtonBaseTokens & { + hovered: Partial; + pressed: Partial; +}; + +// @public (undocumented) +export type CompoundButtonVariants = ButtonVariants; + +// @public (undocumented) +export type CompoundButtonVariantTokens = { + [variant in CompoundButtonVariants]: Partial; +}; + // @public export const renderButton: (state: ButtonState) => JSX.Element; +// @public +export const renderCompoundButton: (state: CompoundButtonState) => JSX.Element; + // @public export const useButton: (props: ButtonProps, ref: React.Ref, defaultProps?: ButtonProps | undefined) => ButtonState; @@ -98,6 +145,12 @@ export const useButtonState: (draftState: ButtonState) => void; // @public (undocumented) export const useButtonStyles: (state: ButtonState, selectors: ButtonStyleSelectors) => void; +// @public +export const useCompoundButton: (props: CompoundButtonProps, ref: React.Ref, defaultProps?: CompoundButtonProps | undefined) => CompoundButtonState; + +// @public (undocumented) +export const useCompoundButtonStyles: (state: CompoundButtonState, selectors: import("../Button").ButtonStyleSelectors) => void; + // (No @packageDocumentation comment for this package) diff --git a/packages/react-button/src/CompoundButton.ts b/packages/react-button/src/CompoundButton.ts new file mode 100644 index 00000000000000..efb920c2c458c2 --- /dev/null +++ b/packages/react-button/src/CompoundButton.ts @@ -0,0 +1 @@ +export * from './components/CompoundButton/index'; diff --git a/packages/react-button/src/components/Button/Button.types.ts b/packages/react-button/src/components/Button/Button.types.ts index 35e353abf82502..edeffdd9b9c895 100644 --- a/packages/react-button/src/components/Button/Button.types.ts +++ b/packages/react-button/src/components/Button/Button.types.ts @@ -2,6 +2,9 @@ import * as React from 'react'; import { ComponentProps, ShorthandProps } from '@fluentui/react-utilities'; import { ObjectShorthandProps } from '@fluentui/react-utilities'; +/** + * {@docCategory Button} + */ export type ButtonProps = ComponentProps & React.ButtonHTMLAttributes & { /** @@ -63,6 +66,9 @@ export interface ButtonState extends ButtonProps { children?: ObjectShorthandProps>; } +/** + * {@docCategory Button} + */ export type ButtonStyleSelectors = { disabled?: boolean; iconOnly?: boolean; @@ -70,51 +76,66 @@ export type ButtonStyleSelectors = { size?: string; }; -export type ButtonVariants = - | 'base' - | 'disabled' - | 'iconOnly' - | 'primary' - | 'small' - | 'large' - // TODO: get rid of these combinations, use individual variants in matchers - | 'primaryDisabled' - | 'iconOnlySmall' - | 'iconOnlyLarge'; - +/** + * {@docCategory Button} + */ export type ButtonTokens = { height: string; + maxWidth: string; + minWidth: string; paddingX: string; paddingY: string; - minWidth: string; - maxWidth: string; fontSize: string; fontWeight: number; lineHeight: string; - iconWidth: string; + iconFontSize: string; iconHeight: string; iconSpacing: string; - - color: string; - content2Color: string; + iconWidth: string; background: string; - backgroundHover: string; - backgroundPressed: string; - backgroundActive: string; + color: string; borderColor: string; - borderColorHover: string; - borderColorActive: string; - borderWidth: string; borderRadius: string; + borderWidth: string; shadow: string; - shadowPressed: string; + + hovered: Partial<{ + background: string; + borderColor: string; + color: string; + }>; + + pressed: Partial<{ + background: string; + borderColor: string; + color: string; + shadow: string; + }>; }; +/** + * {@docCategory Button} + */ +export type ButtonVariants = + | 'base' + | 'disabled' + | 'iconOnly' + | 'primary' + | 'small' + | 'large' + // TODO: get rid of these combinations, use individual variants in matchers + | 'primaryDisabled' + | 'iconOnlySmall' + | 'iconOnlyLarge'; + +/** + * {@docCategory Button} + */ export type ButtonVariantTokens = { [variant in ButtonVariants]: Partial; }; diff --git a/packages/react-button/src/components/Button/index.ts b/packages/react-button/src/components/Button/index.ts index c26f65b46d62d8..6fe8c36b96d647 100644 --- a/packages/react-button/src/components/Button/index.ts +++ b/packages/react-button/src/components/Button/index.ts @@ -1,6 +1,6 @@ -export * from './Button.types'; export * from './Button'; +export * from './Button.types'; export * from './renderButton'; export * from './useButton'; -export { useButtonStyles } from './useButtonStyles'; export * from './useButtonState'; +export { useButtonStyles } from './useButtonStyles'; diff --git a/packages/react-button/src/components/Button/useButton.ts b/packages/react-button/src/components/Button/useButton.ts index bcd442dc9c9631..46923039ed53c7 100644 --- a/packages/react-button/src/components/Button/useButton.ts +++ b/packages/react-button/src/components/Button/useButton.ts @@ -23,7 +23,6 @@ export const useButton = (props: ButtonProps, ref: React.Ref, defau ref: resolvedRef, as: 'button', icon: { as: 'span' }, - content: { as: 'span', children: props.children }, loader: { as: 'span' }, }, defaultProps, diff --git a/packages/react-button/src/components/Button/useButtonStyles.ts b/packages/react-button/src/components/Button/useButtonStyles.ts index a47ba7f8ba1aaa..ed9b682923d2d0 100644 --- a/packages/react-button/src/components/Button/useButtonStyles.ts +++ b/packages/react-button/src/components/Button/useButtonStyles.ts @@ -1,10 +1,10 @@ -import { ButtonState, ButtonStyleSelectors, ButtonVariantTokens } from './Button.types'; import { ax, makeStyles } from '@fluentui/react-make-styles'; import { Theme } from '@fluentui/react-theme'; +import { ButtonState, ButtonStyleSelectors, ButtonVariantTokens } from './Button.types'; // TODO: These are named in design specs but not hoisted to global/alias yet. // We're tracking these here to determine how we can hoist them. -const buttonSpacing = { +export const buttonSpacing = { smallest: '2px', smaller: '4px', small: '6px', @@ -20,39 +20,43 @@ export const makeButtonTokens = (theme: Theme): ButtonVariantTokens => ({ // TODO: these are not in the global/alias theme currently // When they are shown in the token UI, we need to make it clear there is no global/alias mapping support height: '32px', + maxWidth: '280px', + minWidth: '96px', paddingX: buttonSpacing.large, paddingY: '0', - minWidth: '96px', - maxWidth: '280px', - color: theme.alias.color.neutral.neutralForeground1, - content2Color: theme.alias.color.neutral.neutralForeground2, background: theme.alias.color.neutral.neutralBackground1, - backgroundPressed: theme.alias.color.neutral.neutralBackground1, + color: theme.alias.color.neutral.neutralForeground1, + borderColor: theme.alias.color.neutral.neutralStroke1, borderRadius: theme.global.borderRadius.medium, borderWidth: theme.global.strokeWidth.thin, - borderColor: theme.alias.color.neutral.neutralStroke1, - - backgroundHover: theme.alias.color.neutral.neutralBackground1Hover, - borderColorHover: theme.alias.color.neutral.neutralStroke1Hover, - - backgroundActive: theme.alias.color.neutral.neutralBackground1Pressed, - borderColorActive: theme.alias.color.neutral.neutralStroke1Pressed, - fontWeight: theme.global.type.fontWeights.semibold, fontSize: theme.global.type.fontSizes.base[300], + fontWeight: theme.global.type.fontWeights.semibold, lineHeight: theme.global.type.lineHeights.base[300], + iconFontSize: '20px', + iconHeight: '20px', iconSpacing: buttonSpacing.small, iconWidth: '20px', - iconHeight: '20px', + + hovered: { + background: theme.alias.color.neutral.neutralBackground1Hover, + borderColor: theme.alias.color.neutral.neutralStroke1Hover, + color: theme.alias.color.neutral.neutralForeground1, + }, + + pressed: { + background: theme.alias.color.neutral.neutralBackground1Pressed, + borderColor: theme.alias.color.neutral.neutralStroke1Pressed, + color: theme.alias.color.neutral.neutralForeground1, + }, }, disabled: { background: theme.alias.color.neutral.neutralBackgroundDisabled, borderColor: theme.alias.color.neutral.neutralStrokeDisabled, color: theme.alias.color.neutral.neutralForegroundDisabled, - content2Color: theme.alias.color.neutral.neutralForegroundDisabled, }, small: { paddingX: buttonSpacing.medium, @@ -61,8 +65,9 @@ export const makeButtonTokens = (theme: Theme): ButtonVariantTokens => ({ minWidth: '64px', height: '24px', fontSize: theme.global.type.fontSizes.base[200], - lineHeight: theme.global.type.fontSizes.base[200], + lineHeight: theme.global.type.lineHeights.base[200], fontWeight: theme.global.type.fontWeights.regular, + iconSpacing: buttonSpacing.smaller, }, large: { paddingX: buttonSpacing.larger, @@ -72,7 +77,8 @@ export const makeButtonTokens = (theme: Theme): ButtonVariantTokens => ({ // TODO: 24px is not on the global ramp of line heights // 22px = theme.global.type.lineHeights.base[400] // 28px = theme.global.type.lineHeights.base[500] - lineHeight: '24px', + lineHeight: theme.global.type.lineHeights.base[400], + iconFontSize: '24px', iconWidth: '24px', iconHeight: '24px', iconSpacing: buttonSpacing.small, @@ -81,8 +87,8 @@ export const makeButtonTokens = (theme: Theme): ButtonVariantTokens => ({ // If not, it means there is cruft in the variant tokens definition. // All tokens in a variant should be mapped to some style property. iconOnly: { - paddingX: buttonSpacing.small, - paddingY: buttonSpacing.small, + paddingX: buttonSpacing.smaller, + paddingY: buttonSpacing.smaller, minWidth: '32px', maxWidth: '32px', }, @@ -90,11 +96,11 @@ export const makeButtonTokens = (theme: Theme): ButtonVariantTokens => ({ // we essentially need to update component token mappings based on variant matchers. // fow the sake of progress for now, we're extending variants to have combinations. iconOnlySmall: { - paddingX: buttonSpacing.smallest, - paddingY: buttonSpacing.smallest, + paddingX: buttonSpacing.smaller, + paddingY: buttonSpacing.smaller, borderRadius: theme.global.borderRadius.small, - minWidth: '24px', - maxWidth: '24px', + minWidth: '28px', + maxWidth: '28px', }, iconOnlyLarge: { paddingX: buttonSpacing.small, @@ -108,25 +114,33 @@ export const makeButtonTokens = (theme: Theme): ButtonVariantTokens => ({ background: theme.alias.color.brand.brandBackground, borderColor: 'transparent', - borderColorHover: 'transparent', - borderColorActive: 'transparent', - - backgroundHover: theme.alias.color.brand.brandBackgroundHover, - backgroundPressed: theme.alias.color.brand.brandBackgroundPressed, // TODO: spec calls out "shadow 4 __brand__", are we missing tokens? shadow: theme.alias.shadow.shadow4, - // TODO: spec calls out "shadow 2 __darker__", are we missing tokens? - shadowPressed: theme.alias.shadow.shadow2, + hovered: { + background: theme.alias.color.brand.brandBackgroundHover, + borderColor: 'transparent', + color: theme.alias.color.neutral.neutralForegroundInvertedAccessible, + }, + + pressed: { + background: theme.alias.color.brand.brandBackgroundPressed, + borderColor: 'transparent', + color: theme.alias.color.neutral.neutralForegroundInvertedAccessible, + // TODO: spec calls out "shadow 2 __darker__", are we missing tokens? + shadow: theme.alias.shadow.shadow2, + }, }, primaryDisabled: { background: theme.alias.color.neutral.neutralBackgroundDisabled, // borderColor: theme.alias.color.neutral.neutralStrokeDisabled, color: theme.alias.color.neutral.neutralForegroundDisabled, - content2Color: theme.alias.color.neutral.neutralForegroundDisabled, shadow: 'none', - shadowPressed: 'none', + + pressed: { + shadow: 'none', + }, }, }); @@ -160,13 +174,13 @@ const useStyles = makeStyles({ outline: 'none', ':hover': { - background: buttonTokens.base.backgroundHover, - borderColor: buttonTokens.base.borderColorHover, + background: buttonTokens.base.hovered?.background, + borderColor: buttonTokens.base.hovered?.borderColor, cursor: 'pointer', }, ':active': { - background: buttonTokens.base.backgroundActive, - borderColor: buttonTokens.base.borderColorActive, + background: buttonTokens.base.pressed?.background, + borderColor: buttonTokens.base.pressed?.borderColor, outline: 'none', }, // TODO: this is for toggle button only. Use here in regular button? @@ -177,7 +191,8 @@ const useStyles = makeStyles({ const buttonTokens = makeButtonTokens(theme); return { - padding: `${buttonTokens.small.paddingX} ${buttonTokens.small.paddingY}`, + gap: buttonTokens.small.iconSpacing, + padding: `${buttonTokens.small.paddingY} ${buttonTokens.small.paddingX}`, minWidth: buttonTokens.small.minWidth, height: buttonTokens.small.height, borderRadius: buttonTokens.small.borderRadius, @@ -188,7 +203,7 @@ const useStyles = makeStyles({ return { gap: buttonTokens.large.iconSpacing, - padding: `${buttonTokens.large.paddingX} ${buttonTokens.large.paddingY}`, + padding: `${buttonTokens.large.paddingY} ${buttonTokens.large.paddingX}`, height: buttonTokens.large.height, borderRadius: buttonTokens.large.borderRadius, }; @@ -222,15 +237,15 @@ const useStyles = makeStyles({ boxShadow: buttonTokens.primary.shadow, ':hover': { - background: buttonTokens.primary.backgroundHover, - borderColor: buttonTokens.primary.borderColorHover, + background: buttonTokens.primary.hovered?.background, + borderColor: buttonTokens.primary.hovered?.borderColor, }, ':active': { - background: buttonTokens.primary.backgroundPressed, + background: buttonTokens.primary.pressed?.background, // TODO: spec calls out "shadow 2 __darker__", are we missing tokens? boxShadow: buttonTokens.primary.shadow, - borderColor: buttonTokens.primary.borderColorActive, + borderColor: buttonTokens.primary.pressed?.borderColor, }, // TODO: focus @@ -248,7 +263,7 @@ const useStyles = makeStyles({ cursor: 'default', }, ':active': { - boxShadow: buttonTokens.primaryDisabled.shadowPressed, + boxShadow: buttonTokens.primaryDisabled.pressed?.shadow, }, }; }, @@ -265,7 +280,7 @@ const useStyles = makeStyles({ const buttonTokens = makeButtonTokens(theme); return { - padding: `${buttonTokens.iconOnlySmall.paddingX} ${buttonTokens.iconOnlySmall.paddingY}`, + padding: `${buttonTokens.iconOnlySmall.paddingY} ${buttonTokens.iconOnlySmall.paddingX}`, minWidth: buttonTokens.iconOnlySmall.minWidth, maxWidth: buttonTokens.iconOnlySmall.maxWidth, borderRadius: buttonTokens.iconOnlySmall.borderRadius, @@ -275,7 +290,7 @@ const useStyles = makeStyles({ const buttonTokens = makeButtonTokens(theme); return { - padding: `${buttonTokens.iconOnlyLarge.paddingX} ${buttonTokens.iconOnlyLarge.paddingY}`, + padding: `${buttonTokens.iconOnlyLarge.paddingY} ${buttonTokens.iconOnlyLarge.paddingX}`, minWidth: buttonTokens.iconOnlyLarge.minWidth, maxWidth: buttonTokens.iconOnlyLarge.maxWidth, borderRadius: buttonTokens.iconOnlyLarge.borderRadius, @@ -319,6 +334,7 @@ const useStyles = makeStyles({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + fontSize: buttonTokens.base.iconFontSize, height: buttonTokens.base.iconHeight, width: buttonTokens.base.iconWidth, }; @@ -327,8 +343,9 @@ const useStyles = makeStyles({ const buttonTokens = makeButtonTokens(theme); return { - width: buttonTokens.large.iconWidth, + fontSize: buttonTokens.large.iconFontSize, height: buttonTokens.large.iconHeight, + width: buttonTokens.large.iconWidth, }; }, }); diff --git a/packages/react-button/src/components/CompoundButton/CompoundButton.tsx b/packages/react-button/src/components/CompoundButton/CompoundButton.tsx new file mode 100644 index 00000000000000..418e16e948b4cf --- /dev/null +++ b/packages/react-button/src/components/CompoundButton/CompoundButton.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { CompoundButtonProps, CompoundButtonStyleSelectors } from './CompoundButton.types'; +import { useCompoundButton } from './useCompoundButton'; +import { useCompoundButtonStyles } from './useCompoundButtonStyles'; +import { renderCompoundButton } from './renderCompoundButton'; + +/** + * Define a styled CompoundButton, using the `useCompoundButton` hook. + * {@docCategory Button} + */ +export const CompoundButton = React.forwardRef((props, ref) => { + const state = useCompoundButton(props, ref); + + const receivedChildren = !!state.children?.children; + const receivedIcon = !!state.icon?.children; + + const styleSelectors: CompoundButtonStyleSelectors = { + disabled: state.disabled, + primary: state.primary, + iconOnly: receivedIcon && !receivedChildren, + size: state.size, + }; + + useCompoundButtonStyles(state, styleSelectors); + + return renderCompoundButton(state); +}); + +CompoundButton.displayName = 'CompoundButton'; diff --git a/packages/react-button/src/components/CompoundButton/CompoundButton.types.ts b/packages/react-button/src/components/CompoundButton/CompoundButton.types.ts new file mode 100644 index 00000000000000..c3f9fcc3a0fc8a --- /dev/null +++ b/packages/react-button/src/components/CompoundButton/CompoundButton.types.ts @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { ObjectShorthandProps, ShorthandProps } from '@fluentui/react-utilities'; +import { ButtonProps, ButtonState, ButtonStyleSelectors, ButtonTokens, ButtonVariants } from '../Button/Button.types'; + +/** + * {@docCategory Button} + */ +export interface CompoundButtonProps extends ButtonProps { + /** + * Second line of text that describes the action this button takes. + */ + secondaryContent?: ShorthandProps>; + + /** + * Container that wraps the children and secondaryContent slots. + */ + contentContainer?: ShorthandProps>; +} + +/** + * {@docCategory Button} + */ +export interface CompoundButtonState extends Omit, ButtonState { + contentContainer?: ObjectShorthandProps>; + secondaryContent?: ObjectShorthandProps>; +} + +/** + * {@docCategory Button} + */ +export type CompoundButtonStyleSelectors = ButtonStyleSelectors; + +type CompoundButtonBaseTokens = { + secondaryContentColor: string; + secondaryContentFontSize: string; + secondaryContentFontWeight: string | number; + secondaryContentGap: string; +}; + +/** + * {@docCategory Button} + */ +export type CompoundButtonTokens = ButtonTokens & + CompoundButtonBaseTokens & { + hovered: Partial; + pressed: Partial; + }; + +/** + * {@docCategory Button} + */ +export type CompoundButtonVariants = ButtonVariants; + +/** + * {@docCategory Button} + */ +export type CompoundButtonVariantTokens = { + [variant in CompoundButtonVariants]: Partial; +}; diff --git a/packages/react-button/src/components/CompoundButton/index.ts b/packages/react-button/src/components/CompoundButton/index.ts new file mode 100644 index 00000000000000..31991820ecfe76 --- /dev/null +++ b/packages/react-button/src/components/CompoundButton/index.ts @@ -0,0 +1,5 @@ +export * from './CompoundButton'; +export * from './CompoundButton.types'; +export * from './renderCompoundButton'; +export * from './useCompoundButton'; +export { useCompoundButtonStyles } from './useCompoundButtonStyles'; diff --git a/packages/react-button/src/components/CompoundButton/renderCompoundButton.tsx b/packages/react-button/src/components/CompoundButton/renderCompoundButton.tsx new file mode 100644 index 00000000000000..4363bd8fc2878a --- /dev/null +++ b/packages/react-button/src/components/CompoundButton/renderCompoundButton.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import { CompoundButtonState } from './CompoundButton.types'; +import { compoundButtonShorthandProps } from './useCompoundButton'; + +/** + * Define the render function. Given the state of a button, renders it. + */ +export const renderCompoundButton = (state: CompoundButtonState) => { + const { slots, slotProps } = getSlots(state, compoundButtonShorthandProps); + const { /*loading,*/ iconPosition, iconOnly } = state; + + return ( + + {/*loading && */} + {iconPosition !== 'after' && } + {!iconOnly && ( + + + + + )} + {iconPosition === 'after' && } + + ); +}; diff --git a/packages/react-button/src/components/CompoundButton/useCompoundButton.ts b/packages/react-button/src/components/CompoundButton/useCompoundButton.ts new file mode 100644 index 00000000000000..dc4261a841432d --- /dev/null +++ b/packages/react-button/src/components/CompoundButton/useCompoundButton.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { makeMergeProps, resolveShorthandProps } from '@fluentui/react-utilities'; +import { CompoundButtonProps, CompoundButtonState } from './CompoundButton.types'; +import { useButtonState } from '../Button/useButtonState'; + +/** + * Consts listing which props are shorthand props. + */ +export const compoundButtonShorthandProps = ['icon', 'children', 'contentContainer', 'secondaryContent']; + +const mergeProps = makeMergeProps({ + deepMerge: compoundButtonShorthandProps, +}); + +/** + * Given user props, returns state and render function for a Button. + */ +export const useCompoundButton = ( + props: CompoundButtonProps, + ref: React.Ref, + defaultProps?: CompoundButtonProps, +): CompoundButtonState => { + // Ensure that the `ref` prop can be used by other things (like useFocusRects) to refer to the root. + // NOTE: We are assuming refs should not mutate to undefined. Either they are passed or not. + // eslint-disable-next-line react-hooks/rules-of-hooks + const resolvedRef = ref || React.useRef(); + const state = mergeProps( + { + ref: resolvedRef, + as: 'button', + // Slots inherited from Button + icon: { as: 'span' }, + loader: { as: 'span' }, + // Slots exclusive to CompoundButton + contentContainer: { as: 'span', children: null }, + secondaryContent: { as: 'span' }, + }, + defaultProps, + resolveShorthandProps(props, compoundButtonShorthandProps), + ); + + useButtonState(state); + + return state; +}; diff --git a/packages/react-button/src/components/CompoundButton/useCompoundButtonStyles.ts b/packages/react-button/src/components/CompoundButton/useCompoundButtonStyles.ts new file mode 100644 index 00000000000000..4d4aab0447c591 --- /dev/null +++ b/packages/react-button/src/components/CompoundButton/useCompoundButtonStyles.ts @@ -0,0 +1,271 @@ +import { ax, makeStyles } from '@fluentui/react-make-styles'; +import { Theme } from '@fluentui/react-theme'; +import { buttonSpacing, useButtonStyles } from '../Button/useButtonStyles'; +import { CompoundButtonState, CompoundButtonStyleSelectors, CompoundButtonVariantTokens } from './CompoundButton.types'; + +export const makeCompoundButtonTokens = (theme: Theme): CompoundButtonVariantTokens => ({ + base: { + // root tokens + height: 'auto', + paddingX: buttonSpacing.large, + paddingY: buttonSpacing.large, + + // icon tokens + iconFontSize: '40px', + iconSpacing: buttonSpacing.large, + iconHeight: '40px', + iconWidth: '40px', + + // secondary content tokens + secondaryContentColor: theme.alias.color.neutral.neutralForeground2, + secondaryContentFontSize: theme.global.type.fontSizes.base[200], + secondaryContentFontWeight: theme.global.type.fontWeights.regular, + secondaryContentGap: buttonSpacing.smaller, + + hovered: { + secondaryContentColor: theme.alias.color.neutral.neutralForeground2Hover, + }, + + pressed: { + secondaryContentColor: theme.alias.color.neutral.neutralForeground2Pressed, + }, + }, + disabled: { + secondaryContentColor: theme.alias.color.neutral.neutralForegroundDisabled, + + hovered: { + secondaryContentColor: theme.alias.color.neutral.neutralForeground2Hover, + }, + + pressed: { + secondaryContentColor: theme.alias.color.neutral.neutralForeground2Pressed, + }, + }, + small: { + paddingX: buttonSpacing.medium, + paddingY: buttonSpacing.medium, + + fontSize: theme.global.type.fontSizes.base[300], + lineHeight: theme.global.type.lineHeights.base[300], + }, + large: { + paddingX: buttonSpacing.larger, + paddingY: buttonSpacing.larger, + + secondaryContentFontSize: theme.global.type.fontSizes.base[300], + }, + iconOnly: { + maxWidth: '52px', + minWidth: '52px', + }, + iconOnlySmall: { + maxWidth: '48px', + minWidth: '48px', + }, + iconOnlyLarge: { + maxWidth: '56px', + minWidth: '56px', + }, + primary: { + secondaryContentColor: theme.alias.color.neutral.neutralForegroundInvertedAccessible, + + hovered: { + secondaryContentColor: theme.alias.color.neutral.neutralForegroundInvertedAccessible, + }, + + pressed: { + secondaryContentColor: theme.alias.color.neutral.neutralForegroundInvertedAccessible, + }, + }, + primaryDisabled: {}, +}); + +const useStyles = makeStyles({ + root: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + gap: compoundButtonTokens.base.iconSpacing, + height: compoundButtonTokens.base.height, + padding: `${compoundButtonTokens.base.paddingY} ${compoundButtonTokens.base.paddingX}`, + }; + }, + rootSmall: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + padding: `${compoundButtonTokens.small.paddingY} ${compoundButtonTokens.small.paddingX}`, + }; + }, + rootLarge: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + padding: `${compoundButtonTokens.large.paddingY} ${compoundButtonTokens.large.paddingX}`, + }; + }, + rootIconOnly: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + maxWidth: compoundButtonTokens.iconOnly.maxWidth, + minWidth: compoundButtonTokens.iconOnly.minWidth, + }; + }, + rootIconOnlySmall: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + maxWidth: compoundButtonTokens.iconOnlySmall.maxWidth, + minWidth: compoundButtonTokens.iconOnlySmall.minWidth, + }; + }, + rootIconOnlyLarge: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + maxWidth: compoundButtonTokens.iconOnlyLarge.maxWidth, + minWidth: compoundButtonTokens.iconOnlyLarge.minWidth, + }; + }, + childrenSmall: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + fontSize: compoundButtonTokens.small.fontSize, + lineHeight: compoundButtonTokens.small.lineHeight, + }; + }, + icon: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + fontSize: compoundButtonTokens.base.iconFontSize, + height: compoundButtonTokens.base.iconHeight, + width: compoundButtonTokens.base.iconWidth, + }; + }, + contentContainer: { + display: 'flex', + flexDirection: 'column', + textAlign: 'left', + }, + secondaryContent: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + lineHeight: '100%', + + color: compoundButtonTokens.base.secondaryContentColor, + fontSize: compoundButtonTokens.base.secondaryContentFontSize, + fontWeight: compoundButtonTokens.base.secondaryContentFontWeight, + marginTop: compoundButtonTokens.base.secondaryContentGap, + + ':hover': { + color: + compoundButtonTokens.base.hovered?.secondaryContentColor || compoundButtonTokens.base.secondaryContentColor, + }, + + ':active': { + color: + compoundButtonTokens.base.pressed?.secondaryContentColor || + compoundButtonTokens.base.hovered?.secondaryContentColor || + compoundButtonTokens.base.secondaryContentColor, + }, + }; + }, + secondaryContentLarge: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + fontSize: compoundButtonTokens.large.secondaryContentFontSize, + }; + }, + secondaryContentPrimary: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + color: compoundButtonTokens.primary.secondaryContentColor, + + ':hover': { + color: compoundButtonTokens.primary.hovered?.secondaryContentColor, + }, + + ':active': { + color: compoundButtonTokens.primary.pressed?.secondaryContentColor, + }, + }; + }, + secondaryContentDisabled: theme => { + const compoundButtonTokens = makeCompoundButtonTokens(theme); + + return { + color: compoundButtonTokens.disabled.secondaryContentColor, + + ':hover': { + color: compoundButtonTokens.disabled.hovered?.secondaryContentColor, + }, + + ':active': { + color: compoundButtonTokens.disabled.pressed?.secondaryContentColor, + }, + }; + }, +}); + +export const useCompoundButtonStyles = (state: CompoundButtonState, selectors: CompoundButtonStyleSelectors) => { + // Save the class names used in useButtonStyles and undefine them at the state level so that they are always applied + // last. + const { + className: rootClassName, + children: { className: childrenClassName } = { className: undefined }, + icon: { className: iconClassName } = { className: undefined }, + } = state; + state.className = undefined; + if (state.children) { + state.children.className = undefined; + } + if (state.icon) { + state.icon.className = undefined; + } + useButtonStyles(state, selectors); + + const styles = useStyles(); + + state.className = ax( + state.className, + styles.root, + selectors.size === 'small' && styles.rootSmall, + selectors.size === 'large' && styles.rootLarge, + selectors.iconOnly && styles.rootIconOnly, + selectors.iconOnly && selectors.size === 'small' && styles.rootIconOnlySmall, + selectors.iconOnly && selectors.size === 'large' && styles.rootIconOnlyLarge, + rootClassName, + ); + + if (state.children) { + state.children.className = ax( + state.children.className, + selectors.size === 'small' && styles.childrenSmall, + childrenClassName, + ); + } + + if (state.icon) { + state.icon.className = ax(state.icon.className, styles.icon, iconClassName); + } + + if (state.contentContainer) { + state.contentContainer.className = ax(styles.contentContainer, state.contentContainer.className); + } + + if (state.secondaryContent) { + state.secondaryContent.className = ax( + styles.secondaryContent, + selectors.size === 'large' && styles.secondaryContentLarge, + selectors.primary && styles.secondaryContentPrimary, + selectors.disabled && styles.secondaryContentDisabled, + state.secondaryContent.className, + ); + } +}; diff --git a/packages/react-button/src/index.ts b/packages/react-button/src/index.ts index 8b166a86e4df1f..ba51d4c104dbac 100644 --- a/packages/react-button/src/index.ts +++ b/packages/react-button/src/index.ts @@ -1 +1,2 @@ export * from './Button'; +export * from './CompoundButton'; diff --git a/packages/react-examples/src/react-button/Button/Button.stories.tsx b/packages/react-examples/src/react-button/Button/Button.stories.tsx index 5e4af8c68ad814..032824308ebd03 100644 --- a/packages/react-examples/src/react-button/Button/Button.stories.tsx +++ b/packages/react-examples/src/react-button/Button/Button.stories.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Button, ButtonProps } from '@fluentui/react-button'; +import { Playground, PlaygroundProps, PropDefinition } from '../Playground'; // TODO: this is here while waiting for react-icons to merge const SVGIcon = () => ( @@ -98,3 +99,26 @@ export const Disabled = () => ( > ); + +export const buttonBaseProps: PropDefinition[] = [ + { propName: 'content', propType: 'string', defaultValue: 'This is a button', dependsOnProps: ['~iconOnly'] }, + { propName: 'disabled', propType: 'boolean' }, + { propName: 'icon', propType: 'boolean' }, + { propName: 'iconOnly', propType: 'boolean', dependsOnProps: ['icon'] }, + { + propName: 'iconPosition', + propType: ['before', 'after'], + defaultValue: 'before', + dependsOnProps: ['icon', '~iconOnly'], + }, + { propName: 'primary', propType: 'boolean' }, + { propName: 'size', propType: ['small', 'medium', 'large'], defaultValue: 'medium' }, +]; + +const buttonProps: PlaygroundProps['sections'] = [{ sectionName: 'Button props', propList: buttonBaseProps }]; + +export const ButtonPlayground = () => ( + + + +); diff --git a/packages/react-examples/src/react-button/CompoundButton/CompoundButton.stories.tsx b/packages/react-examples/src/react-button/CompoundButton/CompoundButton.stories.tsx new file mode 100644 index 00000000000000..2b870ebbfbb4d1 --- /dev/null +++ b/packages/react-examples/src/react-button/CompoundButton/CompoundButton.stories.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { buttonBaseProps } from '../Button/Button.stories'; +import { Playground, PlaygroundProps, PropDefinition } from '../Playground'; + +import { CompoundButton } from '@fluentui/react-button'; + +const compoundButtonBaseProps: PropDefinition[] = [ + { + propName: 'secondaryContent', + propType: 'string', + defaultValue: 'This is the secondary content', + dependsOnProps: ['~iconOnly'], + }, +]; + +const compoundButtonProps: PlaygroundProps['sections'] = [ + { sectionName: 'Button props', propList: buttonBaseProps }, + { sectionName: 'CompoundButton props', propList: compoundButtonBaseProps }, +]; + +export const CompoundButtonPlayground = () => ( + + + +); diff --git a/packages/react-examples/src/react-button/Playground.tsx b/packages/react-examples/src/react-button/Playground.tsx new file mode 100644 index 00000000000000..a2cf4191bbf2f6 --- /dev/null +++ b/packages/react-examples/src/react-button/Playground.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { Checkbox, Dropdown, IDropdownOption, Stack, TextField } from '@fluentui/react'; +import { Text } from '@fluentui/react-text'; + +/* eslint-disable @typescript-eslint/naming-convention */ + +export interface PropDefinition { + propName: string; + propType: 'boolean' | 'string' | string[]; + defaultValue?: boolean | string; + dependsOnProps?: string[]; +} + +export interface PlaygroundProps { + children: JSX.Element; + sections: Array<{ + sectionName: string; + propList: PropDefinition[]; + }>; +} + +const tableStyle: React.CSSProperties = { + border: '1px solid black', +}; +const cellStyle: React.CSSProperties = { + border: '1px solid black', + padding: '5px', +}; + +export const Playground = (props: PlaygroundProps): JSX.Element => { + const { children, sections } = props; + + const [componentProps, setComponentProps] = React.useState<{ [key in string]: boolean | string } | null>(null); + const newProps: { [key in string]: boolean | string } = {}; + + const playgroundSections: JSX.Element[] = []; + + for (const section of sections) { + const sectionList: JSX.Element[] = []; + for (const prop of section.propList) { + const propType = prop.propType; + let isPropEnabled = true; + + if (componentProps && prop.dependsOnProps) { + for (const dependentProp of prop.dependsOnProps) { + isPropEnabled = + isPropEnabled && + (dependentProp[0] === '~' ? !componentProps[dependentProp.substr(1)] : !!componentProps[dependentProp]); + } + } + + if (propType === 'boolean') { + newProps[prop.propName] = prop.defaultValue || false; + + const onBooleanPropChange = (ev?: React.FormEvent, checked?: boolean) => { + const newComponentProps: { [key in string]: boolean | string } = { ...componentProps }; + newComponentProps[prop.propName] = checked || false; + setComponentProps(newComponentProps); + }; + + sectionList.push( + + {prop.propName}: + + + + , + ); + } else if (propType === 'string') { + newProps[prop.propName] = prop.defaultValue || ''; + + const onStringPropChange = ( + ev?: React.FormEvent, + newValue?: string, + ) => { + const newComponentProps: { [key in string]: boolean | string } = { ...componentProps }; + newComponentProps[prop.propName] = newValue || ''; + setComponentProps(newComponentProps); + }; + + sectionList.push( + + {prop.propName}: + + + + , + ); + } else { + const defaultSelectedKey = prop.defaultValue || propType[0]; + newProps[prop.propName] = prop.defaultValue || propType[0]; + + const onOptionsPropChange = ( + ev?: React.FormEvent, + option?: IDropdownOption, + index?: number, + ) => { + const newComponentProps: { [key in string]: boolean | string } = { ...componentProps }; + if (option) { + newComponentProps[prop.propName] = (option.key as string) || ''; + setComponentProps(newComponentProps); + } + }; + + sectionList.push( + + {prop.propName}: + + ({ key: value, text: value }))} + // eslint-disable-next-line react/jsx-no-bind + onChange={onOptionsPropChange} + /> + + , + ); + } + } + playgroundSections.push( + <> + + + {section.sectionName} + + + {sectionList} + >, + ); + } + + if (componentProps === null) { + setComponentProps(newProps); + } + + const elementProps = { + ...componentProps, + children: componentProps && !componentProps.iconOnly && !componentProps.children && componentProps.content, + icon: componentProps && componentProps.icon ? 'x' : undefined, + }; + + return ( + <> + {React.cloneElement(children, elementProps || {})} + + {playgroundSections} + + > + ); +};