Repository: mui/material-ui (TypeScript)
Date: 2023-07-01 22:20:50+00:00
Task type: Bug Fix

Issue:
TextField component's with select option and disableScrollLock menu stays fixed when scrolling
### Duplicates

- [X] I have searched the existing issues

### Latest version

- [X] I have tested the latest version

### Steps to reproduce 🕹

Link to live example: https://codesandbox.io/s/bitter-violet-wpfhc4?file=/src/App.js

Steps:

1. Create **TextField** in container that can be scrolled
2. Set **select** property to `true`
3. Set **disableScrollLock** to `true` in MenuProps


### Current behavior 😯

When select dropdown menu is open and page is scrolled,  TextField moves, but menu popper stays in fixed position

### Expected behavior 🤔

Expecting for drop down menu to stay attached to it's anchor element and move together on scroll

### Context 🔦

_No response_

### Your environment 🌎

_No response_


Issue discussion:
Thanks, it looks like a bug. Would you like to contribute to the project by investigating it?
> Thanks, it looks like a bug. Would you like to contribute to the project by investigating it?

I am afraid I don't have possibility to look into it anytime soon. 
I'm open to taking a look at this. 

To confirm, the expected behavior is that the dropdown options menu doesn't move on scroll correct?
> I'm open to taking a look at this.
> 
> To confirm, the expected behavior is that the dropdown options menu doesn't move on scroll correct?

Menu should be attached to input. When it is opened by default it is attached to bottom center of it's anchor element (in this case it is input or input wrapper div, not sure exactly), but when you scroll, input scrolls with the page, but menu stays in fixed position. Expected behavior for menu is to allways stick to anchor element on scroll.
> > I'm open to taking a look at this.
> > To confirm, the expected behavior is that the dropdown options menu doesn't move on scroll correct?
> 
> Menu should be attached to input. When it is opened by default it is attached to bottom center of it's anchor element (in this case it is input or input wrapper div, not sure exactly), but when you scroll, input scrolls with the page, but menu stays in fixed position. Expected behavior for menu is to allways stick to anchor element on scroll.

This makes sense. Let me start looking into this.
Similar to #17353 

Solution patch:
diff --git a/docs/pages/material-ui/api/popover.json b/docs/pages/material-ui/api/popover.json
--- a/docs/pages/material-ui/api/popover.json
+++ b/docs/pages/material-ui/api/popover.json
@@ -23,6 +23,7 @@
     "children": { "type": { "name": "node" } },
     "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } },
     "container": { "type": { "name": "union", "description": "HTML element<br>&#124;&nbsp;func" } },
+    "disableScrollLock": { "type": { "name": "bool" }, "default": "false" },
     "elevation": { "type": { "name": "custom", "description": "integer" }, "default": "8" },
     "marginThreshold": { "type": { "name": "number" }, "default": "16" },
     "onClose": { "type": { "name": "func" } },
diff --git a/docs/translations/api-docs/popover/popover.json b/docs/translations/api-docs/popover/popover.json
--- a/docs/translations/api-docs/popover/popover.json
+++ b/docs/translations/api-docs/popover/popover.json
@@ -21,9 +21,10 @@
     "container": {
       "description": "An HTML element, component instance, or function that returns either. The <code>container</code> will passed to the Modal component.<br>By default, it uses the body of the anchorEl&#39;s top-level document object, so it&#39;s simply <code>document.body</code> most of the time."
     },
