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;
+};