boldr/boldr-ui

View on GitHub
src/util/Collapse.js

Summary

Maintainability
A
1 hr
Test Coverage
import React, { PureComponent, Children, cloneElement } from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import { Motion, spring } from 'react-motion';

/**
 * The `Collapse` component is used to animate a single child entering
 * or leaving. This uses the `react-motion` library to animate the height,
 * padding-top, and padding-bottom of an element when the `collapsed` prop
 * changes.
 * @param {Function} child the child component
 */
export default class Collapse extends PureComponent {
  static propTypes = {
    /**
     * An optional style to merge with the `Motion` style.
     */
    style: PropTypes.object,

    /**
     * An optional default style to merge with the `Motion` default style.
     */
    defaultStyle: PropTypes.object,

    /**
     * Boolean if the children are currently collapsed.
     */
    collapsed: PropTypes.bool.isRequired,

    /**
     * A single child to collapse or expand.
     */
    children: PropTypes.element.isRequired,

    /**
     * The spring config to use for the animation.
     */
    springConfig: PropTypes.object.isRequired,

    /**
     * Boolean if the the single child entering or leaving should be animated.
     */
    animate: PropTypes.bool,
  };

  static defaultProps = {
    animate: true,
    springConfig: {
      precision: 0.5,
    },
  };

  constructor(props) {
    super(props);

    if (!props.collapsed) {
      this.state = { initialOpen: true };
    } else {
      this.state = { height: 0, paddingTop: 0, paddingBottom: 0 };
    }
  }

  componentWillReceiveProps(nextProps) {
    if (this.state.initialOpen && nextProps.collapsed) {
      this.setState({ initialOpen: false });
    }
  }

  _spring(collapsed, initialOpen, value, config) {
    const nextValue = !collapsed ? Math.max(0, value) : 0;
    if (initialOpen && !collapsed) {
      return nextValue;
    }

    return spring(nextValue, config);
  }

  _setHeight = child => {
    if (this._child && typeof this._child.ref === 'function') {
      this._child.ref(child);
    }

    let height = 0;
    let paddingTop = 0;
    let paddingBottom = 0;
    if (child !== null) {
      const node = ReactDOM.findDOMNode(child);
      const cs = window.getComputedStyle(node);
      height = node.offsetHeight;
      paddingTop = parseInt(cs.getPropertyValue('padding-top'), 10);
      paddingBottom = parseInt(cs.getPropertyValue('padding-bottom'), 10);
    }

    this.setState({ height, paddingTop, paddingBottom });
  };

  render() {
    const { height, paddingTop, paddingBottom, initialOpen } = this.state;
    const {
      children,
      collapsed,
      defaultStyle,
      style: motionStyle,
      springConfig,
      animate,
    } = this.props;

    if (!animate) {
      return collapsed ? null : children;
    }

    return (
      <Motion
        style={{
          ...motionStyle,
          height: this._spring(collapsed, initialOpen, height, springConfig),
          paddingTop: this._spring(collapsed, initialOpen, paddingTop, springConfig),
          paddingBottom: this._spring(collapsed, initialOpen, paddingBottom, springConfig),
        }}
        defaultStyle={{
          ...defaultStyle,
          height,
          paddingTop,
          paddingBottom,
        }}
      >
        {style => {
          if (collapsed && !style.height) {
            return null;
          }

          const child = Children.only(children);
          this._child = child;
          let nextStyle = child.props.style;
          if (collapsed || style.height !== height) {
            nextStyle = Object.assign({}, child.props.style, {
              ...style,
              overflow: 'hidden',
            });
          }
          return cloneElement(child, {
            ref: this._setHeight,
            style: nextStyle,
          });
        }}
      </Motion>
    );
  }
}