diff --git a/apps/vr-tests-web-components/package.json b/apps/vr-tests-web-components/package.json index 9344068688569..f02a15f3f8eb5 100644 --- a/apps/vr-tests-web-components/package.json +++ b/apps/vr-tests-web-components/package.json @@ -10,7 +10,7 @@ "lint": "ESLINT_USE_FLAT_CONFIG=false eslint src --ext .ts,.tsx", "start": "storybook dev", "type-check": "tsc -p . --baseUrl . --noEmit", - "test-vr": "storywright --browsers chromium --url dist/storybook --destpath dist/screenshots --waitTimeScreenshot 500 --concurrency 4 --headless true --stepsApi parameters" + "test-vr": "storywright --browsers chromium --url dist/storybook --destpath dist/screenshots --waitTimeScreenshot 500 --concurrency 4 --headless true --stepsApi parameters --bailOnStoriesError" }, "dependencies": { "react": "19.2.0", diff --git a/apps/vr-tests-web-components/src/stories/checkbox/checkbox-indeterminate.stories.tsx b/apps/vr-tests-web-components/src/stories/checkbox/checkbox-indeterminate.stories.tsx index eca4c88a30974..041c08005ca73 100644 --- a/apps/vr-tests-web-components/src/stories/checkbox/checkbox-indeterminate.stories.tsx +++ b/apps/vr-tests-web-components/src/stories/checkbox/checkbox-indeterminate.stories.tsx @@ -22,7 +22,7 @@ export default { steps: new Steps() .snapshot('normal', { cropTo: '.testWrapper' }) .executeScript( - 'document.getElementsByTagName("fluent-checkbox").forEach(checkbox => checkbox.indeterminate = true)', + 'Array.from(document.getElementsByTagName("fluent-checkbox")).forEach(checkbox => checkbox.indeterminate = true)', ) .snapshot('indeterminate', { cropTo: '.testWrapper' }) .end(), diff --git a/apps/vr-tests-web-components/src/stories/link/link.stories.tsx b/apps/vr-tests-web-components/src/stories/link/link.stories.tsx index 495b6776a72b1..18a5590899161 100644 --- a/apps/vr-tests-web-components/src/stories/link/link.stories.tsx +++ b/apps/vr-tests-web-components/src/stories/link/link.stories.tsx @@ -46,7 +46,7 @@ export const WithLongText = () =>

This paragraph contains a link which is very long. - Fluent links wrap correctly between lines when they are very long. This is + Fluent links wrap correctly between lines when they are very long. This is because they are inline elements.

`); diff --git a/apps/vr-tests-web-components/src/stories/menu-list/menu-list.stories.tsx b/apps/vr-tests-web-components/src/stories/menu-list/menu-list.stories.tsx index d164e0bc48823..72894ce07e228 100644 --- a/apps/vr-tests-web-components/src/stories/menu-list/menu-list.stories.tsx +++ b/apps/vr-tests-web-components/src/stories/menu-list/menu-list.stories.tsx @@ -175,14 +175,14 @@ export const RadioWithIconsRTL = getStoryVariant(RadioWithIcons, RTL); export const WithSubmenu = () => parse(` - + Item 1 Subitem 1.1 Subitem 1.2 - + Item 2 Subitem 2.1 diff --git a/apps/vr-tests-web-components/src/stories/radio-group/radio-group.stories.tsx b/apps/vr-tests-web-components/src/stories/radio-group/radio-group.stories.tsx index bce4adf959d61..5e9837e371a1c 100644 --- a/apps/vr-tests-web-components/src/stories/radio-group/radio-group.stories.tsx +++ b/apps/vr-tests-web-components/src/stories/radio-group/radio-group.stories.tsx @@ -22,9 +22,9 @@ export default { storyWright: { steps: new Steps() .snapshot('normal', { cropTo: '.testWrapper' }) - .click('[role="radio"]:first-child') + .click('fluent-radio:first-child') .snapshot('1st selected', { cropTo: '.testWrapper' }) - .click('[role="radio"]:nth-child(2)') + .click('fluent-radio:nth-child(2)') .snapshot('2nd selected', { cropTo: '.testWrapper' }) .end(), }, diff --git a/apps/vr-tests-web-components/src/stories/slider/slider.stories.tsx b/apps/vr-tests-web-components/src/stories/slider/slider.stories.tsx index 05bb73009251a..395f6783a772f 100644 --- a/apps/vr-tests-web-components/src/stories/slider/slider.stories.tsx +++ b/apps/vr-tests-web-components/src/stories/slider/slider.stories.tsx @@ -19,8 +19,8 @@ export default { storyWright: { steps: new Steps() .snapshot('normal', { cropTo: '.testWrapper' }) - .focus('[role="slider"]') - .keys('[role="slider"]', Keys.rightArrow) + .focus('fluent-slider') + .keys('fluent-slider', Keys.rightArrow) .snapshot('rightArrow', { cropTo: '.testWrapper' }) .end(), }, diff --git a/change/@fluentui-react-button-5368ba44-0210-43a8-9960-27fb5a61f347.json b/change/@fluentui-react-button-5368ba44-0210-43a8-9960-27fb5a61f347.json new file mode 100644 index 0000000000000..efa4bc34f8bdf --- /dev/null +++ b/change/@fluentui-react-button-5368ba44-0210-43a8-9960-27fb5a61f347.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: introduce headless style hooks for button components", + "packageName": "@fluentui/react-button", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/monosize.config.mjs b/monosize.config.mjs index 38c713396a12d..a30fed8bfc4b7 100644 --- a/monosize.config.mjs +++ b/monosize.config.mjs @@ -13,6 +13,8 @@ const config = { tableName: 'fluentuilatest', }), bundler: webpackBundler(config => { + config.resolve ??= {}; + config.resolve.extensions = ['.headless.js', '.js', '.jsx', '.ts', '.tsx']; return config; }), reportResolvers: { diff --git a/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.headless.ts b/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.headless.ts new file mode 100644 index 0000000000000..621ced016cd5c --- /dev/null +++ b/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.headless.ts @@ -0,0 +1,51 @@ +'use client'; + +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { ButtonSlots, ButtonState } from './Button.types'; + +export const buttonClassNames: SlotClassNames = { + root: 'fui-Button', + icon: 'fui-Button__icon', +}; + +/** + * Attaches only semantic slot class names and state modifiers + */ +export const useButtonStyles_unstable = (state: ButtonState): ButtonState => { + 'use no memo'; + + const { appearance, disabled, disabledFocusable, icon, iconOnly, iconPosition, shape, size } = state; + + state.root.className = [ + buttonClassNames.root, + + // Appearance + appearance && `${buttonClassNames.root}--${appearance}`, + + // Size + `${buttonClassNames.root}--${size}`, + + // Shape + `${buttonClassNames.root}--${shape}`, + + // Disabled styles + disabled && `${buttonClassNames.root}--disabled`, + disabledFocusable && `${buttonClassNames.root}--disabledFocusable`, + + // Icon styles + icon && iconPosition === 'before' && `${buttonClassNames.root}--iconBefore`, + icon && iconPosition === 'after' && `${buttonClassNames.root}--iconAfter`, + iconOnly && `${buttonClassNames.root}--iconOnly`, + + // User provided class name + state.root.className, + ] + .filter(Boolean) + .join(' '); + + if (state.icon) { + state.icon.className = [buttonClassNames.icon, state.icon.className].filter(Boolean).join(' '); + } + + return state; +}; diff --git a/packages/react-components/react-button/library/src/components/CompoundButton/useCompoundButtonStyles.styles.headless.ts b/packages/react-components/react-button/library/src/components/CompoundButton/useCompoundButtonStyles.styles.headless.ts new file mode 100644 index 0000000000000..7ad9383b509ba --- /dev/null +++ b/packages/react-components/react-button/library/src/components/CompoundButton/useCompoundButtonStyles.styles.headless.ts @@ -0,0 +1,64 @@ +'use client'; + +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { CompoundButtonSlots, CompoundButtonState } from './CompoundButton.types'; + +// Re-export the same slot class names mapping used by the griffel styles file +export const compoundButtonClassNames: SlotClassNames = { + root: 'fui-CompoundButton', + icon: 'fui-CompoundButton__icon', + contentContainer: 'fui-CompoundButton__contentContainer', + secondaryContent: 'fui-CompoundButton__secondaryContent', +}; + +/** + * Attaches only semantic slot class names and state modifiers + */ +export const useCompoundButtonStyles_unstable = (state: CompoundButtonState): CompoundButtonState => { + 'use no memo'; + + const { appearance, disabled, disabledFocusable, icon, iconOnly, iconPosition, shape, size } = state; + + state.root.className = [ + compoundButtonClassNames.root, + + // Appearance + appearance && `${compoundButtonClassNames.root}--${appearance}`, + + // Size + size && `${compoundButtonClassNames.root}--${size}`, + + // Shape + shape && `${compoundButtonClassNames.root}--${shape}`, + + // Disabled styles + disabled && `${compoundButtonClassNames.root}--disabled`, + disabledFocusable && `${compoundButtonClassNames.root}--disabledFocusable`, + + // Icon styles + icon && iconPosition === 'before' && `${compoundButtonClassNames.root}--iconBefore`, + icon && iconPosition === 'after' && `${compoundButtonClassNames.root}--iconAfter`, + icon && iconOnly && `${compoundButtonClassNames.root}--iconOnly`, + + // User provided class name + state.root.className, + ] + .filter(Boolean) + .join(' '); + + if (state.icon) { + state.icon.className = [compoundButtonClassNames.icon, state.icon.className].filter(Boolean).join(' '); + } + + state.contentContainer.className = [compoundButtonClassNames.contentContainer, state.contentContainer.className] + .filter(Boolean) + .join(' '); + + if (state.secondaryContent) { + state.secondaryContent.className = [compoundButtonClassNames.secondaryContent, state.secondaryContent.className] + .filter(Boolean) + .join(' '); + } + + return state; +}; diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonStyles.styles.headless.ts b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonStyles.styles.headless.ts new file mode 100644 index 0000000000000..735d2024ca0a2 --- /dev/null +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonStyles.styles.headless.ts @@ -0,0 +1,58 @@ +'use client'; + +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { MenuButtonSlots, MenuButtonState } from './MenuButton.types'; + +export const menuButtonClassNames: SlotClassNames = { + root: 'fui-MenuButton', + icon: 'fui-MenuButton__icon', + menuIcon: 'fui-MenuButton__menuIcon', +}; + +/** + * Attaches only semantic slot class names and state modifiers + */ +export const useMenuButtonStyles_unstable = (state: MenuButtonState): MenuButtonState => { + 'use no memo'; + + const { appearance, disabled, disabledFocusable, shape, size, icon, iconOnly } = state; + const expanded = !!state.root['aria-expanded']; + + state.root.className = [ + menuButtonClassNames.root, + + // Appearance + appearance && `${menuButtonClassNames.root}--${appearance}`, + + // Size + size && `${menuButtonClassNames.root}--${size}`, + + // Shape + shape && `${menuButtonClassNames.root}--${shape}`, + + // Disabled styles + disabled && `${menuButtonClassNames.root}--disabled`, + disabledFocusable && `${menuButtonClassNames.root}--disabledFocusable`, + + // Expanded + expanded && `${menuButtonClassNames.root}--expanded`, + + // Icons + icon && iconOnly && `${menuButtonClassNames.root}--iconOnly`, + + // User provided class name + state.root.className, + ] + .filter(Boolean) + .join(' '); + + if (state.icon) { + state.icon.className = [menuButtonClassNames.icon, state.icon.className].filter(Boolean).join(' '); + } + + if (state.menuIcon) { + state.menuIcon.className = [menuButtonClassNames.menuIcon, state.menuIcon.className].filter(Boolean).join(' '); + } + + return state; +}; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonStyles.styles.headless.ts b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonStyles.styles.headless.ts new file mode 100644 index 0000000000000..2822d6f7df43a --- /dev/null +++ b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonStyles.styles.headless.ts @@ -0,0 +1,58 @@ +'use client'; + +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { SplitButtonSlots, SplitButtonState } from './SplitButton.types'; + +export const splitButtonClassNames: SlotClassNames = { + root: 'fui-SplitButton', + menuButton: 'fui-SplitButton__menuButton', + primaryActionButton: 'fui-SplitButton__primaryActionButton', +}; + +/** + * Attaches only semantic slot class names and state modifiers + */ +export const useSplitButtonStyles_unstable = (state: SplitButtonState): SplitButtonState => { + 'use no memo'; + + const { appearance, disabled, disabledFocusable, shape, size } = state; + + state.root.className = [ + splitButtonClassNames.root, + + // Appearance + appearance && `${splitButtonClassNames.root}--${appearance}`, + + // Size + size && `${splitButtonClassNames.root}--${size}`, + + // Shape + shape && `${splitButtonClassNames.root}--${shape}`, + + // Disabled styles + disabled && `${splitButtonClassNames.root}--disabled`, + disabledFocusable && !disabled && `${splitButtonClassNames.root}--disabledFocusable`, + + // User provided class name + state.root.className, + ] + .filter(Boolean) + .join(' '); + + if (state.primaryActionButton) { + state.primaryActionButton.className = [ + splitButtonClassNames.primaryActionButton, + state.primaryActionButton.className, + ] + .filter(Boolean) + .join(' '); + } + + if (state.menuButton) { + state.menuButton.className = [splitButtonClassNames.menuButton, state.menuButton.className] + .filter(Boolean) + .join(' '); + } + + return state; +}; diff --git a/packages/react-components/react-button/library/src/components/ToggleButton/useToggleButtonStyles.styles.headless.ts b/packages/react-components/react-button/library/src/components/ToggleButton/useToggleButtonStyles.styles.headless.ts new file mode 100644 index 0000000000000..20500ab961eeb --- /dev/null +++ b/packages/react-components/react-button/library/src/components/ToggleButton/useToggleButtonStyles.styles.headless.ts @@ -0,0 +1,53 @@ +'use client'; + +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { ButtonSlots } from '../Button/Button.types'; +import type { ToggleButtonState } from './ToggleButton.types'; + +export const toggleButtonClassNames: SlotClassNames = { + root: 'fui-ToggleButton', + icon: 'fui-ToggleButton__icon', +}; + +/** + * Attaches only semantic slot class names and state modifiers + */ +export const useToggleButtonStyles_unstable = (state: ToggleButtonState): ToggleButtonState => { + 'use no memo'; + + const { appearance, disabled, disabledFocusable, shape, size, checked, iconOnly } = state; + + state.root.className = [ + toggleButtonClassNames.root, + + // Appearance + appearance && `${toggleButtonClassNames.root}--${appearance}`, + + // Size + size && `${toggleButtonClassNames.root}--${size}`, + + // Shape + shape && `${toggleButtonClassNames.root}--${shape}`, + + // Checked + checked && `${toggleButtonClassNames.root}--checked`, + + // Icons + iconOnly && `${toggleButtonClassNames.root}--iconOnly`, + + // Disabled + disabled && `${toggleButtonClassNames.root}--disabled`, + disabledFocusable && `${toggleButtonClassNames.root}--disabledFocusable`, + + // User provided class name + state.root.className, + ] + .filter(Boolean) + .join(' '); + + if (state.icon) { + state.icon.className = [toggleButtonClassNames.icon, state.icon.className].filter(Boolean).join(' '); + } + + return state; +};