diff --git a/packages/components/src/menu/README.md b/packages/components/src/menu/README.md index 6732610c0c6cae..b34e21696459d5 100644 --- a/packages/components/src/menu/README.md +++ b/packages/components/src/menu/README.md @@ -1,344 +1,589 @@ -# `Menu` +# Menu -
-This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. -
+ -`Menu` displays a menu to the user (such as a set of actions or functions). The menu is rendered in a popover (this pattern is also known as a "Dropdown menu"), which is triggered by a button. +

See the WordPress Storybook for more detailed, interactive documentation.

-## Design guidelines +Menu is a collection of React components that combine to render +ARIA-compliant [menu](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) and +[menu button](https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/) patterns. -### Usage +`Menu` itself is a wrapper component and context provider. +It is responsible for managing the state of the menu and its items, and for +rendering the `Menu.TriggerButton` (or the `Menu.SubmenuTriggerItem`) +component, and the `Menu.Popover` component. -#### When to use a `Menu` +## Props -Use a `Menu` when you want users to: +### `as` -- Choose an action or change a setting from a list, AND -- Only see the available choices contextually. +The HTML element or React component to render the component as. -`Menu` is a React component to render an expandable menu of buttons. It is similar in purpose to a `` element, with the distinction that it does not maintain a value. Instead, each option behaves as an action button. + +If you need to display all the available options at all times, consider using a Toolbar instead. Use a `Menu` to display a list of actions after the user interacts with a button. + +**Do** +Use a `Menu` to display a list of actions after the user interacts with an icon. + +**Don’t** use a `Menu` for important actions that should always be visible. Use a `Toolbar` instead. + +**Don’t** +Don’t use a `Menu` for frequently used actions. Use a `Toolbar` instead. + +### Behavior + +Generally, the parent button should indicate that interacting with it will show a `Menu`. + +The parent button should retain the same visual styling regardless of whether the `Menu` is displayed or not. + +### Placement + +The `Menu` should typically appear directly below, or below and to the left of, the parent button. If there isn’t enough space below to display the full `Menu`, it can be displayed instead above the parent button. diff --git a/packages/components/src/menu/stories/index.story.tsx b/packages/components/src/menu/stories/index.story.tsx index 37ebb6f905dc84..de9e4cdd652102 100644 --- a/packages/components/src/menu/stories/index.story.tsx +++ b/packages/components/src/menu/stories/index.story.tsx @@ -20,10 +20,10 @@ import Button from '../../button'; import Modal from '../../modal'; import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill'; import { ContextSystemProvider } from '../../context'; -import type { MenuProps } from '../types'; +import type { Props } from '../types'; const meta: Meta< typeof Menu > = { - id: 'components-experimental-menu', + id: 'components-menu', title: 'Components (Experimental)/Actions/Menu', component: Menu, subcomponents: { @@ -183,7 +183,7 @@ export const WithSubmenu: StoryObj< typeof Menu > = { }; export const WithCheckboxes: StoryObj< typeof Menu > = { - render: function WithCheckboxes( props: MenuProps ) { + render: function WithCheckboxes( props: Props ) { const [ isAChecked, setAChecked ] = useState( false ); const [ isBChecked, setBChecked ] = useState( true ); const [ multipleCheckboxesValue, setMultipleCheckboxesValue ] = @@ -333,7 +333,7 @@ export const WithCheckboxes: StoryObj< typeof Menu > = { }; export const WithRadios: StoryObj< typeof Menu > = { - render: function WithRadios( props: MenuProps ) { + render: function WithRadios( props: Props ) { const [ radioValue, setRadioValue ] = useState( 'two' ); const onRadioChange: React.ComponentProps< typeof Menu.RadioItem @@ -411,7 +411,7 @@ const modalOnTopOfMenuPopover = css` `; export const WithModals: StoryObj< typeof Menu > = { - render: function WithModals( props: MenuProps ) { + render: function WithModals( props: Props ) { const [ isOuterModalOpen, setOuterModalOpen ] = useState( false ); const [ isInnerModalOpen, setInnerModalOpen ] = useState( false ); @@ -527,7 +527,7 @@ const Fill = ( { children }: { children: React.ReactNode } ) => { }; export const WithSlotFill: StoryObj< typeof Menu > = { - render: ( props: MenuProps ) => { + render: ( props: Props ) => { return ( @@ -579,7 +579,7 @@ const toolbarVariantContextValue = { }; export const ToolbarVariant: StoryObj< typeof Menu > = { - render: ( props: MenuProps ) => ( + render: ( props: Props ) => ( // TODO: add toolbar @@ -619,7 +619,7 @@ export const ToolbarVariant: StoryObj< typeof Menu > = { }; export const InsideModal: StoryObj< typeof Menu > = { - render: function InsideModal( props: MenuProps ) { + render: function InsideModal( props: Props ) { const [ isModalOpen, setModalOpen ] = useState( false ); return ( <> diff --git a/packages/components/src/menu/styles.ts b/packages/components/src/menu/styles.ts index cda5c7321f38b4..1235d6ae7ec1b4 100644 --- a/packages/components/src/menu/styles.ts +++ b/packages/components/src/menu/styles.ts @@ -12,7 +12,7 @@ import { COLORS, font, rtl, CONFIG } from '../utils'; import { space } from '../utils/space'; import Icon from '../icon'; import { Truncate } from '../truncate'; -import type { MenuContext } from './types'; +import type { ContextProps } from './types'; const ANIMATION_PARAMS = { SCALE_AMOUNT_OUTER: 0.82, @@ -42,8 +42,8 @@ const TOOLBAR_VARIANT_BOX_SHADOW = `0 0 0 ${ CONFIG.borderWidth } ${ TOOLBAR_VAR const GRID_TEMPLATE_COLS = 'minmax( 0, max-content ) 1fr'; -export const MenuPopoverOuterWrapper = styled.div< - Pick< MenuContext, 'variant' > +export const PopoverOuterWrapper = styled.div< + Pick< ContextProps, 'variant' > >` position: relative; @@ -95,7 +95,7 @@ export const MenuPopoverOuterWrapper = styled.div< } `; -export const MenuPopoverInnerWrapper = styled.div` +export const PopoverInnerWrapper = styled.div` position: relative; /* Same as popover component */ /* TODO: is there a way to read the sass variable? */ @@ -219,7 +219,7 @@ const baseItem = css` } /* When the item is the trigger of an open submenu */ - ${ MenuPopoverInnerWrapper }:not(:focus) &:not(:focus)[aria-expanded="true"] { + ${ PopoverInnerWrapper }:not(:focus) &:not(:focus)[aria-expanded="true"] { background-color: ${ LIGHT_BACKGROUND_COLOR }; color: ${ COLORS.theme.foreground }; } @@ -229,15 +229,15 @@ const baseItem = css` } `; -export const MenuItem = styled( Ariakit.MenuItem )` +export const Item = styled( Ariakit.MenuItem )` ${ baseItem }; `; -export const MenuCheckboxItem = styled( Ariakit.MenuItemCheckbox )` +export const CheckboxItem = styled( Ariakit.MenuItemCheckbox )` ${ baseItem }; `; -export const MenuRadioItem = styled( Ariakit.MenuItemRadio )` +export const RadioItem = styled( Ariakit.MenuItemRadio )` ${ baseItem }; `; @@ -249,14 +249,14 @@ export const ItemPrefixWrapper = styled.span` * Even when the item is not checked, occupy the same screen space to avoid * the space collapside when no items are checked. */ - ${ MenuCheckboxItem } > &, - ${ MenuRadioItem } > & { + ${ CheckboxItem } > &, + ${ RadioItem } > & { /* Same width as the check icons */ min-width: ${ space( 6 ) }; } - ${ MenuCheckboxItem } > &, - ${ MenuRadioItem } > &, + ${ CheckboxItem } > &, + ${ RadioItem } > &, &:not( :empty ) { margin-inline-end: ${ space( 2 ) }; } @@ -278,7 +278,7 @@ export const ItemPrefixWrapper = styled.span` } `; -export const MenuItemContentWrapper = styled.div` +export const ItemContentWrapper = styled.div` /* * Always occupy the second column, since the first column * is taken by the prefix wrapper (when displayed). @@ -293,7 +293,7 @@ export const MenuItemContentWrapper = styled.div` pointer-events: none; `; -export const MenuItemChildrenWrapper = styled.div` +export const ItemChildrenWrapper = styled.div` flex: 1; display: inline-flex; @@ -317,19 +317,19 @@ export const ItemSuffixWrapper = styled.span` * When the parent menu item is active, except when it's a non-focused/hovered * submenu trigger (in that case, color should not be inherited) */ - [data-active-item]:not( [data-focus-visible] ) *:not(${ MenuPopoverInnerWrapper }) &, + [data-active-item]:not( [data-focus-visible] ) *:not(${ PopoverInnerWrapper }) &, /* When the parent menu item is disabled */ - [aria-disabled='true'] *:not(${ MenuPopoverInnerWrapper }) & { + [aria-disabled='true'] *:not(${ PopoverInnerWrapper }) & { color: inherit; } `; -export const MenuGroup = styled( Ariakit.MenuGroup )` +export const Group = styled( Ariakit.MenuGroup )` /* Ignore this element when calculating the layout. Useful for subgrid */ display: contents; `; -export const MenuGroupLabel = styled( Ariakit.MenuGroupLabel )` +export const GroupLabel = styled( Ariakit.MenuGroupLabel )` /* Occupy the width of all grid columns (ie. full width) */ grid-column: 1 / -1; @@ -338,8 +338,8 @@ export const MenuGroupLabel = styled( Ariakit.MenuGroupLabel )` padding-inline: ${ ITEM_PADDING_INLINE }; `; -export const MenuSeparator = styled( Ariakit.MenuSeparator )< - Pick< MenuContext, 'variant' > +export const Separator = styled( Ariakit.MenuSeparator )< + Pick< ContextProps, 'variant' > >` /* Occupy the width of all grid columns (ie. full width) */ grid-column: 1 / -1; @@ -370,22 +370,22 @@ export const SubmenuChevronIcon = styled( Icon )` ) }; `; -export const MenuItemLabel = styled( Truncate )` +export const ItemLabel = styled( Truncate )` font-size: ${ font( 'default.fontSize' ) }; line-height: 20px; color: inherit; `; -export const MenuItemHelpText = styled( Truncate )` +export const ItemHelpText = styled( Truncate )` font-size: ${ font( 'helpText.fontSize' ) }; line-height: 16px; color: ${ LIGHTER_TEXT_COLOR }; overflow-wrap: anywhere; [data-active-item]:not( [data-focus-visible] ) - *:not( ${ MenuPopoverInnerWrapper } ) + *:not( ${ PopoverInnerWrapper } ) &, - [aria-disabled='true'] *:not( ${ MenuPopoverInnerWrapper } ) & { + [aria-disabled='true'] *:not( ${ PopoverInnerWrapper } ) & { color: inherit; } `; diff --git a/packages/components/src/menu/submenu-trigger-item.tsx b/packages/components/src/menu/submenu-trigger-item.tsx index 23932a14bdaff4..9ea24d259af300 100644 --- a/packages/components/src/menu/submenu-trigger-item.tsx +++ b/packages/components/src/menu/submenu-trigger-item.tsx @@ -13,16 +13,16 @@ import { chevronRightSmall } from '@wordpress/icons'; * Internal dependencies */ import type { WordPressComponentProps } from '../context'; -import type { MenuItemProps } from './types'; -import { MenuContext } from './context'; -import { MenuItem } from './item'; +import type { ItemProps } from './types'; +import { Context } from './context'; +import { Item } from './item'; import * as Styled from './styles'; -export const MenuSubmenuTriggerItem = forwardRef< +export const SubmenuTriggerItem = forwardRef< HTMLDivElement, - WordPressComponentProps< MenuItemProps, 'div', false > ->( function MenuSubmenuTriggerItem( { suffix, ...otherProps }, ref ) { - const menuContext = useContext( MenuContext ); + WordPressComponentProps< ItemProps, 'div', false > +>( function SubmenuTriggerItem( { suffix, ...otherProps }, ref ) { + const menuContext = useContext( Context ); if ( ! menuContext?.store.parent ) { throw new Error( @@ -36,10 +36,10 @@ export const MenuSubmenuTriggerItem = forwardRef< accessibleWhenDisabled store={ menuContext.store } render={ - ->( function MenuTriggerButton( { children, disabled = false, ...props }, ref ) { - const menuContext = useContext( MenuContext ); + WordPressComponentProps< TriggerButtonProps, 'button', false > +>( function TriggerButton( { children, disabled = false, ...props }, ref ) { + const menuContext = useContext( Context ); if ( ! menuContext?.store ) { throw new Error( diff --git a/packages/components/src/menu/types.ts b/packages/components/src/menu/types.ts index f9bb0782529d1f..4532d97fb13dd9 100644 --- a/packages/components/src/menu/types.ts +++ b/packages/components/src/menu/types.ts @@ -3,7 +3,7 @@ */ import type * as Ariakit from '@ariakit/react'; -export interface MenuContext { +export interface ContextProps { /** * The ariakit store shared across all Menu subcomponents. */ @@ -14,7 +14,7 @@ export interface MenuContext { variant?: 'toolbar'; } -export interface MenuProps { +export interface Props { /** * The elements, which should include one instance of the `Menu.TriggerButton` * component and one instance of the `Menu.Popover` component. @@ -50,7 +50,7 @@ export interface MenuProps { placement?: Ariakit.MenuProviderProps[ 'placement' ]; } -export interface MenuPopoverProps { +export interface PopoverProps { /** * The contents of the menu popover, which should include instances of the * `Menu.Item`, `Menu.CheckboxItem`, `Menu.RadioItem`, `Menu.Group`, and @@ -98,7 +98,7 @@ export interface MenuPopoverProps { hideOnEscape?: Ariakit.MenuProps[ 'hideOnEscape' ]; } -export interface MenuTriggerButtonProps { +export interface TriggerButtonProps { /** * The contents of the menu trigger button. */ @@ -139,7 +139,7 @@ export interface MenuTriggerButtonProps { accessibleWhenDisabled?: Ariakit.MenuButtonProps[ 'accessibleWhenDisabled' ]; } -export interface MenuGroupProps { +export interface GroupProps { /** * The contents of the menu group, which should include one instance of the * `Menu.GroupLabel` component and one or more instances of `Menu.Item`, @@ -148,7 +148,7 @@ export interface MenuGroupProps { children: Ariakit.MenuGroupProps[ 'children' ]; } -export interface MenuGroupLabelProps { +export interface GroupLabelProps { /** * The contents of the menu group label, which should provide an accessible * label for the menu group. @@ -156,7 +156,7 @@ export interface MenuGroupLabelProps { children: Ariakit.MenuGroupLabelProps[ 'children' ]; } -export interface MenuItemProps { +export interface ItemProps { /** * The contents of the menu item, which could include one instance of the * `Menu.ItemLabel` component and/or one instance of the `Menu.ItemHelpText` @@ -203,7 +203,7 @@ export interface MenuItemProps { store?: Ariakit.MenuItemProps[ 'store' ]; } -export interface MenuCheckboxItemProps { +export interface CheckboxItemProps { /** * The contents of the menu item, which could include one instance of the * `Menu.ItemLabel` component and/or one instance of the `Menu.ItemHelpText` @@ -267,7 +267,7 @@ export interface MenuCheckboxItemProps { onChange?: Ariakit.MenuItemCheckboxProps[ 'onChange' ]; } -export interface MenuRadioItemProps { +export interface RadioItemProps { /** * The contents of the menu item, which could include one instance of the * `Menu.ItemLabel` component and/or one instance of the `Menu.ItemHelpText` @@ -330,4 +330,4 @@ export interface MenuRadioItemProps { onChange?: Ariakit.MenuItemRadioProps[ 'onChange' ]; } -export interface MenuSeparatorProps {} +export interface SeparatorProps {} diff --git a/storybook/manager-head.html b/storybook/manager-head.html index d3f156a6eb788b..a4f6941e981114 100644 --- a/storybook/manager-head.html +++ b/storybook/manager-head.html @@ -7,6 +7,7 @@ 'boxcontrol', 'customselectcontrol-v2', 'dimensioncontrol', + 'menu', 'navigation', 'navigator', 'progressbar',