diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js
index 2dab67d629332..11e17aba3b30d 100644
--- a/packages/block-editor/src/hooks/block-bindings.js
+++ b/packages/block-editor/src/hooks/block-bindings.js
@@ -51,7 +51,7 @@ const useToolsPanelDropdownMenuProps = () => {
: {};
};
-function BlockBindingsPanelDropdown( { fieldsList, attribute, binding } ) {
+function BlockBindingsPanelMenuContent( { fieldsList, attribute, binding } ) {
const { clientId } = useBlockEditContext();
const registeredSources = getBlockBindingsSources();
const { updateBlockBindings } = useBlockBindingsUtils();
@@ -179,22 +179,21 @@ function EditableBlockBindingsPanelItems( {
placement={
isMobile ? 'bottom-start' : 'left-start'
}
- gutter={ isMobile ? 8 : 36 }
- trigger={
- -
-
-
- }
>
-
+
}>
+
+
+
+
+
);
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index fef1769c19b0f..7b5ec64bd44ca 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -19,6 +19,7 @@
### Experimental
- Add new `Badge` component ([#66555](https://github.com/WordPress/gutenberg/pull/66555)).
+- `Menu`: refactor to more granular sub-components ([#67422](https://github.com/WordPress/gutenberg/pull/67422)).
### Internal
diff --git a/packages/components/src/menu/index.tsx b/packages/components/src/menu/index.tsx
index 9886f32482321..2e0fc91cfbc34 100644
--- a/packages/components/src/menu/index.tsx
+++ b/packages/components/src/menu/index.tsx
@@ -6,23 +6,14 @@ import * as Ariakit from '@ariakit/react';
/**
* WordPress dependencies
*/
-import {
- useContext,
- useMemo,
- cloneElement,
- isValidElement,
- useCallback,
-} from '@wordpress/element';
-import { isRTL } from '@wordpress/i18n';
-import { chevronRightSmall } from '@wordpress/icons';
+import { useContext, useMemo } from '@wordpress/element';
+import { isRTL as isRTLFn } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import { useContextSystem, contextConnect } from '../context';
-import type { WordPressComponentProps } from '../context';
+import { useContextSystem, contextConnectWithoutRef } from '../context';
import type { MenuContext as MenuContextType, MenuProps } from './types';
-import * as Styled from './styles';
import { MenuContext } from './context';
import { MenuItem } from './item';
import { MenuCheckboxItem } from './checkbox-item';
@@ -32,49 +23,36 @@ import { MenuGroupLabel } from './group-label';
import { MenuSeparator } from './separator';
import { MenuItemLabel } from './item-label';
import { MenuItemHelpText } from './item-help-text';
+import { MenuTriggerButton } from './trigger-button';
+import { MenuSubmenuTriggerItem } from './submenu-trigger-item';
+import { MenuPopover } from './popover';
-const UnconnectedMenu = (
- props: WordPressComponentProps< MenuProps, 'div', false >,
- ref: React.ForwardedRef< HTMLDivElement >
-) => {
+const UnconnectedMenu = ( props: MenuProps ) => {
const {
- // Store props
- open,
+ children,
defaultOpen = false,
+ open,
onOpenChange,
placement,
- // Menu trigger props
- trigger,
-
- // Menu props
- gutter,
- children,
- shift,
- modal = true,
-
// From internal components context
variant,
-
- // Rest
- ...otherProps
- } = useContextSystem< typeof props & Pick< MenuContextType, 'variant' > >(
- props,
- 'Menu'
- );
+ } = useContextSystem<
+ // @ts-expect-error TODO: missing 'className' in MenuProps
+ typeof props & Pick< MenuContextType, 'variant' >
+ >( props, 'Menu' );
const parentContext = useContext( MenuContext );
- const computedDirection = isRTL() ? 'rtl' : 'ltr';
+ const rtl = isRTLFn();
// If an explicit value for the `placement` prop is not passed,
// apply a default placement of `bottom-start` for the root menu popover,
// and of `right-start` for nested menu popovers.
let computedPlacement =
- props.placement ??
- ( parentContext?.store ? 'right-start' : 'bottom-start' );
+ placement ?? ( parentContext?.store ? 'right-start' : 'bottom-start' );
// Swap left/right in case of RTL direction
- if ( computedDirection === 'rtl' ) {
+ if ( rtl ) {
if ( /right/.test( computedPlacement ) ) {
computedPlacement = computedPlacement.replace(
'right',
@@ -97,7 +75,7 @@ const UnconnectedMenu = (
setOpen( willBeOpen ) {
onOpenChange?.( willBeOpen );
},
- rtl: computedDirection === 'rtl',
+ rtl,
} );
const contextValue = useMemo(
@@ -105,134 +83,53 @@ const UnconnectedMenu = (
[ menuStore, variant ]
);
- // Extract the side from the applied placement — useful for animations.
- // Using `currentPlacement` instead of `placement` to make sure that we
- // use the final computed placement (including "flips" etc).
- const appliedPlacementSide = Ariakit.useStoreState(
- menuStore,
- 'currentPlacement'
- ).split( '-' )[ 0 ];
-
- if (
- menuStore.parent &&
- ! ( isValidElement( trigger ) && MenuItem === trigger.type )
- ) {
- // eslint-disable-next-line no-console
- console.warn(
- 'For nested Menus, the `trigger` should always be a `MenuItem`.'
- );
- }
-
- const hideOnEscape = useCallback(
- ( event: React.KeyboardEvent< Element > ) => {
- // Pressing Escape can cause unexpected consequences (ie. exiting
- // full screen mode on MacOs, close parent modals...).
- event.preventDefault();
- // Returning `true` causes the menu to hide.
- return true;
- },
- []
- );
-
- const wrapperProps = useMemo(
- () => ( {
- dir: computedDirection,
- style: {
- direction:
- computedDirection as React.CSSProperties[ 'direction' ],
- },
- } ),
- [ computedDirection ]
- );
-
return (
- <>
- { /* Menu trigger */ }
-
- { trigger.props.suffix }
-
- >
- ),
- } )
- : trigger
- }
- />
-
- { /* Menu popover */ }
- (
- // Two wrappers are needed for the entry animation, where the menu
- // container scales with a different factor than its contents.
- // The {...renderProps} are passed to the inner wrapper, so that the
- // menu element is the direct parent of the menu item elements.
-
-
-
- ) }
- >
-
- { children }
-
-
- >
+
+ { children }
+
);
};
-export const Menu = Object.assign( contextConnect( UnconnectedMenu, 'Menu' ), {
- Context: Object.assign( MenuContext, {
- displayName: 'Menu.Context',
- } ),
- Item: Object.assign( MenuItem, {
- displayName: 'Menu.Item',
- } ),
- RadioItem: Object.assign( MenuRadioItem, {
- displayName: 'Menu.RadioItem',
- } ),
- CheckboxItem: Object.assign( MenuCheckboxItem, {
- displayName: 'Menu.CheckboxItem',
- } ),
- Group: Object.assign( MenuGroup, {
- displayName: 'Menu.Group',
- } ),
- GroupLabel: Object.assign( MenuGroupLabel, {
- displayName: 'Menu.GroupLabel',
- } ),
- Separator: Object.assign( MenuSeparator, {
- displayName: 'Menu.Separator',
- } ),
- ItemLabel: Object.assign( MenuItemLabel, {
- displayName: 'Menu.ItemLabel',
- } ),
- ItemHelpText: Object.assign( MenuItemHelpText, {
- displayName: 'Menu.ItemHelpText',
- } ),
-} );
+export const Menu = Object.assign(
+ contextConnectWithoutRef( UnconnectedMenu, 'Menu' ),
+ {
+ Context: Object.assign( MenuContext, {
+ displayName: 'Menu.Context',
+ } ),
+ Item: Object.assign( MenuItem, {
+ displayName: 'Menu.Item',
+ } ),
+ RadioItem: Object.assign( MenuRadioItem, {
+ displayName: 'Menu.RadioItem',
+ } ),
+ CheckboxItem: Object.assign( MenuCheckboxItem, {
+ displayName: 'Menu.CheckboxItem',
+ } ),
+ Group: Object.assign( MenuGroup, {
+ displayName: 'Menu.Group',
+ } ),
+ GroupLabel: Object.assign( MenuGroupLabel, {
+ displayName: 'Menu.GroupLabel',
+ } ),
+ Separator: Object.assign( MenuSeparator, {
+ displayName: 'Menu.Separator',
+ } ),
+ ItemLabel: Object.assign( MenuItemLabel, {
+ displayName: 'Menu.ItemLabel',
+ } ),
+ ItemHelpText: Object.assign( MenuItemHelpText, {
+ displayName: 'Menu.ItemHelpText',
+ } ),
+ Popover: Object.assign( MenuPopover, {
+ displayName: 'Menu.Popover',
+ } ),
+ TriggerButton: Object.assign( MenuTriggerButton, {
+ displayName: 'Menu.TriggerButton',
+ } ),
+ SubmenuTriggerItem: Object.assign( MenuSubmenuTriggerItem, {
+ displayName: 'Menu.SubmenuTriggerItem',
+ } ),
+ }
+);
export default Menu;
diff --git a/packages/components/src/menu/item.tsx b/packages/components/src/menu/item.tsx
index 6d09bdf3d0f59..84ff050bcc223 100644
--- a/packages/components/src/menu/item.tsx
+++ b/packages/components/src/menu/item.tsx
@@ -15,7 +15,7 @@ export const MenuItem = forwardRef<
HTMLDivElement,
WordPressComponentProps< MenuItemProps, 'div', false >
>( function MenuItem(
- { prefix, suffix, children, hideOnClick = true, ...props },
+ { prefix, suffix, children, hideOnClick = true, store, ...props },
ref
) {
const menuContext = useContext( MenuContext );
@@ -26,13 +26,19 @@ export const MenuItem = forwardRef<
);
}
+ // In most cases, the menu store will be retrieved from context (ie. the store
+ // created by the top-level menu component). But in rare cases (ie.
+ // `Menu.SubmenuTriggerItem`), the context store wouldn't be correct. This is
+ // why the component accepts a `store` prop to override the context store.
+ const computedStore = store ?? menuContext.store;
+
return (
{ prefix }
diff --git a/packages/components/src/menu/popover.tsx b/packages/components/src/menu/popover.tsx
new file mode 100644
index 0000000000000..19972a31027ce
--- /dev/null
+++ b/packages/components/src/menu/popover.tsx
@@ -0,0 +1,103 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ useContext,
+ useMemo,
+ forwardRef,
+ useCallback,
+} from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { WordPressComponentProps } from '../context';
+import type { MenuPopoverProps } from './types';
+import * as Styled from './styles';
+import { MenuContext } from './context';
+
+export const MenuPopover = forwardRef<
+ HTMLDivElement,
+ WordPressComponentProps< MenuPopoverProps, 'div', false >
+>( function MenuPopover(
+ { gutter, children, shift, modal = true, ...otherProps },
+ ref
+) {
+ const menuContext = useContext( MenuContext );
+
+ // Extract the side from the applied placement — useful for animations.
+ // Using `currentPlacement` instead of `placement` to make sure that we
+ // use the final computed placement (including "flips" etc).
+ const appliedPlacementSide = Ariakit.useStoreState(
+ menuContext?.store,
+ 'currentPlacement'
+ )?.split( '-' )[ 0 ];
+
+ const hideOnEscape = useCallback(
+ ( event: React.KeyboardEvent< Element > ) => {
+ // Pressing Escape can cause unexpected consequences (ie. exiting
+ // full screen mode on MacOs, close parent modals...).
+ event.preventDefault();
+ // Returning `true` causes the menu to hide.
+ return true;
+ },
+ []
+ );
+
+ const computedDirection = Ariakit.useStoreState( menuContext?.store, 'rtl' )
+ ? 'rtl'
+ : 'ltr';
+
+ const wrapperProps = useMemo(
+ () => ( {
+ dir: computedDirection,
+ style: {
+ direction:
+ computedDirection as React.CSSProperties[ 'direction' ],
+ },
+ } ),
+ [ computedDirection ]
+ );
+
+ if ( ! menuContext?.store ) {
+ throw new Error(
+ 'Menu.Popover can only be rendered inside a Menu component'
+ );
+ }
+
+ return (
+ (
+ // Two wrappers are needed for the entry animation, where the menu
+ // container scales with a different factor than its contents.
+ // The {...renderProps} are passed to the inner wrapper, so that the
+ // menu element is the direct parent of the menu item elements.
+
+
+
+ ) }
+ >
+ { children }
+
+ );
+} );
diff --git a/packages/components/src/menu/stories/index.story.tsx b/packages/components/src/menu/stories/index.story.tsx
index ad4794057e0e0..dcd890370a1e0 100644
--- a/packages/components/src/menu/stories/index.story.tsx
+++ b/packages/components/src/menu/stories/index.story.tsx
@@ -20,6 +20,7 @@ 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';
const meta: Meta< typeof Menu > = {
id: 'components-experimental-menu',
@@ -44,10 +45,15 @@ const meta: Meta< typeof Menu > = {
ItemLabel: Menu.ItemLabel,
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
ItemHelpText: Menu.ItemHelpText,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ TriggerButton: Menu.TriggerButton,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ SubmenuTriggerItem: Menu.SubmenuTriggerItem,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ Popover: Menu.Popover,
},
argTypes: {
children: { control: false },
- trigger: { control: false },
},
tags: [ 'status-private' ],
parameters: {
@@ -61,95 +67,103 @@ const meta: Meta< typeof Menu > = {
};
export default meta;
-export const Default: StoryFn< typeof Menu > = ( props ) => (
+export const Default: StoryFn< typeof Menu > = ( props: MenuProps ) => (
);
-Default.args = {
- trigger: (
-
- ),
-};
+Default.args = {};
-export const WithSubmenu: StoryFn< typeof Menu > = ( props ) => (
+export const WithSubmenu: StoryFn< typeof Menu > = ( props: MenuProps ) => (