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 @@
{
"type": "patch",
"comment": "fix: do not expose default icon in base 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
@@ -1,4 +1,5 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import '@testing-library/jest-dom';
import { ButtonContextProvider } from '../../contexts/ButtonContext';
Expand All @@ -19,10 +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);

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', () => {
Expand All @@ -33,6 +37,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -40,9 +44,6 @@ export const useMenuButtonBase_unstable = (
},

menuIcon: slot.optional(menuIcon, {
defaultProps: {
children: <ChevronDownRegular />,
},
renderByDefault: true,
elementType: 'span',
}),
Expand All @@ -61,8 +62,15 @@ export const useMenuButton_unstable = (
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);
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: <ChevronDownRegular />, ...slot.resolveShorthand(menuIcon) },
},
ref,
);

return {
...baseState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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 @@ -195,6 +195,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,49 @@
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 without a default 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');
// Headless MenuButton ships no default icon; consumers provide their own.
expect(button.querySelector('svg')).not.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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import type * as React from 'react';
import { useMenuButtonBase_unstable } from '@fluentui/react-button';

import type { MenuButtonProps, MenuButtonState } from './MenuButton.types';
import { stringifyDataAttribute } from '../../utils';

/**
* Returns the state for a MenuButton component, given its props and ref.
* The returned state can be modified with hooks before being passed to `renderMenuButton`.
*/
export const useMenuButton = (
props: MenuButtonProps,
ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>,
): MenuButtonState => {
const state: MenuButtonState = useMenuButtonBase_unstable(props, ref);

// Set data attributes for disabled, disabledFocusable, and iconOnly states to simplify styling.
// eslint-disable-next-line react-hooks/immutability
state.root['data-disabled'] = stringifyDataAttribute(state.disabled);
// eslint-disable-next-line react-hooks/immutability
state.root['data-disabled-focusable'] = stringifyDataAttribute(state.disabledFocusable);
// eslint-disable-next-line react-hooks/immutability
state.root['data-icon-only'] = stringifyDataAttribute(state.iconOnly);

return state;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { MenuButton, renderMenuButton, useMenuButton } from './components/MenuButton';
export type { MenuButtonSlots, MenuButtonProps, MenuButtonState } from './components/MenuButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu';
import { MenuButton } from '@fluentui/react-headless-components-preview/menu-button';
import { ChevronDownRegular } from '@fluentui/react-icons';

import styles from './menu-button.module.css';

export const Default = (): React.ReactNode => (
<Menu>
<MenuTrigger disableButtonEnhancement>
<MenuButton className={styles.button} menuIcon={<ChevronDownRegular aria-hidden />}>
Actions
</MenuButton>
</MenuTrigger>
<MenuPopover className={styles.surface}>
<MenuList className={styles.list}>
<MenuItem className={styles.item}>New</MenuItem>
<MenuItem className={styles.item}>Open</MenuItem>
<MenuItem className={styles.item}>Save</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
A menu button is a button that opens a menu of actions or options when clicked.

The headless `MenuButton` is unstyled and ships no default icon — consumers provide their own visuals (including a `menuIcon`) and reuse it as the trigger for a `Menu` instead of the regular `Button` component. See the [Menu](?path=/docs/components-menu--docs) stories for full menu composition with `MenuPopover`, `MenuList`, and `MenuItem`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MenuButton } from '@fluentui/react-headless-components-preview/menu-button';

import descriptionMd from './MenuButtonDescription.md';

export { Default } from './MenuButtonDefault.stories';

export default {
title: 'Components/MenuButton',
component: MenuButton,
parameters: {
docs: {
description: {
component: descriptionMd,
},
},
},
};
Loading
Loading