Repository: mapbox/mr-ui (ts)

Issue:
Buttons!
It's time to create some opinionated button components. 

Here's what I'm thinking we'll want ...

For all buttons, there will be props that make it easy to 

- add a vertically centered icon before the text
- control colors
- stretch the button to the full width of its container
- make it a larger size — e.g. on marketing pages we provide some extra horizontal padding
- disable the button (in which it'll use Assembly's disabled style)
- make the button an icon-only button with a tooltip text explanation (replacing the need for our current IconButton component)

**MbxButton** looks like this:

<img width="202" alt="screen shot 2018-07-27 at 11 13 28 am" src="https://user-images.githubusercontent.com/628431/43341763-4f264fc0-9195-11e8-8641-aee2de28a586.png">

That's what we have throughout Studio and marketing pages. Account Dashboard is the outlier with larger text. There seems to be to be enough enthusiasm for the smaller-text norm that we should stick with that.

**MbxSecondaryButton** is the outlined version of the primary button:

<img width="185" alt="screen shot 2018-07-27 at 12 09 16 pm" src="https://user-images.githubusercontent.com/628431/43341947-efe37172-9195-11e8-8a1c-b15ba616ddf6.png">

**MbxDiscouragingButton** is gray, but not too gray, and outlined:

<img width="109" alt="screen shot 2018-07-27 at 12 07 05 pm" src="https://user-images.githubusercontent.com/628431/43341871-a4f70f3e-9195-11e8-8a4f-8b59cb2c9636.png">

**MbxTextButton** looks like a bold link. Usually it includes an icon, and it can take on whatever text size its container is using.

<img width="123" alt="screen shot 2018-07-27 at 12 12 11 pm" src="https://user-images.githubusercontent.com/628431/43342107-6f2de962-9196-11e8-9ffb-3ad5458f3cc8.png">

**MbxAppButton** is for denser apps like Studio's style editor. Borders are not fully rounded, text is usually smaller — though we could have two sizes, `txt-xs` and `txt-s`. The default color is gray instead of blue, so it's not hogging too much attention in a dense UI. (You'll be able to make it blue with an option).

<img width="98" alt="screen shot 2018-07-27 at 11 16 53 am" src="https://user-images.githubusercontent.com/628431/43342296-135d2eda-9197-11e8-92fd-c430d8560014.png">
<img width="110" alt="screen shot 2018-07-27 at 11 16 56 am" src="https://user-images.githubusercontent.com/628431/43342297-13767674-9197-11e8-946d-0dfadc7bd57d.png">

**MbxAppSecondaryButton** is the same as MbxAppButton but outlined instead of filled.

Pull request description:
MbxButton
Closes #11.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d47027b..b62a1d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,9 @@
 
 ## HEAD
 
-- Add *MbxIconText** component.
-- Add *MbxUnderlineTabs** component.
+- Add **MbxButton** component.
+- Add **MbxIconText** component.
+- Add **MbxUnderlineTabs** component.
 - **PopoverTrigger**
   - If the trigger responds to focus but not click, and you focus the trigger *first* and *then* click, that click *closes* the popover instead of leaving it open even after you move the mouse away.
 
diff --git a/package-lock.json b/package-lock.json
index f7c1eec..fc05d41 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -732,9 +732,9 @@
       "integrity": "sha512-Z5ENbYjbN5NgfITHq/o9Yspm/tx4H+LdcfnPGksb0KjK2DmRlSY4fN8vzrNLLHcMX05puARr2EU+LkFMK+UNtQ=="
     },
     "@mapbox/react-test-kitchen": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/@mapbox/react-test-kitchen/-/react-test-kitchen-0.1.2.tgz",
-      "integrity": "sha512-wl6XVnjf/87k3QWtfOUd2SBvOlpVz/2KCe1vZxKxS2v8QXHhy5rZpMsjQbDg+ya8HxQwpUC71kz4pWbYsQcwLw==",
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@mapbox/react-test-kitchen/-/react-test-kitchen-0.3.0.tgz",
+      "integrity": "sha512-Uotr0R9PZTilKukn19G2SxxbjmspNhoLTFNwZW+Ek0o5qBXDgA8oVg7vyc3ou9NlMMOaS2b3Zf3k1Z+rxZ6lmA==",
       "dev": true,
       "requires": {
         "@mapbox/fusspot": "^0.2.1",
@@ -863,15 +863,6 @@
           "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz",
           "integrity": "sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA=",
           "dev": true
-        },
-        "yargs-parser": {
-          "version": "10.1.0",
-          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz",
-          "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==",
-          "dev": true,
-          "requires": {
-            "camelcase": "^4.1.0"
-          }
         }
       }
     },
