packages/riipen-ui/src/components/Popover.jsx

Summary

Maintainability
A
45 mins
Test Coverage
C
77%
import clsx from "clsx";
import React from "react";
import PropTypes from "prop-types";
import _JSXStyle from "styled-jsx/style";

import ThemeContext from "../styles/ThemeContext";

import {
  getContainer,
  getDocument,
  getOffsetLeft,
  getOffsetTop,
  getClosestScrollableParent,
  debounce
} from "../utils";
import withClasses from "../utils/withClasses";

class Popover extends React.Component {
  static displayName = "Popover";

  static propTypes = {
    /**
     * Either a reference to an anchor element or a function to get the reference
     */
    anchorEl: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),

    /**
     * The location to attach the content too on the anchor element
     */
    anchorPosition: PropTypes.object,

    /**
     * The content of the component
     */
    children: PropTypes.node,

    /**
     * Array of additional CSS classes to use.
     */
    classes: PropTypes.array,

    /**
     * Whether the popover should be closed when its closest scrollable parent is scrolled.
     * [Default] true
     */
    closeOnScrolled: PropTypes.bool,

    /**
     * The type of element to use at the root
     */
    component: PropTypes.string,

    /**
     * The location to attach the anchor to on the content element
     */
    contentPosition: PropTypes.object,

    /**
     * Whether or not the dropdown should take up the entire screen-width.
     */
    fullWidth: PropTypes.bool,

    /**
     * Whether the popover should be displayed
     */
    isOpen: PropTypes.bool,

    /**
     * Function call to handle keydown events,
     */
    onKeyDown: PropTypes.func,

    /**
     * Whether to keep the popout on screen when the anchor element scrolls off
     */
    keepOnScreen: PropTypes.bool,

    /**
     * Whether to lock the scrollbar when the popover is open
     */
    lockScroll: PropTypes.bool,

    /**
     * The marigins of the page the popover should respect
     */
    marginThreshold: PropTypes.number,

    /**
     * Function call to handle clickaway/ close events,
     * if not provided the anchor element must be removed to clear the popover or use isOpen
     */
    onClose: PropTypes.func,

