From 2f5118089eb53fd372cdc3a4e3c554f0318e9e49 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 7 Nov 2025 16:21:08 +0100 Subject: [PATCH 1/4] rfc: unstyled components --- .../convergence/unstyled-components.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md diff --git a/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md b/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md new file mode 100644 index 0000000000000..d8e1ee62d183c --- /dev/null +++ b/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md @@ -0,0 +1,255 @@ +# RFC: Unstyled Components + +## Contributors + +- @dmytrokirpa + +## Summary + +This RFC proposes **unstyled style hook variants** that omit Griffel CSS implementations while preserving base class names (`.fui-[Component]`). This enables partners to use alternative styling solutions (CSS Modules, Tailwind, vanilla CSS) without recomposing components. + +Unstyled variants are opt-in via bundler extension resolution (similar to [raw modules](https://storybooks.fluentui.dev/react/?path=/docs/concepts-developer-unprocessed-styles--docs#how-to-use-raw-modules), ensuring zero breaking changes. + +**Performance Impact:** Internal testing shows **~25% JavaScript bundle size reduction** when using unstyled variants, as Griffel runtime and style implementations are excluded from the bundle. + +## Problem Statement + +Partners want to use Fluent UI v9 with alternative styling solutions but currently must: + +1. Recompose every component manually (high maintenance) +2. Override styles via `className` props (fragile, specificity issues) +3. Use custom style hooks (still depends on Griffel runtime and default styles) + +**Use cases:** + +- Teams using CSS Modules, Tailwind CSS, or vanilla CSS +- Complete design system replacement while keeping Fluent behavior/accessibility +- Bundle size optimization: **~25% JS bundle size reduction** (tested on a few components) by removing Griffel runtime and style implementations + +## Solution + +Ship unstyled style hook variants with `.styles.unstyled.ts` extension, resolved via bundler configuration. The unstyled variant: + +- ✅ Removes all Griffel `makeStyles`/`makeResetStyles` calls +- ✅ Preserves base class names (`.fui-Button`, `.fui-Button__icon`, etc.) +- ✅ Maintains identical hook signature +- ✅ Component files unchanged (still supports `useCustomStyleHook_unstable`) +- ✅ **~25% JS bundle size reduction** (tested) by excluding Griffel runtime + +**Note:** To completely eliminate Griffel from an application, unstyled variants are needed for **all components that use Griffel**, including infrastructure components like `FluentProvider`. This ensures no Griffel runtime is bundled. + +### Example + +**Standard style hook** (`useButtonStyles.styles.ts`): + +```tsx +import { makeStyles, mergeClasses } from '@griffel/react'; + +export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' }; + +const useStyles = makeStyles({ + root: { + /* extensive Griffel styles */ + }, + icon: { + /* icon styles */ + }, +}); + +export const useButtonStyles_unstable = (state: ButtonState) => { + const styles = useStyles(); + state.root.className = mergeClasses(buttonClassNames.root, styles.root, state.root.className); + return state; +}; +``` + +**Unstyled style hook** (`useButtonStyles.styles.unstyled.ts`): + +```tsx +import { mergeClasses } from '@fluentui/react-utilities'; + +export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' }; + +export const useButtonStyles_unstable = (state: ButtonState) => { + // Only apply base class names, no styles + state.root.className = mergeClasses(buttonClassNames.root, state.root.className); + return state; +}; +``` + +**Component unchanged:** + +```tsx +import { useButtonStyles_unstable } from './useButtonStyles.styles'; // ← Resolves to .unstyled.ts when configured + +export const Button = React.forwardRef((props, ref) => { + const state = useButton_unstable(props, ref); + useButtonStyles_unstable(state); // ← Uses unstyled variant when configured + useCustomStyleHook_unstable('useButtonStyles_unstable')(state); // ← Still available + return renderButton_unstable(state); +}); +``` + +### Bundler Configuration + +**Webpack:** + +```js +module.exports = { + resolve: { extensions: ['.unstyled.js', '...'] }, +}; +``` + +**Vite:** + +```js +export default { + resolve: { extensions: ['.unstyled.js', '...'] }, +}; +``` + +**Next.js:** + +```js +module.exports = { + webpack: config => { + config.resolve.extensions = ['.unstyled.js', ...config.resolve.extensions]; + return config; + }, +}; +``` + +## Implementation + +### Option A: Statically Generated Files (Recommended) + +Generate `.styles.unstyled.ts` files and check them into the repository. + +**Pros:** Simple, visible in codebase, easy to verify +**Cons:** Duplicate files to maintain + +**Process:** + +1. Scan for `use*Styles.styles.ts` files (including infrastructure components like `FluentProvider`) +2. Generate `use*Styles.styles.unstyled.ts` by: + - Keeping class name exports (`*ClassNames`) + - Keeping CSS variable exports (for reference) + - Removing all `makeStyles`/`makeResetStyles` calls + - Removing Griffel imports + - Simplifying hook to only apply base class names + +### Option B: Build-Time Transform + +Transform imports at build time via bundler plugin. + +**Pros:** Single source of truth, automatic +**Cons:** Complex build config, harder to debug + +## Usage Examples + +### CSS Modules + +```css +/* Button.module.css */ +:global(.fui-Button) { + padding: 8px 16px; + background-color: var(--primary-color); + color: white; +} +``` + +### Tailwind CSS + +```css +/* Global CSS */ +.fui-Button { + @apply px-4 py-2 bg-blue-500 text-white rounded; +} +``` + +### Custom Style Hook + +```tsx + + + +``` + +## Options Considered + +### Option A: Unstyled Style Hooks via Extension Resolution (Chosen) + +✅ Opt-in, zero breaking changes, follows raw modules pattern, component API unchanged +👎 Requires bundler configuration + +### Option B: Separate Package + +✅ Clear separation, no bundler config +👎 Another package to maintain, partners must change imports + +### Option C: Runtime Flag + +✅ No bundler config, can toggle dynamically +👎 Runtime overhead, Griffel still bundled + +## Migration + +**For standard users:** No changes required. + +**For unstyled users:** + +1. Configure bundler to resolve `.unstyled.js` extensions +2. Verify base class names (`.fui-*`) are applied +3. Apply custom CSS targeting `.fui-*` classes +4. Optionally use custom style hooks via `FluentProvider` + +## Open Questions + +1. **Preserve CSS variable exports?** +2. **Use `mergeClasses` in unstyled hooks?** +3. **Handle nested component styles?** +4. **Generate for styling utility hooks?** +5. **Keep unstyled variants in sync?** Automated tests + build-time validation? +6. **Keep `useCustomStyleHook_unstable`?** + +## Testing Strategy + +- Behavioral tests (excluding style assertions) +- Class name verification (`.fui-*` applied correctly) +- Snapshot tests (structure identical) +- Bundler integration tests (Webpack, Vite, Next.js) +- Accessibility tests (ARIA, keyboard navigation) +- Custom style hook tests + +## Implementation Plan + +### Phase 1: Proof of Concept + +- [ ] Generate unstyled variants for 10 core components +- [ ] Test with Webpack and Vite +- [ ] Verify class names and custom hooks + +### Phase 2: Build System + +- [ ] Implement generation script +- [ ] Add sync validation +- [ ] Update CI + +### Phase 3: Full Rollout + +- [ ] Generate for all components (including infrastructure components like `FluentProvider`) +- [ ] Update documentation +- [ ] Add examples + +### Phase 4: Maintenance + +- [ ] Monitor issues +- [ ] Gather feedback + +## References + +- [Unprocessed Styles Documentation](https://react.fluentui.dev/?path=/docs/concepts-developer-unprocessed-styles--docs) From a991d3fc1b64072dad51f619e5323de35d2ec8b7 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 10 Dec 2025 15:08:04 +0100 Subject: [PATCH 2/4] upd --- .../convergence/unstyled-components.md | 205 ++++++++++++------ 1 file changed, 144 insertions(+), 61 deletions(-) diff --git a/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md b/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md index d8e1ee62d183c..c269558ce2b64 100644 --- a/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md +++ b/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md @@ -1,4 +1,4 @@ -# RFC: Unstyled Components +# RFC: Headless Components ## Contributors @@ -6,15 +6,26 @@ ## Summary -This RFC proposes **unstyled style hook variants** that omit Griffel CSS implementations while preserving base class names (`.fui-[Component]`). This enables partners to use alternative styling solutions (CSS Modules, Tailwind, vanilla CSS) without recomposing components. +This RFC proposes **headless style hook variants** that remove all default style implementations from Fluent UI v9 components, while preserving static class names (`.fui-[Component]`). -Unstyled variants are opt-in via bundler extension resolution (similar to [raw modules](https://storybooks.fluentui.dev/react/?path=/docs/concepts-developer-unprocessed-styles--docs#how-to-use-raw-modules), ensuring zero breaking changes. +**Headless mode is opt-in and does not affect existing users.** By default, nothing changes for existing consumers. Teams with custom design requirements can opt in to headless mode. -**Performance Impact:** Internal testing shows **~25% JavaScript bundle size reduction** when using unstyled variants, as Griffel runtime and style implementations are excluded from the bundle. +**The main goal is to provide true flexibility for teams with custom design requirements:** + +- You are no longer forced to override or fight default styles—simply provide your own styling from scratch. +- You only pay for what you use: if you don't need the default Fluent styles, they are not included in your bundle at all. +- This approach enables a clean separation of behavior/accessibility from visual design, making Fluent UI a better foundation for custom design systems. + +**Performance and bundle size improvements are a natural result:** + +- By omitting default styles and their dependencies (like Griffel and tokens), bundle size is reduced (internal testing shows ~25% JS bundle size reduction for Button and Divider components we used for testing with our partners). +- No runtime style engine is included unless you opt in. + +Headless variants are opt-in via bundler extension resolution (similar to [raw modules](https://storybooks.fluentui.dev/react/?path=/docs/concepts-developer-unprocessed-styles--docs#how-to-use-raw-modules)), ensuring zero breaking changes. ## Problem Statement -Partners want to use Fluent UI v9 with alternative styling solutions but currently must: +Partners want to use Fluent UI v9 components as a foundation with alternative styling (not based off Fluent 2) and with other styling solutions than Griffel but currently must: 1. Recompose every component manually (high maintenance) 2. Override styles via `className` props (fragile, specificity issues) @@ -22,21 +33,18 @@ Partners want to use Fluent UI v9 with alternative styling solutions but current **Use cases:** -- Teams using CSS Modules, Tailwind CSS, or vanilla CSS - Complete design system replacement while keeping Fluent behavior/accessibility -- Bundle size optimization: **~25% JS bundle size reduction** (tested on a few components) by removing Griffel runtime and style implementations +- Teams using CSS Modules, Tailwind CSS, or vanilla CSS +- Bundle size optimization: **~25% JS bundle size reduction** (tested on Button/Divider components) by removing style implementations ## Solution -Ship unstyled style hook variants with `.styles.unstyled.ts` extension, resolved via bundler configuration. The unstyled variant: +Ship headless style hook variants with `.styles.headless.ts` extension, resolved via bundler configuration. The headless variant: -- ✅ Removes all Griffel `makeStyles`/`makeResetStyles` calls +- ✅ Removes all styles implementations `makeStyles`/`makeResetStyles` calls - ✅ Preserves base class names (`.fui-Button`, `.fui-Button__icon`, etc.) - ✅ Maintains identical hook signature - ✅ Component files unchanged (still supports `useCustomStyleHook_unstable`) -- ✅ **~25% JS bundle size reduction** (tested) by excluding Griffel runtime - -**Note:** To completely eliminate Griffel from an application, unstyled variants are needed for **all components that use Griffel**, including infrastructure components like `FluentProvider`. This ensures no Griffel runtime is bundled. ### Example @@ -49,7 +57,7 @@ export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' } const useStyles = makeStyles({ root: { - /* extensive Griffel styles */ + /* extensive styles */ }, icon: { /* icon styles */ @@ -58,21 +66,45 @@ const useStyles = makeStyles({ export const useButtonStyles_unstable = (state: ButtonState) => { const styles = useStyles(); + state.root.className = mergeClasses(buttonClassNames.root, styles.root, state.root.className); + + if (state.icon) { + state.icon.className = mergeClasses(buttonClassNames.icon, state.icon.className); + } + return state; }; ``` -**Unstyled style hook** (`useButtonStyles.styles.unstyled.ts`): +**Headless style hook** (`useButtonStyles.styles.headless.ts`): ```tsx -import { mergeClasses } from '@fluentui/react-utilities'; +import { getComponentSlotClassName } from '@fluentui/react-utilities'; // ← applies base className + state-based classes + user className + +// About getComponentSlotClassName: +// This utility dynamically generates class names for component slots based on the component’s state, following a standardized convention. +// - For each state property, it generates a class like `.fui[Component]--[stateName]-[stateValue]` (e.g., `fuiButton--appearance-primary`). +// - For boolean state values, it generates `.fui[Component]--[stateName]` if the value is truthy. +// - Example: If a Button has state `{ appearance: "primary", size: "small" }`, the generated classes are: +// `fuiButton--appearance-primary fuiButton--size-small` +// This makes it easier to target component states in custom CSS and reduces manual maintenance. +// See implementation details: https://github.com/microsoft/fluentui/pull/35548 export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' }; export const useButtonStyles_unstable = (state: ButtonState) => { - // Only apply base class names, no styles - state.root.className = mergeClasses(buttonClassNames.root, state.root.className); + // Applies class names (no styles): + // - component slot classname e.g. `fui-Button` + // - classes based on the component state `fui-Button-appearance-primary fui-Button--size-small etc` + // - user provided class name + state.root.className = getComponentSlotClassName(buttonClassNames.root, state.root, state); + + if (state.icon) { + // Applies base class name and user provided classnames (no styles): + state.icon.className = getComponentSlotClassName(buttonClassNames.icon, state.icon); + } + return state; }; ``` @@ -80,11 +112,11 @@ export const useButtonStyles_unstable = (state: ButtonState) => { **Component unchanged:** ```tsx -import { useButtonStyles_unstable } from './useButtonStyles.styles'; // ← Resolves to .unstyled.ts when configured +import { useButtonStyles_unstable } from './useButtonStyles.styles'; // ← Resolves to .headless.ts when configured export const Button = React.forwardRef((props, ref) => { const state = useButton_unstable(props, ref); - useButtonStyles_unstable(state); // ← Uses unstyled variant when configured + useButtonStyles_unstable(state); // ← Uses headless variant when configured useCustomStyleHook_unstable('useButtonStyles_unstable')(state); // ← Still available return renderButton_unstable(state); }); @@ -96,7 +128,7 @@ export const Button = React.forwardRef((props, ref) => { ```js module.exports = { - resolve: { extensions: ['.unstyled.js', '...'] }, + resolve: { extensions: ['.headless.js', '...'] }, }; ``` @@ -104,7 +136,7 @@ module.exports = { ```js export default { - resolve: { extensions: ['.unstyled.js', '...'] }, + resolve: { extensions: ['.headless.js', '...'] }, }; ``` @@ -113,7 +145,7 @@ export default { ```js module.exports = { webpack: config => { - config.resolve.extensions = ['.unstyled.js', ...config.resolve.extensions]; + config.resolve.extensions = ['.headless.js', ...config.resolve.extensions]; return config; }, }; @@ -123,20 +155,20 @@ module.exports = { ### Option A: Statically Generated Files (Recommended) -Generate `.styles.unstyled.ts` files and check them into the repository. +Generate `.styles.headless.ts` files and check them into the repository. **Pros:** Simple, visible in codebase, easy to verify **Cons:** Duplicate files to maintain **Process:** -1. Scan for `use*Styles.styles.ts` files (including infrastructure components like `FluentProvider`) -2. Generate `use*Styles.styles.unstyled.ts` by: +1. Scan for `use[Component]Styles.styles.ts` files +2. Generate `use[Component]Styles.styles.headless.ts` by: - Keeping class name exports (`*ClassNames`) - - Keeping CSS variable exports (for reference) + - Keeping CSS variable exports (for to keep backward compatibility) - Removing all `makeStyles`/`makeResetStyles` calls - Removing Griffel imports - - Simplifying hook to only apply base class names + - Simplifying hook to only apply base class names and class names based on component state ### Option B: Build-Time Transform @@ -147,14 +179,19 @@ Transform imports at build time via bundler plugin. ## Usage Examples -### CSS Modules +### CSS ```css -/* Button.module.css */ -:global(.fui-Button) { - padding: 8px 16px; - background-color: var(--primary-color); - color: white; +/* Button.css */ +.fui-Button { + display: flex; + align-items: center; + justify-content: center; +} + +.fui-Button--appearance-primary { + background-color: var(--colorPrimaryBackground); + color: var(--colorPrimaryForeground); } ``` @@ -163,25 +200,56 @@ Transform imports at build time via bundler plugin. ```css /* Global CSS */ .fui-Button { - @apply px-4 py-2 bg-blue-500 text-white rounded; + @apply flex items-center justify-center; +} + +.fui-Button--appearance-primary { + @apply bg-primary-background text-primary-foreground; } ``` -### Custom Style Hook +### Griffel (CSS-in-JS) ```tsx +import { makeStyles } from '@griffel/react'; + +const useButtonClasses = makeStaticStyles({ + 'root': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + ['&.fui-Button--appearance-primary']: { + backgroundColor: tokens.colorPrimaryBackground, + color: tokens.colorPrimaryForeground; + } + }, +}); + + +function useButtonStyles_unstable(state: ButtonState) { + const classes = useButtonClasses(); + + state.root.className = mergeClasses( + state.root.className, + classes.root, + getSlotClassNameProp(slot.root) + ); +} + - +; +} ``` ## Options Considered -### Option A: Unstyled Style Hooks via Extension Resolution (Chosen) +### Option A: Headless Style Hooks via Extension Resolution (Chosen) ✅ Opt-in, zero breaking changes, follows raw modules pattern, component API unchanged 👎 Requires bundler configuration @@ -200,9 +268,9 @@ Transform imports at build time via bundler plugin. **For standard users:** No changes required. -**For unstyled users:** +**For headless users:** -1. Configure bundler to resolve `.unstyled.js` extensions +1. Configure bundler to resolve `.headless.js` extensions 2. Verify base class names (`.fui-*`) are applied 3. Apply custom CSS targeting `.fui-*` classes 4. Optionally use custom style hooks via `FluentProvider` @@ -210,46 +278,61 @@ Transform imports at build time via bundler plugin. ## Open Questions 1. **Preserve CSS variable exports?** -2. **Use `mergeClasses` in unstyled hooks?** -3. **Handle nested component styles?** -4. **Generate for styling utility hooks?** -5. **Keep unstyled variants in sync?** Automated tests + build-time validation? -6. **Keep `useCustomStyleHook_unstable`?** +2. **Use `mergeClasses` in headless hooks?** - Use new `getComponentSlotClassName` utility +3. **Keep headless variants in sync?** - We won't be mapping state classes by hand, we'll use util for that. + The only case is not covered when new slots were added. +4. **Keep `useCustomStyleHook_unstable`?** - Decided to keep it for now ## Testing Strategy -- Behavioral tests (excluding style assertions) -- Class name verification (`.fui-*` applied correctly) -- Snapshot tests (structure identical) -- Bundler integration tests (Webpack, Vite, Next.js) -- Accessibility tests (ARIA, keyboard navigation) - Custom style hook tests +- Class name verification (`.fui-*` applied correctly) ## Implementation Plan ### Phase 1: Proof of Concept -- [ ] Generate unstyled variants for 10 core components -- [ ] Test with Webpack and Vite -- [ ] Verify class names and custom hooks +- [x] Generate headless style hooks for 2 core components (Button, Divider) +- [x] Verify class names and custom hooks +- [x] Validate the approach with partner team(s) and gather feedback. -### Phase 2: Build System +### Phase 2: Gradual Rollout -- [ ] Implement generation script -- [ ] Add sync validation -- [ ] Update CI - -### Phase 3: Full Rollout - -- [ ] Generate for all components (including infrastructure components like `FluentProvider`) +- [ ] Generate headless style hooks for more components (probably Popover, Menu, Toolbar, Tabs, etc.) - [ ] Update documentation - [ ] Add examples -### Phase 4: Maintenance +### Phase 3: Maintenance - [ ] Monitor issues - [ ] Gather feedback +## FAQ + +**Q: Will this break my existing Fluent UI v9 components?** + +A: No. Headless mode is opt-in. If you do not change your bundler configuration, you will continue to get the default Fluent styles. + +**Q: Can I use my own CSS, Tailwind, or CSS-in-JS solution?** + +A: Yes! Headless mode is designed to let you provide your own styling using any method you prefer. + +**Q: How do I switch back to default styles?** + +A: Simply remove the headless extension from your bundler configuration and the default styles will be restored. + +**Q: What if a new slot is added to a component?** + +A: The headless variant will need to be updated to ensure all slots receive the correct class names. Tooling or automation may be provided to help keep these in sync. + +**Q: Is there any runtime cost if I use headless mode?** + +A: No. If you opt in to headless mode, no style engine or default style code is included in your bundle unless you explicitly add it. + +**Q: Is headless mode a replacement for Fluent UI?** + +A: No. Headless mode is a complementary solution that expands Fluent UI's reach by supporting additional use cases. It allows teams to build custom design systems on top of Fluent UI's robust behavior, accessibility, and component architecture, while providing complete control over visual design. This makes Fluent UI a better foundation for organizations with unique design requirements. + ## References - [Unprocessed Styles Documentation](https://react.fluentui.dev/?path=/docs/concepts-developer-unprocessed-styles--docs) From 5266418c05c05e7f0f7d92ffc8f520beefe6691e Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 1 Jan 2026 12:47:05 +0100 Subject: [PATCH 3/4] update the RFC and provide and example/showcase for button component --- ...-beac5135-2566-4ed6-b65c-52abdf1e38e8.json | 7 + .../convergence/unstyled-components.md | 939 ++++++++++++++---- .../src/components/AccordionHeader/index.ts | 1 + .../AccordionHeader/useAccordionHeader.tsx | 66 +- .../useAccordionHeaderBase.tsx | 76 ++ .../src/components/AccordionPanel/index.ts | 1 + .../AccordionPanel/useAccordionPanel.ts | 27 +- .../AccordionPanel/useAccordionPanelBase.ts | 46 + .../bundle-size/ButtonUnstyled.fixture.js | 7 + .../library/etc/react-button.api.md | 24 +- .../react-button/library/src/Button.tsx | 4 +- .../react-button/library/src/MenuButton.ts | 1 + .../src/components/Button/Button.types.ts | 37 +- .../src/components/Button/ButtonUnstyled.tsx | 22 + .../library/src/components/Button/index.ts | 4 +- .../src/components/Button/useButton.ts | 36 +- .../src/components/Button/useButtonBase.ts | 44 + .../Button/useButtonBehavior.test.tsx | 98 ++ .../src/components/MenuButton/index.ts | 1 + .../components/MenuButton/useMenuButton.tsx | 29 +- .../MenuButton/useMenuButtonBase.tsx | 45 + .../react-button/library/src/index.ts | 5 +- 22 files changed, 1189 insertions(+), 331 deletions(-) create mode 100644 change/@fluentui-react-button-beac5135-2566-4ed6-b65c-52abdf1e38e8.json create mode 100644 packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeaderBase.tsx create mode 100644 packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanelBase.ts create mode 100644 packages/react-components/react-button/library/bundle-size/ButtonUnstyled.fixture.js create mode 100644 packages/react-components/react-button/library/src/components/Button/ButtonUnstyled.tsx create mode 100644 packages/react-components/react-button/library/src/components/Button/useButtonBase.ts create mode 100644 packages/react-components/react-button/library/src/components/Button/useButtonBehavior.test.tsx create mode 100644 packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.tsx diff --git a/change/@fluentui-react-button-beac5135-2566-4ed6-b65c-52abdf1e38e8.json b/change/@fluentui-react-button-beac5135-2566-4ed6-b65c-52abdf1e38e8.json new file mode 100644 index 0000000000000..414a47652d4e5 --- /dev/null +++ b/change/@fluentui-react-button-beac5135-2566-4ed6-b65c-52abdf1e38e8.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add headless hook and unstyled component", + "packageName": "@fluentui/react-button", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md b/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md index c269558ce2b64..43f6518e48c30 100644 --- a/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md +++ b/docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md @@ -1,4 +1,4 @@ -# RFC: Headless Components +# RFC: Unstyled Components & Base Hooks ## Contributors @@ -6,333 +6,912 @@ ## Summary -This RFC proposes **headless style hook variants** that remove all default style implementations from Fluent UI v9 components, while preserving static class names (`.fui-[Component]`). +This RFC proposes a layered component architecture for Fluent UI v9 that introduces: -**Headless mode is opt-in and does not affect existing users.** By default, nothing changes for existing consumers. Teams with custom design requirements can opt in to headless mode. +- Base hooks per component (behavior-only, zero styling opinions) +- Unstyled component variants built on top of the base hooks +- Continued support for the existing styled components and hooks -**The main goal is to provide true flexibility for teams with custom design requirements:** +The approach removes default style implementations when desired and enables per-component choice without requiring bundler configuration. -- You are no longer forced to override or fight default styles—simply provide your own styling from scratch. -- You only pay for what you use: if you don't need the default Fluent styles, they are not included in your bundle at all. -- This approach enables a clean separation of behavior/accessibility from visual design, making Fluent UI a better foundation for custom design systems. +Key goals: -**Performance and bundle size improvements are a natural result:** +- Clean separation of behavior/accessibility from visual design +- Per-component mixing: choose styled, unstyled, or base hook as needed +- No extra packages or entrypoints required; exports come from the main component package -- By omitting default styles and their dependencies (like Griffel and tokens), bundle size is reduced (internal testing shows ~25% JS bundle size reduction for Button and Divider components we used for testing with our partners). -- No runtime style engine is included unless you opt in. +Performance and bundle size improvements follow naturally when default styles and Griffel runtime are omitted for chosen components. -Headless variants are opt-in via bundler extension resolution (similar to [raw modules](https://storybooks.fluentui.dev/react/?path=/docs/concepts-developer-unprocessed-styles--docs#how-to-use-raw-modules)), ensuring zero breaking changes. +## Quick Start + +The simplest way to use unstyled components: + +```tsx +import { ButtonUnstyled } from '@fluentui/react-components'; +import './button.css'; + +function App() { + return ( + + Click me + + ); +} +``` + +```css +.custom-button { + /* custom button styles */ +} + +.custom-button:hover { + /* hover styles */ +} + +.custom-button:disabled { + /* disabled styles */ +} + +.custom-button__icon { + /* custom icon styles */ +} +``` + +That's it! Unstyled components provide Fluent's accessible behavior and structure, but no default styles or base class names. You provide all styling via CSS, CSS Modules, Tailwind, or any other solution using your own class names. + +## When to Use What + +Understanding when to use each variant: + +| Use Case | Solution | Example | +| -------------------------------------- | ------------------------------------- | ----------------------------------- | +| **Default Fluent UI styling** | Styled component (`Button`) | Standard Fluent UI apps | +| **Custom styling, no default styling** | Unstyled component (`ButtonUnstyled`) | Brand-specific design systems | +| **Completely custom component** | Base hook (`useButtonBase_unstable`) | Building your own component library | + +**Base hooks** (`useButtonBase_unstable`): + +- For building completely custom components from scratch +- Provides only behavior/accessibility, no design opinions +- No default implementations for optional slots (icons, etc.) +- No styles or motion-related logic +- Maximum flexibility, minimum assumptions + +**Unstyled components** (`ButtonUnstyled`): + +- For using Fluent's component structure with your own styling +- No base class names - you provide your own via `className` prop +- Maintains Fluent's component architecture and behavior + +**Styled components** (`Button`): + +- For using Fluent's default styling +- Includes all Fluent design tokens and styles +- Standard Fluent UI experience + +You can mix all three approaches in the same application as needed. ## Problem Statement +Today, teams that want Fluent UI v9’s behavior and structure without Fluent’s default styles are forced to fight the styling layer or re‑implement components, incurring unnecessary code, runtime cost, and fragility. + Partners want to use Fluent UI v9 components as a foundation with alternative styling (not based off Fluent 2) and with other styling solutions than Griffel but currently must: -1. Recompose every component manually (high maintenance) +1. Recompose every component manually (high maintenance, and still need to pay for what they don't use, eg. default styles) 2. Override styles via `className` props (fragile, specificity issues) 3. Use custom style hooks (still depends on Griffel runtime and default styles) **Use cases:** - Complete design system replacement while keeping Fluent behavior/accessibility -- Teams using CSS Modules, Tailwind CSS, or vanilla CSS -- Bundle size optimization: **~25% JS bundle size reduction** (tested on Button/Divider components) by removing style implementations +- Teams using CSS Modules, Tailwind CSS, vanilla CSS, or their own CSS-in-JS solution +- Bundle size optimization: **~25% JS bundle size reduction** (tested on few components in partners codebases) by removing style implementations +- Supporting diverse styling approaches across teams without forcing Fluent 2 design or our styling approach. -## Solution +**Scope and non-goals:** -Ship headless style hook variants with `.styles.headless.ts` extension, resolved via bundler configuration. The headless variant: +- **In scope:** Fluent UI v9 React components, their styling layer (default styles, Griffel runtime), and new behavior/unstyled variants that can be adopted per component. +- **Out of scope:** Redesigning the theming system, changing the public APIs of existing styled components, or mandating a particular alternative styling solution (CSS Modules, Tailwind, etc.). -- ✅ Removes all styles implementations `makeStyles`/`makeResetStyles` calls -- ✅ Preserves base class names (`.fui-Button`, `.fui-Button__icon`, etc.) -- ✅ Maintains identical hook signature -- ✅ Component files unchanged (still supports `useCustomStyleHook_unstable`) +**Target audience:** -### Example +Organizations with custom design systems that need robust, accessible component behavior without being constrained by default Fluent styles. This includes internal teams at Microsoft, enterprise partners, and open-source projects with specific design requirements. -**Standard style hook** (`useButtonStyles.styles.ts`): +## Solution -```tsx -import { makeStyles, mergeClasses } from '@griffel/react'; +Ship base hooks and unstyled components from the main Fluent UI entrypoints (for example `@fluentui/react-components`, and per-component entrypoints where applicable). No bundler configuration is required. The proposed surface per component: -export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' }; +- Styled component (existing): `Button` +- Styled hook (existing): `useButton_unstable` +- Base hook (new): `useButtonBase_unstable` +- Unstyled component (new): `ButtonUnstyled` -const useStyles = makeStyles({ - root: { - /* extensive styles */ - }, - icon: { - /* icon styles */ - }, -}); +Unstyled components: -export const useButtonStyles_unstable = (state: ButtonState) => { - const styles = useStyles(); +- ✅ Include no default styles; teams provide styles via CSS/CSS Modules/Tailwind/etc. +- ✅ Include no base class names; teams provide their own via `className` prop +- ✅ Simple wrappers on top of base hooks that render component structure +- ✅ Maintain consistent component behavior props - state.root.className = mergeClasses(buttonClassNames.root, styles.root, state.root.className); +Base hooks: - if (state.icon) { - state.icon.className = mergeClasses(buttonClassNames.icon, state.icon.className); - } +- ✅ Provide only core behavior and accessibility +- ✅ No default implementations for optional slots (icons, decorators, etc.) +- ✅ No styles-related logic (no Griffel, no design tokens) +- ✅ No motion-related logic (no animations, transitions) +- ✅ Teams have full control over visual implementation - return state; +### Example + +**Base hook** (`useButtonBase_unstable`): + +```tsx +import * as React from 'react'; +import { type ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import type { ButtonBaseProps, ButtonBaseState } from './Button.types'; + +export const useButtonBase_unstable = ( + props: ButtonBaseProps, + ref: React.Ref, +): ButtonBaseState => { + const { as = 'button', disabled = false, disabledFocusable = false, icon, iconPosition = 'before' } = props; + + // NOTE: Base hooks do NOT provide default implementations for optional slots + // Teams using base hooks must explicitly provide icons and other optional elements + const iconShorthand = slot.optional(icon, { elementType: 'span' }); + + return { + disabled, + disabledFocusable, + iconPosition, + iconOnly: Boolean(iconShorthand?.children && !props.children), + root: slot.always>(getIntrinsicElementProps(as, useARIAButtonProps(as, props)), { + elementType: as, + defaultProps: { + ref: ref as React.Ref, + type: as === 'button' ? 'button' : undefined, + }, + }), + icon: iconShorthand, + components: { root: as, icon: 'span' }, + }; }; ``` -**Headless style hook** (`useButtonStyles.styles.headless.ts`): +**Key principles for base hooks:** + +- **No default slot implementations**: Optional slots like icons are only defined if explicitly passed by the consumer +- **No styles logic**: No Griffel imports, no `makeStyles`, no design tokens +- **No motion logic**: No animations, transitions, or motion utilities +- **Pure behavior**: Focus only on accessibility (ARIA), keyboard handling, and semantic structure + +**Unstyled component** (`ButtonUnstyled`): ```tsx -import { getComponentSlotClassName } from '@fluentui/react-utilities'; // ← applies base className + state-based classes + user className - -// About getComponentSlotClassName: -// This utility dynamically generates class names for component slots based on the component’s state, following a standardized convention. -// - For each state property, it generates a class like `.fui[Component]--[stateName]-[stateValue]` (e.g., `fuiButton--appearance-primary`). -// - For boolean state values, it generates `.fui[Component]--[stateName]` if the value is truthy. -// - Example: If a Button has state `{ appearance: "primary", size: "small" }`, the generated classes are: -// `fuiButton--appearance-primary fuiButton--size-small` -// This makes it easier to target component states in custom CSS and reduces manual maintenance. -// See implementation details: https://github.com/microsoft/fluentui/pull/35548 - -export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' }; - -export const useButtonStyles_unstable = (state: ButtonState) => { - // Applies class names (no styles): - // - component slot classname e.g. `fui-Button` - // - classes based on the component state `fui-Button-appearance-primary fui-Button--size-small etc` - // - user provided class name - state.root.className = getComponentSlotClassName(buttonClassNames.root, state.root, state); - - if (state.icon) { - // Applies base class name and user provided classnames (no styles): - state.icon.className = getComponentSlotClassName(buttonClassNames.icon, state.icon); - } - - return state; -}; +import { renderButton_unstable } from './renderButton'; +import { useButtonBase_unstable } from './useButtonBase'; +import type { ButtonBaseProps, ButtonState } from './Button.types'; + +export const ButtonUnstyled = React.forwardRef((props, ref) => { + const state = useButtonBase_unstable(props, ref); + + // No base class names applied - users provide their own via className prop + // No default styles, no state-based classes + + return renderButton_unstable(state as ButtonState); +}); ``` -**Component unchanged:** +**Note:** `ButtonUnstyled` uses `ButtonBaseProps` (behavior props only, no design props like `appearance`, `size`, `shape`). This keeps unstyled components truly minimal. Teams can use `className` prop to apply styling based on their own logic. + +**Styled component unchanged:** ```tsx -import { useButtonStyles_unstable } from './useButtonStyles.styles'; // ← Resolves to .headless.ts when configured +import { useButton_unstable } from './useButton'; +import { useButtonStyles_unstable } from './useButtonStyles.styles'; export const Button = React.forwardRef((props, ref) => { const state = useButton_unstable(props, ref); - useButtonStyles_unstable(state); // ← Uses headless variant when configured - useCustomStyleHook_unstable('useButtonStyles_unstable')(state); // ← Still available + useButtonStyles_unstable(state); return renderButton_unstable(state); }); ``` -### Bundler Configuration +### Export Surface & Naming Conventions -**Webpack:** +We standardize naming to ensure clarity: -```js -module.exports = { - resolve: { extensions: ['.headless.js', '...'] }, -}; +- Base hooks: `use${ComponentName}Base_unstable` +- Unstyled components: `${ComponentName}Unstyled` +- Styled hooks: `use${ComponentName}_unstable` (existing) +- Styled components: `${ComponentName}` (existing) + +```tsx +import { Button, ButtonUnstyled, useButton_unstable, useButtonBase_unstable } from '@fluentui/react-components'; ``` -**Vite:** +## Type Definitions -```js -export default { - resolve: { extensions: ['.headless.js', '...'] }, +Understanding the type relationships is crucial for implementation: + +```tsx +// Base types (behavior only, no design props) +export type ButtonBaseProps = ComponentProps & { + disabled?: boolean; + disabledFocusable?: boolean; + iconPosition?: 'before' | 'after'; + // NO design props (appearance, size, shape) }; -``` -**Next.js:** +export type ButtonBaseState = ComponentState & { + disabled: boolean; + disabledFocusable: boolean; + iconPosition: 'before' | 'after'; + iconOnly: boolean; + // NO design state +}; -```js -module.exports = { - webpack: config => { - config.resolve.extensions = ['.headless.js', ...config.resolve.extensions]; - return config; - }, +// Full types (includes design props) +export type ButtonProps = ButtonBaseProps & { + appearance?: 'primary' | 'secondary' | 'outline' | 'subtle' | 'transparent'; + size?: 'small' | 'medium' | 'large'; + shape?: 'rounded' | 'circular' | 'square'; +}; + +export type ButtonState = ButtonBaseState & { + appearance: 'primary' | 'secondary' | 'outline' | 'subtle' | 'transparent'; + size: 'small' | 'medium' | 'large'; + shape: 'rounded' | 'circular' | 'square'; }; ``` +**Key points:** + +- `ButtonBaseProps` / `ButtonBaseState`: Behavior only, used by base hooks and unstyled components +- `ButtonProps` / `ButtonState`: Includes design props, used by styled components +- Base hooks accept and return base types only +- Unstyled components accept base props only (keeps them minimal) + ## Implementation -### Option A: Statically Generated Files (Recommended) +### Implementation Overview + +For each component package: + +1. Introduce a base hook that encapsulates behavior/accessibility only (no design props) +2. Implement an unstyled component that wraps the base hook and renders (no default styles, no base class names, no state-based classes) +3. Keep the existing styled hook and styled component unchanged +4. Export all four symbols from the main package entry + +### Implementation Checklist + +For each component: + +- [ ] **Create base types** + + - [ ] Create `{Component}BaseProps` type (behavior props only, no design props) + - [ ] Create `{Component}BaseState` type (behavior state only) + +- [ ] **Create base hook** + + - [ ] Create `use{Component}Base_unstable` hook + - [ ] Uses `{Component}BaseProps` and returns `{Component}BaseState` + - [ ] No design props (appearance, size, shape, etc.) + - [ ] Handles accessibility via appropriate ARIA hooks (e.g., `useARIAButtonProps`) + - [ ] Returns slots with proper structure + - [ ] No styling, no Griffel, no tokens + - [ ] No default implementations for optional slots (icons, decorators, etc.) + - [ ] No motion logic (animations, transitions) + - [ ] Pure behavior and accessibility only + +- [ ] **Create unstyled component** + + - [ ] Uses `{Component}BaseProps` (not full `{Component}Props`) + - [ ] Calls `use{Component}Base_unstable` + - [ ] Does NOT apply base class names - users provide via `className` prop + - [ ] Uses existing `render{Component}_unstable` function + - [ ] No default styles, no base class names, no state-based classes + +- [ ] **Export and test** + - [ ] Export from main package entry point + - [ ] Add tests for base hook (accessibility, behavior) + - [ ] Add tests for unstyled component (rendering, behavior) + - [ ] Verify no Griffel styles are included + - [ ] Verify bundle size reduction + +## Developer Workflow + +Unstyled components provide flexibility for styling. Teams can choose their preferred styling approach based on their needs: -Generate `.styles.headless.ts` files and check them into the repository. +### CSS Organization -**Pros:** Simple, visible in codebase, easy to verify -**Cons:** Duplicate files to maintain +Unstyled components do not apply any class names by default. You provide your own via the `className` prop. Use any styling approach: -**Process:** +- **Pure CSS** - Simple and zero dependencies +- **CSS Modules** - Component-scoped styling +- **Tailwind CSS** - Utility-first approach +- **Griffel** - CSS-in-JS with runtime processing +- **Any other CSS solution** - Complete flexibility -1. Scan for `use[Component]Styles.styles.ts` files -2. Generate `use[Component]Styles.styles.headless.ts` by: - - Keeping class name exports (`*ClassNames`) - - Keeping CSS variable exports (for to keep backward compatibility) - - Removing all `makeStyles`/`makeResetStyles` calls - - Removing Griffel imports - - Simplifying hook to only apply base class names and class names based on component state +**Note:** Teams can define their own wrapper components that accept appearance/size/other design props and map those to `className` when calling `ButtonUnstyled`. -### Option B: Build-Time Transform +### Third-party Package Compatibility -Transform imports at build time via bundler plugin. +With the layered approach, third-party packages remain styled unless they opt into the unstyled component/base hook. Consumers have two options: -**Pros:** Single source of truth, automatic -**Cons:** Complex build config, harder to debug +1. Use the styled components from dependencies as-is +2. Where control is possible, switch imports to unstyled variants provided by Fluent packages ## Usage Examples -### CSS +### Pure CSS ```css /* Button.css */ -.fui-Button { +.btn { display: flex; align-items: center; justify-content: center; } -.fui-Button--appearance-primary { +/* Use className-based targeting */ +.btn-primary { background-color: var(--colorPrimaryBackground); color: var(--colorPrimaryForeground); } + +.btn-large { + padding: 12px 24px; + font-size: 16px; +} ``` -### Tailwind CSS +```tsx +// App.tsx +import { ButtonUnstyled } from '@fluentui/react-components'; +import './button.css'; + +function App() { + return Click me; +} +``` + +### CSS Modules ```css -/* Global CSS */ -.fui-Button { - @apply flex items-center justify-center; +/* Button.module.css */ +.button { + display: flex; + align-items: center; + justify-content: center; } -.fui-Button--appearance-primary { - @apply bg-primary-background text-primary-foreground; +.primary { + background-color: var(--colorPrimaryBackground); + color: var(--colorPrimaryForeground); +} +``` + +```tsx +// App.tsx +import { ButtonUnstyled } from '@fluentui/react-components'; +import styles from './Button.module.css'; + +function App() { + return Click me; +} +``` + +### Tailwind CSS + +```tsx +// Use className prop to apply Tailwind classes directly +import { ButtonUnstyled } from '@fluentui/react-components'; + +function App() { + const appearance = 'primary'; // or from props/state + return ( + + Click me + + ); } ``` ### Griffel (CSS-in-JS) +You can use Griffel with unstyled components by applying styles via `className`: + ```tsx -import { makeStyles } from '@griffel/react'; +import { makeStyles, mergeClasses } from '@griffel/react'; +import { ButtonUnstyled } from '@fluentui/react-components'; -const useButtonClasses = makeStaticStyles({ - 'root': { +const useButtonClasses = makeStyles({ + root: { display: 'flex', alignItems: 'center', justifyContent: 'center', + }, - ['&.fui-Button--appearance-primary']: { - backgroundColor: tokens.colorPrimaryBackground, - color: tokens.colorPrimaryForeground; - } + primary: { + backgroundColor: 'blue', + color: 'white', }, }); - -function useButtonStyles_unstable(state: ButtonState) { +function App() { const classes = useButtonClasses(); - state.root.className = mergeClasses( - state.root.className, - classes.root, - getSlotClassNameProp(slot.root) + return Click me; +} +``` + +**Note:** Custom style hooks (`customStyleHooks_unstable`) work with styled components (`Button`), not unstyled components (`ButtonUnstyled`). For unstyled components, apply styles directly via the `className` prop. + +## Options Considered + +- Layered architecture with base hooks + unstyled components (Chosen) + - Pros: No bundler requirement; per-component mixing; clear naming; minimal maintenance + - Cons: Third-party packages remain styled unless switched +- Bundler-based global flip via extension resolution (discarded) + - Pros: Global flip across dependencies; no import migration + - Cons: Requires toolchain config; no per-component mixing + +## Migration + +**For standard users:** No changes required. Continue using styled components as before. + +**For unstyled/headless users:** + +### Migration Examples + +#### Before (Styled Component) + +```tsx +import { Button } from '@fluentui/react-components'; + +function App() { + return ( + ); } +``` + +#### After (Unstyled with CSS) - - -; +```tsx +import { ButtonUnstyled } from '@fluentui/react-components'; +import './button.css'; + +function App() { + return Click me; } ``` -## Options Considered +### Migration Steps -### Option A: Headless Style Hooks via Extension Resolution (Chosen) +1. **Import unstyled components** from the main Fluent UI entrypoint (for example, `import { ButtonUnstyled } from '@fluentui/react-components'`) +2. **Or import base hooks** to build bespoke components (e.g., `useButtonBase_unstable`) +3. **Choose a styling approach** (CSS, CSS Modules, Tailwind, Griffel, etc.) +4. **Apply custom CSS/styles** via `className` prop with your own class names +5. **Use `className` prop** to apply conditional styling (note: unstyled components don't accept design props) +6. **Use `ThemelessFluentProvider`** instead of `FluentProvider` (optional, for smaller bundle) if you're not using Fluent UI tokens or Griffel -✅ Opt-in, zero breaking changes, follows raw modules pattern, component API unchanged -👎 Requires bundler configuration +## Design API Rationale -### Option B: Separate Package +**Q: Why don't unstyled components accept design-related props like `appearance` and `size`?** -✅ Clear separation, no bundler config -👎 Another package to maintain, partners must change imports +A: Unstyled components use `ButtonBaseProps` which excludes design props. This keeps them truly minimal and forces teams to explicitly handle styling. -### Option C: Runtime Flag +**Why this approach?** -✅ No bundler config, can toggle dynamically -👎 Runtime overhead, Griffel still bundled +- **Clear separation:** Base hooks and unstyled components focus purely on behavior/semantic structure and slots +- **Explicit styling:** Teams must consciously apply styling, making it clear where styles come from +- **No hidden defaults:** Base hooks don't include default implementations for optional slots (icons, etc.) or motion logic +- **Flexibility:** Teams can use any prop naming or structure for their design system +- **Simplicity:** No need to maintain design prop logic, motion logic, or default slot implementations in base hooks/unstyled components -## Migration +**How to handle design variants?** + +Use `className` prop with your own logic: + +```tsx +Click me +``` + +For comparison, truly "headless" libraries like react-aria or Base UI ship components without design opinions from the API itself; Fluent UI's base hooks provide behavior only (no default slot implementations, no styles, no motion), while unstyled components are simple wrappers that render the component structure. + +## Bundle Size & CSS Measurements + +Internal testing shows **~25% JavaScript bundle size reduction** for `Button` and `Divider` components by removing Griffel runtime and default style implementations when using unstyled variants. + +### Measurement Methodology + +- **Tool:** Webpack bundle analyzer +- **Included:** Component code, dependencies, Griffel runtime (for styled), style implementations +- **Excluded:** Application code, other dependencies +- **Comparison:** Same component functionality, different styling approaches + +### Results + +**Griffel + AOT + CSS extraction (current default):** + +- JavaScript: 82.432 kB (includes Griffel runtime + style logic) +- CSS: 13 kB (extracted styles) +- Total: ~95.4 kB + +**Unstyled + custom CSS:** + +- JavaScript: 25.161 kB (no Griffel runtime, no style logic) +- CSS: 2.52 kB (minimal base styles if any) +- Total: ~27.6 kB +- **Reduction: ~71% total bundle size** + +In isolated component-level benchmarks we see ~71% total bundle reduction; in real-world partner applications that adopt unstyled variants for selected components, we typically see more modest JS savings around ~25% due to other application code and dependencies. + +**When to expect these savings:** + +- Using `ButtonUnstyled` instead of `Button` +- Using `ThemelessFluentProvider` instead of `FluentProvider` (additional savings) +- Not importing Griffel or default style hooks +- Providing your own CSS instead of Fluent's default styles + +**Note:** Actual savings vary by component complexity and your custom CSS size. Measurements are for Button and Divider components; other components may show different results. + +## Slot Structure Stability & API Guarantees + +Component slot structures are considered **public API** and follow semantic versioning: + +- **Patch versions:** No slot structure changes +- **Minor versions:** May add new slots, but existing slots remain stable +- **Major versions:** May rename or remove slots + +This stability guarantee enables teams to safely build styling systems on top of Fluent's component structure. If a component's slot structure must change, it will be communicated as a breaking change with migration guidance. -**For standard users:** No changes required. +**Slot structure changes:** If a component's slot structure changes (new slots added, slots renamed), the unstyled component will be updated to reflect the new structure. -**For headless users:** +## Validation & Testing -1. Configure bundler to resolve `.headless.js` extensions -2. Verify base class names (`.fui-*`) are applied -3. Apply custom CSS targeting `.fui-*` classes -4. Optionally use custom style hooks via `FluentProvider` +- **Slot structure parity:** Verify that unstyled components have the same slot structure as their styled counterparts +- **Bundle analysis:** Ensure no Griffel CSS or default style code is emitted when using unstyled components -## Open Questions +## Resolved Questions -1. **Preserve CSS variable exports?** -2. **Use `mergeClasses` in headless hooks?** - Use new `getComponentSlotClassName` utility -3. **Keep headless variants in sync?** - We won't be mapping state classes by hand, we'll use util for that. - The only case is not covered when new slots were added. -4. **Keep `useCustomStyleHook_unstable`?** - Decided to keep it for now +During the pilot phase with Button and partner validation, we confirmed: + +1. **Unstyled components provide significant value over base hooks alone:** + + - Eliminate boilerplate for common use case (rendering component structure) + - Provide consistent component structure across the library + - Lower barrier to entry than using base hooks directly + - Partner feedback: "Much easier than building from base hooks" + +2. **Collocating unstyled components with styled components is the right approach:** + - No separate package to maintain or version + - Per-component opt-in without bundler configuration + - Clearer imports: same package, different variant + - Simpler mental model for developers + +## Breaking Changes + +**For standard Fluent UI v9 users:** No breaking changes. Unstyled mode is entirely opt-in. The public API remains unchanged, and existing code continues to work exactly as before. + +**For teams adopting unstyled/base hook variants:** This is not considered a breaking change, since teams explicitly opt in to unstyled/base hook usage and can always switch back by using the regular styled components instead, or by replacing custom components built on top of base hooks with the default Fluent UI components. + +However, teams adopting unstyled mode should understand the required changes: + +- **Must provide complete styling:** Unstyled components have no default styles or class names; your app is responsible for all styling +- **Provider choice:** Use `ThemelessFluentProvider` to avoid bundling unused Fluent tokens (optional but recommended for smaller bundles). Use `FluentProvider` if you need design tokens or Griffel overrides +- **Third-party dependencies:** Dependencies using `@fluentui/react-components` remain styled. +- **Per-component mixing supported:** Choose styled, unstyled, or base hook per component without bundler config ## Testing Strategy -- Custom style hook tests -- Class name verification (`.fui-*` applied correctly) +### Testing Checklist + +- [ ] Custom `className` preserved +- [ ] Behavior/accessibility works (keyboard, focus, etc.) +- [ ] Slot structure correct +- [ ] Base hook returns correct state structure +- [ ] No Griffel or style-related imports in base hook +- [ ] No default implementations for optional slots in base hook +- [ ] No motion logic in base hook ## Implementation Plan -### Phase 1: Proof of Concept +### Phase 1: Pilot -- [x] Generate headless style hooks for 2 core components (Button, Divider) +- [x] Implement `useButtonBase_unstable` and `ButtonUnstyled` - [x] Verify class names and custom hooks -- [x] Validate the approach with partner team(s) and gather feedback. - -### Phase 2: Gradual Rollout - -- [ ] Generate headless style hooks for more components (probably Popover, Menu, Toolbar, Tabs, etc.) -- [ ] Update documentation -- [ ] Add examples +- [x] Validate the approach with partner team(s) + +### Phase 2: Rollout + +- [ ] Document type patterns (ButtonBaseProps, ButtonBaseState) +- [ ] Create codegen templates for base hooks +- [ ] Create codegen templates for unstyled components +- [ ] Add ESLint rules to ensure base hooks don't use design props +- [ ] Add ESLint rules to ensure base hooks don't include default slot implementations +- [ ] Add ESLint rules to ensure base hooks don't include motion logic +- [ ] Add tests to ensure unstyled components don't include Griffel +- [ ] Apply pattern to additional components (Divider, Menu, Tabs, etc.) +- [ ] Update Storybook with unstyled component examples +- [ ] Create migration guide with before/after examples +- [ ] Update documentation and examples +- [ ] Evaluate codemods for automated migration (nice-to-have) +- [ ] Add ESLint rules to help identify migration opportunities (nice-to-have) ### Phase 3: Maintenance -- [ ] Monitor issues -- [ ] Gather feedback +- [ ] Monitor adoption and feedback ## FAQ **Q: Will this break my existing Fluent UI v9 components?** -A: No. Headless mode is opt-in. If you do not change your bundler configuration, you will continue to get the default Fluent styles. +A: No. Unstyled mode is opt-in. If you do not change your bundler configuration, you will continue to get the default Fluent styles. No public APIs change. **Q: Can I use my own CSS, Tailwind, or CSS-in-JS solution?** -A: Yes! Headless mode is designed to let you provide your own styling using any method you prefer. +A: Yes! Unstyled mode is designed to let you provide your own styling using any method you prefer (pure CSS, CSS Modules, Tailwind CSS, Griffel, or any other solution). You provide your own class names via the `className` prop. + +**Q: Should I use `FluentProvider` or `ThemelessFluentProvider`?** + +A: + +- **Use `ThemelessFluentProvider`** if you're providing all styling with your own CSS/design system and don't need Fluent's theme tokens. This results in a smaller bundle by excluding Fluent's design token system. +- **Use `FluentProvider`** if you want to access Fluent's design tokens (as CSS variables) in your custom styles, or if you're mixing styled and unstyled components. + +**What's the difference?** + +- `FluentProvider`: Includes Fluent's complete theme system (colors, spacing, typography tokens) +- `ThemelessFluentProvider`: Minimal provider without theme tokens (smaller bundle) + +Both providers support the same component behavior and functionality. + +**Q: How do I discover what slots are available for styling?** + +A: Each component has documented slots: + +**Common patterns:** + +- Button: `root`, `icon` +- Menu: `root`, item slots, icon slots +- Dialog: `root`, `surface`, `title`, `content` + +**Discovery methods:** + +1. Check component documentation for slot information +2. Review TypeScript types (e.g., `ButtonSlots`) in the component package +3. Refer to the styled component's structure as a guide + +All slots in unstyled components have the same structure as their styled counterparts. **Q: How do I switch back to default styles?** -A: Simply remove the headless extension from your bundler configuration and the default styles will be restored. +A: Simply switch to the regular component import instead of unstyled one (eg. `import { Button } from '@fluentui/react-components'` instead of `import { ButtonUnstyled } from '@fluentui/react-components'`) **Q: What if a new slot is added to a component?** -A: The headless variant will need to be updated to ensure all slots receive the correct class names. Tooling or automation may be provided to help keep these in sync. +A: The unstyled variant will be updated to reflect the new slot structure. Tooling or automation may be provided to help keep these in sync. Slot structures follow semantic versioning (see [Slot Structure Stability](#slot-structure-stability--api-guarantees)). + +**Q: Is there any runtime cost if I use unstyled mode?** + +A: No. If you opt in to unstyled mode, no style engine or default style code is included in your bundle unless you explicitly add it. + +**Q: Are design-related props like `appearance` and `size` still available?** + +A: No. Unstyled components use `ButtonBaseProps` which excludes design props. This keeps them minimal and forces explicit styling. Use `className` prop to apply styling based on your own logic. See [Design API Rationale](#design-api-rationale) for details. + +**Q: What's the difference between base hooks and unstyled components?** + +A: + +- **Base hooks** (`useButtonBase_unstable`): Provide behavior/accessibility and define the semantic slot structure, but do not apply visual styles, class names, default slot implementations (icons, etc.), or motion logic. Use when building completely custom components. +- **Unstyled components** (`ButtonUnstyled`): Wrappers over base hooks that render the component structure. Use when you want Fluent's structure with your own styling. + +**Q: Can I mix styled and unstyled components in the same app?** + +A: Yes! You can use `Button`, `ButtonUnstyled`, and `useButtonBase_unstable` all in the same application. Choose the right tool for each use case. + +**Q: Do unstyled components work with Fluent's theming system?** + +A: Unstyled components don't use Fluent's default styles, but you can still access design tokens via `FluentProvider` (CSS variables) or via token exports from `@fluentui/react-components`. Use `ThemelessFluentProvider` if you don't need Fluent theme tokens and are providing all styling yourself (smaller bundle). + +**Q: How do I handle responsive design with unstyled components?** + +A: Use standard CSS media queries targeting the base class names (`.fui-Button`), or use responsive utilities from your CSS framework (Tailwind, etc.). + +**Q: Do unstyled components work with Server-Side Rendering (SSR)?** + +A: Yes! Unstyled components work with all React SSR frameworks (Next.js, Remix, etc.): + +- **No hydration mismatches:** Since unstyled components have no runtime style injection, there's no risk of style-related hydration issues. +- **CSS Modules/Tailwind:** Work seamlessly with SSR, following the framework's standard CSS handling. +- **Griffel with SSR:** If using Griffel with unstyled components via `className`, follow Griffel's SSR setup (CSS extraction). + +The main benefit: no Griffel runtime means simpler SSR setup and fewer potential hydration issues. + +**Q: How do I ensure I haven't broken accessibility with custom styles?** + +A: Follow these practices: + +1. **Preserve focus indicators:** Always style `:focus` and `:focus-visible` states +2. **Maintain sufficient contrast:** Use contrast checkers for text and interactive elements (WCAG AA: 4.5:1 minimum) +3. **Test with screen readers:** Behavior is preserved, but ensure styles don't interfere (e.g., `display: none` hides from screen readers) +4. **Test keyboard navigation:** Ensure all interactive elements are keyboard accessible +5. **High contrast mode:** Test in Windows High Contrast Mode or use `prefers-contrast` media query +6. **Use browser DevTools:** Lighthouse accessibility audits can catch common issues + +The unstyled components preserve all ARIA attributes, keyboard handling, and semantic structure. Your responsibility is to ensure custom styles don't interfere with accessibility (e.g., hiding focus indicators, insufficient contrast). + +**Q: How do I migrate a large codebase to unstyled components?** + +A: Use an incremental migration strategy: + +1. **Start with new features:** Use unstyled components for new development while keeping existing code unchanged +2. **Migrate by feature area:** Convert one feature/page at a time rather than all at once +3. **Create a design system layer:** Build wrapper components that use unstyled components with your design system props +4. **Use search/replace carefully:** Find all imports of specific components (e.g., `import { Button }`) and evaluate each usage +5. **Test thoroughly:** Ensure behavior and accessibility remain intact after migration +6. **Monitor bundle size:** Track bundle size improvements as you migrate components + +**Example incremental approach:** + +```tsx +// Step 1: Create design system wrapper +export const DSButton = ({ variant, ...props }) => ( + +); + +// Step 2: Migrate imports gradually +// Old: import { Button } from '@fluentui/react-components'; +// New: import { DSButton as Button } from '@/design-system'; + +// Step 3: Update implementation over time +``` + +This allows you to migrate at your own pace while maintaining a working application throughout the process. + +**Q: Is unstyled mode a replacement for Fluent UI?** + +A: No. Unstyled mode is a complementary solution that expands Fluent UI's reach by supporting additional use cases. It allows teams to build custom design systems on top of Fluent UI's robust behavior, accessibility, and component architecture, while providing complete control over visual design. This makes Fluent UI a better foundation for organizations with unique design requirements. + +## Component Comparison + +| Feature | Styled Component | Unstyled Component | Base Hook | +| ---------------------------- | ----------------- | -------------------------------- | ----------------- | +| Default styles | ✅ Yes | ❌ No | ❌ No | +| Base class names | ✅ Yes | ❌ No | ❌ No | +| Design props | ✅ Yes | ❌ No (uses ButtonBaseProps) | ❌ No | +| Default slot implementations | ✅ Yes | ❌ No | ❌ No | +| Motion logic | ✅ Yes | ❌ No | ❌ No | +| Behavior/Accessibility | ✅ Yes | ✅ Yes | ✅ Yes | +| Bundle size | Larger | Smaller | Smallest | +| Use case | Default Fluent UI | Custom styling, Fluent structure | Completely custom | + +## Architecture Diagram + +```text +┌─────────────────────────────────────────┐ +│ ButtonProps (Full API) │ +│ (includes design props) │ +└─────────────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + │ │ +┌───────▼────────┐ ┌─────────▼─────────┐ +│ Button │ │ ButtonUnstyled │ +│ (Styled) │ │ (Unstyled) │ +│ │ │ ButtonBaseProps │ +└───────┬────────┘ └─────────┬─────────┘ + │ │ + │ ┌────────▼─────────┐ + │ │ useButtonBase │ + │ │ _unstable │ + │ │ ButtonBaseProps │ + │ └──────────────────┘ + │ +┌───────▼────────┐ +│ useButton_ │ +│ unstable │ +│ (adds design) │ +└───────┬────────┘ + │ +┌───────▼────────┐ +│ useButtonStyles│ +│ _unstable │ +│ (Griffel) │ +└────────────────┘ +``` + +## Real-World Use Cases + +### Use Case 1: Brand-Specific Design System + +A company wants to use Fluent's accessible button behavior but with their brand colors and styling. + +**Solution:** Use `ButtonUnstyled` with custom CSS via `className` prop. + +```tsx +import { ButtonUnstyled } from '@fluentui/react-components'; +import './brand-button.css'; // Your brand styles + +Click me; +``` + +### Use Case 2: Tailwind-First Team + +A team using Tailwind CSS wants Fluent's behavior without Fluent's styles. + +**Solution:** Use `ButtonUnstyled` and apply Tailwind classes via `className`. + +```tsx +import { ButtonUnstyled } from '@fluentui/react-components'; + +Click me; +``` + +### Use Case 3: Custom Component Library + +A team wants to build their own component library on top of Fluent's behavior. + +**Solution:** Use `useButtonBase_unstable` to build completely custom components. + +```tsx +import * as React from 'react'; +import { useButtonBase_unstable, renderButton_unstable } from '@fluentui/react-components'; +import type { ButtonBaseProps } from '@fluentui/react-components'; + +type CustomButtonProps = ButtonBaseProps & { + appearance?: 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'link'; + size?: 'tiny' | 'small' | 'medium' | 'large' | 'huge'; +}; + +const MyCustomButton = React.forwardRef( + ({ appearance = 'primary', size = 'medium', ...props }, ref) => { + // Used as a headless hook to build a custom component + const state = useButtonBase_unstable(props, ref); + + state.root.className = ['btn', `btn-${appearance}`, `btn-${size}`, state.root.className].join(' '); + + if (state.components.root === 'a') { + return {state.root.children}; + } + + return ; + }, +); +``` + +## Troubleshooting + +### My unstyled component doesn't have any styling + +- Unstyled components do not apply any class names by default +- You must provide your own class names via the `className` prop +- Ensure you've defined CSS for the classes you're applying -**Q: Is there any runtime cost if I use headless mode?** +### Can I use design props with unstyled components? -A: No. If you opt in to headless mode, no style engine or default style code is included in your bundle unless you explicitly add it. +- Currently, `ButtonUnstyled` uses `ButtonBaseProps` which doesn't include design props +- Use `className` prop to apply styles based on your own logic -**Q: Is headless mode a replacement for Fluent UI?** +### How do I style based on component state? -A: No. Headless mode is a complementary solution that expands Fluent UI's reach by supporting additional use cases. It allows teams to build custom design systems on top of Fluent UI's robust behavior, accessibility, and component architecture, while providing complete control over visual design. This makes Fluent UI a better foundation for organizations with unique design requirements. +- Use `className` prop with conditional logic +- Use CSS attribute selectors if you add data attributes +- Note: Unstyled components don't have design state, only behavior state ## References -- [Unprocessed Styles Documentation](https://react.fluentui.dev/?path=/docs/concepts-developer-unprocessed-styles--docs) +- [Mantine UI Unstyled components](https://mantine.dev/styles/unstyled/) diff --git a/packages/react-components/react-accordion/library/src/components/AccordionHeader/index.ts b/packages/react-components/react-accordion/library/src/components/AccordionHeader/index.ts index 1f25c7dbe108c..8f8e61b5e6855 100644 --- a/packages/react-components/react-accordion/library/src/components/AccordionHeader/index.ts +++ b/packages/react-components/react-accordion/library/src/components/AccordionHeader/index.ts @@ -9,5 +9,6 @@ export type { } from './AccordionHeader.types'; export { renderAccordionHeader_unstable } from './renderAccordionHeader'; export { useAccordionHeader_unstable } from './useAccordionHeader'; +export { useAccordionHeaderBase_unstable } from './useAccordionHeaderBase'; export { useAccordionHeaderContextValues_unstable } from './useAccordionHeaderContextValues'; export { accordionHeaderClassNames, useAccordionHeaderStyles_unstable } from './useAccordionHeaderStyles.styles'; diff --git a/packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeader.tsx b/packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeader.tsx index 14645b0f83f51..4c96b4ae423c8 100644 --- a/packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeader.tsx +++ b/packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeader.tsx @@ -1,14 +1,12 @@ 'use client'; import * as React from 'react'; -import { getIntrinsicElementProps, useEventCallback, slot, isResolvedShorthand } from '@fluentui/react-utilities'; -import { useARIAButtonProps } from '@fluentui/react-aria'; +import { slot } from '@fluentui/react-utilities'; import type { AccordionHeaderProps, AccordionHeaderState } from './AccordionHeader.types'; -import { useAccordionContext_unstable } from '../../contexts/accordion'; import { ChevronRightRegular } from '@fluentui/react-icons'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import { useAccordionItemContext_unstable } from '../../contexts/accordionItem'; import { motionTokens } from '@fluentui/react-motion'; +import { useAccordionHeaderBase_unstable } from './useAccordionHeaderBase'; /** * Returns the props and state required to render the component @@ -19,15 +17,8 @@ export const useAccordionHeader_unstable = ( props: AccordionHeaderProps, ref: React.Ref, ): AccordionHeaderState => { - const { icon, button, expandIcon, inline = false, size = 'medium', expandIconPosition = 'start' } = props; - const { value, disabled, open } = useAccordionItemContext_unstable(); - const requestToggle = useAccordionContext_unstable(ctx => ctx.requestToggle); - - /** - * force disabled state on button if accordion isn't collapsible - * and this is the only item opened - */ - const disabledFocusable = useAccordionContext_unstable(ctx => !ctx.collapsible && ctx.openItems.length === 1 && open); + const { expandIcon, inline = false, size = 'medium', expandIconPosition = 'start' } = props; + const state = useAccordionHeaderBase_unstable(props, ref); const { dir } = useFluent(); @@ -35,54 +26,14 @@ export const useAccordionHeader_unstable = ( let expandIconRotation: 0 | 90 | -90 | 180; if (expandIconPosition === 'end') { // If expand icon is at the end, the chevron points up [^] when open, and down [v] when closed - expandIconRotation = open ? -90 : 90; + expandIconRotation = state.open ? -90 : 90; } else { // Otherwise, the chevron points down [v] when open, and right [>] (or left [<] in RTL) when closed - expandIconRotation = open ? 90 : dir !== 'rtl' ? 0 : 180; + expandIconRotation = state.open ? 90 : dir !== 'rtl' ? 0 : 180; } - const buttonSlot = slot.always(button, { - elementType: 'button', - defaultProps: { - disabled, - disabledFocusable, - 'aria-expanded': open, - type: 'button', - }, - }); - - buttonSlot.onClick = useEventCallback(event => { - if (isResolvedShorthand(button)) { - button.onClick?.(event); - } - if (!event.defaultPrevented) { - requestToggle({ value, event }); - } - }); - return { - disabled, - open, - size, - inline, - expandIconPosition, - components: { - root: 'div', - button: 'button', - expandIcon: 'span', - icon: 'div', - }, - root: slot.always( - getIntrinsicElementProps('div', { - // FIXME: - // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` - // but since it would be a breaking change to fix it, we are casting ref to it's proper type - ref: ref as React.Ref, - ...props, - }), - { elementType: 'div' }, - ), - icon: slot.optional(icon, { elementType: 'div' }), + ...state, expandIcon: slot.optional(expandIcon, { renderByDefault: true, defaultProps: { @@ -98,6 +49,7 @@ export const useAccordionHeader_unstable = ( }, elementType: 'span', }), - button: useARIAButtonProps(buttonSlot.as, buttonSlot), + size, + inline, }; }; diff --git a/packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeaderBase.tsx b/packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeaderBase.tsx new file mode 100644 index 0000000000000..df7f1d7b8149a --- /dev/null +++ b/packages/react-components/react-accordion/library/src/components/AccordionHeader/useAccordionHeaderBase.tsx @@ -0,0 +1,76 @@ +'use client'; + +import * as React from 'react'; +import { getIntrinsicElementProps, useEventCallback, slot, isResolvedShorthand } from '@fluentui/react-utilities'; +import { useARIAButtonProps } from '@fluentui/react-aria'; +import type { AccordionHeaderProps, AccordionHeaderState } from './AccordionHeader.types'; +import { useAccordionContext_unstable } from '../../contexts/accordion'; +import { useAccordionItemContext_unstable } from '../../contexts/accordionItem'; + +type AccordionHeaderBaseProps = Omit; + +type AccordionHeaderBaseState = Omit; + +/** + * Returns the props and state required to render the component + * @param props - AccordionHeader properties + * @param ref - reference to root HTMLElement of AccordionHeader + */ +export const useAccordionHeaderBase_unstable = ( + props: AccordionHeaderBaseProps, + ref: React.Ref, +): AccordionHeaderBaseState => { + const { icon, button, expandIcon, expandIconPosition = 'start' } = props; + const { value, disabled, open } = useAccordionItemContext_unstable(); + const requestToggle = useAccordionContext_unstable(ctx => ctx.requestToggle); + + /** + * force disabled state on button if accordion isn't collapsible + * and this is the only item opened + */ + const disabledFocusable = useAccordionContext_unstable(ctx => !ctx.collapsible && ctx.openItems.length === 1 && open); + + const buttonSlot = slot.always(button, { + elementType: 'button', + defaultProps: { + disabled, + disabledFocusable, + 'aria-expanded': open, + type: 'button', + }, + }); + + buttonSlot.onClick = useEventCallback(event => { + if (isResolvedShorthand(button)) { + button.onClick?.(event); + } + if (!event.defaultPrevented) { + requestToggle({ value, event }); + } + }); + + return { + disabled, + open, + expandIconPosition, + components: { + root: 'div', + button: 'button', + expandIcon: 'span', + icon: 'div', + }, + root: slot.always( + getIntrinsicElementProps('div', { + // FIXME: + // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` + // but since it would be a breaking change to fix it, we are casting ref to it's proper type + ref: ref as React.Ref, + ...props, + }), + { elementType: 'div' }, + ), + icon: slot.optional(icon, { elementType: 'div' }), + expandIcon: slot.optional(expandIcon, { elementType: 'span' }), + button: useARIAButtonProps(buttonSlot.as, buttonSlot), + }; +}; diff --git a/packages/react-components/react-accordion/library/src/components/AccordionPanel/index.ts b/packages/react-components/react-accordion/library/src/components/AccordionPanel/index.ts index 0b1ceb7121f3f..43d2b6a5f0dd6 100644 --- a/packages/react-components/react-accordion/library/src/components/AccordionPanel/index.ts +++ b/packages/react-components/react-accordion/library/src/components/AccordionPanel/index.ts @@ -2,4 +2,5 @@ export { AccordionPanel } from './AccordionPanel'; export type { AccordionPanelProps, AccordionPanelSlots, AccordionPanelState } from './AccordionPanel.types'; export { renderAccordionPanel_unstable } from './renderAccordionPanel'; export { useAccordionPanel_unstable } from './useAccordionPanel'; +export { useAccordionPanelBase_unstable } from './useAccordionPanelBase'; export { accordionPanelClassNames, useAccordionPanelStyles_unstable } from './useAccordionPanelStyles.styles'; diff --git a/packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanel.ts b/packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanel.ts index 473538bbcc54e..c1d4fe3796cfc 100644 --- a/packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanel.ts +++ b/packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanel.ts @@ -1,13 +1,10 @@ 'use client'; import * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import { useTabsterAttributes } from '@fluentui/react-tabster'; import { presenceMotionSlot } from '@fluentui/react-motion'; import { Collapse } from '@fluentui/react-motion-components-preview'; -import { useAccordionContext_unstable } from '../../contexts/accordion'; import type { AccordionPanelProps, AccordionPanelState } from './AccordionPanel.types'; -import { useAccordionItemContext_unstable } from '../../contexts/accordionItem'; +import { useAccordionPanelBase_unstable } from './useAccordionPanelBase'; /** * Returns the props and state required to render the component @@ -18,31 +15,19 @@ export const useAccordionPanel_unstable = ( props: AccordionPanelProps, ref: React.Ref, ): AccordionPanelState => { - const { open } = useAccordionItemContext_unstable(); - const focusableProps = useTabsterAttributes({ focusable: { excludeFromMover: true } }); - const navigation = useAccordionContext_unstable(ctx => ctx.navigation); + const baseState = useAccordionPanelBase_unstable(props, ref); return { - open, + ...baseState, components: { - root: 'div', + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...baseState.components, collapseMotion: Collapse, }, - root: slot.always( - getIntrinsicElementProps('div', { - // FIXME: - // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` - // but since it would be a breaking change to fix it, we are casting ref to it's proper type - ref: ref as React.Ref, - ...props, - ...(navigation && focusableProps), - }), - { elementType: 'div' }, - ), collapseMotion: presenceMotionSlot(props.collapseMotion, { elementType: Collapse, defaultProps: { - visible: open, + visible: baseState.open, unmountOnExit: true, }, }), diff --git a/packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanelBase.ts b/packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanelBase.ts new file mode 100644 index 0000000000000..eb6f00402cb79 --- /dev/null +++ b/packages/react-components/react-accordion/library/src/components/AccordionPanel/useAccordionPanelBase.ts @@ -0,0 +1,46 @@ +'use client'; + +import * as React from 'react'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import { useTabsterAttributes } from '@fluentui/react-tabster'; +import { Collapse } from '@fluentui/react-motion-components-preview'; +import { useAccordionContext_unstable } from '../../contexts/accordion'; +import type { AccordionPanelProps, AccordionPanelState } from './AccordionPanel.types'; +import { useAccordionItemContext_unstable } from '../../contexts/accordionItem'; + +type AccordionPanelBaseProps = Omit; + +type AccordionPanelBaseState = Omit; + +/** + * Returns the props and state required to render the component + * @param props - AccordionPanel properties + * @param ref - reference to root HTMLElement of AccordionPanel + */ +export const useAccordionPanelBase_unstable = ( + props: AccordionPanelBaseProps, + ref: React.Ref, +): AccordionPanelBaseState => { + const { open } = useAccordionItemContext_unstable(); + const focusableProps = useTabsterAttributes({ focusable: { excludeFromMover: true } }); + const navigation = useAccordionContext_unstable(ctx => ctx.navigation); + + return { + open, + components: { + root: 'div', + collapseMotion: Collapse, + }, + root: slot.always( + getIntrinsicElementProps('div', { + // FIXME: + // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` + // but since it would be a breaking change to fix it, we are casting ref to it's proper type + ref: ref as React.Ref, + ...props, + ...(navigation && focusableProps), + }), + { elementType: 'div' }, + ), + }; +}; diff --git a/packages/react-components/react-button/library/bundle-size/ButtonUnstyled.fixture.js b/packages/react-components/react-button/library/bundle-size/ButtonUnstyled.fixture.js new file mode 100644 index 0000000000000..8f8c63d7c5bbb --- /dev/null +++ b/packages/react-components/react-button/library/bundle-size/ButtonUnstyled.fixture.js @@ -0,0 +1,7 @@ +import { ButtonUnstyled } from '@fluentui/react-button'; + +console.log(ButtonUnstyled); + +export default { + name: 'ButtonUnstyled', +}; diff --git a/packages/react-components/react-button/library/etc/react-button.api.md b/packages/react-components/react-button/library/etc/react-button.api.md index e237a037018e4..565a59650378b 100644 --- a/packages/react-components/react-button/library/etc/react-button.api.md +++ b/packages/react-components/react-button/library/etc/react-button.api.md @@ -7,6 +7,7 @@ import type { ARIAButtonSlotProps } from '@fluentui/react-aria'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import { DistributiveOmit } from '@fluentui/react-utilities'; import { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; @@ -29,15 +30,19 @@ export interface ButtonContextValue { } // @public (undocumented) -export type ButtonProps = ComponentProps & { +export type ButtonDesignProps = { appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent'; - disabledFocusable?: boolean; - disabled?: boolean; - iconPosition?: 'before' | 'after'; shape?: 'rounded' | 'circular' | 'square'; size?: ButtonSize; }; +// @public (undocumented) +export type ButtonProps = ComponentProps & { + disabledFocusable?: boolean; + disabled?: boolean; + iconPosition?: 'before' | 'after'; +} & ButtonDesignProps; + // @public (undocumented) export type ButtonSlots = { root: NonNullable>>; @@ -45,10 +50,13 @@ export type ButtonSlots = { }; // @public (undocumented) -export type ButtonState = ComponentState & Required> & { +export type ButtonState = ComponentState & Required> & Required & { iconOnly: boolean; }; +// @public +export const ButtonUnstyled: React_2.ForwardRefExoticComponent>; + // @public export const CompoundButton: ForwardRefComponent; @@ -135,6 +143,9 @@ export type ToggleButtonState = ButtonState & Required) => ButtonState; +// @public +export const useButtonBase_unstable: (props: ButtonBaseProps, ref: React_2.Ref) => ButtonBaseState; + // @internal export const useButtonContext: () => ButtonContextValue; @@ -150,6 +161,9 @@ export const useCompoundButtonStyles_unstable: (state: CompoundButtonState) => C // @public export const useMenuButton_unstable: ({ menuIcon, ...props }: MenuButtonProps, ref: React_2.Ref) => MenuButtonState; +// @public +export const useMenuButtonBase_unstable: ({ menuIcon, ...props }: MenuButtonBaseProps, ref: React_2.Ref) => MenuButtonBaseState; + // @public (undocumented) export const useMenuButtonStyles_unstable: (state: MenuButtonState) => MenuButtonState; diff --git a/packages/react-components/react-button/library/src/Button.tsx b/packages/react-components/react-button/library/src/Button.tsx index ce711cd84c1d2..ac768b45075b3 100644 --- a/packages/react-components/react-button/library/src/Button.tsx +++ b/packages/react-components/react-button/library/src/Button.tsx @@ -1,8 +1,10 @@ -export type { ButtonProps, ButtonSlots, ButtonState } from './components/Button/index'; +export type { ButtonProps, ButtonDesignProps, ButtonSlots, ButtonState } from './components/Button/index'; export { Button, + ButtonUnstyled, buttonClassNames, renderButton_unstable, useButtonStyles_unstable, useButton_unstable, + useButtonBase_unstable, } from './components/Button/index'; diff --git a/packages/react-components/react-button/library/src/MenuButton.ts b/packages/react-components/react-button/library/src/MenuButton.ts index 785ec93d69667..5fd3ddb978428 100644 --- a/packages/react-components/react-button/library/src/MenuButton.ts +++ b/packages/react-components/react-button/library/src/MenuButton.ts @@ -5,4 +5,5 @@ export { renderMenuButton_unstable, useMenuButtonStyles_unstable, useMenuButton_unstable, + useMenuButtonBase_unstable, } from './components/MenuButton/index'; diff --git a/packages/react-components/react-button/library/src/components/Button/Button.types.ts b/packages/react-components/react-button/library/src/components/Button/Button.types.ts index 201f264913be0..92ac5d95fd552 100644 --- a/packages/react-components/react-button/library/src/components/Button/Button.types.ts +++ b/packages/react-components/react-button/library/src/components/Button/Button.types.ts @@ -18,7 +18,7 @@ export type ButtonSlots = { */ export type ButtonSize = 'small' | 'medium' | 'large'; -export type ButtonProps = ComponentProps & { +export type ButtonDesignProps = { /** * A button can have its content and borders styled for greater emphasis or to be subtle. * - 'secondary' (default): Gives emphasis to the button in such a way that it indicates a secondary action. @@ -31,6 +31,22 @@ export type ButtonProps = ComponentProps & { */ appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent'; + /** + * A button can be rounded, circular, or square. + * + * @default 'rounded' + */ + shape?: 'rounded' | 'circular' | 'square'; + + /** + * A button supports different sizes. + * + * @default 'medium' + */ + size?: ButtonSize; +}; + +export type ButtonProps = ComponentProps & { /** * When set, allows the button to be focusable even when it has been disabled. This is used in scenarios where it * is important to keep a consistent tab order for screen reader and keyboard users. The primary example of this @@ -53,24 +69,11 @@ export type ButtonProps = ComponentProps & { * @default 'before' */ iconPosition?: 'before' | 'after'; - - /** - * A button can be rounded, circular, or square. - * - * @default 'rounded' - */ - shape?: 'rounded' | 'circular' | 'square'; - - /** - * A button supports different sizes. - * - * @default 'medium' - */ - size?: ButtonSize; -}; +} & ButtonDesignProps; export type ButtonState = ComponentState & - Required> & { + Required> & + Required & { /** * A button can contain only an icon. * diff --git a/packages/react-components/react-button/library/src/components/Button/ButtonUnstyled.tsx b/packages/react-components/react-button/library/src/components/Button/ButtonUnstyled.tsx new file mode 100644 index 0000000000000..f4d870c16bb90 --- /dev/null +++ b/packages/react-components/react-button/library/src/components/Button/ButtonUnstyled.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; +import { renderButton_unstable } from './renderButton'; +import type { ButtonState } from './Button.types'; +import { useButtonBase_unstable } from './useButtonBase'; +type ButtonBaseProps = Parameters[0]; + +/** + * ButtonUnstyled - an unstyled version of the Button component, has no default Fluent styles applied but provides + * the necessary structure and behavior. + * + * @param props - Button props + * @param ref - Ref to the button element + */ +export const ButtonUnstyled = React.forwardRef((props, ref) => { + const state = useButtonBase_unstable(props, ref); + + return renderButton_unstable(state as ButtonState); +}); + +ButtonUnstyled.displayName = 'ButtonUnstyled'; diff --git a/packages/react-components/react-button/library/src/components/Button/index.ts b/packages/react-components/react-button/library/src/components/Button/index.ts index 0c8139e9838f2..d590d384a991e 100644 --- a/packages/react-components/react-button/library/src/components/Button/index.ts +++ b/packages/react-components/react-button/library/src/components/Button/index.ts @@ -1,6 +1,8 @@ export { Button } from './Button'; +export { ButtonUnstyled } from './ButtonUnstyled'; // Explicit exports to omit ButtonCommons -export type { ButtonProps, ButtonSlots, ButtonState } from './Button.types'; +export type { ButtonProps, ButtonDesignProps, ButtonSlots, ButtonState } from './Button.types'; export { renderButton_unstable } from './renderButton'; export { useButton_unstable } from './useButton'; +export { useButtonBase_unstable } from './useButtonBase'; export { buttonClassNames, useButtonStyles_unstable } from './useButtonStyles.styles'; diff --git a/packages/react-components/react-button/library/src/components/Button/useButton.ts b/packages/react-components/react-button/library/src/components/Button/useButton.ts index 1351591be6e54..290724ba2388a 100644 --- a/packages/react-components/react-button/library/src/components/Button/useButton.ts +++ b/packages/react-components/react-button/library/src/components/Button/useButton.ts @@ -1,13 +1,12 @@ 'use client'; import * as React from 'react'; -import { ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; import { useButtonContext } from '../../contexts/ButtonContext'; +import { useButtonBase_unstable } from './useButtonBase'; import type { ButtonProps, ButtonState } from './Button.types'; /** - * Given user props, defines default props for the Button, calls useButtonState, and returns processed state. + * Given user props, defines default props for the Button, calls useButtonBase_unstable, and returns processed state. * @param props - User provided props to the Button component. * @param ref - User provided ref to be passed to the Button component. */ @@ -16,34 +15,13 @@ export const useButton_unstable = ( ref: React.Ref, ): ButtonState => { const { size: contextSize } = useButtonContext(); - const { - appearance = 'secondary', - as = 'button', - disabled = false, - disabledFocusable = false, - icon, - iconPosition = 'before', - shape = 'rounded', - size = contextSize ?? 'medium', - } = props; - const iconShorthand = slot.optional(icon, { elementType: 'span' }); + const { appearance = 'secondary', shape = 'rounded', size = contextSize ?? 'medium' } = props; + const state = useButtonBase_unstable(props, ref); + return { - // Props passed at the top-level + ...state, appearance, - disabled, - disabledFocusable, - iconPosition, shape, - size, // State calculated from a set of props - iconOnly: Boolean(iconShorthand?.children && !props.children), // Slots definition - components: { root: 'button', icon: 'span' }, - root: slot.always>(getIntrinsicElementProps(as, useARIAButtonProps(props.as, props)), { - elementType: 'button', - defaultProps: { - ref: ref as React.Ref, - type: as === 'button' ? 'button' : undefined, - }, - }), - icon: iconShorthand, + size, }; }; diff --git a/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts b/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts new file mode 100644 index 0000000000000..11316e134a3c1 --- /dev/null +++ b/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts @@ -0,0 +1,44 @@ +'use client'; + +import * as React from 'react'; +import { type ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria'; +import { DistributiveOmit, getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import { ButtonDesignProps, ButtonProps, ButtonState } from './Button.types'; + +export type ButtonBaseProps = DistributiveOmit; + +export type ButtonBaseState = DistributiveOmit; + +/** + * Given user props, defines default props for the Button base state and returns it, without design props and their defaults. + * + * @param props - User provided props to the Button component. + * @param ref - User provided ref to be passed to the Button component. + * @returns Button base state + */ +export const useButtonBase_unstable = ( + props: ButtonBaseProps, + ref: React.Ref, +): ButtonBaseState => { + const { as = 'button', disabled = false, disabledFocusable = false, icon, iconPosition = 'before' } = props; + const iconShorthand = slot.optional(icon, { elementType: 'span' }); + + return { + disabled, + disabledFocusable, + iconPosition, + iconOnly: Boolean(iconShorthand?.children && !props.children), + root: slot.always>( + getIntrinsicElementProps(as, useARIAButtonProps(props.as, props as ARIAButtonSlotProps<'a'>)), + { + elementType: as, + defaultProps: { + ref: ref as React.Ref, + type: as === 'button' ? 'button' : undefined, + }, + }, + ), + icon: iconShorthand, + components: { root: as, icon: 'span' }, + }; +}; diff --git a/packages/react-components/react-button/library/src/components/Button/useButtonBehavior.test.tsx b/packages/react-components/react-button/library/src/components/Button/useButtonBehavior.test.tsx new file mode 100644 index 0000000000000..289b8af5e1661 --- /dev/null +++ b/packages/react-components/react-button/library/src/components/Button/useButtonBehavior.test.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { render, renderHook } from '@testing-library/react'; +import { useButtonBase_unstable } from './useButtonBase'; + +describe('useButtonBase', () => { + it('returns the correct initial state', () => { + const { result } = renderHook(() => useButtonBase_unstable({}, React.createRef())); + expect(result.current).toMatchObject({ + components: { root: 'button', icon: 'span' }, + disabled: false, + disabledFocusable: false, + iconPosition: 'before', + iconOnly: false, + root: { type: 'button' }, + icon: undefined, + }); + }); + + it('returns the correct state with passed props', () => { + const { result } = renderHook(() => useButtonBase_unstable({ disabled: true, icon: 'icon' }, React.createRef())); + expect(result.current).toMatchObject({ + components: { root: 'button', icon: 'span' }, + disabled: true, + disabledFocusable: false, + iconPosition: 'before', + iconOnly: true, + root: { type: 'button' }, + icon: { + children: 'icon', + }, + }); + }); + + describe('used as a headless hook to build a custom component', () => { + type ButtonBaseProps = Parameters[0]; + + type CustomButtonProps = ButtonBaseProps & { + appearance?: 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'link'; + size?: 'tiny' | 'small' | 'medium' | 'large' | 'huge'; + }; + + const CustomButton = React.forwardRef( + ({ appearance = 'primary', size = 'medium', className, ...props }, ref) => { + // Used as a headless hook to build a custom component + const state = useButtonBase_unstable(props, ref); + + state.root.className = ['btn', `btn-${appearance}`, `btn-${size}`, className].filter(Boolean).join(' '); + + // Render the root slot as an anchor or button based on the as prop + if (state.root.as === 'a') { + return ; + } + + // Render the root slot as a button + return