boldr/boldr-ui

View on GitHub
src/Popover/Popover.js

Summary

Maintainability
B
4 hrs
Test Coverage
/**
 * Design:
 *
 * Popover component is just a shell, responsible for assembling Trigger and Content.
 *
 * The actual opening / closing of the shell is done by Content, and what happens is the Trigger control.
 *
 * Popover component is a recursive component that supports nesting.
 *
 *
 *            context                       context
 * ------> ------>
 * Popover               Popover child                    Popover grand-child     ......
 *            <------                       <------
 * IsOutsideStacked isOutsideStacked
 *
 */
import React, { Component, Children } from 'react';
import ReactDOM from 'react-dom';
import cx from 'classnames';
import noop from 'lodash.noop';
import uniqueId from 'lodash.uniqueid';
import isFunction from 'lodash.isfunction';
import isBoolean from 'lodash.isboolean';
import PropTypes from 'prop-types';

import kindOf from '../util/kindOf';
import isPromise from '../util/isPromise';
import PopoverContent from './Content';
import PopoverTrigger from './triggers/Trigger';

const SKIPPED = () => {};

function handleBeforeHook(beforeFn, arity, continuation) {
  // There are parameters that are passed into continuation, which is controlled by the outside when the call is made
  if (arity >= 1) {
    return beforeFn(continuation);
  }

  // no parameters, if the return Promise so resolve after the call continuation;
  // If the return is not Promise, direct call Promise
  const mayBePromise = beforeFn();
  if (!isPromise(mayBePromise) && mayBePromise !== SKIPPED) {
    return continuation();
  }

  return mayBePromise.then(continuation);
}

export const PopoverContextType = {
  _boldruiPopover: PropTypes.shape({
    close: PropTypes.func.isRequired,
    open: PropTypes.func.isRequired,
    getContentNode: PropTypes.func.isRequired,
    getTriggerNode: PropTypes.func.isRequired,

    //  Used to maintain the Popover stack to handle nested problems
    registerDescendant: PropTypes.func,
    unregisterDescendant: PropTypes.func,
  }),
};

export default class Popover extends Component {
  static propTypes = {
    prefix: PropTypes.string,
    className: PropTypes.string,

    // custom classname for trigger wrapper
    wrapperClassName: PropTypes.string,

    // container的display属性
    display: PropTypes.string,

    // position strategy
    position: PropTypes.func.isRequired,

    // 定位时的偏移量
    cushion: PropTypes.number,

    // Only the user triggered the opening / closing will trigger the two destroyed
    onBeforeClose: PropTypes.func,
    onBeforeShow: PropTypes.func,

    // it will be called whenever it is opened / closed
    onClose: PropTypes.func,
    onShow: PropTypes.func,

    // defaults to body
    containerSelector: PropTypes.string,

    children: PropTypes.node.isRequired,

    // both must appear together
    visible: PropTypes.bool,
    onVisibleChange: PropTypes.func,
  };

  static defaultProps = {
    prefix: 'boldrui',
    className: '',
    wrapperClassName: '',
    display: 'block',
    onBeforeClose: noop,
    onBeforeShow: noop,
    onClose: noop,
    onShow: noop,
    cushion: 0,
    containerSelector: 'body',
  };

  static contextTypes = PopoverContextType;

  static childContextTypes = PopoverContextType;
  constructor(props) {
    super(props);

    // id is used to uniquely identify the popover instance
    this.id = uniqueId(`${props.prefix}-popover-internal-id-`);

    // Collect popover children
    this.descendants = [];

    if (!this.isVisibilityControlled(props)) {
      this.state = {
        visible: false,
      };
    }

    this.isUnmounted = false;
  }
  getChildContext() {
    return {
      _boldruiPopover: {
        close: this.close,
        open: this.open,
        getContentNode: this.getPopoverNode,
        getTriggerNode: this.getTriggerNode,

        registerDescendant: this.registerDescendant,
        unregisterDescendant: this.unregisterDescendant,
      },
    };
  }

  registerDescendant = popover => {
    this.descendants.push(popover);
  };

  unregisterDescendant = popover => {
    const idx = this.descendants.indexOf(popover);
    this.descendants.splice(idx, 1);
  };

  isVisibilityControlled(props) {
    const { visible, onVisibleChange } = props || this.props;
    const hasOnChange = isFunction(onVisibleChange);
    const hasVisible = isBoolean(visible);

    if ((hasVisible && !hasOnChange) || (hasOnChange && !hasVisible)) {
      throw new Error('visible and onVisibleChange must be used together');
    }

    return hasVisible && hasOnChange;
  }