    /**
     * Styles to apply to the component
     */
    styles: PropTypes.object
  };

  static defaultProps = {
    closeOnScrolled: true,
    component: "span",
    fullWidth: false,
    isOpen: true,
    lockScroll: true,
    marginThreshold: 16,
    styles: {}
  };

  constructor(props) {
    super(props);
    this.positionChangeEventHandler = debounce(this.updatePosition, 10);
    this.handleCloseEvent = this.handleCloseEvent.bind(this);
    this.state = {
      scrollContainer: null,
      scrollContainerStyle: null,
      contentStyles: {
        top: 0,
        left: 0,
        visibility: "hidden"
      }
    };

    this.contentRef = React.createRef();
  }

  componentDidMount() {
    const { anchorEl, lockScroll } = this.props;

    window.addEventListener("resize", this.positionChangeEventHandler);
    window.addEventListener("scroll", this.positionChangeEventHandler);
    window.addEventListener("mousedown", this.handleCloseEvent);
    window.addEventListener("keydown", this.handleCloseEvent);

    if (anchorEl) {
      this.manageScrollableParentEventListeners(false);
    }

    if (anchorEl && lockScroll) {
      this.lockParentScroll();
    }

    this.setPositioningStyle();
  }

  componentDidUpdate(prevProps) {
    const anchorChange =
      prevProps.anchorEl === null && this.props.anchorEl !== null;

    /* need to NOT check that previous prop was !isOpen
     * in order to cause positioning of popover to be correct
     * when it is opened by a controlled Tooltip being opened
     */
    const openChange = this.props.isOpen;
    const anchorPositionChange = !(
      prevProps.anchorPosition === this.props.anchorPosition
    );
    const contentPositionChange = !(
      prevProps.contentPosition === this.props.contentPosition
    );

    if (anchorChange) {
      // add event listeners
      this.manageScrollableParentEventListeners(false);
    }

    if (
      anchorChange ||
      openChange ||
      contentPositionChange ||
      anchorPositionChange
    ) {
      // need to use requestAnimationFrame in order for popover to get proper position on first open
      window.requestAnimationFrame(this.setPositioningStyle);
    }
  }

  componentWillUnmount() {
    const { anchorEl, lockScroll } = this.props;
    window.removeEventListener("resize", this.positionChangeEventHandler);
    window.removeEventListener("scroll", this.positionChangeEventHandler);
    window.removeEventListener("mousedown", this.handleCloseEvent);
    window.removeEventListener("keydown", this.handleCloseEvent);

    this.manageScrollableParentEventListeners(true);

    if (anchorEl && lockScroll) {
      this.resetParentScroll();
    }
  }

  getAnchorEl = () => {
    const { anchorEl } = this.props;
    return typeof anchorEl === "function" ? anchorEl() : anchorEl;
  };

  setPositioningStyle = () => {
    const contentRef = this.contentRef.current;

    const {
      anchorPosition = {
        vertical: "top",
        horizontal: "left"
      },
      contentPosition = {
        vertical: "top",
        horizontal: "left"
      },
      fullWidth,
      keepOnScreen
    } = this.props;

    const anchorEl = this.getAnchorEl();
    if (!anchorEl || !contentRef) return;

    // Set top and left of element based on location of anchor
    const anchorRect = anchorEl.getBoundingClientRect();
    let top = anchorRect.top;
    let left = anchorRect.left;

    // Offset Content Based on anchorPosition props
    const anchorVerticalOffset = getOffsetTop(
      anchorRect,
      anchorPosition.vertical
    );
    const anchorHorizonalOffset = getOffsetLeft(
      anchorRect,
      anchorPosition.horizontal
    );

    top += anchorVerticalOffset;
    left += anchorHorizonalOffset;

    // Offset Content Based on contentPosition props
    const contentRect = contentRef.getBoundingClientRect();

    const contentVerticalOffset = getOffsetTop(
      contentRect,
      contentPosition.vertical
    );
    const contentHorizontalOffset = getOffsetLeft(
      contentRect,
      contentPosition.horizontal
    );

    top -= contentVerticalOffset;
    left -= contentHorizontalOffset;

    // Move menu back into view if out of screen
    if (keepOnScreen) {
      const { marginThreshold } = this.props;
      const viewContainer = getContainer(anchorEl);

      const heightMax = viewContainer.innerHeight - marginThreshold;
      const widthMax = viewContainer.innerWidth - marginThreshold;

      // Check Vertical Constraints
      if (top + contentRect.height > heightMax) {
        top -= top + contentRect.height - heightMax;
      } else if (top < marginThreshold) {
        top = marginThreshold;
      }

      // Check Horizontal Constraints
      if (left + contentRect.width > widthMax) {
        left -= left + contentRect.width - widthMax;
      } else if (left < marginThreshold) {
        left = marginThreshold;
      }
    }

    // Handle fullWidth menu
    if (fullWidth) {
      left = 0;
    }

    this.setState(
      Object.assign(this.state, {
        contentStyles: {
          top: `${top}px`,
          left: `${left}px`,
          position: "fixed"
        }
      })
    );
  };

  manageScrollableParentEventListeners(remove = true) {
    const { anchorEl, closeOnScrolled } = this.props;
    if (anchorEl) {
      const closestScrollableParent = getClosestScrollableParent(anchorEl);
      if (closestScrollableParent) {
        if (remove) {
          closestScrollableParent.removeEventListener(
            "resize",
            closeOnScrolled ? this.handleClose : this.positionChangeEventHandler
          );
          closestScrollableParent.removeEventListener(
            "scroll",
            closeOnScrolled ? this.handleClose : this.positionChangeEventHandler
          );
        } else {
          closestScrollableParent.addEventListener(
            "resize",
            closeOnScrolled ? this.handleClose : this.positionChangeEventHandler
          );
          closestScrollableParent.addEventListener(
            "scroll",
            closeOnScrolled ? this.handleClose : this.positionChangeEventHandler
          );
        }
      }
    }
  }

  isDecendant(parent, child) {
    const node = child.parentNode;
    if (node == null) {
      return false;
    } else if (node === parent) {
      return true;
    }
    return this.isDecendant(parent, node);
  }

  handleCloseEvent = event => {
    const { isOpen, anchorEl } = this.props;
    const contentRef = this.contentRef.current;
    if (!contentRef || !isOpen) return;
    if (event.type === "mousedown") {
      // Check if mouse click happened inside popover
      // Or on the anchorEl
      const target = event.target;
      if (
        contentRef === target ||
        anchorEl === target ||
        this.isDecendant(contentRef, target) ||
        this.isDecendant(anchorEl, target)
      ) {
        return;
      }
      this.handleClose();
    } else if (event.type === "keydown" && event.key === "Escape") {
      this.handleClose();
    } else if (event.type === "keydown" && this.props.onKeyDown) {
      // stop this event from triggering other keydown listeners
      event.stopImmediatePropagation();

      this.props.onKeyDown(event);
    }
  };

  handleClose = () => {
    const { onClose } = this.props;
    if (onClose) onClose();
  };

  // Find the outer most scrollable content and remove the scrollbar
  lockParentScroll() {
    const anchorEl = this.getAnchorEl();
    const parent = anchorEl.parentElement;
    const container = getDocument(anchorEl);

    this.state.scrollContainer =
      parent.nodeName === "HTML" &&
      window.getComputedStyle(parent)["overflow-y"] === "scroll"
        ? parent
        : container.body;

    this.state.scrollContainerStyle = this.state.scrollContainer.style.overflow;
    this.state.scrollContainer.style.overflow = "hidden";
  }

  resetParentScroll() {
    this.state.scrollContainer.style.overflow = this.state.scrollContainerStyle;
  }

  updatePosition = () => {
    if (!this.props.anchorEl) return undefined;
    return this.setPositioningStyle();
  };

  static contextType = ThemeContext;

  render() {
    const {
      anchorEl,
      classes,
      children,
      component,
      fullWidth,
      styles,
      isOpen
    } = this.props;
    const theme = this.context;
    const className = clsx(classes, "popover", { open: isOpen });
    const Component = component;

    return (
      <>
        {anchorEl && isOpen && (
          <Component
            style={{ ...styles, ...this.state.contentStyles }}
            ref={this.contentRef}
            className={className}
          >
            {children}
          </Component>
        )}
        <style jsx>{`
          .popover {
            background: ${theme.palette.common.white};
            border-radius: ${theme.shape.borderRadius.md};
            box-shadow: ${theme.shadows[4]};
            box-sizing: border-box;
            max-height: calc(100% - 32px);
            max-width: ${fullWidth ? "100%" : "calc(100% - 32px)"};
            min-height: 16px;
            min-width: 16px;
            outline: 0;
            position: absolute;
            ${fullWidth ? "width: 100%;" : ""}
            z-index: ${theme.zIndex.middle};
          }

          .popover::webkit-scrollbar {
            display: none;
          }
        `}</style>
      </>
    );
  }
}

export default withClasses(Popover);