Repository: mui/material-ui (TypeScript)
Date: 2023-10-03 20:59:15+00:00
Task type: Feature

Issue:
[Menu][base-ui] Add the `anchor` prop to the Menu
As discussed in the comments of https://github.com/mui/material-ui/issues/38324, there's a need to directly provide the anchor element to the menu, so it can be anchored to an element that didn't trigger it.


Issue discussion:

Solution patch:
diff --git a/docs/pages/base-ui/api/menu.json b/docs/pages/base-ui/api/menu.json
--- a/docs/pages/base-ui/api/menu.json
+++ b/docs/pages/base-ui/api/menu.json
@@ -1,6 +1,12 @@
 {
   "props": {
     "actions": { "type": { "name": "custom", "description": "ref" } },
+    "anchor": {
+      "type": {
+        "name": "union",
+        "description": "HTML element<br>&#124;&nbsp;object<br>&#124;&nbsp;func"
+      }
+    },
     "onItemsChange": { "type": { "name": "func" } },
     "slotProps": {
       "type": {
diff --git a/docs/translations/api-docs-base/menu/menu.json b/docs/translations/api-docs-base/menu/menu.json
--- a/docs/translations/api-docs-base/menu/menu.json
+++ b/docs/translations/api-docs-base/menu/menu.json
@@ -4,6 +4,7 @@
     "actions": {
       "description": "A ref with imperative actions that can be performed on the menu."
     },
+    "anchor": { "description": "The element based on which the menu is positioned." },
     "onItemsChange": {
       "description": "Function called when the items displayed in the menu change."
     },
diff --git a/packages/mui-base/src/Menu/Menu.tsx b/packages/mui-base/src/Menu/Menu.tsx
--- a/packages/mui-base/src/Menu/Menu.tsx
+++ b/packages/mui-base/src/Menu/Menu.tsx
@@ -1,7 +1,7 @@
 'use client';
 import * as React from 'react';
 import PropTypes from 'prop-types';
-import { refType } from '@mui/utils';
+import { HTMLElementType, refType } from '@mui/utils';
 import { PolymorphicComponent } from '../utils/PolymorphicComponent';
 import { MenuOwnerState, MenuProps, MenuRootSlotProps, MenuTypeMap } from './Menu.types';
 import { getMenuUtilityClass } from './menuClasses';
@@ -38,12 +38,22 @@ const Menu = React.forwardRef(function Menu<RootComponentType extends React.Elem
   props: MenuProps<RootComponentType>,
   forwardedRef: React.ForwardedRef<Element>,
 ) {
-  const { actions, children, onItemsChange, slotProps = {}, slots = {}, ...other } = props;
+  const {
+    actions,
+    anchor: anchorProp,
+    children,
+    onItemsChange,
+    slotProps = {},
+    slots = {},
+    ...other
+  } = props;
 
   const { contextValue, getListboxProps, dispatch, open, triggerElement } = useMenu({
     onItemsChange,
   });
 
+  const anchor = anchorProp ?? triggerElement;
+
   React.useImperativeHandle(
     actions,
     () => ({
@@ -79,7 +89,7 @@ const Menu = React.forwardRef(function Menu<RootComponentType extends React.Elem
     ownerState,
   });
 
-  if (open === true && triggerElement == null) {
+  if (open === true && anchor == null) {
     return (
       <Root {...rootProps}>
         <Listbox {...listboxProps}>
@@ -90,7 +100,7 @@ const Menu = React.forwardRef(function Menu<RootComponentType extends React.Elem
   }
 
   return (
-    <Popper {...rootProps} open={open} anchorEl={triggerElement} slots={{ root: Root }}>
+    <Popper {...rootProps} open={open} anchorEl={anchor} slots={{ root: Root }}>
       <Listbox {...listboxProps}>
         <MenuProvider value={contextValue}>{children}</MenuProvider>
       </Listbox>
@@ -107,6 +117,14 @@ Menu.propTypes /* remove-proptypes */ = {
    * A ref with imperative actions that can be performed on the menu.
    */
   actions: refType,
+  /**
+   * The element based on which the menu is positioned.
+   */
+  anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
+    HTMLElementType,
+    PropTypes.object,
+    PropTypes.func,
+  ]),
   /**
    * @ignore
    */
diff --git a/packages/mui-base/src/Menu/Menu.types.ts b/packages/mui-base/src/Menu/Menu.types.ts
--- a/packages/mui-base/src/Menu/Menu.types.ts
+++ b/packages/mui-base/src/Menu/Menu.types.ts
@@ -3,7 +3,7 @@ import { Simplify } from '@mui/types';
 import { PolymorphicProps, SlotComponentProps } from '../utils';
 import { UseMenuListboxSlotProps } from '../useMenu';
 import { ListAction } from '../useList';
-import { Popper } from '../Popper';
+import { Popper, PopperProps } from '../Popper';
 
 export interface MenuRootSlotPropsOverrides {}
 export interface MenuListboxSlotPropsOverrides {}
@@ -24,6 +24,10 @@ export interface MenuOwnProps {
    * A ref with imperative actions that can be performed on the menu.
    */
   actions?: React.Ref<MenuActions>;
+  /**
+   * The element based on which the menu is positioned.
+   */
+  anchor?: PopperProps['anchorEl'];
   children?: React.ReactNode;
   className?: string;
   /**


Test patch:
diff --git a/packages/mui-base/src/Menu/Menu.test.tsx b/packages/mui-base/src/Menu/Menu.test.tsx
--- a/packages/mui-base/src/Menu/Menu.test.tsx
+++ b/packages/mui-base/src/Menu/Menu.test.tsx
@@ -409,4 +409,68 @@ describe('<Menu />', () => {
       expect(handleItemsChange.callCount).to.equal(2);
     });
   });
+
+  describe('prop: anchor', () => {
+    it('should be placed near the specified element', async () => {
+      function TestComponent() {
+        const [anchor, setAnchor] = React.useState<HTMLElement | null>(null);
+
+        return (
+          <div>
+            <DropdownContext.Provider value={testContext}>
+              <Menu
+                anchor={anchor}
+                slotProps={{ root: { 'data-testid': 'popup', placement: 'bottom-start' } }}
+              >
+                <MenuItem>1</MenuItem>
+                <MenuItem>2</MenuItem>
+              </Menu>
+            </DropdownContext.Provider>
+            <div data-testid="anchor" style={{ marginTop: '100px' }} ref={setAnchor} />
+          </div>
+        );
+      }
+
+      const { getByTestId } = render(<TestComponent />);
+
+      const popup = getByTestId('popup');
+      const anchor = getByTestId('anchor');
+
+      const anchorPosition = anchor.getBoundingClientRect();
+
+      expect(popup.style.getPropertyValue('transform')).to.equal(
+        `translate(${anchorPosition.left}px, ${anchorPosition.bottom}px)`,
+      );
+    });
+
+    it('should be placed at the specified position', async () => {
+      const boundingRect = {
+        x: 200,
+        y: 100,
+        top: 100,
+        left: 200,
+        bottom: 100,
+        right: 200,
+        height: 0,
+        width: 0,
+        toJSON: () => {},
+      };
+
+      const virtualElement = { getBoundingClientRect: () => boundingRect };
+
+      const { getByTestId } = render(
+        <DropdownContext.Provider value={testContext}>
+          <Menu
+            anchor={virtualElement}
+            slotProps={{ root: { 'data-testid': 'popup', placement: 'bottom-start' } }}
+          >
+            <MenuItem>1</MenuItem>
+            <MenuItem>2</MenuItem>
+          </Menu>
+        </DropdownContext.Provider>,
+      );
+      const popup = getByTestId('popup');
+      expect(popup.style.getPropertyValue('transform')).to.equal(`translate(200px, 100px)`);
+    });
+  });
 });