@@ -18458,6 +18449,23 @@
         }
       }
     },
+    "yargs-parser": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz",
+      "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^4.1.0"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+          "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+          "dev": true
+        }
+      }
+    },
     "zwitch": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.3.tgz",
diff --git a/package.json b/package.json
index a8a9554..c3948bc 100644
--- a/package.json
+++ b/package.json
@@ -62,7 +62,7 @@
     "@mapbox/batfish": "^1.9.2",
     "@mapbox/eslint-config-mapbox": "^1.2.1",
     "@mapbox/jsxtreme-markdown": "^0.9.3",
-    "@mapbox/react-test-kitchen": "^0.1.2",
+    "@mapbox/react-test-kitchen": "^0.3.0",
     "babel-cli": "^6.26.0",
     "babel-core": "^6.26.3",
     "babel-eslint": "^8.2.6",
diff --git a/scripts/build-docs-data.js b/scripts/build-docs-data.js
index b85af16..f258a3d 100755
--- a/scripts/build-docs-data.js
+++ b/scripts/build-docs-data.js
@@ -34,11 +34,14 @@ function processExampleFile(filename) {
       .trim();
 
     const highlightedCode = Prism.highlight(code, Prism.languages.jsx, 'jsx');
+    const renderedDescription = encodeJsx(
+      jsxtremeMarkdown.toJsx(descriptionMatch[1].trim())
+    );
 
     return `{
       exampleModule: require('${filename}'),
-      code: \`${highlightedCode}\`,
-      description: ${jsxtremeMarkdown.toJsx(descriptionMatch[1].trim())}
+      code: \`${encodeJsx(highlightedCode)}\`,
+      description: ${renderedDescription}
     }`;
   });
 }
