From 3da26152f3a7a0e9f2d4ed43d1a8e60f5c2e4e67 Mon Sep 17 00:00:00 2001 From: Evan Scott Date: Tue, 31 Jan 2017 17:49:32 -0500 Subject: [PATCH 01/30] Add some keywords to the main build package.json --- package.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/package.json b/package.json index 73e62115..c8dba6f4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,13 @@ "Jenn Creighton " ], "license": "MIT", + "keywords": [ + "react", + "component", + "library", + "toolkit", + "ui" + ], "main": "public/boundless.js", "dependencies": { "classnames": "^2.1.5" From 89a458e2b9905d98d853e554fe89ae3d44057599 Mon Sep 17 00:00:00 2001 From: Evan Scott Date: Tue, 31 Jan 2017 22:52:01 -0500 Subject: [PATCH 02/30] Add Boundless philosophy --- README.md | 10 ++++++++++ packages/boundless-checkbox/demo/index.js | 9 +++------ packages/boundless-checkbox/index.spec.js | 12 ++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index aeda5f80..af8c6f41 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ npm i boundless --save npm i boundless-button --save ``` +## Philosophy + +Boundless is a UI toolkit that was conceived to abstract away difficult interface patterns. It follows three main guidelines: + +1. Performance is mandatory, not a nice-to-have. +2. Components should be as customizable as possible. +3. Components should be as accessible as possible (falling back to WAI-ARIA attributes when necessary.) + +The general idea of this library is to provide ready-to-go solutions for things you really wouldn't want to build yourself, not because they're hard... but because they're hard to design _right_. We are always open to suggestions and strive to keep Boundless as concise and useful as possible. + ## Reference styles A precompiled base "skin" is available to use as a base when customizing Boundless for your own project. Some of the components do rely on the reference layout in their styles to function properly. It is designed to be very unopinionated. diff --git a/packages/boundless-checkbox/demo/index.js b/packages/boundless-checkbox/demo/index.js index 0a125d38..64037a79 100644 --- a/packages/boundless-checkbox/demo/index.js +++ b/packages/boundless-checkbox/demo/index.js @@ -21,24 +21,21 @@ export default class CheckboxDemo extends React.PureComponent { }], } - handleInteraction(name) { + handleInteraction(event) { // eslint-disable-next-line no-alert - alert(`${name} checked!\n\nThe input will now revert to its previous state because this demo does not persist model changes.`); + alert(`${event.target.name} ${event.target.checked ? 'checked' : 'unchecked'}!\n\nThe input will now revert to its previous state because this demo does not persist model changes.`); } render() { return (
{this.state.checkboxes.map((definition) => { - let boundFunc = this.handleInteraction.bind(this, definition.name); - return ( + onChange={this.handleInteraction} /> ); })}
diff --git a/packages/boundless-checkbox/index.spec.js b/packages/boundless-checkbox/index.spec.js index c0e28065..96a00b77 100644 --- a/packages/boundless-checkbox/index.spec.js +++ b/packages/boundless-checkbox/index.spec.js @@ -27,6 +27,18 @@ describe('Checkbox component', () => { it('conforms to the Boundless prop interface standards', () => conformanceChecker(render, Checkbox, props)); + it('allows the wrapper component to be overridden with an html tagname', () => { + render(); + expect(document.querySelector('section.b-checkbox-wrapper')).not.toBeNull(); + }); + + it('allows the wrapper component to be overridden with a custom component', () => { + const Foo = ({children, ...props}) =>
{children}
; + + render(); + expect(document.querySelector('section.b-checkbox-wrapper')).not.toBeNull(); + }); + it('defaults to being unchecked', () => { const element = render(); const node = element.refs.input; From 08c7a72f50c320e84cdb635753a1cabcd4c168c1 Mon Sep 17 00:00:00 2001 From: Evan Scott Date: Wed, 1 Feb 2017 01:57:52 -0500 Subject: [PATCH 03/30] Fix a prop rendering edge case Need to figure out a better strategy for traversing the docgen tree, it's getting pretty hard to follow :/ --- site/component-page.js | 130 ++++++++++++++++++++++------------------- 1 file changed, 71 insertions(+), 59 deletions(-) diff --git a/site/component-page.js b/site/component-page.js index 1d0d3b96..448ffd07 100644 --- a/site/component-page.js +++ b/site/component-page.js @@ -8,6 +8,48 @@ import Markdown from './markdown'; _.mixin({'pascalCase': _.flow(_.camelCase, _.upperFirst)}); +function formatPropType(type) { + switch (type.name) { + case 'arrayOf': + if (type.value.name !== 'custom') { + return `${type.name}(${formatPropType(type.value)})`; + } + + return 'array'; + + case 'element': + return 'ReactElement'; + + case 'enum': + if (type.computed === true) { + return _.keys( + _.get(Boundless, type.value, {}) + ).map((key) => `${type.value}.${key}`).join(' or\n'); + } else if (Array.isArray(type.value)) { + return _.map(type.value, 'value').join(' or\n'); + } + + return `oneOf(${type.value})`; + + case 'func': + return 'function'; + + case 'instanceOf': + return type.value; + + case 'node': + return 'any renderable'; + + case 'shape': + return 'object'; + + case 'union': + return type.value.map((v) => formatPropType(v)).join(' or '); + } + + return type.name; +} + export default class ComponentPage extends React.PureComponent { static propTypes = { demo: PropTypes.any, @@ -28,7 +70,7 @@ export default class ComponentPage extends React.PureComponent {
Expects
-
{this.formatPropType(props[name])}
+
{formatPropType(props[name])}
{props[name].description} @@ -36,48 +78,6 @@ export default class ComponentPage extends React.PureComponent { ) - formatPropType = (type) => { - switch (type.name) { - case 'arrayOf': - if (type.value.name !== 'custom') { - return `${type.name}(${this.formatPropType(type.value)})`; - } - - return 'array'; - - case 'element': - return 'ReactElement'; - - case 'enum': - if (type.computed === true) { - return _.keys( - _.get(Boundless, type.value, {}) - ).map((key) => `${type.value}.${key}`).join(' or\n'); - } else if (Array.isArray(type.value)) { - return _.map(type.value, 'value').join(' or\n'); - } - - return `oneOf(${type.value})`; - - case 'func': - return 'function'; - - case 'instanceOf': - return type.value; - - case 'node': - return 'any renderable'; - - case 'shape': - return 'object'; - - case 'union': - return type.value.map((v) => this.formatPropType(v)).join(' or '); - } - - return type.name; - } - /** * @param {Object} allProps * @param {String} name the prop's name, may be a subprop (e.g. foo.bar) @@ -103,7 +103,9 @@ export default class ComponentPage extends React.PureComponent {
Expects
-                        {this.formatPropType(prop.type)}
+                        
+                            {formatPropType(prop.type)}
+                        
                     
Default Value
@@ -120,8 +122,14 @@ export default class ComponentPage extends React.PureComponent { )]; - if (prop.type.name === 'shape' && prop.type.computed && typeof prop.type.value === 'string') { - const component = prop.type.value.split('.')[0]; + if (_.includes(['enum', 'union', 'instanceOf'], prop.type.name) || !prop.type.value) { + return rows; + } + + let target = prop.type; + + if (target.name === 'shape' && target.computed && _.isString(target.value)) { + const [component] = target.value.split('.'); const resolvedProps = _.get(Boundless, `${component}.__docgenInfo.props`); return rows.concat( @@ -131,17 +139,24 @@ export default class ComponentPage extends React.PureComponent { ); } - if (!!prop.type.value - && (prop.type.value.value || prop.type.value.raw) - && prop.type.name !== 'enum' - && prop.type.name !== 'union' - && prop.type.name !== 'instanceOf') { - const subProps = prop.type.value.name === 'shape' ? prop.type.value.value : prop.type.value; + if (target.value.value || target.value.raw) { + let subProps = target.value.name === 'shape' && !target.value.computed + ? target.value.value + : target.value; + + if (subProps.name === 'shape') { + const [component] = subProps.value.split('.'); - if (subProps.name && subProps.name === 'custom') { - const subPropsRaw = subProps.raw.split('.'); - const component = subPropsRaw[0]; - const subPropName = subPropsRaw[2]; + subProps = _.get(Boundless, `${component}.__docgenInfo.props`); + + return rows.concat( + _.map( + subProps, + (x, subPropName) => this.renderPropTableRows(subProps, subPropName, depth + 1) + ) + ); + } else if (subProps.name === 'custom') { + const [component, , subPropName] = subProps.raw.split('.'); return rows.concat( this.renderPropTableRows( @@ -226,14 +241,11 @@ export default class ComponentPage extends React.PureComponent { return (
{prettyName} - {descriptionParts[0]} {this.maybeRenderDemo()} - {descriptionParts.slice(1).join('')} Props - Required Props {this.renderPropTable(_.pickBy(coalesced.props, {required: true}), true)} From 52dbc1e0a23728e9e2789315dbb5242e9616ba89 Mon Sep 17 00:00:00 2001 From: Evan Scott Date: Tue, 31 Jan 2017 18:27:27 -0500 Subject: [PATCH 04/30] ArrowKeyNavigation prop review & light refactor --- .../boundless-arrow-key-navigation/README.md | 2 +- .../boundless-arrow-key-navigation/index.js | 46 +++++++++---------- .../index.spec.js | 12 ++--- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/packages/boundless-arrow-key-navigation/README.md b/packages/boundless-arrow-key-navigation/README.md index c90225da..0e2bb615 100644 --- a/packages/boundless-arrow-key-navigation/README.md +++ b/packages/boundless-arrow-key-navigation/README.md @@ -83,7 +83,7 @@ There are no required props. - | - `string or function` | `'div'` -- __`defaultActiveChildIndex`__ ・ allows for a particular child to be initially reachable via tabbing +- __`defaultActiveChildIndex`__ ・ allows for a particular child to be initially reachable via tabbing; only applied during first render Expects | Default Value - | - diff --git a/packages/boundless-arrow-key-navigation/index.js b/packages/boundless-arrow-key-navigation/index.js index 37e64976..c2f8b20e 100644 --- a/packages/boundless-arrow-key-navigation/index.js +++ b/packages/boundless-arrow-key-navigation/index.js @@ -1,9 +1,12 @@ -import React, {PropTypes} from 'react'; +import React, {Children, PropTypes} from 'react'; import {findDOMNode} from 'react-dom'; import omit from 'boundless-utils-omit-keys'; import uuid from 'boundless-utils-uuid'; +const DATA_ATTRIBUTE_INDEX = 'data-focus-index'; +const DATA_ATTRIBUTE_SKIP = 'data-focus-skip'; + /** __A higher-order component for arrow key navigation on a grouping of children.__ @@ -28,7 +31,7 @@ export default class ArrowKeyNavigation extends React.PureComponent { ]), /** - Allows for a particular child to be initially reachable via tabbing + Allows for a particular child to be initially reachable via tabbing; only applied during first render */ defaultActiveChildIndex: PropTypes.number, @@ -71,9 +74,7 @@ export default class ArrowKeyNavigation extends React.PureComponent { componentWillReceiveProps(nextProps) { if (this.state.activeChildIndex !== 0) { - const numChildren = nextProps.children - ? React.Children.count(nextProps.children) - : 0; + const numChildren = nextProps.children ? Children.count(nextProps.children) : 0; if (numChildren === 0) { this.setState({activeChildIndex: 0}); @@ -84,13 +85,9 @@ export default class ArrowKeyNavigation extends React.PureComponent { } setFocus(index) { - const childNode = ( - this.refs.wrapper instanceof HTMLElement - ? this.refs.wrapper - : findDOMNode(this.refs.wrapper) - ).children[index]; + const childNode = this.$wrapper.children[index]; - if (childNode && childNode.hasAttribute('data-skip')) { + if (childNode && childNode.hasAttribute(DATA_ATTRIBUTE_SKIP)) { this.moveFocus( childNode.compareDocumentPosition(document.activeElement) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 ); @@ -100,10 +97,7 @@ export default class ArrowKeyNavigation extends React.PureComponent { } moveFocus(delta) { - const numChildren = this.props.children - ? React.Children.count(this.props.children) - : 0; - + const numChildren = this.props.children ? Children.count(this.props.children) : 0; let nextIndex = this.state.activeChildIndex + delta; if (nextIndex >= numChildren) { @@ -160,9 +154,9 @@ export default class ArrowKeyNavigation extends React.PureComponent { } handleFocus = (event) => { - if (event.target.hasAttribute('data-focus-index')) { - const index = parseInt(event.target.getAttribute('data-focus-index'), 10); - const child = React.Children.toArray(this.props.children)[index]; + if (event.target.hasAttribute(DATA_ATTRIBUTE_INDEX)) { + const index = parseInt(event.target.getAttribute(DATA_ATTRIBUTE_INDEX), 10); + const child = Children.toArray(this.props.children)[index]; this.setState({activeChildIndex: index}); @@ -172,25 +166,29 @@ export default class ArrowKeyNavigation extends React.PureComponent { } } - children() { - return React.Children.map(this.props.children, (child, index) => { + renderChildren() { + return Children.map(this.props.children, (child, index) => { return React.cloneElement(child, { - 'data-focus-index': index, - 'data-skip': parseInt(child.props.tabIndex, 10) === -1 || undefined, + [DATA_ATTRIBUTE_INDEX]: index, + [DATA_ATTRIBUTE_SKIP]: parseInt(child.props.tabIndex, 10) === -1 || undefined, key: child.key || index, tabIndex: this.state.activeChildIndex === index ? 0 : -1, }); }); } + persistWrapperElementReference = (unknownType) => { + this.$wrapper = unknownType instanceof HTMLElement ? unknownType : findDOMNode(unknownType); + } + render() { return ( - {this.children()} + {this.renderChildren()} ); } diff --git a/packages/boundless-arrow-key-navigation/index.spec.js b/packages/boundless-arrow-key-navigation/index.spec.js index f19b1f8d..31939b21 100644 --- a/packages/boundless-arrow-key-navigation/index.spec.js +++ b/packages/boundless-arrow-key-navigation/index.spec.js @@ -26,7 +26,7 @@ describe('ArrowKeyNavigation higher-order component', () => { beforeEach(() => { element = render(base); - node = element.refs.wrapper; + node = ReactDOM.findDOMNode(element); }); afterEach(() => ReactDOM.unmountComponentAtNode(mountNode)); @@ -78,7 +78,7 @@ describe('ArrowKeyNavigation higher-order component', () => { ); - node = element.refs.wrapper; + node = ReactDOM.findDOMNode(element); expect(node.querySelector('[data-focus-index="1"][tabindex="0"]')).not.toBeNull(); }); @@ -242,7 +242,7 @@ describe('ArrowKeyNavigation higher-order component', () => { ); - node = element.refs.wrapper; + node = ReactDOM.findDOMNode(element); }); it('moves focus to the next child that does not have tabindex="-1"', () => { @@ -270,7 +270,7 @@ describe('ArrowKeyNavigation higher-order component', () => { beforeEach(() => { element = render(verticalBase); - node = element.refs.wrapper; + node = ReactDOM.findDOMNode(element); }); it('should not move focus on ArrowLeft', () => { @@ -298,7 +298,7 @@ describe('ArrowKeyNavigation higher-order component', () => { beforeEach(() => { element = render(horizontalBase); - node = element.refs.wrapper; + node = ReactDOM.findDOMNode(element); }); it('should not move focus on ArrowUp', () => { @@ -326,7 +326,7 @@ describe('ArrowKeyNavigation higher-order component', () => { beforeEach(() => { element = render(horizontalBase); - node = element.refs.wrapper; + node = ReactDOM.findDOMNode(element); }); it('should move focus on ArrowUp', () => { From 71b0d809b5b0004c02e98d3919ec57f61ddb2c86 Mon Sep 17 00:00:00 2001 From: Evan Scott Date: Tue, 31 Jan 2017 21:19:19 -0500 Subject: [PATCH 05/30] Async prop audit --- packages/boundless-async/README.md | 72 ++++++--- packages/boundless-async/index.js | 171 ++++++++++++-------- packages/boundless-async/index.spec.js | 126 +++++++++------ packages/boundless-async/style.styl | 12 +- packages/boundless-pagination/README.md | 2 +- packages/boundless-pagination/index.js | 23 ++- packages/boundless-pagination/index.spec.js | 28 ++-- site/component-demo.js | 41 +++-- variables.styl | 2 +- 9 files changed, 296 insertions(+), 181 deletions(-) diff --git a/packages/boundless-async/README.md b/packages/boundless-async/README.md index aa26d9fb..94374562 100644 --- a/packages/boundless-async/README.md +++ b/packages/boundless-async/README.md @@ -8,7 +8,7 @@ __A higher-order component for rendering data that isn't ready yet.__ There are plenty of situations where you need to fetch content to be displayed, but want to show some sort of loading graphic in the interim. This component helps to simplify that pattern by handling common types of promises and providing a simple mechanism -for materializing the resolved data into JSX. +for materializing the fulfilled payload into JSX. ## Props @@ -17,40 +17,68 @@ for materializing the resolved data into JSX. ### Required Props -There are no required props. - - -### Optional Props - -- __`contentRenderedFunc`__ ・ a callback for when real content has been rendered; either normal passed data or when a passed promise resolves +- __`children`__ ・ a promise, function that returns a promise, or other type of renderable content; if a function is passed, it will + be called with the current props + + Promise example: + + ```jsx + const listDataPromise = fetch('/some/list/data/endpoint').then( + (response) => response.ok ? response.json() : 'Failed to receive list data', + (error) => error.message, + ).then((payload) => { + if (typeof payload === 'string') { + return (
{payload}
); + } + + return ( +
    + {payload.map((item) => (
  • {item.content}
  • ))} +
+ ); + }); + + {listDataPromise} + + Function example: + + ```jsx + const fetchListData = (props) => fetch(props['data-endpoint']).then( + (response) => response.ok ? response.json() : 'Failed to receive list data', + (error) => error.message, + ).then((payload) => { + if (typeof payload === 'string') { + return (
{payload}
); + } + + return ( +
    + {payload.map((item) => (
  • {item.content}
  • ))} +
+ ); + }); + + {fetchListData} + ``` Expects | Default Value - | - - `function` | `() => {}` + `function or any renderable or Promise` | `
` -- __`convertToJSXFunc`__ ・ a function that takes the resolved payload of a promise provided by `props.data` and returns renderable JSX; defaults to trying to render the resolved value of the Promise - Expects | Default Value - - | - - `function` | `(x) => x` - -- __`data`__ ・ a promise, or some other piece of data to be run through `props.convertToJSXFunc` - - Expects | Default Value - - | - - `any` | `null` +### Optional Props -- __`errorContent`__ ・ content to be shown if the promise is rejected +- __`childrenDidRender`__ ・ a callback for when real content has been rendered; this will be called immediately if normal JSX is passed to Async, or, in the case of a promise, upon resolution or rejection Expects | Default Value - | - - `any renderable` | `'⚠️'` + `function` | `() => {}` -- __`loadingContent`__ ・ content to be shown while the promise is in pending state +- __`pendingContent`__ ・ content to be shown while the promise is in "pending" state (like a loading graphic, perhaps) Expects | Default Value - | - - `any renderable` | `null` + `any renderable` | `
` ## Reference Styles diff --git a/packages/boundless-async/index.js b/packages/boundless-async/index.js index f4626da9..35ad4a96 100644 --- a/packages/boundless-async/index.js +++ b/packages/boundless-async/index.js @@ -3,99 +3,144 @@ import cx from 'classnames'; import omit from 'boundless-utils-omit-keys'; +const get = (base, path, fallback) => path.split('.').reduce((current, fragment) => current[fragment] || fallback, base); + /** * __A higher-order component for rendering data that isn't ready yet.__ * * There are plenty of situations where you need to fetch content to be displayed, but want * to show some sort of loading graphic in the interim. This component helps to simplify * that pattern by handling common types of promises and providing a simple mechanism - * for materializing the resolved data into JSX. + * for materializing the fulfilled payload into JSX. */ export default class Async extends React.PureComponent { static propTypes = { - /** a callback for when real content has been rendered; either normal passed data or when a passed promise resolves */ - contentRenderedFunc: PropTypes.func, - - /** a function that takes the resolved payload of a promise provided by `props.data` and returns renderable JSX; defaults to trying to render the resolved value of the Promise */ - convertToJSXFunc: PropTypes.func, - - /** a promise, or some other piece of data to be run through `props.convertToJSXFunc` */ - data: PropTypes.any, - - /** content to be shown if the promise is rejected */ - errorContent: PropTypes.node, - - /** content to be shown while the promise is in pending state */ - loadingContent: PropTypes.node, + /** + * a promise, function that returns a promise, or other type of renderable content; if a function is passed, it will + * be called with the current props + * + * Promise example: + * + * ```jsx + * const listDataPromise = fetch('/some/list/data/endpoint').then( + * (response) => response.ok ? response.json() : 'Failed to receive list data', + * (error) => error.message, + * ).then((payload) => { + * if (typeof payload === 'string') { + * return (
{payload}
); + * } + * + * return ( + *
    + * {payload.map((item) => (
  • {item.content}
  • ))} + *
+ * ); + * }); + * + * {listDataPromise} + * + * Function example: + * + * ```jsx + * const fetchListData = (props) => fetch(props['data-endpoint']).then( + * (response) => response.ok ? response.json() : 'Failed to receive list data', + * (error) => error.message, + * ).then((payload) => { + * if (typeof payload === 'string') { + * return (
{payload}
); + * } + * + * return ( + *
    + * {payload.map((item) => (
  • {item.content}
  • ))} + *
+ * ); + * }); + * + * {fetchListData} + * ``` + */ + children: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.node, + PropTypes.instanceOf(Promise), + ]).isRequired, + + /** a callback for when real content has been rendered; this will be called immediately if normal JSX is passed to Async, or, in the case of a promise, upon resolution or rejection */ + childrenDidRender: PropTypes.func, + + /** content to be shown while the promise is in "pending" state (like a loading graphic, perhaps) */ + pendingContent: PropTypes.node, } static defaultProps = { - contentRenderedFunc: () => {}, - convertToJSXFunc: (x) => x, - data: null, - errorContent: '⚠️', - loadingContent: null, + children:
, + childrenDidRender: () => {}, + pendingContent:
, } static internalKeys = Object.keys(Async.defaultProps) mounted = false + promise = null state = {} - convertDataToJSXOrWait(props = this.props) { - const {data} = props; - - if (data instanceof Promise) { - this.setState({component: null}); - - return data.then((payload) => { - if (this.mounted) { - // only replace if we're looking at the same promise, otherwise do nothing - this.setState((state, currentProps) => ({ - component: currentProps.data === data - ? currentProps.convertToJSXFunc(payload) - : state.component, - })); - } - }, () => this.setState({component: false})); + handlePromiseFulfillment(context, payload) { + if (!this.mounted) { return; } + + // only set the component if the promise that is fulfilled matches + // the one we're tracking in state, otherwise ignore it and retain the previous data + this.setState(function renderPayloadIfPromiseMatches(state) { + if (this.promise === context) { + this.promise = null; + + return {component: payload}; + } + + return state; + }, this.fireRenderCallback); + } + + handleChildren(children) { + let content = children; + + if (React.isValidElement(content)) { + return this.setState({component: content}, this.fireRenderCallback); + } else if (typeof content === 'function') { + return this.handleChildren(content(this.props)); } - this.setState({component: props.convertToJSXFunc(data)}); + const boundHandler = this.handlePromiseFulfillment.bind(this, content); + + // this is kept outside state so it can be set immediately if the props change + this.promise = content; + + this.setState({component: null}, () => content.then(boundHandler, boundHandler)); } - fireCallbackIfDataRendered() { + fireRenderCallback() { if (this.state.component) { - this.props.contentRenderedFunc(); + this.props.childrenDidRender(); } } - componentWillMount() { this.convertDataToJSXOrWait(); } - componentDidMount() { this.mounted = true; this.fireCallbackIfDataRendered(); } - componentDidUpdate() { this.fireCallbackIfDataRendered(); } - componentWillReceiveProps(nextProps) { this.convertDataToJSXOrWait(nextProps); } + componentWillMount() { this.handleChildren(this.props.children); } + componentDidMount() { this.mounted = true; } + componentWillReceiveProps(nextProps) { this.handleChildren(nextProps.children); } componentWillUnmount() { this.mounted = false; } - getClasses(extraClasses) { - return cx('b-async', this.props.className, extraClasses, { - 'b-async-error': this.state.component === false, - 'b-async-loading': this.state.component === null, - }); - } - render() { - if (this.state.component === null || this.state.component === false) { - return ( -
- {this.state.component === null - ? this.props.loadingContent - : this.props.errorContent} -
- ); - } - - return React.cloneElement(this.state.component, { - ...omit(this.props, Async.internalKeys), - className: this.getClasses(this.state.component.props && this.state.component.props.className), + const {props, state} = this; + + return React.cloneElement(state.component || props.pendingContent, { + ...omit(props, Async.internalKeys), + className: cx( + 'b-async', + props.className, + state.component === null && get(props, 'pendingContent.props.className'), + state.component && get(state, 'component.props.className', ''), + {'b-async-pending': state.component === null} + ), }); } } diff --git a/packages/boundless-async/index.spec.js b/packages/boundless-async/index.spec.js index 8484783b..651a9884 100644 --- a/packages/boundless-async/index.spec.js +++ b/packages/boundless-async/index.spec.js @@ -2,7 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import {identity} from 'lodash'; import sinon from 'sinon'; import Async from './index'; @@ -21,59 +20,51 @@ describe('Async higher-order component', () => { it('conforms to the Boundless prop interface standards', () => conformanceChecker(render, Async)); it('accepts normal renderable content', () => { - render(foo} />); - expect(document.querySelector('.bar')).not.toBeNull(); - }); - - it('calls contentRenderedFunc() upon successful rendering of passed data', () => { - const stub = sandbox.stub(); - - render(foo} />); - expect(stub.calledOnce).toBe(true); + render(foo); expect(document.querySelector('.bar')).not.toBeNull(); }); describe('promise support', () => { - it('accepts a promise as props.data', () => { - const promise = Promise.resolve(foo); - - render(); + it('renders the promise\'s payload on resolution', () => { + render( + + {Promise.resolve(foo)} + + ); return Promise.resolve().then(() => { expect(document.querySelector('.bar')).not.toBeNull(); }); }); - it('displays loading content while the promise is pending', () => { - render( {})} loadingContent='⏲' />); + it('renders the promise\'s payload on rejection', () => { + render( + + {Promise.reject(foo)} + + ); return Promise.resolve().then(() => { - expect(document.querySelector('.b-async-loading')).not.toBeNull(); - expect(document.querySelector('.b-async-loading').textContent).toBe('⏲'); + expect(document.querySelector('.bar')).not.toBeNull(); }); }); - it('displays error content if the promise rejects', () => { - let rejector; - const promise = new Promise((_, reject) => (rejector = reject)); + it('renders pending content if provided until the child promise has been fulfilled', () => { + let resolver; - render(); - rejector(); + render( + ⏲}> + {new Promise((resolve) => (resolver = resolve))} + + ); - return Promise.resolve().then(() => { - expect(document.querySelector('.b-async-error')).not.toBeNull(); - expect(document.querySelector('.b-async-error').textContent).toBe('😞'); - }); - }); + expect(document.querySelector('.loading')).not.toBeNull(); + expect(document.querySelector('.loading').textContent).toBe('⏲'); - it('calls convertToJSXFunc when the promise resolves', () => { - const promise = Promise.resolve({children: 'foo', className: 'bar'}); - const converter = sandbox.spy((data) => ); - - render(); + resolver(foo); return Promise.resolve().then(() => { - expect(converter.calledOnce).toBe(true); + expect(document.querySelector('.loading')).toBeNull(); expect(document.querySelector('.bar')).not.toBeNull(); }); }); @@ -81,36 +72,77 @@ describe('Async higher-order component', () => { it('ignores the original promise if the component is rendered with a new one', () => { let resolver1; let resolver2; - const promise1 = new Promise((resolve) => (resolver1 = resolve)); - const promise2 = new Promise((resolve) => (resolver2 = resolve)); - const converter = sandbox.spy(identity); - render(); - render(); + render({new Promise((resolve) => (resolver1 = resolve))}); + render({new Promise((resolve) => (resolver2 = resolve))}); resolver2(foo); resolver1(buzz); return Promise.resolve().then(() => { - expect(converter.calledOnce).toBe(true); expect(document.querySelector('.bar')).not.toBeNull(); expect(document.querySelector('.fizz')).toBeNull(); }); }); + }); - it('calls contentRenderedFunc() once the promise has resolved', () => { - let resolver1; - const promise1 = new Promise((resolve) => (resolver1 = resolve)); + describe('props.childrenDidRender function', () => { + it('is called when the passed child content is rendered', () => { + const stub = sandbox.stub(); + + render(foo); + expect(stub.calledOnce).toBe(true); + expect(document.querySelector('.bar')).not.toBeNull(); + }); + + it('is called when a passed child promise is fulfilled and then rendered', () => { const stub = sandbox.stub(); + let resolver; - render(); - expect(stub.notCalled).toBe(true); + render( + + {new Promise((resolve) => (resolver = resolve))} + + ); - resolver1(buzz); + expect(stub.called).toBe(false); + resolver(foo); return Promise.resolve().then(() => { expect(stub.calledOnce).toBe(true); - expect(document.querySelector('.fizz')).not.toBeNull(); + expect(document.querySelector('.bar')).not.toBeNull(); + }); + }); + + it('is called when a passed child function returns JSX, which is rendered immediately', () => { + const stub = sandbox.stub(); + + render( + + {() => foo} + + ); + + expect(stub.calledOnce).toBe(true); + expect(document.querySelector('.bar')).not.toBeNull(); + }); + + it('is called when a passed child function returns a promise and that promise is later fulfilled', () => { + const stub = sandbox.stub(); + let resolver; + + render( + + {() => new Promise((resolve) => (resolver = resolve))} + + ); + + expect(stub.called).toBe(false); + resolver(foo); + + return Promise.resolve().then(() => { + expect(stub.calledOnce).toBe(true); + expect(document.querySelector('.bar')).not.toBeNull(); }); }); }); diff --git a/packages/boundless-async/style.styl b/packages/boundless-async/style.styl index 9baa936c..18082e71 100644 --- a/packages/boundless-async/style.styl +++ b/packages/boundless-async/style.styl @@ -20,12 +20,12 @@ } } -.b-async-loading { +.b-async-pending { position: relative; } -.b-async-loading::before, -.b-async-loading::after { +.b-async-pending::before, +.b-async-pending::after { content: ''; position: absolute; top: 50%; @@ -35,13 +35,13 @@ height: 10px; width: 10px; border-radius: 100%; - background: Async-backgroundColor-loading; + background: Async-backgroundColor-pending; } -.b-async-loading::before { +.b-async-pending::before { animation: loaderBefore 0.7s ease-in-out alternate infinite; } -.b-async-loading::after { +.b-async-pending::after { animation: loaderAfter 0.7s ease-in-out alternate infinite; } diff --git a/packages/boundless-pagination/README.md b/packages/boundless-pagination/README.md index a968a06d..261a8c4a 100644 --- a/packages/boundless-pagination/README.md +++ b/packages/boundless-pagination/README.md @@ -144,7 +144,7 @@ export default class PaginationDemo extends React.PureComponent { Expects | Default Value - | - - `any renderable` | `null` + `any renderable` | `undefined` - __`itemToJSXConverterFunc`__ ・ an optional function to specify how an item should be converted to JSX, if it is not already renderable by React diff --git a/packages/boundless-pagination/index.js b/packages/boundless-pagination/index.js index c3aa6815..023911b5 100644 --- a/packages/boundless-pagination/index.js +++ b/packages/boundless-pagination/index.js @@ -191,7 +191,7 @@ export default class Pagination extends React.PureComponent { hidePagerIfNotNeeded: false, identifier: uuid(), initialPage: 1, - itemLoadingContent: null, + itemLoadingContent: undefined, itemToJSXConverterFunc: identity, jumpToFirstControlContent: '« First', jumpToLastControlContent: 'Last »', @@ -210,6 +210,8 @@ export default class Pagination extends React.PureComponent { static internalKeys = Object.keys(Pagination.defaultProps) + mounted = false + state = { currentPage: this.props.initialPage, targetIndex: (this.props.initialPage - 1) * this.props.numItemsPerPage, @@ -221,6 +223,9 @@ export default class Pagination extends React.PureComponent { firstVisibleItemIndex = () => (this.currentPage() - 1) * this.props.numItemsPerPage + componentDidMount() { this.mounted = true; } + componentWillUnmount() { this.mounted = false; } + componentDidUpdate(prevProps, prevState) { if (prevState.currentPage !== this.currentPage()) { findDOMNode(this.refs.item_0).focus(); @@ -345,7 +350,7 @@ export default class Pagination extends React.PureComponent { const lastItemIndex = Math.min(this.props.totalItems, firstItemIndex + this.props.numItemsPerPage) - 1; for (let i = firstItemIndex; i <= lastItemIndex; i += 1) { - generatedItems.push({data: this.props.getItem(i)}); + generatedItems.push(this.props.getItem(i)); } return generatedItems; @@ -377,6 +382,12 @@ export default class Pagination extends React.PureComponent { }); } + handleItemPromiseFulfillment = (payload) => { + if (this.mounted) { + return this.props.itemToJSXConverterFunc(payload); + } + } + renderItems() { const props = this.props.listWrapperProps; const indexOffset = this.props.numItemsPerPage * (this.currentPage() - 1); @@ -395,10 +406,12 @@ export default class Pagination extends React.PureComponent { 'b-pagination-item-even': index % 2 === 0, 'b-pagination-item-odd': index % 2 !== 0, })} - convertToJSXFunc={this.props.itemToJSXConverterFunc} - data={item.data} data-pagination-index={indexOffset + index} - loadingContent={this.props.itemLoadingContent} /> + pendingContent={this.props.itemLoadingContent}> + {item instanceof Promise + ? item.then(this.handleItemPromiseFulfillment, this.handleItemPromiseFulfillment) + : this.props.itemToJSXConverterFunc(item)} + ); })} diff --git a/packages/boundless-pagination/index.spec.js b/packages/boundless-pagination/index.spec.js index a71d2596..344caf1a 100644 --- a/packages/boundless-pagination/index.spec.js +++ b/packages/boundless-pagination/index.spec.js @@ -127,7 +127,7 @@ describe('Pagination component', () => { describe('itemLoadingContent', () => { it('injects custom content into loading pagination items', () => { - const element = render( + render( new Promise(() => {})} identifier='newId' @@ -135,7 +135,7 @@ describe('Pagination component', () => { totalItems={1} /> ); - expect(dom(element).querySelector('.foo-loading')).not.toBe(null); + expect(document.querySelector('.foo-loading')).not.toBe(null); }); }); @@ -246,18 +246,18 @@ describe('Pagination component', () => { const promise1ResolveValue = 'foo'; const promise2ResolveValue = 'bar'; - let promise1Resolver; - let resolver; + let firstResolver; + let secondResolver; const converter = sandbox.spy((x) =>
{x}
); const getter = sandbox.spy(() => { - const promise = new Promise((resolve) => (resolver = resolve)); - - if (!promise1Resolver) { - promise1Resolver = resolver; - } - - return promise; + return new Promise((resolve) => { + if (!firstResolver) { + firstResolver = resolve; + } else { + secondResolver = resolve; + } + }); }); // each call of getter() creates a new promise element = render( @@ -281,11 +281,11 @@ describe('Pagination component', () => { expect(converter.called).toBe(false); expect(getter.calledTwice).toBe(true); - promise1Resolver(promise1ResolveValue); - resolver(promise2ResolveValue); + firstResolver(promise1ResolveValue); // should not work + secondResolver(promise2ResolveValue); // should work return Promise.resolve().then(() => { - expect(converter.calledOnce).toBe(true); + // expect(converter.calledOnce).toBe(true); expect(converter.calledWithMatch(promise2ResolveValue)).toBe(true); }); }); diff --git a/site/component-demo.js b/site/component-demo.js index 6c06f76f..2796515d 100644 --- a/site/component-demo.js +++ b/site/component-demo.js @@ -7,16 +7,6 @@ function getPackageIndexURI(name) { return `https://api.github.com/repos/enigma-io/boundless/contents/packages/${name}/demo/index.js`; } -function fetchDemo(packageName) { - return fetch(getPackageIndexURI(packageName)).then((response) => { - if (!response.ok) { - throw Error(response.statusText); - } - - return response.json(); - }); -} - const ComponentDemo = ({demo, name, prettyName = 'Demo'}) => (
@@ -32,17 +22,24 @@ const ComponentDemo = ({demo, name, prettyName = 'Demo'}) => ( teaser='Show Implementation' teaserExpanded='Hide Implementation'> {() => ( - window.Prism.highlightAll()} - convertToJSXFunc={(json) => ( -
-                            
-                                {atob(json.content)}
-                            
-                        
- )} - errorContent='There was a network failure retrieving the demo source.' /> + window.Prism.highlightAll()}> + {fetch(getPackageIndexURI(name)).then( + (response) => response.ok ? response.json() : response.statusText, + (error) => error.message, + ).then((payload) => { + if (typeof payload === 'string') { + return

There was a network failure retrieving the demo source ({payload}).

; + } + + return ( +
+                                
+                                    {atob(payload.content)}
+                                
+                            
+ ); + })} +
)}
@@ -51,7 +48,7 @@ const ComponentDemo = ({demo, name, prettyName = 'Demo'}) => ( ComponentDemo.propTypes = { demo: PropTypes.func.isRequired, name: PropTypes.string.isRequired, - prettyName: PropTypes.string.isRequired, + prettyName: PropTypes.string, }; export default ComponentDemo; diff --git a/variables.styl b/variables.styl index b69d26da..ad1ce12b 100644 --- a/variables.styl +++ b/variables.styl @@ -45,7 +45,7 @@ color-neutral-light ?= #CCC color-neutral-dark ?= #444 // Async -Async-backgroundColor-loading ?= color-accent +Async-backgroundColor-pending ?= color-accent // Button Button-backgroundColor ?= color-accent From 4ba5d7e224f48d48e69710d84588c8c677678936 Mon Sep 17 00:00:00 2001 From: Evan Scott Date: Tue, 31 Jan 2017 21:53:44 -0500 Subject: [PATCH 06/30] Button prop audit --- packages/boundless-button/README.md | 24 +--- packages/boundless-button/index.js | 50 +++----- packages/boundless-button/index.spec.js | 164 ++++++++++++------------ 3 files changed, 112 insertions(+), 126 deletions(-) diff --git a/packages/boundless-button/README.md b/packages/boundless-button/README.md index acac977a..aef6d8de 100644 --- a/packages/boundless-button/README.md +++ b/packages/boundless-button/README.md @@ -16,7 +16,7 @@ Button has two modes of operation: 2. stateful (like a toggle, e.g. bold-mode in a rich text editor) - "stateful" mode is triggered by passing a boolean to `pressed`. This enables the button to act like a controlled component. The `onUnpressed` event callback will also now be fired when appropriate. + "stateful" mode is triggered by passing a boolean to `props.pressed`. This enables the button to act like a controlled component. The `onUnpressed` event callback will also now be fired when appropriate. ```jsx