diff --git a/apps/vr-tests-react-components/src/stories/Tabs.stories.tsx b/apps/vr-tests-react-components/src/stories/Tabs.stories.tsx index 6924e258e895c9..409afb8012da87 100644 --- a/apps/vr-tests-react-components/src/stories/Tabs.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Tabs.stories.tsx @@ -206,3 +206,31 @@ export const WithIconOnlyAndVertical = () => ( ); WithIconOnlyAndVertical.storyName = 'With icon only and vertical'; + +export const SubtleCircularAppearance = () => ( + + First + + Second + + Third + +); + +export const SubtleCircularAppearanceDarkMode = getStoryVariant(SubtleCircularAppearance, DARK_MODE); + +export const SubtleCircularAppearanceHighContrast = getStoryVariant(SubtleCircularAppearance, HIGH_CONTRAST); + +export const FilledCircularAppearance = () => ( + + First + + Second + + Third + +); + +export const FilledCircularAppearanceDarkMode = getStoryVariant(FilledCircularAppearance, DARK_MODE); + +export const FilledCircularAppearanceHighContrast = getStoryVariant(FilledCircularAppearance, HIGH_CONTRAST); diff --git a/change/@fluentui-react-tabs-3bd10027-20f4-490c-9384-f87d93d8fe3a.json b/change/@fluentui-react-tabs-3bd10027-20f4-490c-9384-f87d93d8fe3a.json new file mode 100644 index 00000000000000..376fa2cd8307c1 --- /dev/null +++ b/change/@fluentui-react-tabs-3bd10027-20f4-490c-9384-f87d93d8fe3a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feature: add rounded tab variant", + "packageName": "@fluentui/react-tabs", + "email": "kirpadv@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-tabs/library/etc/react-tabs.api.md b/packages/react-components/react-tabs/library/etc/react-tabs.api.md index cefd7fea77e99e..ac9c5249cf0af4 100644 --- a/packages/react-components/react-tabs/library/etc/react-tabs.api.md +++ b/packages/react-components/react-tabs/library/etc/react-tabs.api.md @@ -66,7 +66,7 @@ export type TabListContextValues = { // @public export type TabListProps = ComponentProps & { - appearance?: 'transparent' | 'subtle'; + appearance?: 'transparent' | 'subtle' | 'subtle-circular' | 'filled-circular'; reserveSelectedTabSpace?: boolean; defaultSelectedValue?: TabValue; disabled?: boolean; @@ -109,7 +109,7 @@ export type TabSlots = { // @public export type TabState = ComponentState & Pick & Required> & { - appearance?: 'transparent' | 'subtle'; + appearance?: 'transparent' | 'subtle' | 'subtle-circular' | 'filled-circular'; iconOnly: boolean; selected: boolean; contentReservedSpaceClassName?: string; diff --git a/packages/react-components/react-tabs/library/src/components/Tab/Tab.types.ts b/packages/react-components/react-tabs/library/src/components/Tab/Tab.types.ts index a939cf2435f9b4..a565d5576da60c 100644 --- a/packages/react-components/react-tabs/library/src/components/Tab/Tab.types.ts +++ b/packages/react-components/react-tabs/library/src/components/Tab/Tab.types.ts @@ -50,9 +50,10 @@ export type TabState = ComponentState & Pick & Required> & { /** - * A tab supports 'transparent' and 'subtle' appearance. + * A tab supports 'transparent', 'subtle', `subtle-circular` and `filled-circular` appearance. */ - appearance?: 'transparent' | 'subtle'; + appearance?: 'transparent' | 'subtle' | 'subtle-circular' | 'filled-circular'; + /** * A tab can have only an icon slot filled and no content. */ diff --git a/packages/react-components/react-tabs/library/src/components/Tab/useTabStyles.styles.ts b/packages/react-components/react-tabs/library/src/components/Tab/useTabStyles.styles.ts index e65c7d92baddd0..ecd0084fbed5ce 100644 --- a/packages/react-components/react-tabs/library/src/components/Tab/useTabStyles.styles.ts +++ b/packages/react-components/react-tabs/library/src/components/Tab/useTabStyles.styles.ts @@ -88,89 +88,168 @@ const useRootStyles = makeStyles({ }, transparent: { backgroundColor: tokens.colorTransparentBackground, - ':hover': { + ':enabled:hover': { backgroundColor: tokens.colorTransparentBackgroundHover, }, - ':active': { + ':enabled:active': { backgroundColor: tokens.colorTransparentBackgroundPressed, }, - '& .fui-Tab__icon': { + [`& .${tabClassNames.icon}`]: { color: tokens.colorNeutralForeground2, }, - ':hover .fui-Tab__icon': { + [`:enabled:hover .${tabClassNames.icon}`]: { color: tokens.colorNeutralForeground2Hover, }, - ':active .fui-Tab__icon': { + [`:enabled:active .${tabClassNames.icon}`]: { color: tokens.colorNeutralForeground2Pressed, }, - '& .fui-Tab__content': { + [`& .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground2, }, - ':hover .fui-Tab__content': { + [`:enabled:hover .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground2Hover, }, - ':active .fui-Tab__content': { + [`:enabled:active .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground2Pressed, }, }, subtle: { backgroundColor: tokens.colorSubtleBackground, - ':hover': { + ':enabled:hover': { backgroundColor: tokens.colorSubtleBackgroundHover, }, - ':active': { + ':enabled:active': { backgroundColor: tokens.colorSubtleBackgroundPressed, }, - '& .fui-Tab__icon': { + [`& .${tabClassNames.icon}`]: { color: tokens.colorNeutralForeground2, }, - ':hover .fui-Tab__icon': { + [`:enabled:hover .${tabClassNames.icon}`]: { color: tokens.colorNeutralForeground2Hover, }, - ':active .fui-Tab__icon': { + [`:enabled:active .${tabClassNames.icon}`]: { color: tokens.colorNeutralForeground2Pressed, }, - '& .fui-Tab__content': { + [`& .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground2, }, - ':hover .fui-Tab__content': { + [`:enabled:hover .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground2Hover, }, - ':active .fui-Tab__content': { + [`:enabled:active .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground2Pressed, }, }, disabled: { backgroundColor: tokens.colorTransparentBackground, - '& .fui-Tab__icon': { + [`& .${tabClassNames.icon}`]: { color: tokens.colorNeutralForegroundDisabled, }, - '& .fui-Tab__content': { + [`& .${tabClassNames.content}`]: { color: tokens.colorNeutralForegroundDisabled, }, cursor: 'not-allowed', }, selected: { - '& .fui-Tab__icon': { + [`& .${tabClassNames.icon}`]: { color: tokens.colorCompoundBrandForeground1, }, - ':hover .fui-Tab__icon': { + [`:enabled:hover .${tabClassNames.icon}`]: { color: tokens.colorCompoundBrandForeground1Hover, }, - ':active .fui-Tab__icon': { + [`:enabled:active .${tabClassNames.icon}`]: { color: tokens.colorCompoundBrandForeground1Pressed, }, - '& .fui-Tab__content': { + [`& .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground1, }, - ':hover .fui-Tab__content': { + [`:enabled:hover .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground1Hover, }, - ':active .fui-Tab__content': { + [`:enabled:active .${tabClassNames.content}`]: { color: tokens.colorNeutralForeground1Pressed, }, }, + circular: { + borderRadius: tokens.borderRadiusCircular, + [`& .${tabClassNames.icon}`]: { + color: 'inherit', + }, + [`& .${tabClassNames.content}`]: { + color: 'inherit', + }, + }, + subtleCircular: { + backgroundColor: tokens.colorTransparentBackground, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorTransparentStroke}`, + color: tokens.colorNeutralForeground2, + ':enabled:hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorNeutralStroke1Hover}`, + color: tokens.colorNeutralForeground2Hover, + }, + ':enabled:active': { + backgroundColor: tokens.colorNeutralBackground1Pressed, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorNeutralStroke1Pressed}`, + color: tokens.colorNeutralForeground2Pressed, + }, + }, + subtleCircularSelected: { + backgroundColor: tokens.colorBrandBackground2, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorCompoundBrandStroke}`, + color: tokens.colorBrandForeground2, + ':enabled:hover': { + backgroundColor: tokens.colorBrandBackground2Hover, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorCompoundBrandStrokeHover}`, + color: tokens.colorBrandForeground2Hover, + }, + ':enabled:active': { + backgroundColor: tokens.colorBrandBackground2Pressed, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorCompoundBrandStrokePressed}`, + color: tokens.colorBrandForeground2Pressed, + }, + }, + subtleCircularDisabled: { + backgroundColor: tokens.colorTransparentBackground, + color: tokens.colorNeutralForegroundDisabled, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorTransparentStroke}`, + }, + subtleCircularDisabledSelected: { + border: `solid ${tokens.strokeWidthThin} ${tokens.colorNeutralStrokeDisabled}`, + }, + filledCircular: { + backgroundColor: tokens.colorNeutralBackground3, + color: tokens.colorNeutralForeground2, + ':enabled:hover': { + backgroundColor: tokens.colorNeutralBackground3Hover, + color: tokens.colorNeutralForeground2Hover, + }, + ':enabled:active': { + backgroundColor: tokens.colorNeutralBackground3Pressed, + color: tokens.colorNeutralForeground2Pressed, + }, + }, + filledCircularSelected: { + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + ':enabled:hover': { + backgroundColor: tokens.colorBrandBackgroundHover, + color: tokens.colorNeutralForegroundOnBrand, + }, + ':enabled:active': { + backgroundColor: tokens.colorBrandBackgroundPressed, + color: tokens.colorNeutralForegroundOnBrand, + }, + }, + filledCircularDisabled: { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + border: `solid ${tokens.strokeWidthThin} ${tokens.colorTransparentStroke}`, + color: tokens.colorNeutralForegroundDisabled, + }, + filledCircularDisabledSelected: { + border: `solid ${tokens.strokeWidthThin} ${tokens.colorNeutralStrokeDisabled}`, + }, }); /** @@ -291,20 +370,20 @@ const useActiveIndicatorStyles = makeStyles({ '::after': { backgroundColor: tokens.colorCompoundBrandStroke, }, - ':hover::after': { + ':enabled:hover::after': { backgroundColor: tokens.colorCompoundBrandStrokeHover, }, - ':active::after': { + ':enabled:active::after': { backgroundColor: tokens.colorCompoundBrandStrokePressed, }, '@media (forced-colors: active)': { '::after': { backgroundColor: 'ButtonText', }, - ':hover::after': { + ':enabled:hover::after': { backgroundColor: 'ButtonText', }, - ':active::after': { + ':enabled:active::after': { backgroundColor: 'ButtonText', }, }, @@ -472,35 +551,36 @@ export const useTabIndicatorStyles_unstable = (state: TabState): TabState => { const pendingIndicatorStyles = usePendingIndicatorStyles(); const activeIndicatorStyles = useActiveIndicatorStyles(); - const { disabled, selected, size, vertical } = state; - - state.root.className = mergeClasses( - tabClassNames.root, - rootStyles.root, - - // pending indicator (before pseudo element) - pendingIndicatorStyles.base, - size === 'small' && (vertical ? pendingIndicatorStyles.smallVertical : pendingIndicatorStyles.smallHorizontal), - size === 'medium' && (vertical ? pendingIndicatorStyles.mediumVertical : pendingIndicatorStyles.mediumHorizontal), - size === 'large' && (vertical ? pendingIndicatorStyles.largeVertical : pendingIndicatorStyles.largeHorizontal), - disabled && pendingIndicatorStyles.disabled, - - // active indicator (after pseudo element) - selected && activeIndicatorStyles.base, - selected && !disabled && activeIndicatorStyles.selected, - selected && - size === 'small' && - (vertical ? activeIndicatorStyles.smallVertical : activeIndicatorStyles.smallHorizontal), - selected && - size === 'medium' && - (vertical ? activeIndicatorStyles.mediumVertical : activeIndicatorStyles.mediumHorizontal), - selected && - size === 'large' && - (vertical ? activeIndicatorStyles.largeVertical : activeIndicatorStyles.largeHorizontal), - selected && disabled && activeIndicatorStyles.disabled, - - state.root.className, - ); + const { appearance, disabled, selected, size, vertical } = state; + + const classes: Parameters = [tabClassNames.root, rootStyles.root]; + + if (appearance !== 'subtle-circular' && appearance !== 'filled-circular') { + classes.push( + // pending indicator (before pseudo element) + pendingIndicatorStyles.base, + size === 'small' && (vertical ? pendingIndicatorStyles.smallVertical : pendingIndicatorStyles.smallHorizontal), + size === 'medium' && (vertical ? pendingIndicatorStyles.mediumVertical : pendingIndicatorStyles.mediumHorizontal), + size === 'large' && (vertical ? pendingIndicatorStyles.largeVertical : pendingIndicatorStyles.largeHorizontal), + disabled && pendingIndicatorStyles.disabled, + + // active indicator (after pseudo element) + selected && activeIndicatorStyles.base, + selected && !disabled && activeIndicatorStyles.selected, + selected && + size === 'small' && + (vertical ? activeIndicatorStyles.smallVertical : activeIndicatorStyles.smallHorizontal), + selected && + size === 'medium' && + (vertical ? activeIndicatorStyles.mediumVertical : activeIndicatorStyles.mediumHorizontal), + selected && + size === 'large' && + (vertical ? activeIndicatorStyles.largeVertical : activeIndicatorStyles.largeHorizontal), + selected && disabled && activeIndicatorStyles.disabled, + ); + } + + state.root.className = mergeClasses(...classes, state.root.className); useTabAnimatedIndicatorStyles_unstable(state); @@ -525,16 +605,36 @@ export const useTabButtonStyles_unstable = (state: TabState, slot: TabState['roo const { appearance, disabled, selected, size, vertical } = state; + const isSubtleCircular = appearance === 'subtle-circular'; + const isFilledCircular = appearance === 'filled-circular'; + const isCircular = isSubtleCircular || isFilledCircular; + + const circularAppearance = [ + rootStyles.circular, + // subtle-circular appearance + isSubtleCircular && rootStyles.subtleCircular, + selected && isSubtleCircular && rootStyles.subtleCircularSelected, + disabled && isSubtleCircular && rootStyles.subtleCircularDisabled, + selected && disabled && isSubtleCircular && rootStyles.subtleCircularDisabledSelected, + // filled-circular appearance + isFilledCircular && rootStyles.filledCircular, + selected && isFilledCircular && rootStyles.filledCircularSelected, + disabled && isFilledCircular && rootStyles.filledCircularDisabled, + selected && disabled && isFilledCircular && rootStyles.filledCircularDisabledSelected, + ]; + slot.className = mergeClasses( rootStyles.button, + // orientation vertical ? rootStyles.vertical : rootStyles.horizontal, + // size size === 'small' && (vertical ? rootStyles.smallVertical : rootStyles.smallHorizontal), size === 'medium' && (vertical ? rootStyles.mediumVertical : rootStyles.mediumHorizontal), size === 'large' && (vertical ? rootStyles.largeVertical : rootStyles.largeHorizontal), focusStyles.base, !disabled && appearance === 'subtle' && rootStyles.subtle, !disabled && appearance === 'transparent' && rootStyles.transparent, - !disabled && selected && rootStyles.selected, + ...(isCircular ? circularAppearance : [!disabled && selected && rootStyles.selected]), disabled && rootStyles.disabled, slot.className, diff --git a/packages/react-components/react-tabs/library/src/components/TabList/TabList.types.ts b/packages/react-components/react-tabs/library/src/components/TabList/TabList.types.ts index 16f1feaed473cb..79c84b8ce5d58b 100644 --- a/packages/react-components/react-tabs/library/src/components/TabList/TabList.types.ts +++ b/packages/react-components/react-tabs/library/src/components/TabList/TabList.types.ts @@ -42,10 +42,13 @@ export type TabListProps = ComponentProps & { * A tab list can supports 'transparent' and 'subtle' appearance. *- 'subtle': Minimizes emphasis to blend into the background until hovered or focused. *- 'transparent': No background and border styling + *- 'subtle-circular': Adds background and border styling + *- 'filled-circular': Adds background styling + * * The appearance affects each of the contained tabs. * @default 'transparent' */ - appearance?: 'transparent' | 'subtle'; + appearance?: 'transparent' | 'subtle' | 'subtle-circular' | 'filled-circular'; /** * Tab size may change between unselected and selected states. diff --git a/packages/react-components/react-tabs/library/src/components/TabList/useTabListStyles.styles.ts b/packages/react-components/react-tabs/library/src/components/TabList/useTabListStyles.styles.ts index 00084869116197..4726160d44b5ab 100644 --- a/packages/react-components/react-tabs/library/src/components/TabList/useTabListStyles.styles.ts +++ b/packages/react-components/react-tabs/library/src/components/TabList/useTabListStyles.styles.ts @@ -1,5 +1,6 @@ import { SlotClassNames } from '@fluentui/react-utilities'; import { makeStyles, mergeClasses } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; import type { TabListSlots, TabListState } from './TabList.types'; export const tabListClassNames: SlotClassNames = { @@ -25,6 +26,12 @@ const useStyles = makeStyles({ alignItems: 'stretch', flexDirection: 'column', }, + roundedSmall: { + gap: tokens.spacingHorizontalSNudge, + }, + rounded: { + gap: tokens.spacingHorizontalS, + }, }); /** @@ -33,14 +40,17 @@ const useStyles = makeStyles({ export const useTabListStyles_unstable = (state: TabListState): TabListState => { 'use no memo'; - const { vertical } = state; + const { appearance, vertical, size } = state; const styles = useStyles(); + const isRounded = appearance === 'subtle-circular' || appearance === 'filled-circular'; + state.root.className = mergeClasses( tabListClassNames.root, styles.root, vertical ? styles.vertical : styles.horizontal, + isRounded && (size === 'small' ? styles.roundedSmall : styles.rounded), state.root.className, ); diff --git a/packages/react-components/react-tabs/stories/src/Tabs/TabListAppearance.stories.tsx b/packages/react-components/react-tabs/stories/src/Tabs/TabListAppearance.stories.tsx index 1cda26d74ce74c..b6ba4bcd2acd3a 100644 --- a/packages/react-components/react-tabs/stories/src/Tabs/TabListAppearance.stories.tsx +++ b/packages/react-components/react-tabs/stories/src/Tabs/TabListAppearance.stories.tsx @@ -19,7 +19,9 @@ export const Appearance = () => { return ( <> First Tab - Second Tab + + Second Tab + Third Tab Fourth Tab @@ -34,6 +36,12 @@ export const Appearance = () => { {renderTabs()} + + {renderTabs()} + + + {renderTabs()} + ); }; @@ -41,7 +49,8 @@ export const Appearance = () => { Appearance.parameters = { docs: { description: { - story: 'A tab list can have a `transparent` or `subtle` appearance. The default is `transparent`.', + story: + 'A tab list can have a `transparent`, `subtle`, `subtle-circular` and `filled-circular` appearance. The default is `transparent`.', }, }, };