e1-bsd/omni-common-ui

View on GitHub
src/components/Breadcrumbs/index.jsx

Summary

Maintainability
B
5 hrs
Test Coverage
import styles from './style.postcss';

import React, { PureComponent } from 'react';
import { Link } from 'react-router';
import ReactGA from 'react-ga';
import is from 'is_js';
import classnames from 'classnames';
import debounce from 'lodash.debounce';
import Icon from 'components/Icon';
import PropTypes from 'prop-types';
import testClass from 'domain/testClass';

const RESIZE_DEBOUNCE_MS = 100;

class Breadcrumbs extends PureComponent {
  constructor() {
    super();
    this.state = {
      isIntermediateCrumbsCollapsed: false,
      collapsedAtWidth: null,  // populated below
    };
  }

  componentDidMount() {
    this._updateIntermediateCrumbsCollapsedState();
    window.addEventListener('resize', this.onResizeHandler = debounce(() => {
      this._updateIntermediateCrumbsCollapsedState();
    }, RESIZE_DEBOUNCE_MS, {
      leading: true,  // nicer iOS screen rotate
      trailing: true,
    }), false);
  }

  componentDidUpdate() {
    this._updateIntermediateCrumbsCollapsedState();
  }

  componentWillUnmount() {
    if (! this.onResizeHandler) return;
    window.removeEventListener('resize', this.onResizeHandler);
  }

  _isOverflowingHorizontally() {  // returns: content width (if overflowing)
    const el = this.listNode;
    const curOverflow = el.style.overflow;

    // set overflows: ul hidden, children visible
    if (! curOverflow || curOverflow === 'visible') {
      el.style.overflow = 'hidden';
    }
    const childOverflows = [];
    Array.prototype.forEach.call(el.children, (childEl) => {
      childOverflows.push(childEl.style.overflow);
      childEl.style.overflow = 'visible';  // eslint-disable-line
    });

    // check clientWidth against scrollWidth; scrollWidth is the width of content
    const isOverflowing = el.clientWidth < el.scrollWidth;
    const { scrollWidth } = el;

    // restore overflow
    el.style.overflow = curOverflow;
    Array.prototype.forEach.call(el.children, (childEl) => {
      childEl.style.overflow = childOverflows.shift();  // eslint-disable-line
    });

    return isOverflowing && scrollWidth;
  }

  _updateIntermediateCrumbsCollapsedState() {
    if (! this.props.singleLineMode || ! this.listNode) return;

    const navBoxSizing = this.navNode.style.boxSizing;
    this.navNode.style.boxSizing = 'content-box';  // to get width without padding
    const navWidth = Number.parseInt(
      getComputedStyle(this.navNode)
        .getPropertyValue('width')
    , 10);
    this.navNode.style.boxSizing = navBoxSizing;  // restore

    let contentWidth;
    if (this.state.isIntermediateCrumbsCollapsed &&
        (is.not.number(this.state.collapsedAtWidth) ||
        navWidth > this.state.collapsedAtWidth)) {
      this.setState({
        isIntermediateCrumbsCollapsed: false,
        collapsedAtWidth: null,
      });
    } else if (! this.state.isIntermediateCrumbsCollapsed &&  // eslint-disable-line
        (contentWidth = this._isOverflowingHorizontally())) {
      // overflowing - collapse intermediate crumbs
      this.setState({
        isIntermediateCrumbsCollapsed: true,
        collapsedAtWidth: navWidth > this.state.collapsedAtWidth ||
            is.not.number(this.state.collapsedAtWidth) ?
              contentWidth :
              this.state.collapsedAtWidth,
      });
    }
  }

  _onLinkClick(linkLabel) {
    ReactGA.event({
      category: 'Navigation',
      action: 'Clicked breadcrumb',
      label: `Clicked breadcrumb ${linkLabel}`,
    });
  }

  render() {
    if (! this.props.items || this.props.items.length < 1) return null;

    // make a copy of props.items so that we can mangle it
    let itemsToRender = this.props.items;
    // push collapsed intermediate crumbs if enabled
    if (this.state.isIntermediateCrumbsCollapsed) {
      itemsToRender = [this.props.items[0]];
      if (this.props.items.length > 2) {
        itemsToRender.push({
          label: '…',
          clickable: false,
        });
      }
      itemsToRender.push(this.props.items[this.props.items.length - 1]);
    }

    return <nav className={classnames(styles.Breadcrumbs, this.props.className, {
      [styles.__wrap]: ! this.props.singleLineMode,
    })}>
      <ul className={styles.Breadcrumbs_list}
          ref={(_node) => {
            if (! _node) return;
            this.listNode = _node;
            this.navNode = _node.parentElement;
          }}>
        {
          itemsToRender.length === 0 ? null :
          <li className={classnames(styles.Breadcrumbs_crumb, styles.__clickable, testClass('crumb-home'))}>
            <Link to="/"
                onClick={() => this._onLinkClick('Home button')}
                draggable={false}>
              <Icon id="home" />
            </Link>
          </li>
        }
        {itemsToRender.map((item, idx) => {
          const indexedCrumbClassName = styles[`Breadcrumbs_crumb_${idx}`];
          const itemClassNames = classnames(styles.Breadcrumbs_crumb, {
            [indexedCrumbClassName]: !! indexedCrumbClassName,
            [styles.__clickable]: !! item.clickable,
          });
          const itemKey = item.label + item.href;
          return <li key={itemKey}
              className={itemClassNames}>
            {item.clickable ? <Link to={item.href}
                onClick={() => this._onLinkClick(item.label)}
                draggable={false}>
              {item.label}
            </Link> : <span>
              {item.label}
            </span>}
          </li>;
        })}
      </ul>
    </nav>;
  }
}

Breadcrumbs.propTypes = {
  className: PropTypes.string,
  items: PropTypes.arrayOf(PropTypes.shape({
    label: PropTypes.string.isRequired,
    href: PropTypes.string.isRequired,
    clickable: PropTypes.bool.isRequired,
    backLinkHref: PropTypes.string,  // it's an override
  })).isRequired,
  singleLineMode: PropTypes.bool,
};

export default Breadcrumbs;