Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ClickAwayListener] Handle portaled element #18586

Closed
2 tasks done
ccmartell opened this issue Nov 26, 2019 · 29 comments · Fixed by #20406
Closed
2 tasks done

[ClickAwayListener] Handle portaled element #18586

ccmartell opened this issue Nov 26, 2019 · 29 comments · Fixed by #20406
Labels
bug 🐛 Something doesn't work component: ClickAwayListener The React component good first issue Great for first contributions. Enable to learn the contribution process.

Comments

@ccmartell
Copy link

  • The issue is present in the latest release.
  • I have searched the issues of this repository and believe that this is not a duplicate.

Current Behavior 😯

When a select component is within a popper element and disablePortal is true, the dropdown items are positioned absolutely in relation to the popper element and not the page. This is also true for any absolutely positioned container that uses transform (which is what popper is using).

Expected Behavior 🤔

Expected behavior would be that the position of the dropdown would match the location of the select element and display correctly.

Steps to Reproduce 🕹

I have mocked the issue here

Steps:

  1. Place absolutely positioned component and add a transform (or just use popper)
  2. Add select component

Context 🔦

When using a clickaway listener with popper to display a popup, a portaled menu will cause the clickaway listener to fire, closing the popper and losing our work. We then were able to use disablePortal in the MenuProps, but that causes this issue. We have done a workaround using Popover as it doesn't have a transform and displays the Select menu correctly.

Your Environment 🌎

Tech Version
Material-UI v4.7.0
React v16.11.0
Browser Chrome
TypeScript No
@oliviertassinari
Copy link
Member

Your problem is related to a "couple" of others 😆:

  • [Popover] On mobile Chrome while zoomed, opening popover causes page to jump #17636: A change of the Popover's positioning logic to use Popper would fix the placement issue.
  • [Menu] Clicking outside a menu blocks click from bubbling up #11243: However, even if we fix the placement issue, the backdrop logic won't be correct. Either the backdrop needs to be portal (to block interactions with the rest of the page) or we need to use a click away listener.
    • If we want to support a portal backdrop, I would suggest the addition of a event.muiHandled logic to prevent the click away listener to trigger (cf. swipeable drawer) or a stop propagation or a prevent default.
    • If you nest two click away listeners, and if you click outside of both, both will trigger. I doubt it should be the case if they are nested. I would suggest the addition of a event.muiHandled logic to prevent it (cf. swipeable drawer).

@oliviertassinari oliviertassinari added bug 🐛 Something doesn't work good first issue Great for first contributions. Enable to learn the contribution process. labels Dec 5, 2019
@oliviertassinari
Copy link
Member

We have a full reproduction example in #17990 that we can leverage. Based on it, I could come up with this potential solution.

Before
before

After
click-away

I would propose the following fix, let me know what you think about it:

diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
index c689731b6..8e38b6635 100644
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
@@ -16,10 +16,17 @@ function mapEventPropToEvent(eventProp) {
  * For instance, if you need to hide a menu when people click anywhere else on your page.
  */
 const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref) {
-  const { children, mouseEvent = 'onClick', touchEvent = 'onTouchEnd', onClickAway } = props;
+  const {
+    children,
+    disableReactTree = false,
+    mouseEvent = 'onClick',
+    onClickAway,
+    touchEvent = 'onTouchEnd',
+  } = props;
   const movedRef = React.useRef(false);
   const nodeRef = React.useRef(null);
   const mountedRef = React.useRef(false);
+  const syntheticEvent = React.useRef(false);

   React.useEffect(() => {
     mountedRef.current = true;
@@ -40,6 +47,9 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
   const handleRef = useForkRef(children.ref, handleOwnRef);

   const handleClickAway = useEventCallback(event => {
+    const insideReactTree = syntheticEvent.current;
+    syntheticEvent.current = false;
+
     // Ignore events that have been `event.preventDefault()` marked.
     if (event.defaultPrevented) {
       return;
@@ -67,7 +77,8 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
     if (
       doc.documentElement &&
       doc.documentElement.contains(event.target) &&
-      !nodeRef.current.contains(event.target)
+      !nodeRef.current.contains(event.target) &&
+      (disableReactTree || !insideReactTree)
     ) {
       onClickAway(event);
     }
@@ -106,7 +117,39 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
     return undefined;
   }, [handleClickAway, mouseEvent]);

-  return <React.Fragment>{React.cloneElement(children, { ref: handleRef })}</React.Fragment>;
+  const handleSyntheticMouse = event => {
+    syntheticEvent.current = true;
+
+    const childrenProps = children.props;
+    if (childrenProps[mouseEvent]) {
+      childrenProps[mouseEvent](event);
+    }
+  };
+
+  const handleSyntheticTouch = event => {
+    syntheticEvent.current = true;
+
+    const childrenProps = children.props;
+    if (childrenProps[touchEvent]) {
+      childrenProps[touchEvent](event);
+    }
+  };
+
+  const childrenProps = {};
+
+  if (mouseEvent !== false) {
+    childrenProps[mouseEvent] = handleSyntheticMouse;
+  }
+
+  if (touchEvent !== false) {
+    childrenProps[touchEvent] = handleSyntheticTouch;
+  }
+
+  return (
+    <React.Fragment>
+      {React.cloneElement(children, { ref: handleRef, ...childrenProps })}
+    </React.Fragment>
+  );
 });

 ClickAwayListener.propTypes = {
@@ -114,6 +157,11 @@ ClickAwayListener.propTypes = {
    * The wrapped element.
    */
   children: elementAcceptingRef.isRequired,
+  /**
+   * If `true`, the React tree is ignored and only the DOM tree is considered.
+   * This prop changes how portaled elements are handled.
+   */
+  disableReactTree: PropTypes.bool,
   /**
    * The mouse event to listen to. You can disable the listener by providing `false`.
    */

cc @Izhaki as it might impact your hook API
cc @dmtrKovalenko for context

@oliviertassinari oliviertassinari changed the title Select dropdown positioning using disablePortal within popper element [ClickAwayLister] Handle portaled element Dec 5, 2019
@eps1lon
Copy link
Member

eps1lon commented Dec 9, 2019

I don't think this is a good first issue.

This discussion should happen in the react core. Special casing where we want to ignore the component tree for event bubbling is (as with most special cases) not a good idea.

We should rather explore alternative APIs or contribute these use cases to the appropriate issues in the react repositories. Otherwise we introduce more suprises and bundle size into the API.

@eps1lon eps1lon added discussion and removed bug 🐛 Something doesn't work good first issue Great for first contributions. Enable to learn the contribution process. labels Dec 9, 2019
@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 9, 2019

@eps1lon Do you mean that we shouldn't expose a disableReactTree prop?

Yeah, I think that we can wait to see a compelling use case for this prop 👍. Including the React tree should likely be the default behavior, and changed, independently from the DOM tree.

@Izhaki
Copy link
Contributor

Izhaki commented Dec 9, 2019

I need ClickAwayListener to intercept a mouseup outside and element with drag - nothing to do with MUI.

And the idea of having a dependency in mui/core for this seems to me a bit off.

The point I'm trying to make is that this functionality is possibly far more generic to be included in MUI. I'd consider just extracting it to its own package (I've seen at least 3 bespoke implementation of it already in other libraries, who would probably be delighted to see a package for it. currently one needs to have come across mui to know it exist).

@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 9, 2019

And the idea of having a dependency in mui/core for this seems to me a bit off.

@Izhaki What makes you uncomfortable about it? Material-UI's primary mission is to bring material for developers to build UIs. Material Design is our default theme.
We may implement other design specs in the future (in 2-3 years, once we support enough rich components?).

@Izhaki
Copy link
Contributor

Izhaki commented Dec 9, 2019

What makes you uncomfortable about it?

I'm writing a library that has nothing to do with mui. I just need ClickAwayListener.

@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 9, 2019

@Izhaki Would you feel better with a @material-ui/click-away-listener package?

@Izhaki
Copy link
Contributor

Izhaki commented Dec 9, 2019

Yes! (and No!)

I mean - all things are equal tree-shaking wise. But it's different having dependency on @material-ui/click-away-listener compared to @material-ui/core it communicates a completely different thing.

But it's a bit of rabbit hole really... why do this for clickAwayListener and not useForkRef (for instance)?

So you can make a case for @material-ui/utils described as "Non mui-specific React and Dom utils..." where ClickAwayListener goes, and useForkRef, and anything that others may need even if not using core.

@oliviertassinari
Copy link
Member

@Izhaki I'm trying to navigate with your fears and the scope of our options. Exposing all our modules as packages could be interesting, especially with unstyled components.

@Izhaki
Copy link
Contributor

Izhaki commented Dec 9, 2019

I have updated my comment.

I don't really have a firm solution for this. But I do think there's a lot in this project that would be useful for people who don't use core, and it would be nice not to expose these via core.

@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 9, 2019

@Izhaki I was recently thinking of another aspect of this problem: the documentation. I was considering adding a link to sources in the docs page (had the idea from Reach) and to automatically pull the bundle size info from (https://material-ui.com/size-snapshot), rather than hard coding it in the markdown. I think that it would reduce people's fears of bloatness and to better audit the quality of what they are "buying" (using).

@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 10, 2019

Ok so after @eps1lon's recommendation:

  • We should delay the introduction of the disableReactTree prop, it would be great to wait for the outcome of createPortal: support option to stop propagation of events in React tree facebook/react#11387 to settle on this prop. We are working on the edges of React, it's better to maximize for future flexibility. We want to reduce the chance of introducing a breaking change in the future.
  • Clickaway shouldn't fire by default when coming from a descendant of a portaled element. At least if we based this on the fact that: 1. from the use cases shared, it makes a better default 2. React changed it moving from the unstable to the final portal API.
  • In the future, we might be able to rely on the React's scopes .containsNode() API instead of a double event listener. At least, if React releases this API, we might in v5.
  • It's also related to Doesn't Seem To Work With Portals Pomax/react-onclickoutside#273. They propose the usage of hardcoded selectors to ignore elements. This could be a solution but it doesn't seem to take full advantage of the options available: meaning, something that works outside of the box.
  • I think that we can move forward with the above fix. The change of behavior is considered a bug fix. I hope it won't break people logic. If it does, we will have to revert and use a different strategy. At the minimum, we will learn more about the underlying problem, which is great.

@oliviertassinari oliviertassinari added bug 🐛 Something doesn't work good first issue Great for first contributions. Enable to learn the contribution process. and removed discussion labels Dec 10, 2019
@ccmartell
Copy link
Author

ccmartell commented Dec 10, 2019

Hi. I thought I would chime in on this. I think the scope of my issue has changed from popper positioning issues to clickaway listener issues? My main concern was the positioning of the popup menu when using a transform (similar to how popper works). Popover does not use a position transform but instead uses absolute positioning. The popover works correctly with a select dropdown field. See my original codesandbox for the example.

@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 10, 2019

When using a clickaway listener with popper to display a popup, a portaled menu will cause the clickaway listener to fire, closing the popper and losing our work.

@ccmartell We are going after the root cause.
I don't think that we should spend the time required to solve the positioning issue you have reported.

@SofianeDjellouli
Copy link
Contributor

SofianeDjellouli commented Feb 16, 2020

Hi,
I'll give it a try by:

  • using Popper in Popover,
  • handling the backdrop logic.
    If that's OK with you.

@victorhurdugaci
Copy link

Hey folks! I was about to open a dupe of this issue. In case you're looking for more repros or test cases, here's my scenario in which onClickAway fires incorrectly.

import React from "react";
import { ClickAwayListener, Select, MenuItem } from "@material-ui/core";

export default function App() {
  return (
    <ClickAwayListener onClickAway={() => console.log("Clicked away")}>
      <Select value={"A"}>
        <MenuItem value={"A"}>A</MenuItem>
        <MenuItem value={"B"}>B</MenuItem>
        <MenuItem value={"C"}>C</MenuItem>
      </Select>
    </ClickAwayListener>
  );
}

If you click the Select, it fires the onClickAway event.

@oliviertassinari
Copy link
Member

oliviertassinari commented Apr 1, 2020

@victorhurdugaci Do you want to complete the proposed patch in #18586 (comment) with a pull request :)?

@NMinhNguyen
Copy link
Contributor

I’ll try take a look this weekend unless someone else gets to it before me :) I know that you’ve provided a diff to apply but would like to get familiar with the underlying problem first @oliviertassinari

@oliviertassinari oliviertassinari changed the title [ClickAwayLister] Handle portaled element [CLickAwayListener] Handle portaled element Apr 4, 2020
@oliviertassinari oliviertassinari changed the title [CLickAwayListener] Handle portaled element [ClickAwayListener] Handle portaled element Apr 4, 2020
@pawlarius
Copy link

Hi, I am sorry to continue commenting on this closed PR, but I just tried the disableReactTree props, while it fixed to don't trigger onClickAway from ClickAwayListener, it still has some issues when using Select inside a ClickAwayListener wrapped by Popper.

I have made the demo here https://codesandbox.io/s/material-demo-89jyg?file=/demo.js.
In the demo, the Select are using props MenuProps={{disablePortal: true}}, if I dont use that, the Select will trigger ClickAwayListener onClickAway even though I am using disableReactTree={true}.

But if I use MenuProps={{disablePortal: true}}, it will cause wrong positioning and size of the Select Menu popup..

@oliviertassinari
Copy link
Member

@pawlarius What problem to you try to solve?

@depiction
Copy link

depiction commented May 6, 2020

@pawlarius I'm seeing the same issue in my project. I passed a classes prop to MenuProps then added custom styles to correct the position.

    <Select
        MenuProps={{
            classes: { paper: classes.selectPaper },
            disablePortal: true
        }}
    >
        <MenuItem value={10}>Ten</MenuItem>
    </Select>

    const styles = theme => ({
        selectPaper: {
            // hack to fix Material UI popover position when disablePortal is true
            left: `${theme.spacing(2)}px !important`,
            maxHeight: 'none !important',
            top: `${theme.spacing(2)}px !important`
        }
    })

@pawlarius

This comment has been minimized.

@pawlarius

This comment has been minimized.

@NMinhNguyen

This comment has been minimized.

@showduhtung
Copy link

@pawlarius I'm seeing the same issue in my project. I passed a classes prop to MenuProps then added custom styles to correct the position.

    <Select
        MenuProps={{
            classes: { paper: classes.selectPaper },
            disablePortal: true
        }}
    >
        <MenuItem value={10}>Ten</MenuItem>
    </Select>

    const styles = theme => ({
        selectPaper: {
            // hack to fix Material UI popover position when disablePortal is true
            left: `${theme.spacing(2)}px !important`,
            maxHeight: 'none !important',
            top: `${theme.spacing(2)}px !important`
        }
    })

Do you have a suggestion for the zIndex positioning? for some reason, for the life of me, I can't figure out how to solve this issue of the component ghostly hovering over the other, which can be clicked through as well.

Screen Shot 2020-12-17 at 10 03 39 PM

@oliviertassinari
Copy link
Member

@showduhtung v5 ClickAwayListener supports portals, so you do no longer need to disable portal on the Modal. In your problem, best to open the dev tools and try different combinations.

@showduhtung
Copy link

showduhtung commented Dec 18, 2020

@oliviertassinari Hmm, I tested this test snippet in https://github.com/NMinhNguyen/material-ui/commit/3b7a19f50577ede28223375047ebab23c613bf02 and it works, but it doesn't seem to work the same with components. I haven't tested for all other portal using components, but as you can see in my sandbox, while the Portal is being acknowledged within ClickAwayListener, seems to still be a portal as it's still triggering ClickAwayListener.

Sandbox: https://codesandbox.io/s/wild-wood-i7d7g?file=/src/App.js

@oliviertassinari
Copy link
Member

oliviertassinari commented Dec 21, 2020

@showduhtung You need to test with v5, but in any case, I can reproduce the click away that triggers when interacting with a select: https://codesandbox.io/s/quirky-aryabhata-9btw1?file=/src/App.js.

It seems that the click event can't find a target and fallback to the body. I could make it work with this diff:

diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
index 735c990f09..fc862ee5b7 100644
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
@@ -73,6 +73,11 @@ function ClickAwayListener(props) {
       return;
     }

+    // The click event couldn't find its target, ignore
+    if (event.target.nodeName === 'BODY' && event.type === 'click') {
+      return;
+    }
+
     let insideDOM;

     // If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js

Is this the problem you are facing? Do you want to open a new issue for it so we can continue the exploration? An alternative solution would be to determine the click outside during the mouse down event and delay the trigger.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug 🐛 Something doesn't work component: ClickAwayListener The React component good first issue Great for first contributions. Enable to learn the contribution process.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

10 participants