From 5b19a30721fb473c5db9e5dc9c1be623672d697f Mon Sep 17 00:00:00 2001 From: David Clark Date: Thu, 2 Aug 2018 12:45:54 -0700 Subject: [PATCH] MbxButton (#20) --- CHANGELOG.md | 5 +- package-lock.json | 32 ++- package.json | 2 +- scripts/build-docs-data.js | 17 +- .../__snapshots__/mbx-button.test.js.snap | 106 ++++++++ .../__tests__/mbx-button-test-cases.js | 139 ++++++++++ .../mbx-button/__tests__/mbx-button.test.js | 146 ++++++++++ .../mbx-button/examples/mbx-button-a.js | 44 +++ .../mbx-button/examples/mbx-button-b.js | 16 ++ .../mbx-button/examples/mbx-button-c.js | 29 ++ .../mbx-button/examples/mbx-button-d.js | 68 +++++ src/components/mbx-button/index.js | 3 + src/components/mbx-button/mbx-button.js | 254 ++++++++++++++++++ src/components/mbx-icon-text/mbx-icon-text.js | 4 +- src/test-cases-app/test-cases-app.js | 4 +- 15 files changed, 848 insertions(+), 21 deletions(-) create mode 100644 src/components/mbx-button/__tests__/__snapshots__/mbx-button.test.js.snap create mode 100644 src/components/mbx-button/__tests__/mbx-button-test-cases.js create mode 100644 src/components/mbx-button/__tests__/mbx-button.test.js create mode 100644 src/components/mbx-button/examples/mbx-button-a.js create mode 100644 src/components/mbx-button/examples/mbx-button-b.js create mode 100644 src/components/mbx-button/examples/mbx-button-c.js create mode 100644 src/components/mbx-button/examples/mbx-button-d.js create mode 100644 src/components/mbx-button/index.js create mode 100644 src/components/mbx-button/mbx-button.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d47027b0..b62a1d0b 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 f7c1eeca..fc05d414 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 a8a9554e..c3948bc6 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 b85af16c..f258a3dd 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() || '
'; + const renderedDescription = encodeJsx( + jsxtremeMarkdown.toJsx(propData.description || ' ').trim() || '
' + ); 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, '`').replace(/\$/, '$'); +} 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 00000000..0075c14b --- /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`] = ` + +`; + +exports[`AppSecondary renders as expected 1`] = ` + +`; + +exports[`Destructive renders as expected 1`] = ` + +`; + +exports[`Disabled renders as expected 1`] = ` + +`; + +exports[`Discouraging renders as expected 1`] = ` + +`; + +exports[`Div styled like a medium destructive button with some extended props and transformed classes renders as expected 1`] = ` +
+ Save +
+`; + +exports[`Full width, alternate color renders as expected 1`] = ` + +`; + +exports[`Link with outline and icon text, extra padding renders as expected 1`] = ` + + + Go do things + + +`; + +exports[`Primary renders as expected 1`] = ` + +`; + +exports[`Secondary renders as expected 1`] = ` + +`; 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 00000000..d0b6652d --- /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: Go do things, + 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 00000000..12e09a74 --- /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/components/mbx-button/examples/mbx-button-a.js b/src/components/mbx-button/examples/mbx-button-a.js new file mode 100644 index 00000000..ad1dbd9e --- /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 ( +
+
+ Primary +
+
+ + Secondary + +
+
+ + Discouraging + +
+
+ + Destructive + +
+
+ + AppPrimary + +
+
+ + AppSecondary + +
+
+ ); + } +} + +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 00000000..c81944ba --- /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 ( + {}} width="large" outline={true} color="purple"> + Save your map + + ); + } +} 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 00000000..84614546 --- /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 ``. + +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 ( +
+ + You are here + +
+ ); + } +} 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 00000000..ce068967 --- /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 ( +
+
+
+ + Door + +
+
+ + Dog + +
+
+ + Dash + +
+
+
+
+ + A + +
+
+ + B + +
+
+ + C + +
+
+
+ ); + } +} + +function noop() {} diff --git a/src/components/mbx-button/index.js b/src/components/mbx-button/index.js new file mode 100644 index 00000000..f5a42cc3 --- /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 00000000..4a38a433 --- /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 `