tajo / react-portal

🎯 React component for transportation of modals, lightboxes, loading bars... to document.body or else.
MIT License
2.14k stars 169 forks source link

Parent portal closes when clicking inside of a nested portal #191

Open zhouzi opened 6 years ago

zhouzi commented 6 years ago

This issue was originally discussed in #101, before the rewrite.

I created a bin reproducing it with the latest version of react-portal: https://www.webpackbin.com/bins/-L2PNDWSpoEmiOUuzvFO

kebot commented 6 years ago

In v3, portal component listen to mouseup and touchstart events, but in v4 it listen to click event, which not always works fine for me.

https://github.com/tajo/react-portal/blob/3.x/lib/portal.js#L27

skimi commented 6 years ago

This can be fixed using event bubbling through the react-dom. The react doc has a chapter for this : https://reactjs.org/docs/portals.html#event-bubbling-through-portals

I personally created my own portal wrapper like this :

import uniqueId from 'lodash/uniqueId';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

class Portal extends Component {
  componentDidMount() {
    const { onClickOutside } = this.props;

    if (!onClickOutside) return;

    window.addEventListener('click', this.handleDocumentClick);
  }

  componentWillUnmount() {
    if (this.defaultNode) {
      document.body.removeChild(this.defaultNode);
    }

    this.defaultNode = null;
    window.removeEventListener('click', this.handleDocumentClick);
  }

  id = this.props.id || uniqueId()

  handleDocumentClick = (e) => {
    if (!e.parentModals || !e.parentModals.includes(this.id)) {
      this.props.onClickOutside(e);
    }
  }

  handleClickInsideModal = (e) => {
    const currentParentModals = e.nativeEvent.parentModals || [];

    e.nativeEvent.parentModals = [
      ...currentParentModals,
      this.id,
    ];
  }

  render() {
    const { children } = this.props;

    if (!this.defaultNode) {
      this.defaultNode = document.createElement('div');
      document.body.appendChild(this.defaultNode);
    }

    return ReactDOM.createPortal(
      <div onClick={this.handleClickInsideModal}>
        {children}
      </div>,
      this.defaultNode
    );
  }
}

Portal.propTypes = {
  children: PropTypes.node,
  onClickOutside: PropTypes.func,
  id: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.number,
  ])
};

export default Portal;

The onClick event that react creates passes through nested Portal.

diegohaz commented 5 years ago

You can also nest portals in the DOM:

import React from "react";
import ReactDOM from "react-dom";

const PortalContext = React.createContext(
  typeof document !== "undefined" ? document.body : null
);

export function Portal({ children }) {
  // if it's a nested portal, context is the parent portal
  // otherwise it's document.body
  const context = React.useContext(PortalContext);
  const [container] = React.useState(() => {
    if (typeof document !== "undefined") {
      const portal = document.createElement("div");
      portal.className = "portal";
      return portal;
    }
    // ssr
    return null;
  });

  React.useLayoutEffect(() => {
    if (container && context) {
      context.appendChild(container);
      return () => {
        context.removeChild(container);
      };
    }
    return undefined;
  }, [container, context]);

  if (container) {
    const portal = ReactDOM.createPortal(children, container);
    return (
      <PortalContext.Provider value={container}>
        {portal}
      </PortalContext.Provider>
    );
  }

  // ssr
  return null;
}