  getVisible = (props, state) => {
    if (this.isVisibilityControlled(props)) {
      props = props || this.props;
      return props.visible;
    }

    state = state || this.state;
    return state.visible;
  };

  setVisible = (visible, props, state) => {
    props = props || this.props;
    state = state || this.state;
    const beforeHook = visible ? props.onBeforeShow : props.onBeforeClose;
    const onBefore = (...args) => {
      // make sure that the time will not trigger many times before hook
      if (this.pendingOnBeforeHook) {
        return SKIPPED;
      }

      this.pendingOnBeforeHook = true;
      return beforeHook(...args);
    };

    if (this.isVisibilityControlled(props)) {
      if (this.pendingOnBeforeHook || props.visible === visible) {
        return;
      }

      handleBeforeHook(onBefore, beforeHook.length, () => {
        props.onVisibleChange(visible);
        this.pendingOnBeforeHook = false;
      });
    } else {
      if (this.pendingOnBeforeHook || state.visible === visible) {
        return;
      }

      handleBeforeHook(onBefore, beforeHook.length, () => {
        this.safeSetState({ visible });
        this.pendingOnBeforeHook = false;
      });
    }
  };

  getPopoverNode = () => {
    return document.querySelector(`.${this.id}`);
  };

  onTriggerRefChange = triggerInstance => {
    this.triggerNode = ReactDOM.findDOMNode(triggerInstance);
  };

  getTriggerNode = () => {
    return this.triggerNode;
  };

  open = () => {
    this.setVisible(true);
  };

  close = () => {
    this.setVisible(false);
  };

  injectIsOutsideSelf = impl => {
    this.isOutsideSelf = impl;
  };

  // Popover up in the tree will call this method to see if the node lies outside
  isOutsideStacked = node => {
    if (this.isOutsideSelf) {
      //  In their own internal, certainly not outside
      if (!this.isOutsideSelf(node)) {
        return false;
      }
    }

    //  ask the following Popover whether outside
    if (this.descendants.some(popover => !popover.isOutsideStacked(node))) {
      return false;
    }

    return true;
  };

  validateChildren() {
    const { children } = this.props;
    const childArray = Children.toArray(children);

    if (childArray.length !== 2) {
      throw new Error('There must be one and only one trigger and content in Popover');
    }

    const { trigger, content } = childArray.reduce(
      (state, c) => {
        const type = c.type;
        if (kindOf(type, PopoverTrigger)) {
          state.trigger = c;
        } else if (kindOf(type, PopoverContent)) {
          state.content = c;
        }

        return state;
      },
      { trigger: null, content: null },
    );

    if (!trigger) {
      throw new Error('Missing trigger in Popover');
    }
    if (!content) {
      throw new Error('Missing content in Popover');
    }

    return { trigger, content };
  }

  safeSetState(updater, callback) {
    if (!this.isUnmounted) {
      return this.setState(updater, callback);
    }
  }

  componentDidMount() {
    const { _boldruiPopover: popover } = this.context || {};
    if (popover && popover.registerDescendant) {
      popover.registerDescendant(this);
    }

    if (this.isVisibilityControlled() && this.props.visible) {
      this.props.onShow();
    }
  }

  componentDidUpdate(prevProps, prevState) {
    const visible = this.getVisible();
    if (visible !== this.getVisible(prevProps, prevState)) {
      const afterHook = visible ? this.props.onShow : this.props.onClose;
      afterHook();
    }
  }

  componentWillUnmount() {
    const { _boldruiPopover: popover } = this.context || {};
    if (popover && popover.unregisterDescendant) {
      popover.unregisterDescendant(this);
    }

    this.isUnmounted = true;
  }

  render() {
    const { trigger, content } = this.validateChildren();
    const {
      display,
      prefix,
      className,
      wrapperClassName,
      containerSelector,
      position,
      cushion,
    } = this.props;
    const visible = this.getVisible();

    return (
      <div style={{ display }} className={cx(`${prefix}-popover-wrapper`, wrapperClassName)}>
        {React.cloneElement(trigger, {
          prefix,
          contentVisible: visible,
          onTriggerRefChange: this.onTriggerRefChange,
          getTriggerNode: this.getTriggerNode,
          getContentNode: this.getPopoverNode,
          open: this.open,
          close: this.close,
          isOutsideStacked: this.isOutsideStacked,
          injectIsOutsideSelf: this.injectIsOutsideSelf,
        })}
        {React.cloneElement(content, {
          prefix,
          className,
          id: this.id,
          getContentNode: this.getPopoverNode,
          getAnchor: this.getTriggerNode,
          visible,
          cushion,
          containerSelector,
          placement: position,
        })}
      </div>
    );
  }
}