@@ -54,8 +57,9 @@ function processProps(props) {
   let objectBody = '';
   Object.keys(props).forEach(prop => {
     const propData = props[prop];
-    const renderedDescription =
-      jsxtremeMarkdown.toJsx(propData.description || ' ').trim() || '<div />';
+    const renderedDescription = encodeJsx(
+      jsxtremeMarkdown.toJsx(propData.description || ' ').trim() || '<div />'
+    );
     objectBody += `${prop}: {
       type: ${JSON.stringify(propData.type)},
       required: ${propData.required},
@@ -111,3 +115,8 @@ function generateDocsData() {
 }
 
 generateDocsData();
+
+// Prepare JSX to be written directly to the script without messing things up.
+function encodeJsx(x) {
+  return x.replace(/`/g, '&#96;').replace(/\$/, '&#36;');
+}
diff --git a/src/components/mbx-button/examples/mbx-button-a.js b/src/components/mbx-button/examples/mbx-button-a.js
new file mode 100644
index 0000000..ad1dbd9
--- /dev/null
+++ b/src/components/mbx-button/examples/mbx-button-a.js
@@ -0,0 +1,44 @@
+/*
+All the standard variants.
+*/
+import React from 'react';
+import MbxButton from '../mbx-button';
+
+export default class Example extends React.Component {
+  render() {
+    return (
+      <div>
+        <div className="my6">
+          <MbxButton onClick={noop}>Primary</MbxButton>
+        </div>
+        <div className="my6">
+          <MbxButton variant="secondary" onClick={noop}>
+            Secondary
+          </MbxButton>
+        </div>
+        <div className="my6">
+          <MbxButton variant="discouraging" onClick={noop}>
+            Discouraging
+          </MbxButton>
+        </div>
+        <div className="my6">
+          <MbxButton variant="destructive" onClick={noop}>
+            Destructive
+          </MbxButton>
+        </div>
+        <div className="my6">
+          <MbxButton variant="appPrimary" onClick={noop}>
+            AppPrimary
+          </MbxButton>
+        </div>
+        <div className="my6">
+          <MbxButton variant="appSecondary" onClick={noop}>
+            AppSecondary
+          </MbxButton>
+        </div>
+      </div>
+    );
+  }
+}
+
+function noop() {}
diff --git a/src/components/mbx-button/examples/mbx-button-b.js b/src/components/mbx-button/examples/mbx-button-b.js
new file mode 100644
index 0000000..c81944b
--- /dev/null
+++ b/src/components/mbx-button/examples/mbx-button-b.js
@@ -0,0 +1,16 @@
+/*
+A slightly wider, outlined button with an alternate color, using [MbxIconText](#mbxicontext) to prefix the text with an icon.
+*/
+import React from 'react';
+import MbxButton from '../mbx-button';
+import MbxIconText from '../../mbx-icon-text';
+
+export default class Example extends React.Component {
+  render() {
+    return (
+      <MbxButton onClick={() => {}} width="large" outline={true} color="purple">
+        <MbxIconText iconBefore="floppy">Save your map</MbxIconText>
+      </MbxButton>
+    );
+  }
+}
diff --git a/src/components/mbx-button/examples/mbx-button-c.js b/src/components/mbx-button/examples/mbx-button-c.js
new file mode 100644
index 0000000..8461454
--- /dev/null
+++ b/src/components/mbx-button/examples/mbx-button-c.js
@@ -0,0 +1,29 @@
+/*
+A link that looks like a slightly short button (`size: "medium"`).
+
+The additional prop `data-test` is passed on directly to the `<a>`.
+
+Also, `transformClasses` is used to add the drop-shadow class.
+*/
+import React from 'react';
+import MbxButton from '../mbx-button';
+
+export default class Example extends React.Component {
+  render() {
+    const btnClasses = variantClasses => {
+      return `${variantClasses} shadow-darken25`;
+    };
+    return (
+      <div id="mbx-button-c">
+        <MbxButton
+          href="#mbx-button-c"
+          size="medium"
+          data-test="link-to-c"
+          transformClasses={btnClasses}
+        >
+          You are here
+        </MbxButton>
+      </div>
+    );
+  }
+}
diff --git a/src/components/mbx-button/examples/mbx-button-d.js b/src/components/mbx-button/examples/mbx-button-d.js
new file mode 100644
index 0000000..ce06896
--- /dev/null
+++ b/src/components/mbx-button/examples/mbx-button-d.js
@@ -0,0 +1,68 @@
+/*
+To control button width, you can use `width: "full"` and put the button in a
+wrapper container with an Assembly width class.
+
+Sometimes, for example, you might want a column or row of equal-width buttons.
+*/
+import React from 'react';
+import MbxButton from '../mbx-button';
+
+export default class Example extends React.Component {
+  render() {
+    return (
+      <div>
+        <div className="mb24 flex-parent">
+          <div className="flex-child w120 mr12">
+            <MbxButton size="medium" width="full" onClick={noop}>
+              Door
+            </MbxButton>
+          </div>
+          <div className="flex-child w120 mr12">
+            <MbxButton size="medium" width="full" onClick={noop}>
+              Dog
+            </MbxButton>
+          </div>
+          <div className="flex-child w120 mr12">
+            <MbxButton size="medium" width="full" onClick={noop}>
+              Dash
+            </MbxButton>
+          </div>
+        </div>
+        <div className="w60">
+          <div className="my3">
+            <MbxButton
+              variant="secondary"
+              size="medium"
+              width="full"
+              onClick={noop}
+            >
+              A
+            </MbxButton>
+          </div>
+          <div className="my3">
+            <MbxButton
+              variant="secondary"
+              size="medium"
+              width="full"
+              onClick={noop}
+            >
+              B
+            </MbxButton>
+          </div>
+          <div className="my3">
+            <MbxButton
+              variant="secondary"
+              size="medium"
+              width="full"
+              onClick={noop}
+            >
+              C
+            </MbxButton>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+function noop() {}
diff --git a/src/components/mbx-button/index.js b/src/components/mbx-button/index.js
new file mode 100644
index 0000000..f5a42cc
--- /dev/null
+++ b/src/components/mbx-button/index.js
@@ -0,0 +1,3 @@
+import main from './mbx-button';
+
+export default main;
diff --git a/src/components/mbx-button/mbx-button.js b/src/components/mbx-button/mbx-button.js
new file mode 100644
index 0000000..4a38a43
--- /dev/null
+++ b/src/components/mbx-button/mbx-button.js
@@ -0,0 +1,254 @@
+import React from 'react';
+import classnames from 'classnames';
+import PropTypes from 'prop-types';
+import xtend from 'xtend';
+import omit from '../utils/omit';
+
+// This list must be kept in sync with the propTypes.
+// It's used to identify additional props that should be passed directly
+// to the element.
+const propNames = [
+  'block',
+  'children',
+  'color',
+  'component',
+  'corners',
+  'href',
+  'onClick',
+  'outline',
+  'size',
+  'transformClasses',
+  'variant',
+  'width'
+];
+
+/**
+ * A good-looking button!
+ *
+ * The rendered element will be a `<button>` or an `<a>`, depending on whether
+ * you provide an `href` prop or not. (You can also use the `component` prop to
+ * render a different element altogether.)
+ *
+ * If you'd like to put an icon before or after the text of your button,
+ * use [MbxIconText](#mbxicontext) for the content.
+ *
+ * Any props you provide other than those documented here are passed through
+ * directly to the element itself. This can be useful if you want to disable
+ * the button, assign an ID for testing, add an ARIA attribute, toss in some
+ * custom style properties, etc.
+ */
+class MbxButton extends React.Component {
+  render() {
+    const props = applyVariant(this.props);
+
+    const sizeSmall = props.size === 'small';
+    const sizeMedium = props.size === 'medium';
+    const sizeLarge = props.size === 'large';
+    const widthFull = props.width === 'full';
+    const widthSmall = props.width === 'small';
+    const widthMedium = props.width === 'medium';
+    const widthLarge = props.width === 'large';
+
+    const classes = classnames('btn', {
+      [`btn--${props.color}`]: props.color,
+      'btn--stroke': props.outline,
+      'btn--stroke--2': props.outline && !sizeSmall,
+      round: props.corners,
+      'round-full': !props.corners,
+      h24: sizeSmall,
+      py12: sizeLarge,
+      py6: sizeMedium,
+      px6: widthSmall && sizeSmall,
+      px12: (widthMedium && sizeSmall) || (widthSmall && !sizeSmall),
+      px24: (widthLarge && sizeSmall) || (widthMedium && !sizeSmall),
+      px36: !sizeSmall && widthLarge,
+      'w-full block': widthFull,
+      block: props.block,
+      'txt-s': !sizeSmall,
+      'py3 txt-xs': sizeSmall
+    });
+
+    const universalProps = xtend(
+      {
+        className: props.transformClasses(classes),
+        onClick: props.onClick,
+        children: props.children
+      },
+      omit(props, propNames)
+    );
+
+    if (props.component) {
+      return React.createElement(props.component, universalProps);
+    }
+
+    if (props.href) {
+      return <a href={props.href} {...universalProps} />;
+    }
+
+    return <button type="button" {...universalProps} />;
+  }
+}
+
+const defaults = {
+  color: 'blue',
+  corners: false,
+  outline: false,
+  size: 'large',
+  width: 'medium'
+};
+function applyVariant(props) {
+  switch (props.variant) {
+    case 'primary':
+      return xtend(defaults, props);
+    case 'secondary':
+      return xtend(
+        defaults,
+        {
+          outline: true
+        },
+        props
+      );
+    case 'discouraging':
+      return xtend(
+        defaults,
+        {
+          color: 'gray',
+          outline: true
+        },
+        props
+      );
+    case 'destructive':
+      return xtend(
+        defaults,
+        {
+          color: 'red'
+        },
+        props
+      );
+    case 'appPrimary':
+      return xtend(
+        defaults,
+        {
+          color: 'gray',
+          size: 'small',
+          width: 'small',
+          corners: true
+        },
+        props
+      );
+    case 'appSecondary':
+      return xtend(
+        defaults,
+        {
+          color: 'gray',
+          size: 'small',
+          width: 'small',
+          corners: true,
+          outline: true
+        },
+        props
+      );
+  }
+}
+
+MbxButton.propTypes = {
+  /**
+   * The button's content. A string is recommended, but you can put an element
+   * in here if you think that's right. If you do, it should be inline-level,
+   * using `<span>`s instead of `<div>`s. ([MbxIconText](#mbxicontext) is
+   * inline-level.)
+   */
+  children: PropTypes.node.isRequired,
+  /**
+   * A keyword identifying the standard button styling variant to use.
+   *
+   * *The variant is a starting point*: all of the other styling props can be
+   * used to override details.
+   *
+   * The following variants are available:
+   *
+   * - `"primary"`: For primary actions.
+   * - `"secondary"`: For secondary actions.
+   * - `"discouraging"`: For downplayed actions, the ones people shouldn't
+   *   usually want to perform, like cancelling instead of confirming.
+   * - `"destructive"`: For destructive actions, like deleting something.
+   * - `"appPrimary"`: For primary actions in dense apps.
+   * - `"appSecondary"`: For secondary actions in dense apps.
+   */
+  variant: PropTypes.oneOf([
+    'primary',
+    'secondary',
+    'discouraging',
+    'destructive',
+    'appPrimary',
+    'appSecondary'
+  ]),
+  /**
+   * A callback that receives the click event.
+   *
+   * You definitely need this if you don't use `href`; but you can also provide
+   * both `onClick` and `href`, and `onClick` will be called when the `<a>` is
+   * clicked.
+   */
+  onClick: PropTypes.func,
+  /**
+   * A URL. If `href` is provided, the button will be an `<a>`.
+   */
+  href: PropTypes.string,
+  /**
+   * The size of the button: `"small"`, `"medium"`, or `"large"`.
+   *
+   * This accounts for height, outline thickness, text size, and other details.
+   *
+   * This will override whichever defaults are set by your `variant` of choice.
+   */
+  size: PropTypes.oneOf(['small', 'medium', 'large']),
+  /**
+   * The width of the button: `"small"`, `"medium"`, `"large"`, or `"full"`.
+   *
+   * This is distinguished from `size` because buttons of the same general size
+   * can vary by width.
+   *
+   * This will override whichever defaults are set by your `variant` of choice.
+   */
+  width: PropTypes.oneOf(['small', 'medium', 'large', 'full']),
+  /**
+   * If `true`, the button will be outlined instead of filled with color.
+   */
+  outline: PropTypes.bool,
+  /**
+   * The Assembly color of the button.
+   */
+  color: PropTypes.string,
+  /**
+   * If `true`, the button will have (slightly rounded) corners instead of
+   * the full curve they have by default.
+   */
+  corners: PropTypes.bool,
+  /**
+   * If `true`, the element will be `block` displayed instead of `inline-block`.
+   *
+   * This is sometimes necessary to get your pixel-perfect layout.
+   */
+  block: PropTypes.bool,
+  /**
+   * A callback for transforming the class list. Receives the standard class
+   * list (based on your other props) as an argument; it must return
+   * the transformed class list.
+   */
+  transformClasses: PropTypes.func,
+  /**
+   * An alternate component to render in the style of a button. If the value is
+   * a string, it must refer to a DOM element, like `"div"`. Otherwise, it
+   * can be a React component.
+   */
+  component: PropTypes.oneOfType([PropTypes.func, PropTypes.string])
+};
+
+MbxButton.defaultProps = {
+  variant: 'primary',
+  block: false,
+  transformClasses: x => x
+};
+
+export default MbxButton;
diff --git a/src/components/mbx-icon-text/mbx-icon-text.js b/src/components/mbx-icon-text/mbx-icon-text.js
index ebd8fef..ae66add 100644
--- a/src/components/mbx-icon-text/mbx-icon-text.js
+++ b/src/components/mbx-icon-text/mbx-icon-text.js
@@ -43,7 +43,9 @@ class MbxIconText extends React.Component {
 
 MbxIconText.propTypes = {
   /**
-   * The text. A string is recommended, but you can put an element in here if you think it's right. If you do, it should be inline-level, using `<span>`s instead of `<div>`s.
+   * The text. A string is recommended, but you can put an element in here if
+   * you think it's right. If you do, it should be inline-level,
+   * using `<span>`s instead of `<div>`s.
    */
   children: PropTypes.node.isRequired,
   /**


Test patch:
diff --git a/src/components/mbx-button/__tests__/__snapshots__/mbx-button.test.js.snap b/src/components/mbx-button/__tests__/__snapshots__/mbx-button.test.js.snap
new file mode 100644
index 0000000..0075c14
--- /dev/null
+++ b/src/components/mbx-button/__tests__/__snapshots__/mbx-button.test.js.snap
@@ -0,0 +1,106 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AppPrimary renders as expected 1`] = `
+<button
+  className="btn btn--gray round h24 px6 py3 txt-xs"
+  onClick={[MockFunction]}
+  type="button"
+>
+  AppPrimary
+</button>
+`;
+
+exports[`AppSecondary renders as expected 1`] = `
+<button
+  className="btn btn--gray btn--stroke round h24 px6 py3 txt-xs"
+  onClick={[MockFunction]}
+  type="button"
+>
+  AppSecondary
+</button>
+`;
+
+exports[`Destructive renders as expected 1`] = `
+<button
+  className="btn btn--red round-full py12 px24 txt-s"
+  onClick={[MockFunction]}
+  type="button"
+>
+  Destructive
+</button>
+`;
+
+exports[`Disabled renders as expected 1`] = `
+<button
+  className="btn btn--blue round-full py12 px24 txt-s"
+  disabled={true}
+  type="button"
+>
+  Oops
+</button>
+`;
+
+exports[`Discouraging renders as expected 1`] = `
+<button
+  className="btn btn--gray btn--stroke btn--stroke--2 round-full py12 px24 txt-s"
+  onClick={[MockFunction]}
+  type="button"
+>
+  Discouraging
+</button>
+`;
+
+exports[`Div styled like a medium destructive button with some extended props and transformed classes renders as expected 1`] = `
+<div
+  className="btn btn--red round-full py6 px24 txt-s shadow-darken75 unselectable cursor-pointer"
+  data-test="foo"
+  onClick={[MockFunction]}
+  role="button"
+>
+  Save
+</div>
+`;
+
+exports[`Full width, alternate color renders as expected 1`] = `
+<button
+  className="btn btn--purple round-full py12 w-full block txt-s"
+  type="button"
+>
+  Here we are
+</button>
+`;
+
+exports[`Link with outline and icon text, extra padding renders as expected 1`] = `
+<a
+  className="btn btn--blue btn--stroke btn--stroke--2 round-full py12 px36 txt-s"
+  href="#"
+  onClick={[MockFunction]}
+>
+  <MbxIconText
+    iconBefore="bug"
+    spacing="small"
+  >
+    Go do things
+  </MbxIconText>
+</a>
+`;
+
+exports[`Primary renders as expected 1`] = `
+<button
+  className="btn btn--blue round-full py12 px24 txt-s"
+  onClick={[MockFunction]}
+  type="button"
+>
+  Primary
+</button>
+`;
+
+exports[`Secondary renders as expected 1`] = `
+<button
+  className="btn btn--blue btn--stroke btn--stroke--2 round-full py12 px24 txt-s"
+  onClick={[MockFunction]}
+  type="button"
+>
+  Secondary
+</button>
+`;
diff --git a/src/components/mbx-button/__tests__/mbx-button-test-cases.js b/src/components/mbx-button/__tests__/mbx-button-test-cases.js
new file mode 100644
index 0000000..d0b6652
--- /dev/null
+++ b/src/components/mbx-button/__tests__/mbx-button-test-cases.js
@@ -0,0 +1,139 @@
+import React from 'react';
+import safeSpy from '../../../test-utils/safe-spy';
+import MbxButton from '../mbx-button';
+import MbxIconText from '../../mbx-icon-text';
+
+const testCases = {};
+
+testCases.primary = {
+  description: 'Primary',
+  component: MbxButton,
+  props: {
+    children: 'Primary',
+    onClick: safeSpy()
+  }
+};
+
+testCases.secondary = {
+  description: 'Secondary',
+  component: MbxButton,
+  props: {
+    children: 'Secondary',
+    variant: 'secondary',
+    onClick: safeSpy()
+  }
+};
+
+testCases.discouraging = {
+  description: 'Discouraging',
+  component: MbxButton,
+  props: {
+    children: 'Discouraging',
+    variant: 'discouraging',
+    onClick: safeSpy()
+  }
+};
+
+testCases.destructive = {
+  description: 'Destructive',
+  component: MbxButton,
+  props: {
+    children: 'Destructive',
+    variant: 'destructive',
+    onClick: safeSpy()
+  }
+};
+
+testCases.appPrimary = {
+  description: 'AppPrimary',
+  component: MbxButton,
+  props: {
+    children: 'AppPrimary',
+    variant: 'appPrimary',
+    onClick: safeSpy()
+  }
+};
+
+testCases.appSecondary = {
+  description: 'AppSecondary',
+  component: MbxButton,
+  props: {
+    children: 'AppSecondary',
+    variant: 'appSecondary',
+    onClick: safeSpy()
+  }
+};
+
+testCases.linkOutlinePadding = {
+  description: 'Link with outline and icon text, extra padding',
+  component: MbxButton,
+  props: {
+    children: <MbxIconText iconBefore="bug">Go do things</MbxIconText>,
+    href: '#',
+    onClick: safeSpy(),
+    outline: true,
+    width: 'large'
+  }
+};
+
+testCases.fullWidthPurple = {
+  description: 'Full width, alternate color',
+  component: MbxButton,
+  props: {
+    children: 'Here we are',
+    color: 'purple',
+    width: 'full'
+  }
+};
+
+testCases.weird = {
+  description:
+    'Div styled like a medium destructive button with some extended props and transformed classes',
+  component: MbxButton,
+  props: {
+    children: 'Save',
+    size: 'medium',
+    variant: 'destructive',
+    onClick: safeSpy(),
+    transformClasses: classes => {
+      return classes + ' shadow-darken75 unselectable cursor-pointer';
+    },
+    component: 'div',
+    role: 'button',
+    'data-test': 'foo'
+  }
+};
+
+testCases.disabled = {
+  description: 'Disabled',
+  component: MbxButton,
+  props: {
+    children: 'Oops',
+    disabled: true
+  }
+};
+
+testCases.disabledSecondary = {
+  description: 'Disabled secondary',
+  component: MbxButton,
+  props: {
+    children: 'Oops',
+    disabled: true,
+    variant: 'secondary',
+    onClick: safeSpy()
+  }
+};
+
+testCases.disabledSpecial = {
+  description: 'Disabled appPrimary with color',
+  component: MbxButton,
+  props: {
+    children: 'Oops',
+    variant: 'appPrimary',
+    onClick: safeSpy(),
+    disabled: true,
+    color: 'gray'
+  }
+};
+
+export { testCases };
diff --git a/src/components/mbx-button/__tests__/mbx-button.test.js b/src/components/mbx-button/__tests__/mbx-button.test.js
new file mode 100644
index 0000000..12e09a7
--- /dev/null
+++ b/src/components/mbx-button/__tests__/mbx-button.test.js
@@ -0,0 +1,146 @@
+import React from 'react';
+
+import { shallow } from 'enzyme';
+
+import { testCases } from './mbx-button-test-cases';
+
+describe(testCases.primary.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.primary;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  test('click triggers callback', () => {
+    wrapper.find('button').prop('onClick')();
+    expect(testCase.props.onClick).toHaveBeenCalled();
+  });
+});
+
+describe(testCases.secondary.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.secondary;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe(testCases.discouraging.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.discouraging;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe(testCases.destructive.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.destructive;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe(testCases.appPrimary.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.appPrimary;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe(testCases.appSecondary.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.appSecondary;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe(testCases.linkOutlinePadding.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.linkOutlinePadding;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  test('click still triggers callback, even though it is a link', () => {
+    const mockEvent = {};
+    wrapper.find('a').prop('onClick')(mockEvent);
+    expect(testCase.props.onClick).toHaveBeenCalledWith(mockEvent);
+  });
+});
+
+describe(testCases.fullWidthPurple.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.fullWidthPurple;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe(testCases.weird.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.weird;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe(testCases.disabled.description, () => {
+  let testCase;
+  let wrapper;
+  beforeEach(() => {
+    testCase = testCases.disabled;
+    wrapper = shallow(React.createElement(testCase.component, testCase.props));
+  });
+
+  test('renders as expected', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});
diff --git a/src/test-cases-app/test-cases-app.js b/src/test-cases-app/test-cases-app.js
index 31c0613..94d64ad 100644
--- a/src/test-cases-app/test-cases-app.js
+++ b/src/test-cases-app/test-cases-app.js
@@ -5,7 +5,9 @@ import componentIndex from './component-index'; // eslint-disable-line
 
 class TestCasesApp extends React.Component {
   render() {
-    return <ReactTestKitchen componentIndex={componentIndex} />;
+    return (
+      <ReactTestKitchen projectTitle="Mr. UI" componentIndex={componentIndex} />
+    );
   }
 }