+    "disableScrollLock": { "description": "Disable the scroll lock behavior." },
     "elevation": { "description": "The elevation of the popover." },
     "marginThreshold": {
-      "description": "Specifies how close to the edge of the window the popover can appear."
+      "description": "Specifies how close to the edge of the window the popover can appear. If null, the popover will not be constrained by the window."
     },
     "onClose": {
       "description": "Callback fired when the component requests to be closed. The <code>reason</code> parameter can optionally be used to control the response to <code>onClose</code>."
diff --git a/packages/mui-material/src/Popover/Popover.d.ts b/packages/mui-material/src/Popover/Popover.d.ts
--- a/packages/mui-material/src/Popover/Popover.d.ts
+++ b/packages/mui-material/src/Popover/Popover.d.ts
@@ -91,9 +91,10 @@ export interface PopoverProps
   elevation?: number;
   /**
    * Specifies how close to the edge of the window the popover can appear.
+   * If null, the popover will not be constrained by the window.
    * @default 16
    */
-  marginThreshold?: number;
+  marginThreshold?: number | null;
   onClose?: ModalProps['onClose'];
   /**
    * If `true`, the component is shown.
diff --git a/packages/mui-material/src/Popover/Popover.js b/packages/mui-material/src/Popover/Popover.js
--- a/packages/mui-material/src/Popover/Popover.js
+++ b/packages/mui-material/src/Popover/Popover.js
@@ -125,6 +125,7 @@ const Popover = React.forwardRef(function Popover(inProps, ref) {
     TransitionComponent = Grow,
     transitionDuration: transitionDurationProp = 'auto',
     TransitionProps: { onEntering, ...TransitionProps } = {},
+    disableScrollLock = false,
     ...other
   } = props;
 
@@ -244,13 +245,17 @@ const Popover = React.forwardRef(function Popover(inProps, ref) {
       const widthThreshold = containerWindow.innerWidth - marginThreshold;
 
       // Check if the vertical axis needs shifting
-      if (top < marginThreshold) {
+      if (marginThreshold !== null && top < marginThreshold) {
         const diff = top - marginThreshold;
+
         top -= diff;
+
         elemTransformOrigin.vertical += diff;
-      } else if (bottom > heightThreshold) {
+      } else if (marginThreshold !== null && bottom > heightThreshold) {
         const diff = bottom - heightThreshold;
+
         top -= diff;
+
         elemTransformOrigin.vertical += diff;
       }
 
@@ -269,7 +274,7 @@ const Popover = React.forwardRef(function Popover(inProps, ref) {
       }
 
       // Check if the horizontal axis needs shifting
-      if (left < marginThreshold) {
+      if (marginThreshold !== null && left < marginThreshold) {
         const diff = left - marginThreshold;
         left -= diff;
         elemTransformOrigin.horizontal += diff;
@@ -309,6 +314,13 @@ const Popover = React.forwardRef(function Popover(inProps, ref) {
     setIsPositioned(true);
   }, [getPositioningStyle]);
 
+  React.useEffect(() => {
+    if (disableScrollLock) {
+      window.addEventListener('scroll', setPositioningStyles);
+    }
+    return () => window.removeEventListener('scroll', setPositioningStyles);
+  }, [anchorEl, disableScrollLock, setPositioningStyles]);
+
   const handleEntering = (element, isAppearing) => {
     if (onEntering) {
       onEntering(element, isAppearing);
@@ -403,7 +415,10 @@ const Popover = React.forwardRef(function Popover(inProps, ref) {
   });
 
   return (
-    <RootSlot {...rootProps} {...(!isHostComponent(RootSlot) && { slotProps: rootSlotPropsProp })}>
+    <RootSlot
+      {...rootProps}
+      {...(!isHostComponent(RootSlot) && { slotProps: rootSlotPropsProp, disableScrollLock })}
+    >
       <TransitionComponent
         appear
         in={open}
@@ -525,6 +540,11 @@ Popover.propTypes /* remove-proptypes */ = {
     HTMLElementType,
     PropTypes.func,
   ]),
+  /**
+   * Disable the scroll lock behavior.
+   * @default false
+   */
+  disableScrollLock: PropTypes.bool,
   /**
    * The elevation of the popover.
    * @default 8
@@ -532,6 +552,7 @@ Popover.propTypes /* remove-proptypes */ = {
   elevation: integerPropType,
   /**
    * Specifies how close to the edge of the window the popover can appear.
+   * If null, the popover will not be constrained by the window.
    * @default 16
    */
   marginThreshold: PropTypes.number,


Test patch:
diff --git a/packages/mui-material/src/Popover/Popover.test.js b/packages/mui-material/src/Popover/Popover.test.js
--- a/packages/mui-material/src/Popover/Popover.test.js
+++ b/packages/mui-material/src/Popover/Popover.test.js
@@ -844,8 +844,8 @@ describe('<Popover />', () => {
     });
   });
 
