From 8d24c22d3c8b2ce965e4a30b7262957591ba8114 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 24 Jun 2026 18:15:59 +0200 Subject: [PATCH 1/5] fix(react-button): do not expose default menuIcon in useMenuButtonBase_unstable Move the default ChevronDownRegular icon out of the headless base hook (useMenuButtonBase_unstable) and into the styled useMenuButton_unstable so that headless consumers do not inherit a default icon. The default is applied in a null-safe way so that menuIcon={null} still hides the icon. Add regression tests covering menuIcon={null} on the styled hook and the absence of a default icon on the base hook. --- ...n-de57007e-ed5d-4006-b38f-0715f6088ed4.json | 7 +++++++ .../MenuButton/useMenuButton.test.tsx | 7 +++++++ .../components/MenuButton/useMenuButton.tsx | 18 +++++++++++++----- .../MenuButton/useMenuButtonBase.test.tsx | 5 +++++ 4 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 change/@fluentui-react-button-de57007e-ed5d-4006-b38f-0715f6088ed4.json diff --git a/change/@fluentui-react-button-de57007e-ed5d-4006-b38f-0715f6088ed4.json b/change/@fluentui-react-button-de57007e-ed5d-4006-b38f-0715f6088ed4.json new file mode 100644 index 0000000000000..20094ddabdfe7 --- /dev/null +++ b/change/@fluentui-react-button-de57007e-ed5d-4006-b38f-0715f6088ed4.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: do not expose default icon in base hook", + "packageName": "@fluentui/react-button", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx index d3fe07832432e..3c2077ec61874 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { renderHook } from '@testing-library/react-hooks'; import '@testing-library/jest-dom'; +import { ChevronDownRegular } from '@fluentui/react-icons'; import { ButtonContextProvider } from '../../contexts/ButtonContext'; import type { ButtonContextValue } from '../../contexts/ButtonContext'; import { useMenuButton_unstable } from './useMenuButton'; @@ -23,6 +24,7 @@ describe('useMenuButton_unstable', () => { const { result } = renderHook(() => useMenuButton_unstable({}, React.createRef())); expect(result.current.menuIcon).toBeDefined(); expect(React.isValidElement(result.current.menuIcon?.children)).toBe(true); + expect((result.current.menuIcon?.children as React.ReactElement)?.type).toBe(ChevronDownRegular); }); it('preserves a user-provided menuIcon over the default chevron', () => { @@ -33,6 +35,11 @@ describe('useMenuButton_unstable', () => { expect(result.current.menuIcon?.children).toBe(customIcon); }); + it('hides the menuIcon slot when menuIcon is null', () => { + const { result } = renderHook(() => useMenuButton_unstable({ menuIcon: null }, React.createRef())); + expect(result.current.menuIcon).toBeUndefined(); + }); + it('defaults aria-expanded to false when not provided', () => { const { result } = renderHook(() => useMenuButton_unstable({}, React.createRef())); expect(result.current.root['aria-expanded']).toBe(false); diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx index 6ece2ab7c6c2c..630ad10e4f9f1 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx @@ -10,6 +10,10 @@ import type { MenuButtonBaseProps, MenuButtonBaseState, MenuButtonProps, MenuBut /** * Base hook for MenuButton. * + * The `menuIcon` slot is rendered by default but ships no icon of its own, so headless + * consumers can provide their own visuals. The styled `useMenuButton_unstable` adds the + * default chevron on top of this. + * * @param props - User provided props to the MenuButton component. * @param ref - User provided ref to be passed to the MenuButton component. */ @@ -40,9 +44,6 @@ export const useMenuButtonBase_unstable = ( }, menuIcon: slot.optional(menuIcon, { - defaultProps: { - children: , - }, renderByDefault: true, elementType: 'span', }), @@ -61,8 +62,15 @@ export const useMenuButton_unstable = ( ref: React.Ref, ): MenuButtonState => { const { size: contextSize } = useButtonContext(); - const { appearance = 'secondary', shape = 'rounded', size = contextSize ?? 'medium', ...baseProps } = props; - const baseState = useMenuButtonBase_unstable(baseProps, ref); + const { appearance = 'secondary', menuIcon, shape = 'rounded', size = contextSize ?? 'medium', ...baseProps } = props; + const baseState = useMenuButtonBase_unstable( + { + ...baseProps, + // Default the menuIcon to a chevron, while still allowing `menuIcon={null}` to hide it. + menuIcon: menuIcon === null ? null : { children: , ...slot.resolveShorthand(menuIcon) }, + }, + ref, + ); return { ...baseState, diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx index 186d30b12acf7..381a96deec518 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx @@ -9,6 +9,11 @@ describe('useMenuButtonBase_unstable', () => { expect(result.current.menuIcon).toBeDefined(); }); + it('does not ship a default icon in the menuIcon slot', () => { + const { result } = renderHook(() => useMenuButtonBase_unstable({}, React.createRef())); + expect(result.current.menuIcon?.children).toBeUndefined(); + }); + it('forces aria-expanded to a boolean on root', () => { const { result } = renderHook(() => useMenuButtonBase_unstable({ 'aria-expanded': 'true' }, React.createRef())); expect(result.current.root['aria-expanded']).toBe(true); From 42e7e723edcb80ed5a037d4622914b8164ec55cf Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 24 Jun 2026 20:12:59 +0200 Subject: [PATCH 2/5] test(react-button): assert default menuIcon renders an svg instead of the specific icon --- .../src/components/MenuButton/useMenuButton.test.tsx | 8 +++++--- .../library/src/components/MenuButton/useMenuButton.tsx | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx index 3c2077ec61874..0909a563b4ab6 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; +import { render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import '@testing-library/jest-dom'; -import { ChevronDownRegular } from '@fluentui/react-icons'; import { ButtonContextProvider } from '../../contexts/ButtonContext'; import type { ButtonContextValue } from '../../contexts/ButtonContext'; import { useMenuButton_unstable } from './useMenuButton'; @@ -20,11 +20,13 @@ describe('useMenuButton_unstable', () => { expect(result.current.components).toEqual({ root: 'button', icon: 'span', menuIcon: 'span' }); }); - it('renders a menuIcon slot with a default chevron icon', () => { + it('renders a menuIcon slot with a default icon', () => { const { result } = renderHook(() => useMenuButton_unstable({}, React.createRef())); expect(result.current.menuIcon).toBeDefined(); expect(React.isValidElement(result.current.menuIcon?.children)).toBe(true); - expect((result.current.menuIcon?.children as React.ReactElement)?.type).toBe(ChevronDownRegular); + + const { container } = render(result.current.menuIcon?.children as React.ReactElement); + expect(container.querySelector('svg')).toBeInTheDocument(); }); it('preserves a user-provided menuIcon over the default chevron', () => { diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx index 630ad10e4f9f1..3015ae2217b56 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx @@ -66,7 +66,6 @@ export const useMenuButton_unstable = ( const baseState = useMenuButtonBase_unstable( { ...baseProps, - // Default the menuIcon to a chevron, while still allowing `menuIcon={null}` to hide it. menuIcon: menuIcon === null ? null : { children: , ...slot.resolveShorthand(menuIcon) }, }, ref, From dc74d950cc15beb0439187cdfe2f52eaea557910 Mon Sep 17 00:00:00 2001 From: mainframev Date: Thu, 25 Jun 2026 15:02:47 +0200 Subject: [PATCH 3/5] fix(react-button): render base menuIcon slot only when provided Align useMenuButtonBase_unstable with the renderByDefault convention used across v9 components (useField, useMenuItemBase): render the menuIcon slot only when a menuIcon is supplied instead of an empty span by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/MenuButton/useMenuButton.test.tsx | 9 +++++++++ .../src/components/MenuButton/useMenuButton.tsx | 7 +++---- .../MenuButton/useMenuButtonBase.test.tsx | 14 +++++++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx index 0909a563b4ab6..204af21a57f2d 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx @@ -37,6 +37,15 @@ describe('useMenuButton_unstable', () => { expect(result.current.menuIcon?.children).toBe(customIcon); }); + it('renders the default chevron when menuIcon is explicitly undefined', () => { + const { result } = renderHook(() => useMenuButton_unstable({ menuIcon: undefined }, React.createRef())); + expect(result.current.menuIcon).toBeDefined(); + expect(React.isValidElement(result.current.menuIcon?.children)).toBe(true); + + const { container } = render(result.current.menuIcon?.children as React.ReactElement); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + it('hides the menuIcon slot when menuIcon is null', () => { const { result } = renderHook(() => useMenuButton_unstable({ menuIcon: null }, React.createRef())); expect(result.current.menuIcon).toBeUndefined(); diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx index 3015ae2217b56..74960c094bc88 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx @@ -10,9 +10,9 @@ import type { MenuButtonBaseProps, MenuButtonBaseState, MenuButtonProps, MenuBut /** * Base hook for MenuButton. * - * The `menuIcon` slot is rendered by default but ships no icon of its own, so headless - * consumers can provide their own visuals. The styled `useMenuButton_unstable` adds the - * default chevron on top of this. + * The `menuIcon` slot ships no icon of its own and only renders when a consumer + * provides one, so headless consumers can supply their own visuals. The styled + * `useMenuButton_unstable` adds the default chevron on top of this. * * @param props - User provided props to the MenuButton component. * @param ref - User provided ref to be passed to the MenuButton component. @@ -44,7 +44,6 @@ export const useMenuButtonBase_unstable = ( }, menuIcon: slot.optional(menuIcon, { - renderByDefault: true, elementType: 'span', }), }; diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx index 381a96deec518..80e42abb28dea 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx @@ -4,14 +4,18 @@ import '@testing-library/jest-dom'; import { useMenuButtonBase_unstable } from './useMenuButton'; describe('useMenuButtonBase_unstable', () => { - it('returns a menuIcon slot by default', () => { + it('does not render the menuIcon slot when none is provided', () => { const { result } = renderHook(() => useMenuButtonBase_unstable({}, React.createRef())); - expect(result.current.menuIcon).toBeDefined(); + expect(result.current.menuIcon).toBeUndefined(); }); - it('does not ship a default icon in the menuIcon slot', () => { - const { result } = renderHook(() => useMenuButtonBase_unstable({}, React.createRef())); - expect(result.current.menuIcon?.children).toBeUndefined(); + it('renders the menuIcon slot only when one is provided, shipping no default icon', () => { + const customIcon = ; + const { result } = renderHook(() => + useMenuButtonBase_unstable({ menuIcon: { children: customIcon } }, React.createRef()), + ); + expect(result.current.menuIcon).toBeDefined(); + expect(result.current.menuIcon?.children).toBe(customIcon); }); it('forces aria-expanded to a boolean on root', () => { From 825aad4413db02acc67ac0ba56c84485a3a701be Mon Sep 17 00:00:00 2001 From: mainframev Date: Thu, 25 Jun 2026 21:58:48 +0200 Subject: [PATCH 4/5] test(react-button): assert default menuIcon svg via single component render Render MenuButton once and query result.container for the chevron svg instead of renderHook plus a separate render, per review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MenuButton/useMenuButton.test.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx index 204af21a57f2d..dd013f7df19a5 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.test.tsx @@ -5,6 +5,7 @@ import '@testing-library/jest-dom'; import { ButtonContextProvider } from '../../contexts/ButtonContext'; import type { ButtonContextValue } from '../../contexts/ButtonContext'; import { useMenuButton_unstable } from './useMenuButton'; +import { MenuButton } from './MenuButton'; const wrap = (contextValue: ButtonContextValue = {}): React.FC<{ children?: React.ReactNode }> => { const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( @@ -21,12 +22,8 @@ describe('useMenuButton_unstable', () => { }); it('renders a menuIcon slot with a default icon', () => { - const { result } = renderHook(() => useMenuButton_unstable({}, React.createRef())); - expect(result.current.menuIcon).toBeDefined(); - expect(React.isValidElement(result.current.menuIcon?.children)).toBe(true); - - const { container } = render(result.current.menuIcon?.children as React.ReactElement); - expect(container.querySelector('svg')).toBeInTheDocument(); + const result = render(); + expect(result.container.querySelector('svg')).toBeInTheDocument(); }); it('preserves a user-provided menuIcon over the default chevron', () => { @@ -38,12 +35,8 @@ describe('useMenuButton_unstable', () => { }); it('renders the default chevron when menuIcon is explicitly undefined', () => { - const { result } = renderHook(() => useMenuButton_unstable({ menuIcon: undefined }, React.createRef())); - expect(result.current.menuIcon).toBeDefined(); - expect(React.isValidElement(result.current.menuIcon?.children)).toBe(true); - - const { container } = render(result.current.menuIcon?.children as React.ReactElement); - expect(container.querySelector('svg')).toBeInTheDocument(); + const result = render(); + expect(result.container.querySelector('svg')).toBeInTheDocument(); }); it('hides the menuIcon slot when menuIcon is null', () => { From 4cd6d0862df837c75b7c40b059326b7d80ad93c1 Mon Sep 17 00:00:00 2001 From: Victor Genaev Date: Fri, 26 Jun 2026 10:51:32 +0200 Subject: [PATCH 5/5] Update packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx Co-authored-by: Dmytro Kirpa --- .../src/components/MenuButton/useMenuButton.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx index 74960c094bc88..44ffa53566b81 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButton.tsx @@ -62,16 +62,17 @@ export const useMenuButton_unstable = ( ): MenuButtonState => { const { size: contextSize } = useButtonContext(); const { appearance = 'secondary', menuIcon, shape = 'rounded', size = contextSize ?? 'medium', ...baseProps } = props; - const baseState = useMenuButtonBase_unstable( - { - ...baseProps, - menuIcon: menuIcon === null ? null : { children: , ...slot.resolveShorthand(menuIcon) }, - }, - ref, - ); + const baseState = useMenuButtonBase_unstable(baseProps, ref); return { ...baseState, + menuIcon: slot.optional(menuIcon, { + defaultProps: { + children: , + }, + renderByDefault: true, + elementType: 'span', + }), appearance, shape, size,