diff --git a/change/@fluentui-react-button-3e45fb7a-a8fa-4902-a694-fa9b89a68fd1.json b/change/@fluentui-react-button-3e45fb7a-a8fa-4902-a694-fa9b89a68fd1.json new file mode 100644 index 0000000000000..44624952ab34b --- /dev/null +++ b/change/@fluentui-react-button-3e45fb7a-a8fa-4902-a694-fa9b89a68fd1.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add useMenuButtonBase hook", + "packageName": "@fluentui/react-button", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-headless-components-preview-7246c651-b258-4694-b0e7-45b666b15aae.json b/change/@fluentui-react-headless-components-preview-7246c651-b258-4694-b0e7-45b666b15aae.json new file mode 100644 index 0000000000000..60538f41cb17a --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-7246c651-b258-4694-b0e7-45b666b15aae.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add headless MenuButton", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} 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 3b714a045f72a..b51a074ac4002 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 @@ -77,6 +77,12 @@ export type CompoundButtonState = ComponentState & Omit; +// @public +export type MenuButtonBaseProps = ComponentProps & Pick; + +// @public +export type MenuButtonBaseState = ComponentState & Omit; + // @public (undocumented) export const menuButtonClassNames: SlotClassNames; @@ -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; @@ -167,6 +173,9 @@ export const useCompoundButtonStyles_unstable: (state: CompoundButtonState) => C // @public export const useMenuButton_unstable: (props: MenuButtonProps, ref: React_2.Ref) => MenuButtonState; +// @public +export const useMenuButtonBase_unstable: (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/MenuButton.ts b/packages/react-components/react-button/library/src/MenuButton.ts index 785ec93d69667..00090e080632a 100644 --- a/packages/react-components/react-button/library/src/MenuButton.ts +++ b/packages/react-components/react-button/library/src/MenuButton.ts @@ -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'; diff --git a/packages/react-components/react-button/library/src/components/MenuButton/MenuButton.types.ts b/packages/react-components/react-button/library/src/components/MenuButton/MenuButton.types.ts index f630f660ac85b..a654101361aa9 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/MenuButton.types.ts +++ b/packages/react-components/react-button/library/src/components/MenuButton/MenuButton.types.ts @@ -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 & { /** @@ -13,3 +13,15 @@ export type MenuButtonProps = ComponentProps & export type MenuButtonState = ComponentState & Omit; + +/** + * MenuButton Props without the `appearance`/`size`/`shape` styling props, for headless usage. + */ +export type MenuButtonBaseProps = ComponentProps & + Pick; + +/** + * MenuButton State without the `appearance`/`size`/`shape` styling props, for headless usage. + */ +export type MenuButtonBaseState = ComponentState & + Omit; diff --git a/packages/react-components/react-button/library/src/components/MenuButton/index.ts b/packages/react-components/react-button/library/src/components/MenuButton/index.ts index f81604be3c3ab..0d1c2419c47fa 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/index.ts +++ b/packages/react-components/react-button/library/src/components/MenuButton/index.ts @@ -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'; diff --git a/packages/react-components/react-button/library/src/components/MenuButton/renderMenuButton.tsx b/packages/react-components/react-button/library/src/components/MenuButton/renderMenuButton.tsx index c2df3b3fbb084..22a0c0e95e76a 100644 --- a/packages/react-components/react-button/library/src/components/MenuButton/renderMenuButton.tsx +++ b/packages/react-components/react-button/library/src/components/MenuButton/renderMenuButton.tsx @@ -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(state); const { icon, iconOnly } = state; 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 e7eb8eea12dc9..6ece2ab7c6c2c 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 @@ -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, -): MenuButtonState => { +): MenuButtonBaseState => { const { menuIcon, ...buttonProps } = props; - const buttonState = useButton_unstable(buttonProps, ref); + const buttonState = useButtonBase_unstable(buttonProps, ref); return { ...buttonState, @@ -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, +): 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, + }; +}; 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 new file mode 100644 index 0000000000000..186d30b12acf7 --- /dev/null +++ b/packages/react-components/react-button/library/src/components/MenuButton/useMenuButtonBase.test.tsx @@ -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); + }); +}); diff --git a/packages/react-components/react-button/library/src/index.ts b/packages/react-components/react-button/library/src/index.ts index 871fe61ccff38..dc6590d8804e8 100644 --- a/packages/react-components/react-button/library/src/index.ts +++ b/packages/react-components/react-button/library/src/index.ts @@ -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, diff --git a/packages/react-components/react-headless-components-preview/library/etc/menu-button.api.md b/packages/react-components/react-headless-components-preview/library/etc/menu-button.api.md new file mode 100644 index 0000000000000..458f8dcc0c3ce --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/menu-button.api.md @@ -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; + +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) => MenuButtonState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 54c7d57430ef4..fcd2296771383 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -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", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.test.tsx new file mode 100644 index 0000000000000..46a36a157acbc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.test.tsx @@ -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(Open menu); + 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(Open menu); + const button = result.getByRole('button', { name: 'Open menu' }); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders a custom menuIcon slot', () => { + const result = render(}>Open menu); + expect(result.getByTestId('custom-icon')).toBeInTheDocument(); + }); + + it('renders with state data attributes', () => { + const result = render( + + Disabled + , + ); + 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(Icon} aria-label="Icon menu" />); + const button = result.getByRole('button', { name: 'Icon menu' }); + expect(button).toHaveAttribute('data-icon-only'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.tsx new file mode 100644 index 0000000000000..e07961275f891 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.tsx @@ -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 = React.forwardRef((props, ref) => { + const state = useMenuButton(props, ref); + + return renderMenuButton(state); +}); + +MenuButton.displayName = 'MenuButton'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.types.ts new file mode 100644 index 0000000000000..920f99e082a7c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.types.ts @@ -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; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/index.ts new file mode 100644 index 0000000000000..f3e31740e25fd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/index.ts @@ -0,0 +1,4 @@ +export { MenuButton } from './MenuButton'; +export { renderMenuButton } from './renderMenuButton'; +export { useMenuButton } from './useMenuButton'; +export type { MenuButtonSlots, MenuButtonProps, MenuButtonState } from './MenuButton.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/renderMenuButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/renderMenuButton.ts new file mode 100644 index 0000000000000..8eb7035aca955 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/renderMenuButton.ts @@ -0,0 +1 @@ +export { renderMenuButton_unstable as renderMenuButton } from '@fluentui/react-button'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/useMenuButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/useMenuButton.ts new file mode 100644 index 0000000000000..ed1744db489a9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/useMenuButton.ts @@ -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, +): 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; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/menu-button.ts b/packages/react-components/react-headless-components-preview/library/src/menu-button.ts new file mode 100644 index 0000000000000..0f2695fccb0a7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/menu-button.ts @@ -0,0 +1,2 @@ +export { MenuButton, renderMenuButton, useMenuButton } from './components/MenuButton'; +export type { MenuButtonSlots, MenuButtonProps, MenuButtonState } from './components/MenuButton'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonAppearance.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonAppearance.stories.tsx new file mode 100644 index 0000000000000..bdfbca7756f07 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonAppearance.stories.tsx @@ -0,0 +1,61 @@ +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 styles from './menu-button.module.css'; + +export const Appearance = (): React.ReactNode => ( +
+ + + Primary + + + + Cut + Copy + Paste + + + + + + + Secondary + + + + Cut + Copy + Paste + + + + + + + Subtle + + + + Cut + Copy + Paste + + + + + + + Outline + + + + Cut + Copy + Paste + + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDefault.stories.tsx new file mode 100644 index 0000000000000..582a5a4e61037 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDefault.stories.tsx @@ -0,0 +1,20 @@ +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 styles from './menu-button.module.css'; + +export const Default = (): React.ReactNode => ( + + + Actions + + + + New + Open + Save + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDescription.md b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDescription.md new file mode 100644 index 0000000000000..540925e923333 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDescription.md @@ -0,0 +1,3 @@ +A menu button opens a menu of actions or options when clicked, and renders a chevron to signal that it expands a menu. + +Use it as the trigger for a `Menu`: the headless `MenuButton` composes with the headless `Menu`, `MenuPopover`, `MenuList`, and `MenuItem` to build the dropdown. The component is unstyled — these examples apply their own classes and reuse the same design tokens as the Button and Menu stories. diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDisabled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDisabled.stories.tsx new file mode 100644 index 0000000000000..26749625d6b7c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonDisabled.stories.tsx @@ -0,0 +1,37 @@ +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 styles from './menu-button.module.css'; + +export const Disabled = (): React.ReactNode => ( +
+ + + + Disabled + + + + + New + Open + + + + + + + + Disabled focusable + + + + + New + Open + + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonSize.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonSize.stories.tsx new file mode 100644 index 0000000000000..c534b9475fddc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonSize.stories.tsx @@ -0,0 +1,48 @@ +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 styles from './menu-button.module.css'; + +export const Size = (): React.ReactNode => ( +
+ + + Small + + + + New + Open + Save + + + + + + + Medium + + + + New + Open + Save + + + + + + + Large + + + + New + Open + Save + + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonWithIcon.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonWithIcon.stories.tsx new file mode 100644 index 0000000000000..1bf496c511e6e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/MenuButtonWithIcon.stories.tsx @@ -0,0 +1,44 @@ +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 { EditRegular, MoreHorizontalRegular } from '@fluentui/react-icons'; + +import styles from './menu-button.module.css'; + +export const WithIcon = (): React.ReactNode => ( +
+ {/* Leading icon + label + the default trailing chevron */} + + + }> + Edit + + + + + Rename + Duplicate + Delete + + + + + {/* Icon-only "more options" trigger — a custom menuIcon replaces the chevron */} + + + } + /> + + + + Rename + Duplicate + Delete + + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/index.stories.tsx new file mode 100644 index 0000000000000..089ffeb4c5855 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/index.stories.tsx @@ -0,0 +1,21 @@ +import { MenuButton } from '@fluentui/react-headless-components-preview/menu-button'; + +import descriptionMd from './MenuButtonDescription.md'; + +export { Default } from './MenuButtonDefault.stories'; +export { Appearance } from './MenuButtonAppearance.stories'; +export { Size } from './MenuButtonSize.stories'; +export { WithIcon } from './MenuButtonWithIcon.stories'; +export { Disabled } from './MenuButtonDisabled.stories'; + +export default { + title: 'Components/MenuButton', + component: MenuButton, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/MenuButton/menu-button.module.css b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/menu-button.module.css new file mode 100644 index 0000000000000..cbd5d3fdf80ac --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/MenuButton/menu-button.module.css @@ -0,0 +1,188 @@ +/* + * Story-level styles for the headless MenuButton. Headless components ship no + * CSS; these classes compose the look from the same design tokens the Button + * and Menu stories use, so the MenuButton trigger and its Menu surface read as + * one coherent affordance. + */ + +/* Trigger button — rounded, monochrome, with room for the trailing chevron */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: var(--radius-md); + border: 1px solid transparent; + background: var(--accent); + color: var(--accent-contrast); + font-size: 13px; + font-weight: 500; + cursor: pointer; + user-select: none; + transition: background-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); +} + +.button:hover, +.button[aria-expanded='true'], +.button[data-open] { + background: var(--accent-strong); +} + +.button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.button[data-disabled], +.button[data-disabled-focusable] { + opacity: 0.4; + cursor: not-allowed; +} + +.button[data-disabled]:hover, +.button[data-disabled-focusable]:hover { + background: var(--accent); +} + +/* Both slots render an svg (the leading icon and the trailing menu chevron) */ +.button svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* Appearances */ +.secondary { + background: var(--surface-muted); + color: var(--text); +} + +.secondary:hover, +.secondary[aria-expanded='true'], +.secondary[data-open] { + background: var(--surface-sunken); +} + +.secondary[data-disabled]:hover, +.secondary[data-disabled-focusable]:hover { + background: var(--surface-muted); +} + +.subtle { + background: transparent; + color: var(--text); +} + +.subtle:hover, +.subtle[aria-expanded='true'], +.subtle[data-open] { + background: var(--surface-muted); +} + +.outline { + background: transparent; + color: var(--text); + border-color: var(--border-strong); +} + +.outline:hover, +.outline[aria-expanded='true'], +.outline[data-open] { + background: var(--surface-muted); + border-color: var(--text); +} + +/* Sizes */ +.small { + height: 26px; + padding: 0 8px; + font-size: 12px; +} + +.small svg { + width: 14px; + height: 14px; +} + +.large { + height: 40px; + padding: 0 16px; + font-size: 14px; +} + +.large svg { + width: 18px; + height: 18px; +} + +/* Icon-only — square trigger (e.g. a "more options" button) */ +.button[data-icon-only] { + width: 32px; + padding: 0; +} + +.small[data-icon-only] { + width: 26px; +} + +.large[data-icon-only] { + width: 40px; +} + +/* Menu popover — mirrors the Menu story surface */ +.surface { + background: var(--bg-elev); + border-radius: var(--radius-md); + border: var(--stroke-thin) solid var(--border); + box-shadow: var(--shadow-3); + padding: var(--space-1) 0; + min-width: 180px; +} + +.list { + display: flex; + flex-direction: column; + outline: none; +} + +.item { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: 6px var(--space-3); + border: 0; + background: transparent; + color: var(--text); + font-size: 13.5px; + line-height: 1.4; + text-align: left; + cursor: pointer; + outline: none; +} + +.item:hover, +.item:focus-visible { + background: var(--surface-muted); +} + +.item:focus-visible { + box-shadow: inset 0 0 0 2px var(--accent); +} + +/* Demo layout helpers */ +.demo { + display: flex; + flex-direction: column; + gap: 16px; +} + +.demoRow { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +}