Repository: mapbox/mr-ui
Pull request #1 (2018-07-19 00:59:36)

Add TabList
@colleenmcginnis for review, please.

<img width="398" alt="screen shot 2018-07-18 at 5 57 56 pm" src="https://user-images.githubusercontent.com/628431/42915360-520a2640-8ab4-11e8-864b-e7bc23e9f1bc.png">



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ebd3be..4f20668 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## HEAD
+
+- Add TabList component.
+
 ## 0.1.0
 
 - Start this log.
diff --git a/src/components/tab-list/examples/tab-list-example-basic.js b/src/components/tab-list/examples/tab-list-example-basic.js
new file mode 100644
index 0000000..7f65485
--- /dev/null
+++ b/src/components/tab-list/examples/tab-list-example-basic.js
@@ -0,0 +1,32 @@
+/*
+Basic.
+*/
+import React from 'react';
+import TabList from '../tab-list';
+
+export default class Example extends React.Component {
+  state = {
+    activeItem: ''
+  };
+
+  onChange = id => {
+    this.setState({
+      activeItem: id === this.state.activeItem ? '' : id
+    });
+  };
+
+  render() {
+    return (
+      <TabList
+        onChange={this.onChange}
+        activeItem={this.state.activeItem}
+        items={[
+          { id: 'one', label: 'Label one' },
+          { id: 'two', label: 'Label two' },
+          { id: 'three', label: 'Label three' },
+          { id: 'four', label: 'Label four' }
+        ]}
+      />
+    );
+  }
+}
diff --git a/src/components/tab-list/tab-list.js b/src/components/tab-list/tab-list.js
new file mode 100644
index 0000000..1322616
--- /dev/null
+++ b/src/components/tab-list/tab-list.js
@@ -0,0 +1,206 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import PopoverTrigger from '../popover-trigger';
+
+export default class TabList extends React.PureComponent {
+  /** If you change propTypes, also change propNames. */
+  static propTypes = {
+    items: PropTypes.arrayOf(
+      PropTypes.shape({
+        /** Identifying value for tab list item. */
+        id: PropTypes.string.isRequired,
+        /** The name of the tab to be displayed. */
+        label: PropTypes.string.isRequired,
+        /** Determines if the state of the tab list item is active. */
+        active: PropTypes.bool,
+        /** Link to the page the lab list item should take you to when clicked. */
+        href: PropTypes.string,
+        /** Flag to disable the clickability of an tab list item. */
+        disabled: PropTypes.bool
+      })
+    ).isRequired,
+    /** Callback when an tab list header item is clicked.
+     Note that this should be a function that is passed an `id` argument. */
+    onChange: PropTypes.func,
+    /** Value should coorespond to an `id` within the items array. */
+    activeItem: PropTypes.string,
+    /** Index of the first item in the `items` array that
+     should move to the dropdown on narrow screens. */
+    truncateBy: PropTypes.number,
+    /** CSS classes to apply to the tab list item. */
+    themeItem: PropTypes.string,
+    /** CSS classes to apply to the active tab list item. */
+    themeItemActive: PropTypes.string,
+    /** CSS classes to apply to items when they are truncated. */
+    themeItemTruncated: PropTypes.string,
+    /** CSS classes to apply to the active tab list item. */
+    themeItemActivePopover: PropTypes.string,
+    /** CSS classes to apply to the tab list item container. */
+    themeItemContainer: PropTypes.string,
+    /** CSS classes to apply to the tab list item when `disabled` is true */
+    themeItemDisabled: PropTypes.string
+  };
+
+  static defaultProps = {
+    truncateBy: 2,
+    themeItem: 'border-b-mm border-b--2',
+    themeItemActive: 'border--blue txt-bold',
+    themeItemTruncated: 'border-b--0',
+    themeItemActivePopover: 'border-b block',
+    themeItemContainer: '',
+    themeItemDisabled: 'color-gray-light'
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = { openPopover: false };
+  }
+
+  onChange = id => {
+    const { onChange } = this.props;
+    onChange(id);
+  };
+
+  handlePopoverOpen = () => {
+    this.setState({ openPopover: true });
+  };
+
+  handlePopoverClose = () => {
+    this.setState({ openPopover: false });
+  };
+
+  render() {
+    const {
+      items,
+      activeItem,
+      truncateBy,
+      themeItem,
+      themeItemActive,
+      themeItemTruncated,
+      themeItemDisabled
+    } = this.props;
+
+    const renderAllItems = (item, truncatedClasses) => {
+      /** The classes that the active item should have. */
+      let activeClasses = '';
+      if (item.id === activeItem) {
+        activeClasses = themeItemActive;
+      } else {
+        activeClasses = 'border--transparent';
+      }
+      /** The classes that a disabled item should have. */
+      let disabledClasses = '';
+      if (item.disabled === true) {
+        disabledClasses = themeItemDisabled;
+      }
+      /** The classes that every item should have. */
+      let renderedItemClasses = `px6 mr24 fl-mm align-center-mm align-l py12 ${themeItem} ${truncatedClasses} ${activeClasses} ${disabledClasses}`;
+      let renderedItem = null;
+      /** If the item contains an href, make a link. */
+      if (item.href) {
+        renderedItem = (
+          <a
+            key={item.id}
+            href={item.href}
+            className={`inline-block ${renderedItemClasses}`}
+            data-test={item.id}
+          >
+            {item.label}
+          </a>
+        );
+        /** If the item does not contain an href, make a button. */
+      } else {
+        renderedItem = (
+          <button
+            key={item.id}
+            type="button"
+            className={renderedItemClasses}
+            disabled={item.disabled}
+            data-test={item.id}
+            onClick={() => this.onChange(item.id)}
+          >
+            {item.label}
+          </button>
+        );
+      }
+      return renderedItem;
+    };
+
+    /** These are the tab list items that will never be truncated. */
+    const alwaysRenderedItems = items
+      .slice(0, truncateBy)
+      .map((item, index) => {
+        return (
+          <div key={index} className="fl">
+            {renderAllItems(item, 'inline-block border-b')}
+          </div>
+        );
+      });
+
+    /** These are the tab list items that will be truncated on narrow screens.
+    They should still be rendered as usual on wide screens, and should be hidden
+    on narrow screens using `inline-block-mm none`. */
+    const sometimesHiddenItems = items
+      .slice(truncateBy, items.length)
+      .map((item, index) => {
+        return (
+          <div key={index} className="fl">
+            {renderAllItems(item, 'inline-block-mm none')}
+          </div>
+        );
+      });
+
+    /** These are the tab list items that will be truncated on narrow screens.
+    This is the same content as is in the `sometimesHiddenItems` array, but we _do_
+    want to render them on narrow screens, but they should be stacked (`block`) and
+    should not have a bottom border (`border-b--0`) when active. */
+    const dropdownMenuItems = items
+      .slice(truncateBy, items.length)
+      .map((item, index) => {
+        return (
+          <div key={index} className="block">
+            {renderAllItems(item, themeItemTruncated)}
+          </div>
+        );
+      });
+
+    /** This is the "More +" button that appears on narrow screens and provides
+     a popover that contains the `dropdownMenuItems`. */
+    const moreButton = (
+      <div className="fl">
+        <PopoverTrigger
+          content={dropdownMenuItems}
+          onPopoverOpen={this.handlePopoverOpen}
+          onPopoverClose={this.handlePopoverClose}
+          receiveFocus={true}
+          respondsToFocus={false}
+          respondsToHover={true}
+          trapFocus={true}
+          popoverProps={{
+            placement: 'bottom',
+            themePopover:
+              'round shadow-darken25 h480 scroll-auto px12 py12 scroll-styled'
+          }}
+          triggerProps={{
+            'data-test': 'more-dropdown-menu'
+          }}
+        >
+          <button
+            className="px6 py12 mr12 align-l cursor-pointer none-mm"
+            type="button"
+          >
+            More +
+          </button>
+        </PopoverTrigger>
+      </div>
+    );
+
+    return (
+      <div className="clearfix">
+        {alwaysRenderedItems}
+        {sometimesHiddenItems}
+        {moreButton}
+      </div>
+    );
+  }
+}


Test patch:
diff --git a/src/components/tab-list/__tests__/__snapshots__/tab-list.test.js.snap b/src/components/tab-list/__tests__/__snapshots__/tab-list.test.js.snap
new file mode 100644
index 0000000..3f7aa24
--- /dev/null
+++ b/src/components/tab-list/__tests__/__snapshots__/tab-list.test.js.snap
@@ -0,0 +1,386 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TabList basic renders 1`] = `
+<div
+  className="clearfix"
+>
+  <div
+    className="fl"
+    key="0"
+  >
+    <button
+      className="px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block border-b border--transparent "
+      data-test="one"
+      key="one"
+      onClick={[Function]}
+      type="button"
+    >
+      Label one
+    </button>
+  </div>
+  <div
+    className="fl"
+    key="1"
+  >
+    <button
+      className="px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block border-b border--transparent "
+      data-test="two"
+      key="two"
+      onClick={[Function]}
+      type="button"
+    >
+      Label two
+    </button>
+  </div>
+  <div
+    className="fl"
+    key="0"
+  >
+    <button
+      className="px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--blue txt-bold "
+      data-test="three"
+      key="three"
+      onClick={[Function]}
+      type="button"
+    >
+      Label three
+    </button>
+  </div>
+  <div
+    className="fl"
+    key="1"
+  >
+    <button
+      className="px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--transparent "
+      data-test="four"
+      key="four"
+      onClick={[Function]}
+      type="button"
+    >
+      Label four
+    </button>
+  </div>
+  <div
+    className="fl"
+  >
+    <PopoverTrigger
+      content={
+        Array [
+          <div
+            className="block"
+          >
+            <button
+              className="px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--blue txt-bold "
+              data-test="three"
+              onClick={[Function]}
+              type="button"
+            >
+              Label three
+            </button>
+          </div>,
+          <div
+            className="block"
+          >
+            <button
+              className="px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--transparent "
+              data-test="four"
+              onClick={[Function]}
+              type="button"
+            >
+              Label four
+            </button>
+          </div>,
+        ]
+      }
+      disabled={false}
+      display="inline-block"
+      onPopoverClose={[Function]}
+      onPopoverOpen={[Function]}
+      popoverProps={
+        Object {
+          "placement": "bottom",
+          "themePopover": "round shadow-darken25 h480 scroll-auto px12 py12 scroll-styled",
+        }
+      }
+      receiveFocus={true}
+      renderHiddenContent={false}
+      respondsToClick={true}
+      respondsToFocus={false}
+      respondsToHover={true}
+      trapFocus={true}
+      triggerProps={
+        Object {
+          "data-test": "more-dropdown-menu",
+        }
+      }
+    >
+      <button
+        className="px6 py12 mr12 align-l cursor-pointer none-mm"
+        type="button"
+      >
+        More +
+      </button>
+    </PopoverTrigger>
+  </div>
+</div>
+`;
+
+exports[`TabList links renders 1`] = `
+<div
+  className="clearfix"
+>
+  <div
+    className="fl"
+    key="0"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block border-b border--blue txt-bold "
+      data-test="one"
+      href="#"
+      key="one"
+    >
+      Label one
+    </a>
+  </div>
+  <div
+    className="fl"
+    key="1"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block border-b border--transparent "
+      data-test="two"
+      href="#"
+      key="two"
+    >
+      Label two
+    </a>
+  </div>
+  <div
+    className="fl"
+    key="0"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--transparent "
+      data-test="three"
+      href="#"
+      key="three"
+    >
+      Label three
+    </a>
+  </div>
+  <div
+    className="fl"
+    key="1"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--transparent "
+      data-test="four"
+      href="#"
+      key="four"
+    >
+      Label four
+    </a>
+  </div>
+  <div
+    className="fl"
+  >
+    <PopoverTrigger
+      content={
+        Array [
+          <div
+            className="block"
+          >
+            <a
+              className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--transparent "
+              data-test="three"
+              href="#"
+            >
+              Label three
+            </a>
+          </div>,
+          <div
+            className="block"
+          >
+            <a
+              className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--transparent "
+              data-test="four"
+              href="#"
+            >
+              Label four
+            </a>
+          </div>,
+        ]
+      }
+      disabled={false}
+      display="inline-block"
+      onPopoverClose={[Function]}
+      onPopoverOpen={[Function]}
+      popoverProps={
+        Object {
+          "placement": "bottom",
+          "themePopover": "round shadow-darken25 h480 scroll-auto px12 py12 scroll-styled",
+        }
+      }
+      receiveFocus={true}
+      renderHiddenContent={false}
+      respondsToClick={true}
+      respondsToFocus={false}
+      respondsToHover={true}
+      trapFocus={true}
+      triggerProps={
+        Object {
+          "data-test": "more-dropdown-menu",
+        }
+      }
+    >
+      <button
+        className="px6 py12 mr12 align-l cursor-pointer none-mm"
+        type="button"
+      >
+        More +
+      </button>
+    </PopoverTrigger>
+  </div>
+</div>
+`;
+
+exports[`TabList truncate all renders 1`] = `
+<div
+  className="clearfix"
+>
+  <div
+    className="fl"
+    key="0"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--blue txt-bold "
+      data-test="one"
+      href="#"
+      key="one"
+    >
+      Label one
+    </a>
+  </div>
+  <div
+    className="fl"
+    key="1"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--transparent "
+      data-test="two"
+      href="#"
+      key="two"
+    >
+      Label two
+    </a>
+  </div>
+  <div
+    className="fl"
+    key="2"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--transparent "
+      data-test="three"
+      href="#"
+      key="three"
+    >
+      Label three
+    </a>
+  </div>
+  <div
+    className="fl"
+    key="3"
+  >
+    <a
+      className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 inline-block-mm none border--transparent "
+      data-test="four"
+      href="#"
+      key="four"
+    >
+      Label four
+    </a>
+  </div>
+  <div
+    className="fl"
+  >
+    <PopoverTrigger
+      content={
+        Array [
+          <div
+            className="block"
+          >
+            <a
+              className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--blue txt-bold "
+              data-test="one"
+              href="#"
+            >
+              Label one
+            </a>
+          </div>,
+          <div
+            className="block"
+          >
+            <a
+              className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--transparent "
+              data-test="two"
+              href="#"
+            >
+              Label two
+            </a>
+          </div>,
+          <div
+            className="block"
+          >
+            <a
+              className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--transparent "
+              data-test="three"
+              href="#"
+            >
+              Label three
+            </a>
+          </div>,
+          <div
+            className="block"
+          >
+            <a
+              className="inline-block px6 mr24 fl-mm align-center-mm align-l py12 border-b-mm border-b--2 border-b--0 border--transparent "
+              data-test="four"
+              href="#"
+            >
+              Label four
+            </a>
+          </div>,
+        ]
+      }
+      disabled={false}
+      display="inline-block"
+      onPopoverClose={[Function]}
+      onPopoverOpen={[Function]}
+      popoverProps={
+        Object {
+          "placement": "bottom",
+          "themePopover": "round shadow-darken25 h480 scroll-auto px12 py12 scroll-styled",
+        }
+      }
+      receiveFocus={true}
+      renderHiddenContent={false}
+      respondsToClick={true}
+      respondsToFocus={false}
+      respondsToHover={true}
+      trapFocus={true}
+      triggerProps={
+        Object {
+          "data-test": "more-dropdown-menu",
+        }
+      }
+    >
+      <button
+        className="px6 py12 mr12 align-l cursor-pointer none-mm"
+        type="button"
+      >
+        More +
+      </button>
+    </PopoverTrigger>
+  </div>
+</div>
+`;
diff --git a/src/components/tab-list/__tests__/tab-list-test-cases.js b/src/components/tab-list/__tests__/tab-list-test-cases.js
new file mode 100644
index 0000000..f33439a
--- /dev/null
+++ b/src/components/tab-list/__tests__/tab-list-test-cases.js
@@ -0,0 +1,50 @@
+import safeSpy from '../../../test-utils/safe-spy';
+import TabList from '../tab-list';
+
+const testCases = {};
+
+testCases.basic = {
+  description: 'basic',
+  component: TabList,
+  props: {
+    onChange: safeSpy(),
+    activeItem: 'three',
+    items: [
+      { id: 'one', label: 'Label one' },
+      { id: 'two', label: 'Label two' },
+      { id: 'three', label: 'Label three' },
+      { id: 'four', label: 'Label four' }
+    ]
+  }
+};
+
+testCases.links = {
+  description: 'links',
+  component: TabList,
+  props: {
+    activeItem: 'one',
+    items: [
+      { id: 'one', label: 'Label one', href: '#' },
+      { id: 'two', label: 'Label two', href: '#' },
+      { id: 'three', label: 'Label three', href: '#' },
+      { id: 'four', label: 'Label four', href: '#' }
+    ]
+  }
+};
+
+testCases.truncateAll = {
+  description: 'truncate all',
+  component: TabList,
+  props: {
+    activeItem: 'one',
+    truncateBy: 0,
+    items: [
+      { id: 'one', label: 'Label one', href: '#' },
+      { id: 'two', label: 'Label two', href: '#' },
+      { id: 'three', label: 'Label three', href: '#' },
+      { id: 'four', label: 'Label four', href: '#' }
+    ]
+  }
+};
+
+export { testCases };
diff --git a/src/components/tab-list/__tests__/tab-list.test.js b/src/components/tab-list/__tests__/tab-list.test.js
new file mode 100644
index 0000000..dd5e855
--- /dev/null
+++ b/src/components/tab-list/__tests__/tab-list.test.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import toJson from 'enzyme-to-json';
+import { testCases } from './tab-list-test-cases';
+
+describe('TabList', () => {
+  let testCase;
+  let wrapper;
+
+  describe(testCases.basic.description, () => {
+    beforeEach(() => {
+      testCase = testCases.basic;
+      wrapper = shallow(
+        React.createElement(testCase.component, testCase.props)
+      );
+    });
+
+    test('renders', () => {
+      expect(toJson(wrapper)).toMatchSnapshot();
+    });
+
+    test('onChange is called', () => {
+      wrapper
+        .find('button')
+        .first()
+        .props()
+        .onClick('one');
+      expect(testCase.props.onChange).toHaveBeenCalledTimes(1);
+      expect(testCase.props.onChange).toHaveBeenCalledWith('one');
+    });
+
+    test('activeItem prop updates active item', () => {
+      wrapper.setProps({ activeItem: 'one' });
+      wrapper.update();
+    });
+  });
+  describe(testCases.links.description, () => {
+    beforeEach(() => {
+      testCase = testCases.links;
+      wrapper = shallow(
+        React.createElement(testCase.component, testCase.props)
+      );
+    });
+
+    test('renders', () => {
+      expect(
+        toJson(shallow(React.createElement(testCase.component, testCase.props)))
+      ).toMatchSnapshot();
+    });
+
+    test('activeItem prop updates active item', () => {
+      wrapper.setProps({ activeItem: 'one' });
+      wrapper.update();
+    });
+  });
+  describe(testCases.truncateAll.description, () => {
+    beforeEach(() => {
+      testCase = testCases.truncateAll;
+      wrapper = shallow(
+        React.createElement(testCase.component, testCase.props)
+      );
+    });
+
+    test('renders', () => {
+      expect(
+        toJson(shallow(React.createElement(testCase.component, testCase.props)))
+      ).toMatchSnapshot();
+    });
+
+    test('activeItem prop updates active item', () => {
+      wrapper.setProps({ activeItem: 'one' });
+      wrapper.update();
+    });
+  });
+});