-  [0, 18, 16].forEach((marginThreshold) => {
-    describe(`positioning when \`marginThreshold=${marginThreshold}\``, () => {
+  describe('prop: marginThreshold', () => {
+    [0, 18, 16].forEach((marginThreshold) => {
       function getElementStyleOfOpenPopover(anchorEl = document.createElement('svg')) {
         let style;
         render(
@@ -858,7 +858,7 @@ describe('<Popover />', () => {
               },
             }}
             marginThreshold={marginThreshold}
-            PaperProps={{ component: FakePaper }}
+            slotProps={{ paper: { component: FakePaper } }}
           >
             <div />
           </Popover>,
@@ -866,98 +866,132 @@ describe('<Popover />', () => {
         return style;
       }
 
-      specify('when no movement is needed', () => {
-        const negative = marginThreshold === 0 ? '' : '-';
-        const positioningStyle = getElementStyleOfOpenPopover();
+      describe(`positioning when \`marginThreshold=${marginThreshold}\``, () => {
+        specify('when no movement is needed', () => {
+          const negative = marginThreshold === 0 ? '' : '-';
+          const positioningStyle = getElementStyleOfOpenPopover();
 
-        expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
-        expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
-        expect(positioningStyle.transformOrigin).to.match(
-          new RegExp(`${negative}${marginThreshold}px ${negative}${marginThreshold}px( 0px)?`),
-        );
-      });
-
-      specify('top < marginThreshold', () => {
-        const mockedAnchor = document.createElement('div');
-        stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
-          left: marginThreshold,
-          top: marginThreshold - 1,
-        }));
-        const positioningStyle = getElementStyleOfOpenPopover(mockedAnchor);
-
-        expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
-        expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
-        expect(positioningStyle.transformOrigin).to.match(/0px -1px( 0ms)?/);
-      });
-
-      describe('bottom > heightThreshold', () => {
-        let windowInnerHeight;
-
-        before(() => {
-          windowInnerHeight = window.innerHeight;
-          window.innerHeight = marginThreshold * 2;
-        });
-
-        after(() => {
-          window.innerHeight = windowInnerHeight;
+          expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
+          expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
+          expect(positioningStyle.transformOrigin).to.match(
+            new RegExp(`${negative}${marginThreshold}px ${negative}${marginThreshold}px( 0px)?`),
+          );
         });
 
-        specify('test', () => {
+        specify('top < marginThreshold', () => {
           const mockedAnchor = document.createElement('div');
           stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
             left: marginThreshold,
-            top: marginThreshold + 1,
+            top: marginThreshold - 1,
           }));
-
           const positioningStyle = getElementStyleOfOpenPopover(mockedAnchor);
 
           expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
           expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
-          expect(positioningStyle.transformOrigin).to.match(/0px 1px( 0px)?/);
+          expect(positioningStyle.transformOrigin).to.match(/0px -1px( 0px)?/);
         });
-      });
 
-      specify('left < marginThreshold', () => {
-        const mockedAnchor = document.createElement('div');
-        stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
-          left: marginThreshold - 1,
-          top: marginThreshold,
-        }));
-
-        const positioningStyle = getElementStyleOfOpenPopover(mockedAnchor);
+        describe('bottom > heightThreshold', () => {
+          let windowInnerHeight;
 
-        expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
+          before(() => {
+            windowInnerHeight = window.innerHeight;
+            window.innerHeight = marginThreshold * 2;
+          });
 
-        expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
+          after(() => {
+            window.innerHeight = windowInnerHeight;
+          });
 
-        expect(positioningStyle.transformOrigin).to.match(/-1px 0px( 0px)?/);
-      });
+          specify('test', () => {
+            const mockedAnchor = document.createElement('div');
+            stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
+              left: marginThreshold,
+              top: marginThreshold + 1,
+            }));
 
-      describe('right > widthThreshold', () => {
-        let innerWidthContainer;
+            const positioningStyle = getElementStyleOfOpenPopover(mockedAnchor);
 
-        before(() => {
-          innerWidthContainer = window.innerWidth;
-          window.innerWidth = marginThreshold * 2;
+            expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
+            expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
+            expect(positioningStyle.transformOrigin).to.match(/0px 1px( 0px)?/);
+          });
         });
 
-        after(() => {
-          window.innerWidth = innerWidthContainer;
-        });
-
-        specify('test', () => {
+        specify('left < marginThreshold', () => {
           const mockedAnchor = document.createElement('div');
           stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
-            left: marginThreshold + 1,
+            left: marginThreshold - 1,
             top: marginThreshold,
           }));
 
           const positioningStyle = getElementStyleOfOpenPopover(mockedAnchor);
 
           expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
+
           expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
-          expect(positioningStyle.transformOrigin).to.match(/1px 0px( 0px)?/);
+
+          expect(positioningStyle.transformOrigin).to.match(/-1px 0px( 0px)?/);
         });
+
+        describe('right > widthThreshold', () => {
+          let innerWidthContainer;
+
+          before(() => {
+            innerWidthContainer = window.innerWidth;
+            window.innerWidth = marginThreshold * 2;
+          });
+
+          after(() => {
+            window.innerWidth = innerWidthContainer;
+          });
+
+          specify('test', () => {
+            const mockedAnchor = document.createElement('div');
+            stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
+              left: marginThreshold + 1,
+              top: marginThreshold,
+            }));
+
+            const positioningStyle = getElementStyleOfOpenPopover(mockedAnchor);
+
+            expect(positioningStyle.top).to.equal(`${marginThreshold}px`);
+            expect(positioningStyle.left).to.equal(`${marginThreshold}px`);
+            expect(positioningStyle.transformOrigin).to.match(/1px 0px( 0px)?/);
+          });
+        });
+      });
+    });
+
+    describe('positioning when `marginThreshold=null`', () => {
+      it('should not apply the marginThreshold when marginThreshold is null', () => {
+        const mockedAnchor = document.createElement('div');
+        const valueOutsideWindow = -100;
+        stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
+          top: valueOutsideWindow,
+          left: valueOutsideWindow,
+        }));
+
+        let style;
+        render(
+          <Popover
+            anchorEl={mockedAnchor}
+            open
+            TransitionProps={{
+              onEntering: (node) => {
+                style = node.style;
+              },
+            }}
+            marginThreshold={null}
+            slotProps={{ paper: { component: FakePaper } }}
+          >
+            <div />
+          </Popover>,
+        );
+
+        expect(style.top).to.equal(`${valueOutsideWindow}px`);
+        expect(style.left).to.equal(`${valueOutsideWindow}px`);
+        expect(style.transformOrigin).to.match(/0px 0px( 0px)?/);
       });
     });
   });