From 4380c31b7eb2c79ed1c36d166a0449877aea5849 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 5 Aug 2026 23:31:25 +0200 Subject: [PATCH 1/5] feat(react-button): expose useSplitButtonBase_unstable design-agnostic base hook Splits SplitButton's internal logic into two layers: - useSplitButtonBase_unstable: design-agnostic base hook (no appearance/shape/ size), producing ARIA-wired root/menuButton/primaryActionButton slots with placeholder 'button' element metadata. - useSplitButton_unstable: styled wrapper that composes the base hook and recreates both child slots via slot.optional with the concrete Button/ MenuButton element types and styling defaults. Also widens renderSplitButton_unstable to accept SplitButtonBaseState so it can be reused directly by headless consumers instead of being duplicated. Adds SplitButtonBaseProps/SplitButtonBaseSlots/SplitButtonBaseState, exported from the SplitButton and package root barrels, together with a bundle-size fixture. This unblocks a future headless SplitButton built directly on the base hook. --- ...-05c79b7f-0d9a-4431-9a44-72fc2e726d1a.json | 7 ++ .../bundle-size/useSplitButtonBase.fixture.js | 7 ++ .../library/etc/react-button.api.md | 18 +++- .../react-button/library/src/SplitButton.ts | 10 ++- .../components/SplitButton/SplitButton.tsx | 3 +- .../SplitButton/SplitButton.types.ts | 39 +++++++- .../src/components/SplitButton/index.ts | 11 ++- .../SplitButton/renderSplitButton.tsx | 4 +- .../components/SplitButton/useSplitButton.ts | 90 +++++++++++++++---- .../react-button/library/src/index.ts | 10 ++- 10 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 change/@fluentui-react-button-05c79b7f-0d9a-4431-9a44-72fc2e726d1a.json create mode 100644 packages/react-components/react-button/library/bundle-size/useSplitButtonBase.fixture.js diff --git a/change/@fluentui-react-button-05c79b7f-0d9a-4431-9a44-72fc2e726d1a.json b/change/@fluentui-react-button-05c79b7f-0d9a-4431-9a44-72fc2e726d1a.json new file mode 100644 index 0000000000000..5f5039d8cba69 --- /dev/null +++ b/change/@fluentui-react-button-05c79b7f-0d9a-4431-9a44-72fc2e726d1a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: expose useSplitButtonBase_unstable hook", + "packageName": "@fluentui/react-button", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-button/library/bundle-size/useSplitButtonBase.fixture.js b/packages/react-components/react-button/library/bundle-size/useSplitButtonBase.fixture.js new file mode 100644 index 0000000000000..a1e15d1fbde15 --- /dev/null +++ b/packages/react-components/react-button/library/bundle-size/useSplitButtonBase.fixture.js @@ -0,0 +1,7 @@ +import { useSplitButtonBase_unstable } from '@fluentui/react-button'; + +console.log(useSplitButtonBase_unstable); + +export default { + name: 'useSplitButtonBase_unstable', +}; 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 1c87fa8d4de09..e363230a94f02 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 @@ -115,11 +115,24 @@ export const renderCompoundButton_unstable: (state: CompoundButtonBaseState) => export const renderMenuButton_unstable: (state: MenuButtonBaseState) => JSXElement; // @public -export const renderSplitButton_unstable: (state: SplitButtonState) => JSXElement; +export const renderSplitButton_unstable: (state: SplitButtonBaseState) => JSXElement; // @public export const SplitButton: ForwardRefComponent; +// @public +export type SplitButtonBaseProps = ComponentProps & Omit & Omit; + +// @public (undocumented) +export type SplitButtonBaseSlots = { + root: NonNullable>; + menuButton?: Slot; + primaryActionButton?: Slot; +}; + +// @public +export type SplitButtonBaseState = ComponentState & Omit & Omit; + // @public (undocumented) export const splitButtonClassNames: SlotClassNames; @@ -191,6 +204,9 @@ export const useMenuButtonStyles_unstable: (state: MenuButtonState) => MenuButto // @public export const useSplitButton_unstable: (props: SplitButtonProps, ref: React_2.Ref) => SplitButtonState; +// @public +export const useSplitButtonBase_unstable: (props: SplitButtonBaseProps, ref?: React_2.Ref) => SplitButtonBaseState; + // @public (undocumented) export const useSplitButtonStyles_unstable: (state: SplitButtonState) => SplitButtonState; diff --git a/packages/react-components/react-button/library/src/SplitButton.ts b/packages/react-components/react-button/library/src/SplitButton.ts index 4b3dc83adc8d5..c62e06d0553d9 100644 --- a/packages/react-components/react-button/library/src/SplitButton.ts +++ b/packages/react-components/react-button/library/src/SplitButton.ts @@ -1,8 +1,16 @@ -export type { SplitButtonProps, SplitButtonSlots, SplitButtonState } from './components/SplitButton/index'; +export type { + SplitButtonBaseProps, + SplitButtonBaseSlots, + SplitButtonBaseState, + SplitButtonProps, + SplitButtonSlots, + SplitButtonState, +} from './components/SplitButton/index'; export { SplitButton, renderSplitButton_unstable, splitButtonClassNames, useSplitButtonStyles_unstable, useSplitButton_unstable, + useSplitButtonBase_unstable, } from './components/SplitButton/index'; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx index 6f9824b58e345..09339bcc8d380 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx @@ -20,7 +20,6 @@ export const SplitButton: ForwardRefComponent = React.forwardR useCustomStyleHook_unstable('useSplitButtonStyles_unstable')(state); return renderSplitButton_unstable(state); - // Casting is required due to lack of distributive union to support unions on @types/react -}) as ForwardRefComponent; +}); SplitButton.displayName = 'SplitButton'; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts index 3211fc28a4b02..8de8816e5c803 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts @@ -1,8 +1,13 @@ import type { Button } from '../Button/Button'; import type { MenuButton } from '../MenuButton/MenuButton'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; -import type { ButtonProps, ButtonState } from '../Button/Button.types'; -import type { MenuButtonProps, MenuButtonState } from '../MenuButton/MenuButton.types'; +import type { ButtonBaseProps, ButtonBaseState, ButtonProps, ButtonState } from '../Button/Button.types'; +import type { + MenuButtonBaseProps, + MenuButtonBaseState, + MenuButtonProps, + MenuButtonState, +} from '../MenuButton/MenuButton.types'; export type SplitButtonSlots = { /** @@ -27,3 +32,33 @@ export type SplitButtonProps = ComponentProps & export type SplitButtonState = ComponentState & Omit & Omit; + +export type SplitButtonBaseSlots = { + /** + * Root of the component that wraps the primary action button and menu button. + */ + root: NonNullable>; + + /** + * Button that opens menu with secondary actions in SplitButton. + */ + menuButton?: Slot; + /** + * Button to perform primary action in SplitButton. + */ + primaryActionButton?: Slot; +}; + +/** + * SplitButton props without the `appearance`/`size`/`shape` styling props, for headless usage. + */ +export type SplitButtonBaseProps = ComponentProps & + Omit & + Omit; + +/** + * SplitButton state without the `appearance`/`size`/`shape` styling props, for headless usage. + */ +export type SplitButtonBaseState = ComponentState & + Omit & + Omit; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/index.ts b/packages/react-components/react-button/library/src/components/SplitButton/index.ts index ba8bc5714108e..16243d1c58e99 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/index.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/index.ts @@ -1,5 +1,12 @@ export { SplitButton } from './SplitButton'; -export type { SplitButtonProps, SplitButtonSlots, SplitButtonState } from './SplitButton.types'; +export type { + SplitButtonBaseProps, + SplitButtonBaseSlots, + SplitButtonBaseState, + SplitButtonProps, + SplitButtonSlots, + SplitButtonState, +} from './SplitButton.types'; export { renderSplitButton_unstable } from './renderSplitButton'; -export { useSplitButton_unstable } from './useSplitButton'; +export { useSplitButton_unstable, useSplitButtonBase_unstable } from './useSplitButton'; export { splitButtonClassNames, useSplitButtonStyles_unstable } from './useSplitButtonStyles.styles'; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx b/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx index 3c8982313edc9..3aa41ddc98146 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx @@ -4,12 +4,12 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { SplitButtonSlots, SplitButtonState } from './SplitButton.types'; +import type { SplitButtonSlots, SplitButtonBaseState } from './SplitButton.types'; /** * Renders a SplitButton component by passing the state defined props to the appropriate slots. */ -export const renderSplitButton_unstable = (state: SplitButtonState): JSXElement => { +export const renderSplitButton_unstable = (state: SplitButtonBaseState): JSXElement => { assertSlots(state); return ( diff --git a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts index 0f626da9ec231..1c6ceb29e7ee2 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts @@ -1,8 +1,17 @@ +'use client'; + import type * as React from 'react'; import { useId, slot } from '@fluentui/react-utilities'; +import type { ExtractSlotProps } from '@fluentui/react-utilities'; import { Button } from '../Button/Button'; import { MenuButton } from '../MenuButton/MenuButton'; -import type { SplitButtonProps, SplitButtonState } from './SplitButton.types'; +import type { + SplitButtonBaseProps, + SplitButtonBaseState, + SplitButtonProps, + SplitButtonSlots, + SplitButtonState, +} from './SplitButton.types'; /** * Given user props, defines default props for the SplitButton and returns processed state. @@ -13,8 +22,65 @@ export const useSplitButton_unstable = ( props: SplitButtonProps, ref: React.Ref, ): SplitButtonState => { + const { appearance = 'secondary', shape = 'rounded', size = 'medium', ...rest } = props; + const baseState = useSplitButtonBase_unstable(rest, ref as React.Ref); + + const menuButtonShorthand = slot.optional>>( + baseState.menuButton, + { + defaultProps: { + appearance, + shape, + size, + }, + renderByDefault: true, + elementType: MenuButton, + }, + ); + const primaryActionButtonShorthand = slot.optional< + ExtractSlotProps> + >(baseState.primaryActionButton, { + defaultProps: { + appearance, + shape, + size, + }, + renderByDefault: true, + elementType: Button, + }); + + return { + ...baseState, + // Props passed at the top-level + appearance, + shape, + size, + // Slots definition + components: { root: 'div', menuButton: MenuButton, primaryActionButton: Button }, + menuButton: menuButtonShorthand, + primaryActionButton: primaryActionButtonShorthand, + }; +}; + +/** + * Base hook for SplitButton component, which manages design-agnostic state related to slots + * structure and ARIA attributes, without design-specific props such as `appearance`, `shape`, or + * `size`. + * + * The `menuButton` and `primaryActionButton` slots returned by this hook use placeholder element + * metadata (`'button'`). Consuming layers (styled or headless) must recreate both slots with + * `slot.optional`/`slot.always` and their own concrete child component before rendering, since + * production rendering resolves the element type from the slot's element-type metadata rather + * than from `state.components`. + * + * @param props - User provided props to the SplitButton component. + * @param ref - User provided ref to be passed to the SplitButton component. + */ +export const useSplitButtonBase_unstable = ( + props: SplitButtonBaseProps, + ref?: React.Ref, +): SplitButtonBaseState => { const { - appearance = 'secondary', children, disabled = false, disabledFocusable = false, @@ -23,38 +89,30 @@ export const useSplitButton_unstable = ( menuButton, menuIcon, primaryActionButton, - shape = 'rounded', - size = 'medium', ...rest } = props; const baseId = useId('splitButton-'); const menuButtonShorthand = slot.optional(menuButton, { defaultProps: { - appearance, disabled, disabledFocusable, menuIcon, - shape, - size, }, renderByDefault: true, - elementType: MenuButton, + elementType: 'button', }); const primaryActionButtonShorthand = slot.optional(primaryActionButton, { defaultProps: { - appearance, children, disabled, disabledFocusable, icon, iconPosition, id: baseId + '__primaryActionButton', - shape, - size, }, renderByDefault: true, - elementType: Button, + elementType: 'button', }); // Resolve menu button's aria-labelledby to be labelled by the primary action button if no label was provided by the @@ -70,14 +128,12 @@ export const useSplitButton_unstable = ( return { // Props passed at the top-level - appearance, disabled, disabledFocusable, iconPosition, - shape, - size, // Slots definition - components: { root: 'div', menuButton: MenuButton, primaryActionButton: Button }, - root: slot.always({ ref: ref as React.Ref, ...rest }, { elementType: 'div' }), + // Slots definition + components: { root: 'div', menuButton: 'button', primaryActionButton: 'button' }, + root: slot.always({ ref, ...rest }, { elementType: 'div' }), menuButton: menuButtonShorthand, primaryActionButton: primaryActionButtonShorthand, }; diff --git a/packages/react-components/react-button/library/src/index.ts b/packages/react-components/react-button/library/src/index.ts index e5744ce4e2d2a..00192fbe5c722 100644 --- a/packages/react-components/react-button/library/src/index.ts +++ b/packages/react-components/react-button/library/src/index.ts @@ -43,8 +43,16 @@ export { splitButtonClassNames, useSplitButtonStyles_unstable, useSplitButton_unstable, + useSplitButtonBase_unstable, +} from './SplitButton'; +export type { + SplitButtonBaseProps, + SplitButtonBaseSlots, + SplitButtonBaseState, + SplitButtonProps, + SplitButtonSlots, + SplitButtonState, } from './SplitButton'; -export type { SplitButtonProps, SplitButtonSlots, SplitButtonState } from './SplitButton'; export { ToggleButton, renderToggleButton_unstable, From f1f4bad28328d059de21d1061dea9f58b8e1e538 Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 7 Aug 2026 15:32:02 +0200 Subject: [PATCH 2/5] fix(react-button): address SplitButton base hook review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../library/etc/react-button.api.md | 14 ++- .../SplitButton/SplitButton.test.tsx | 48 ++++++++- .../components/SplitButton/SplitButton.tsx | 15 +-- .../SplitButton/SplitButton.types.ts | 22 ++-- .../SplitButton/renderSplitButton.tsx | 4 +- .../components/SplitButton/useSplitButton.ts | 101 +++++++----------- 6 files changed, 121 insertions(+), 83 deletions(-) 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 e363230a94f02..39c35f1cc174b 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 @@ -115,7 +115,7 @@ export const renderCompoundButton_unstable: (state: CompoundButtonBaseState) => export const renderMenuButton_unstable: (state: MenuButtonBaseState) => JSXElement; // @public -export const renderSplitButton_unstable: (state: SplitButtonBaseState) => JSXElement; +export const renderSplitButton_unstable: (state: SplitButtonState) => JSXElement; // @public export const SplitButton: ForwardRefComponent; @@ -131,7 +131,13 @@ export type SplitButtonBaseSlots = { }; // @public -export type SplitButtonBaseState = ComponentState & Omit & Omit; +export type SplitButtonBaseState = Omit, 'components' | 'menuButton' | 'primaryActionButton'> & { + components: { + root: 'div'; + }; + menuButton: MenuButtonBaseProps; + primaryActionButton: ButtonBaseProps; +}; // @public (undocumented) export const splitButtonClassNames: SlotClassNames; @@ -202,10 +208,10 @@ export const useMenuButtonBase_unstable: (props: MenuButtonBaseProps, ref: React export const useMenuButtonStyles_unstable: (state: MenuButtonState) => MenuButtonState; // @public -export const useSplitButton_unstable: (props: SplitButtonProps, ref: React_2.Ref) => SplitButtonState; +export const useSplitButton_unstable: (props: SplitButtonProps, ref: React_2.Ref) => SplitButtonState; // @public -export const useSplitButtonBase_unstable: (props: SplitButtonBaseProps, ref?: React_2.Ref) => SplitButtonBaseState; +export const useSplitButtonBase_unstable: (props: SplitButtonBaseProps, ref: React_2.Ref) => SplitButtonBaseState; // @public (undocumented) export const useSplitButtonStyles_unstable: (state: SplitButtonState) => SplitButtonState; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx index 641b1502cb376..22f0560b96557 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx @@ -7,7 +7,7 @@ import { isConformant } from '../../testing/isConformant'; import { SplitButton } from './SplitButton'; import { Button } from '../Button/Button'; import { MenuButton } from '../MenuButton/MenuButton'; -import { useSplitButton_unstable } from './useSplitButton'; +import { useSplitButtonBase_unstable, useSplitButton_unstable } from './useSplitButton'; import type { SplitButtonProps } from './SplitButton.types'; describe('SplitButton', () => { @@ -212,4 +212,50 @@ describe('SplitButton', () => { expect(primaryActionButton[SLOT_ELEMENT_TYPE_SYMBOL]).toBe(Button); expect(menuButton[SLOT_ELEMENT_TYPE_SYMBOL]).toBe(MenuButton); }); + + it('normalizes base child props without slot metadata and preserves user overrides', () => { + const { result } = renderHook(() => + useSplitButtonBase_unstable( + { + children: 'This is a button', + disabled: true, + disabledFocusable: true, + icon: 'Test Icon', + iconPosition: 'after', + menuButton: { disabled: false }, + primaryActionButton: { disabled: false, id: 'custom-primary-action' }, + }, + React.createRef(), + ), + ); + + expect(result.current.components).toEqual({ root: 'div' }); + expect(isSlot(result.current.menuButton)).toBe(false); + expect(isSlot(result.current.primaryActionButton)).toBe(false); + expect(result.current.menuButton).toMatchObject({ + disabled: false, + disabledFocusable: true, + 'aria-labelledby': 'custom-primary-action', + }); + expect(result.current.primaryActionButton).toMatchObject({ + children: 'This is a button', + disabled: false, + disabledFocusable: true, + icon: 'Test Icon', + iconPosition: 'after', + id: 'custom-primary-action', + }); + expect(result.current.root).not.toHaveProperty('disabled'); + expect(result.current.root).not.toHaveProperty('disabledFocusable'); + expect(result.current.root).not.toHaveProperty('icon'); + expect(result.current.root).not.toHaveProperty('iconPosition'); + }); + + it('does not label the menu button with a primary action that is not rendered', () => { + const { getByRole } = render( + This primary action is not rendered, + ); + + expect(getByRole('button').getAttribute('aria-labelledby')).toBeNull(); + }); }); diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx index 09339bcc8d380..4e589feb75891 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.tsx @@ -12,14 +12,17 @@ import { useCustomStyleHook_unstable } from '@fluentui/react-shared-contexts'; * SplitButtons are a grouping of two interactive surfaces where interacting with the first one triggers a primary * action, while interacting with the second one opens a menu with secondary actions. */ -export const SplitButton: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useSplitButton_unstable(props, ref); +export const SplitButton: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useSplitButton_unstable(props, ref); - useSplitButtonStyles_unstable(state); + useSplitButtonStyles_unstable(state); - useCustomStyleHook_unstable('useSplitButtonStyles_unstable')(state); + useCustomStyleHook_unstable('useSplitButtonStyles_unstable')(state); - return renderSplitButton_unstable(state); -}); + return renderSplitButton_unstable(state); + // Casting is required due to lack of distributive union to support unions on @types/react + }, +) as ForwardRefComponent; SplitButton.displayName = 'SplitButton'; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts index 8de8816e5c803..23f6afb20da9b 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts @@ -1,13 +1,8 @@ import type { Button } from '../Button/Button'; import type { MenuButton } from '../MenuButton/MenuButton'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; -import type { ButtonBaseProps, ButtonBaseState, ButtonProps, ButtonState } from '../Button/Button.types'; -import type { - MenuButtonBaseProps, - MenuButtonBaseState, - MenuButtonProps, - MenuButtonState, -} from '../MenuButton/MenuButton.types'; +import type { ButtonBaseProps, ButtonProps, ButtonState } from '../Button/Button.types'; +import type { MenuButtonBaseProps, MenuButtonProps, MenuButtonState } from '../MenuButton/MenuButton.types'; export type SplitButtonSlots = { /** @@ -59,6 +54,13 @@ export type SplitButtonBaseProps = ComponentProps & /** * SplitButton state without the `appearance`/`size`/`shape` styling props, for headless usage. */ -export type SplitButtonBaseState = ComponentState & - Omit & - Omit; +export type SplitButtonBaseState = Omit< + ComponentState, + 'components' | 'menuButton' | 'primaryActionButton' +> & { + components: { + root: 'div'; + }; + menuButton: MenuButtonBaseProps; + primaryActionButton: ButtonBaseProps; +}; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx b/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx index 3aa41ddc98146..3c8982313edc9 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/renderSplitButton.tsx @@ -4,12 +4,12 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { SplitButtonSlots, SplitButtonBaseState } from './SplitButton.types'; +import type { SplitButtonSlots, SplitButtonState } from './SplitButton.types'; /** * Renders a SplitButton component by passing the state defined props to the appropriate slots. */ -export const renderSplitButton_unstable = (state: SplitButtonBaseState): JSXElement => { +export const renderSplitButton_unstable = (state: SplitButtonState): JSXElement => { assertSlots(state); return ( diff --git a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts index 1c6ceb29e7ee2..a2699fcca199f 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts @@ -2,14 +2,12 @@ import type * as React from 'react'; import { useId, slot } from '@fluentui/react-utilities'; -import type { ExtractSlotProps } from '@fluentui/react-utilities'; import { Button } from '../Button/Button'; import { MenuButton } from '../MenuButton/MenuButton'; import type { SplitButtonBaseProps, SplitButtonBaseState, SplitButtonProps, - SplitButtonSlots, SplitButtonState, } from './SplitButton.types'; @@ -18,29 +16,23 @@ import type { * @param props - User provided props to the SplitButton component. * @param ref - User provided ref to be passed to the SplitButton component. */ -export const useSplitButton_unstable = ( - props: SplitButtonProps, - ref: React.Ref, -): SplitButtonState => { +export const useSplitButton_unstable = (props: SplitButtonProps, ref: React.Ref): SplitButtonState => { const { appearance = 'secondary', shape = 'rounded', size = 'medium', ...rest } = props; - const baseState = useSplitButtonBase_unstable(rest, ref as React.Ref); + const baseState = useSplitButtonBase_unstable(rest, ref); - const menuButtonShorthand = slot.optional>>( - baseState.menuButton, - { - defaultProps: { - appearance, - shape, - size, - }, - renderByDefault: true, - elementType: MenuButton, + const menuButtonShorthand = slot.optional(props.menuButton, { + defaultProps: { + ...baseState.menuButton, + appearance, + shape, + size, }, - ); - const primaryActionButtonShorthand = slot.optional< - ExtractSlotProps> - >(baseState.primaryActionButton, { + renderByDefault: true, + elementType: MenuButton, + }); + const primaryActionButtonShorthand = slot.optional(props.primaryActionButton, { defaultProps: { + ...baseState.primaryActionButton, appearance, shape, size, @@ -53,6 +45,9 @@ export const useSplitButton_unstable = ( ...baseState, // Props passed at the top-level appearance, + disabled: props.disabled ?? false, + disabledFocusable: props.disabledFocusable ?? false, + iconPosition: props.iconPosition ?? 'before', shape, size, // Slots definition @@ -67,18 +62,14 @@ export const useSplitButton_unstable = ( * structure and ARIA attributes, without design-specific props such as `appearance`, `shape`, or * `size`. * - * The `menuButton` and `primaryActionButton` slots returned by this hook use placeholder element - * metadata (`'button'`). Consuming layers (styled or headless) must recreate both slots with - * `slot.optional`/`slot.always` and their own concrete child component before rendering, since - * production rendering resolves the element type from the slot's element-type metadata rather - * than from `state.components`. + * Concrete wrappers recreate child slots from these base prop defaults before rendering. * * @param props - User provided props to the SplitButton component. * @param ref - User provided ref to be passed to the SplitButton component. */ export const useSplitButtonBase_unstable = ( props: SplitButtonBaseProps, - ref?: React.Ref, + ref: React.Ref, ): SplitButtonBaseState => { const { children, @@ -93,48 +84,38 @@ export const useSplitButtonBase_unstable = ( } = props; const baseId = useId('splitButton-'); - const menuButtonShorthand = slot.optional(menuButton, { - defaultProps: { - disabled, - disabledFocusable, - menuIcon, - }, - renderByDefault: true, - elementType: 'button', - }); - const primaryActionButtonShorthand = slot.optional(primaryActionButton, { - defaultProps: { - children, - disabled, - disabledFocusable, - icon, - iconPosition, - id: baseId + '__primaryActionButton', - }, - renderByDefault: true, - elementType: 'button', - }); + const menuButtonProps: SplitButtonBaseState['menuButton'] = { + disabled, + disabledFocusable, + menuIcon, + ...slot.resolveShorthand(menuButton), + }; + const primaryActionButtonProps: SplitButtonBaseState['primaryActionButton'] = { + children, + disabled, + disabledFocusable, + icon, + iconPosition, + id: baseId + '__primaryActionButton', + ...slot.resolveShorthand(primaryActionButton), + }; // Resolve menu button's aria-labelledby to be labelled by the primary action button if no label was provided by the // user. if ( - menuButtonShorthand && - primaryActionButtonShorthand && - !menuButtonShorthand['aria-label'] && - !menuButtonShorthand['aria-labelledby'] + menuButton !== null && + primaryActionButton !== null && + !menuButtonProps['aria-label'] && + !menuButtonProps['aria-labelledby'] ) { - menuButtonShorthand['aria-labelledby'] = primaryActionButtonShorthand.id; + menuButtonProps['aria-labelledby'] = primaryActionButtonProps.id; } return { - // Props passed at the top-level - disabled, - disabledFocusable, - iconPosition, // Slots definition - components: { root: 'div', menuButton: 'button', primaryActionButton: 'button' }, + components: { root: 'div' }, root: slot.always({ ref, ...rest }, { elementType: 'div' }), - menuButton: menuButtonShorthand, - primaryActionButton: primaryActionButtonShorthand, + menuButton: menuButtonProps, + primaryActionButton: primaryActionButtonProps, }; }; From ec4bef93a158791464de53601eace0adde0d8ea4 Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 7 Aug 2026 16:00:16 +0200 Subject: [PATCH 3/5] fix(react-button): preserve SplitButton base state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react-button/library/etc/react-button.api.md | 2 +- .../components/SplitButton/SplitButton.test.tsx | 16 ++++++++++++++++ .../components/SplitButton/SplitButton.types.ts | 13 +++++++------ .../src/components/SplitButton/useSplitButton.ts | 7 ++++--- 4 files changed, 28 insertions(+), 10 deletions(-) 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 39c35f1cc174b..2d6c8e1fe9d12 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 @@ -131,7 +131,7 @@ export type SplitButtonBaseSlots = { }; // @public -export type SplitButtonBaseState = Omit, 'components' | 'menuButton' | 'primaryActionButton'> & { +export type SplitButtonBaseState = Omit, 'components' | 'menuButton' | 'primaryActionButton'> & Required> & { components: { root: 'div'; }; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx index 22f0560b96557..e5bc666ce6428 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx @@ -3,9 +3,11 @@ import { render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import userEvent from '@testing-library/user-event'; import { isSlot, SLOT_ELEMENT_TYPE_SYMBOL } from '@fluentui/react-utilities'; +import type { SlotRenderFunction } from '@fluentui/react-utilities'; import { isConformant } from '../../testing/isConformant'; import { SplitButton } from './SplitButton'; import { Button } from '../Button/Button'; +import type { ButtonProps } from '../Button/Button.types'; import { MenuButton } from '../MenuButton/MenuButton'; import { useSplitButtonBase_unstable, useSplitButton_unstable } from './useSplitButton'; import type { SplitButtonProps } from './SplitButton.types'; @@ -34,6 +36,15 @@ describe('SplitButton', () => { expect(menuButton).toBeTruthy(); }); + it('preserves default children for a primary action button render function', () => { + const renderPrimaryActionButton: SlotRenderFunction = (_Component, slotProps) => slotProps.children; + const { getByText } = render( + This is a button, + ); + + expect(getByText('This is a button')).toBeTruthy(); + }); + it('primary action button and menu button can both be focused', () => { const { getAllByRole } = render(This is a button); const [primaryActionButton, menuButton] = getAllByRole('button'); @@ -230,6 +241,11 @@ describe('SplitButton', () => { ); expect(result.current.components).toEqual({ root: 'div' }); + expect(result.current).toMatchObject({ + disabled: true, + disabledFocusable: true, + iconPosition: 'after', + }); expect(isSlot(result.current.menuButton)).toBe(false); expect(isSlot(result.current.primaryActionButton)).toBe(false); expect(result.current.menuButton).toMatchObject({ diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts index 23f6afb20da9b..8c5ff064a31a3 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.types.ts @@ -57,10 +57,11 @@ export type SplitButtonBaseProps = ComponentProps & export type SplitButtonBaseState = Omit< ComponentState, 'components' | 'menuButton' | 'primaryActionButton' -> & { - components: { - root: 'div'; +> & + Required> & { + components: { + root: 'div'; + }; + menuButton: MenuButtonBaseProps; + primaryActionButton: ButtonBaseProps; }; - menuButton: MenuButtonBaseProps; - primaryActionButton: ButtonBaseProps; -}; diff --git a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts index a2699fcca199f..9b8d4da558012 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButton.ts @@ -33,6 +33,7 @@ export const useSplitButton_unstable = (props: SplitButtonProps, ref: React.Ref< const primaryActionButtonShorthand = slot.optional(props.primaryActionButton, { defaultProps: { ...baseState.primaryActionButton, + children: props.children, appearance, shape, size, @@ -45,9 +46,6 @@ export const useSplitButton_unstable = (props: SplitButtonProps, ref: React.Ref< ...baseState, // Props passed at the top-level appearance, - disabled: props.disabled ?? false, - disabledFocusable: props.disabledFocusable ?? false, - iconPosition: props.iconPosition ?? 'before', shape, size, // Slots definition @@ -112,6 +110,9 @@ export const useSplitButtonBase_unstable = ( } return { + disabled, + disabledFocusable, + iconPosition, // Slots definition components: { root: 'div' }, root: slot.always({ ref, ...rest }, { elementType: 'div' }), From 3f03d47cf9d015f74f353f48622fedfd76a17682 Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 7 Aug 2026 16:33:35 +0200 Subject: [PATCH 4/5] fix(react-button): preserve menu render children Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/SplitButton/SplitButton.test.tsx | 13 +++++++++++++ .../src/components/SplitButton/useSplitButton.ts | 1 + 2 files changed, 14 insertions(+) diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx index e5bc666ce6428..801b7d59e0def 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx @@ -9,6 +9,7 @@ import { SplitButton } from './SplitButton'; import { Button } from '../Button/Button'; import type { ButtonProps } from '../Button/Button.types'; import { MenuButton } from '../MenuButton/MenuButton'; +import type { MenuButtonProps } from '../MenuButton/MenuButton.types'; import { useSplitButtonBase_unstable, useSplitButton_unstable } from './useSplitButton'; import type { SplitButtonProps } from './SplitButton.types'; @@ -45,6 +46,18 @@ describe('SplitButton', () => { expect(getByText('This is a button')).toBeTruthy(); }); + it('preserves undefined children for a menu button render function', () => { + const renderMenuButton: SlotRenderFunction = (_Component, slotProps) => { + expect(slotProps.children).toBeUndefined(); + return