Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{

@github-actions github-actions Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Avatar Converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Avatar Converged.badgeMask.normal.chromium.png 5 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 618 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 731 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 31 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 170 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 69 Changed
vr-tests-react-components/Skeleton converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Skeleton converged.Opaque Skeleton with rectangle - Dark Mode.default.chromium.png 15 Changed
vr-tests-react-components/TagPicker 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.disabled input hover.chromium.png 658 Changed

There were 1 duplicate changes discarded. Check the build logs for more information.

"type": "minor",
"comment": "feat: add useMenuButtonBase hook",
"packageName": "@fluentui/react-button",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add headless MenuButton",
"packageName": "@fluentui/react-headless-components-preview",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export type CompoundButtonState = ComponentState<CompoundButtonSlots> & Omit<But
// @public
export const MenuButton: ForwardRefComponent<MenuButtonProps>;

// @public
export type MenuButtonBaseProps = ComponentProps<MenuButtonSlots> & Pick<ButtonBaseProps, 'disabled' | 'disabledFocusable'>;

// @public
export type MenuButtonBaseState = ComponentState<MenuButtonSlots> & Omit<ButtonBaseState, keyof ButtonSlots | 'components' | 'iconPosition'>;

// @public (undocumented)
export const menuButtonClassNames: SlotClassNames<MenuButtonSlots>;

Expand All @@ -100,7 +106,7 @@ export { renderButton_unstable as renderToggleButton_unstable }
export const renderCompoundButton_unstable: (state: CompoundButtonState) => JSXElement;

// @public
export const renderMenuButton_unstable: (state: MenuButtonState) => JSXElement;
export const renderMenuButton_unstable: (state: MenuButtonBaseState) => JSXElement;

// @public
export const renderSplitButton_unstable: (state: SplitButtonState) => JSXElement;
Expand Down Expand Up @@ -167,6 +173,9 @@ export const useCompoundButtonStyles_unstable: (state: CompoundButtonState) => C
// @public
export const useMenuButton_unstable: (props: MenuButtonProps, ref: React_2.Ref<HTMLButtonElement | HTMLAnchorElement>) => MenuButtonState;

// @public
export const useMenuButtonBase_unstable: (props: MenuButtonBaseProps, ref: React_2.Ref<HTMLButtonElement | HTMLAnchorElement>) => MenuButtonBaseState;

// @public (undocumented)
export const useMenuButtonStyles_unstable: (state: MenuButtonState) => MenuButtonState;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
export type { MenuButtonProps, MenuButtonSlots, MenuButtonState } from './components/MenuButton/index';
export type {
MenuButtonBaseProps,
MenuButtonBaseState,
MenuButtonProps,
MenuButtonSlots,
MenuButtonState,
} from './components/MenuButton/index';
export {
MenuButton,
menuButtonClassNames,
renderMenuButton_unstable,
useMenuButtonStyles_unstable,
useMenuButton_unstable,
useMenuButtonBase_unstable,
} from './components/MenuButton/index';
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import type { ButtonProps, ButtonSlots, ButtonState } from '../Button/Button.types';
import type { ButtonBaseProps, ButtonBaseState, ButtonProps, ButtonSlots, ButtonState } from '../Button/Button.types';

export type MenuButtonSlots = ButtonSlots & {
/**
Expand All @@ -13,3 +13,15 @@ export type MenuButtonProps = ComponentProps<MenuButtonSlots> &

export type MenuButtonState = ComponentState<MenuButtonSlots> &
Omit<ButtonState, keyof ButtonSlots | 'components' | 'iconPosition'>;

/**
* MenuButton Props without the `appearance`/`size`/`shape` styling props, for headless usage.
*/
export type MenuButtonBaseProps = ComponentProps<MenuButtonSlots> &
Pick<ButtonBaseProps, 'disabled' | 'disabledFocusable'>;

/**
* MenuButton State without the `appearance`/`size`/`shape` styling props, for headless usage.
*/
export type MenuButtonBaseState = ComponentState<MenuButtonSlots> &
Omit<ButtonBaseState, keyof ButtonSlots | 'components' | 'iconPosition'>;
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export type { MenuButtonProps, MenuButtonSlots, MenuButtonState } from './MenuButton.types';
export type {
MenuButtonBaseProps,
MenuButtonBaseState,
MenuButtonProps,
MenuButtonSlots,
MenuButtonState,
} from './MenuButton.types';
export { MenuButton } from './MenuButton';
export { renderMenuButton_unstable } from './renderMenuButton';
export { useMenuButton_unstable } from './useMenuButton';
export { useMenuButton_unstable, useMenuButtonBase_unstable } from './useMenuButton';
export { menuButtonClassNames, useMenuButtonStyles_unstable } from './useMenuButtonStyles.styles';
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
import { assertSlots } from '@fluentui/react-utilities';
import type { JSXElement } from '@fluentui/react-utilities';

import type { MenuButtonSlots, MenuButtonState } from './MenuButton.types';
import type { MenuButtonSlots, MenuButtonBaseState } from './MenuButton.types';

/**
* Renders a MenuButton component by passing the state defined props to the appropriate slots.
*/
export const renderMenuButton_unstable = (state: MenuButtonState): JSXElement => {
export const renderMenuButton_unstable = (state: MenuButtonBaseState): JSXElement => {
assertSlots<MenuButtonSlots>(state);
const { icon, iconOnly } = state;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
import * as React from 'react';
import { ChevronDownRegular } from '@fluentui/react-icons';
import { slot } from '@fluentui/react-utilities';
import { useButton_unstable } from '../Button/index';
import type { MenuButtonProps, MenuButtonState } from './MenuButton.types';
import { useButtonContext } from '../../contexts/ButtonContext';
import { useButtonBase_unstable } from '../Button/index';
import type { MenuButtonBaseProps, MenuButtonBaseState, MenuButtonProps, MenuButtonState } from './MenuButton.types';

/**
* Given user props, returns the final state for a MenuButton.
* Base hook for MenuButton.
*
* @param props - User provided props to the MenuButton component.
* @param ref - User provided ref to be passed to the MenuButton component.
*/
export const useMenuButton_unstable = (
props: MenuButtonProps,
export const useMenuButtonBase_unstable = (
props: MenuButtonBaseProps,
ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>,
): MenuButtonState => {
): MenuButtonBaseState => {
const { menuIcon, ...buttonProps } = props;
const buttonState = useButton_unstable(buttonProps, ref);
const buttonState = useButtonBase_unstable(buttonProps, ref);

return {
...buttonState,
Expand Down Expand Up @@ -44,3 +48,26 @@ export const useMenuButton_unstable = (
}),
};
};

/**
* Given user props, returns the final state for a MenuButton by adding the
* `appearance`/`size`/`shape` styling props on top of the base state.
*
* @param props - User provided props to the MenuButton component.
* @param ref - User provided ref to be passed to the MenuButton component.
*/
export const useMenuButton_unstable = (
props: MenuButtonProps,
ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>,
): MenuButtonState => {
const { size: contextSize } = useButtonContext();
const { appearance = 'secondary', shape = 'rounded', size = contextSize ?? 'medium', ...baseProps } = props;
const baseState = useMenuButtonBase_unstable(baseProps, ref);

return {
...baseState,
appearance,
shape,
size,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import '@testing-library/jest-dom';
import { useMenuButtonBase_unstable } from './useMenuButton';

describe('useMenuButtonBase_unstable', () => {
it('returns a menuIcon slot by default', () => {
const { result } = renderHook(() => useMenuButtonBase_unstable({}, React.createRef()));
expect(result.current.menuIcon).toBeDefined();
});

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

it('does not include appearance/size/shape styling props', () => {
const { result } = renderHook(() => useMenuButtonBase_unstable({}, React.createRef()));
expect(result.current).not.toHaveProperty('appearance');
expect(result.current).not.toHaveProperty('size');
expect(result.current).not.toHaveProperty('shape');
});

it('sets iconOnly true when there are no children', () => {
const { result } = renderHook(() => useMenuButtonBase_unstable({}, React.createRef()));
expect(result.current.iconOnly).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ export {
renderMenuButton_unstable,
useMenuButtonStyles_unstable,
useMenuButton_unstable,
useMenuButtonBase_unstable,
} from './MenuButton';
export type {
MenuButtonProps,
MenuButtonSlots,
MenuButtonState,
MenuButtonBaseProps,
MenuButtonBaseState,
} from './MenuButton';
export type { MenuButtonProps, MenuButtonSlots, MenuButtonState } from './MenuButton';
export {
SplitButton,
renderSplitButton_unstable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## API Report File for "@fluentui/react-headless-components-preview"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import type { ForwardRefComponent } from '@fluentui/react-utilities';
import type { MenuButtonBaseState } from '@fluentui/react-button';
import { MenuButtonBaseProps as MenuButtonProps } from '@fluentui/react-button';
import { MenuButtonSlots } from '@fluentui/react-button';
import type * as React_2 from 'react';
import { renderMenuButton_unstable as renderMenuButton } from '@fluentui/react-button';

// @public
export const MenuButton: ForwardRefComponent<MenuButtonProps>;

export { MenuButtonProps }

export { MenuButtonSlots }

// @public
export type MenuButtonState = MenuButtonBaseState & {
root: {
'data-disabled'?: string;
'data-disabled-focusable'?: string;
'data-icon-only'?: string;
};
};

export { renderMenuButton }

// @public
export const useMenuButton: (props: MenuButtonProps, ref: React_2.Ref<HTMLButtonElement | HTMLAnchorElement>) => MenuButtonState;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@
"import": "./lib/menu.js",
"require": "./lib-commonjs/menu.js"
},
"./menu-button": {
"types": "./dist/menu-button.d.ts",
"node": "./lib-commonjs/menu-button.js",
"import": "./lib/menu-button.js",
"require": "./lib-commonjs/menu-button.js"
},
"./message-bar": {
"types": "./dist/message-bar.d.ts",
"node": "./lib-commonjs/message-bar.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { isConformant } from '../../testing/isConformant';
import { MenuButton } from './MenuButton';

describe('MenuButton', () => {
isConformant({
Component: MenuButton,
displayName: 'MenuButton',
});

it('renders a default state with the chevron menu icon', () => {
const result = render(<MenuButton>Open menu</MenuButton>);
const button = result.getByRole('button', { name: 'Open menu' });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('type', 'button');
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(button.querySelector('svg')).toBeInTheDocument();
});

it('reflects aria-expanded as a boolean', () => {
const result = render(<MenuButton aria-expanded>Open menu</MenuButton>);
const button = result.getByRole('button', { name: 'Open menu' });
expect(button).toHaveAttribute('aria-expanded', 'true');
});

it('renders a custom menuIcon slot', () => {
const result = render(<MenuButton menuIcon={<span data-testid="custom-icon" />}>Open menu</MenuButton>);
expect(result.getByTestId('custom-icon')).toBeInTheDocument();
});

it('renders with state data attributes', () => {
const result = render(
<MenuButton disabled disabledFocusable>
Disabled
</MenuButton>,
);
const button = result.getByRole('button', { name: 'Disabled' });
expect(button).toHaveAttribute('data-disabled');
expect(button).toHaveAttribute('data-disabled-focusable');
});

it('renders icon-only with the data-icon-only attribute', () => {
const result = render(<MenuButton icon={<span>Icon</span>} aria-label="Icon menu" />);
const button = result.getByRole('button', { name: 'Icon menu' });
expect(button).toHaveAttribute('data-icon-only');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use client';

import * as React from 'react';
import type { ForwardRefComponent } from '@fluentui/react-utilities';
import type { MenuButtonProps } from './MenuButton.types';
import { useMenuButton } from './useMenuButton';
import { renderMenuButton } from './renderMenuButton';

/**
* A button that opens a menu, indicated by a chevron icon.
*/
export const MenuButton: ForwardRefComponent<MenuButtonProps> = React.forwardRef((props, ref) => {
const state = useMenuButton(props, ref);

return renderMenuButton(state);
});

MenuButton.displayName = 'MenuButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { MenuButtonBaseState } from '@fluentui/react-button';

export type { MenuButtonBaseProps as MenuButtonProps, MenuButtonSlots } from '@fluentui/react-button';

/**
* MenuButton component state
*/
export type MenuButtonState = MenuButtonBaseState & {
root: {
/**
* Data attribute set when the button is disabled.
*/
'data-disabled'?: string;

/**
* Data attribute set when the button is disabled but still focusable.
*/
'data-disabled-focusable'?: string;

/**
* Data attribute set when the button renders only an icon.
*/
'data-icon-only'?: string;
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { MenuButton } from './MenuButton';
export { renderMenuButton } from './renderMenuButton';
export { useMenuButton } from './useMenuButton';
export type { MenuButtonSlots, MenuButtonProps, MenuButtonState } from './MenuButton.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { renderMenuButton_unstable as renderMenuButton } from '@fluentui/react-button';
Loading
Loading