From 0503507dd26d5a08f0a79339b67868e5f6b32c7e Mon Sep 17 00:00:00 2001 From: jquense Date: Sat, 11 Jul 2015 11:14:06 -0400 Subject: [PATCH] [added] Collapse Component, replaces CollapsibleMixin --- docs/examples/Collapse.js | 28 +++++ src/Collapse.js | 200 +++++++++++++++++++++++++++++++++++ src/CollapsibleNav.js | 54 +++++----- src/Nav.js | 30 ++---- src/Panel.js | 49 +++++---- test/CollapseSpec.js | 216 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 513 insertions(+), 64 deletions(-) create mode 100644 docs/examples/Collapse.js create mode 100644 src/Collapse.js create mode 100644 test/CollapseSpec.js diff --git a/docs/examples/Collapse.js b/docs/examples/Collapse.js new file mode 100644 index 0000000000..c30069d8d0 --- /dev/null +++ b/docs/examples/Collapse.js @@ -0,0 +1,28 @@ +class Example extends React.Component { + constructor(...args){ + super(...args); + + this.state = {}; + } + + render(){ + + return ( +
+ + +
+ + Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. + Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. + +
+
+
+ ); + } +} + +React.render(, mountNode); diff --git a/src/Collapse.js b/src/Collapse.js new file mode 100644 index 0000000000..0b14eec0b4 --- /dev/null +++ b/src/Collapse.js @@ -0,0 +1,200 @@ +/*eslint-disable react/prop-types */ +'use strict'; +import React from 'react'; +import Transition from './Transition'; +import domUtils from './utils/domUtils'; +import createChainedFunction from './utils/createChainedFunction'; + +let capitalize = str => str[0].toUpperCase() + str.substr(1); + +// reading a dimension prop will cause the browser to recalculate, +// which will let our animations work +let triggerBrowserReflow = node => node.offsetHeight; //eslint-disable-line no-unused-expressions + +const MARGINS = { + height: ['marginTop', 'marginBottom'], + width: ['marginLeft', 'marginRight'] +}; + +function getDimensionValue(dimension, elem){ + let value = elem[`offset${capitalize(dimension)}`]; + let computedStyles = domUtils.getComputedStyles(elem); + let margins = MARGINS[dimension]; + + return (value + + parseInt(computedStyles[margins[0]], 10) + + parseInt(computedStyles[margins[1]], 10) + ); +} + +class Collapse extends React.Component { + + constructor(props, context){ + super(props, context); + + this.onEnterListener = this.handleEnter.bind(this); + this.onEnteringListener = this.handleEntering.bind(this); + this.onEnteredListener = this.handleEntered.bind(this); + this.onExitListener = this.handleExit.bind(this); + this.onExitingListener = this.handleExiting.bind(this); + } + + render() { + let enter = createChainedFunction(this.onEnterListener, this.props.onEnter); + let entering = createChainedFunction(this.onEnteringListener, this.props.onEntering); + let entered = createChainedFunction(this.onEnteredListener, this.props.onEntered); + let exit = createChainedFunction(this.onExitListener, this.props.onExit); + let exiting = createChainedFunction(this.onExitingListener, this.props.onExiting); + + return ( + + { this.props.children } + + ); + } + + /* -- Expanding -- */ + handleEnter(elem){ + let dimension = this._dimension(); + elem.style[dimension] = '0'; + } + + handleEntering(elem){ + let dimension = this._dimension(); + + elem.style[dimension] = this._getScrollDimensionValue(elem, dimension); + } + + handleEntered(elem){ + let dimension = this._dimension(); + elem.style[dimension] = null; + } + + /* -- Collapsing -- */ + handleExit(elem){ + let dimension = this._dimension(); + + elem.style[dimension] = this.props.getDimensionValue(dimension, elem) + 'px'; + } + + handleExiting(elem){ + let dimension = this._dimension(); + + triggerBrowserReflow(elem); + elem.style[dimension] = '0'; + } + + _dimension(){ + return typeof this.props.dimension === 'function' + ? this.props.dimension() + : this.props.dimension; + } + + //for testing + _getTransitionInstance(){ + return this.refs.transition; + } + + _getScrollDimensionValue(elem, dimension){ + return elem[`scroll${capitalize(dimension)}`] + 'px'; + } +} + +Collapse.propTypes = { + /** + * Collapse the Component in or out. + */ + in: React.PropTypes.bool, + + /** + * Provide the durration of the animation in milliseconds, used to ensure that finishing callbacks are fired even if the + * original browser transition end events are canceled. + */ + duration: React.PropTypes.number, + + /** + * Specifies the dimension used when collapsing. + * + * _Note: Bootstrap only partially supports this! + * You will need to supply your own css animation for the `.width` css class._ + */ + dimension: React.PropTypes.oneOfType([ + React.PropTypes.oneOf(['height', 'width']), + React.PropTypes.func + ]), + + /** + * A function that returns the height or width of the animating DOM node. Allows for providing some custom logic how much + * Collapse component should animation in its specified dimension. + * + * `getDimensionValue` is called with the current dimension prop value and the DOM node. + */ + getDimensionValue: React.PropTypes.func, + + /** + * A Callback fired before the component starts to expand. + */ + onEnter: React.PropTypes.func, + + /** + * A Callback fired immediately after the component starts to expand. + */ + onEntering: React.PropTypes.func, + + /** + * A Callback fired after the component has expanded. + */ + onEntered: React.PropTypes.func, + + /** + * A Callback fired before the component starts to collapse. + */ + onExit: React.PropTypes.func, + + /** + * A Callback fired immediately after the component starts to collapse. + */ + onExiting: React.PropTypes.func, + + /** + * A Callback fired after the component has collapsed. + */ + onExited: React.PropTypes.func, + + /** + * Specify whether the transitioning component should be unmounted (removed from the DOM) once the exit animation finishes. + */ + unmountOnExit: React.PropTypes.bool, + + /** + * Specify whether the component should collapse or expand when it mounts. + */ + transitionAppear: React.PropTypes.bool +}; + +Collapse.defaultProps = { + in: false, + duration: 300, + dimension: 'height', + transitionAppear: false, + unmountOnExit: false, + getDimensionValue +}; + +export default Collapse; + diff --git a/src/CollapsibleNav.js b/src/CollapsibleNav.js index 427d0b30f3..027f7cb29a 100644 --- a/src/CollapsibleNav.js +++ b/src/CollapsibleNav.js @@ -1,14 +1,13 @@ import React, { cloneElement } from 'react'; import BootstrapMixin from './BootstrapMixin'; -import CollapsibleMixin from './CollapsibleMixin'; +import Collapse from './Collapse'; import classNames from 'classnames'; -import domUtils from './utils/domUtils'; import ValidComponentChildren from './utils/ValidComponentChildren'; import createChainedFunction from './utils/createChainedFunction'; const CollapsibleNav = React.createClass({ - mixins: [BootstrapMixin, CollapsibleMixin], + mixins: [BootstrapMixin], propTypes: { onSelect: React.PropTypes.func, @@ -19,41 +18,48 @@ const CollapsibleNav = React.createClass({ eventKey: React.PropTypes.any }, - getCollapsibleDOMNode() { - return React.findDOMNode(this); - }, - getCollapsibleDimensionValue() { - let height = 0; - let nodes = this.refs; - for (let key in nodes) { - if (nodes.hasOwnProperty(key)) { + // getCollapsibleDimensionValue() { + // let height = 0; + // let nodes = this.refs; + // for (let key in nodes) { + // if (nodes.hasOwnProperty(key)) { - let n = React.findDOMNode(nodes[key]); - let h = n.offsetHeight; - let computedStyles = domUtils.getComputedStyles(n); + // let n = React.findDOMNode(nodes[key]); + // let h = n.offsetHeight; + // let computedStyles = domUtils.getComputedStyles(n); - height += (h + - parseInt(computedStyles.marginTop, 10) + - parseInt(computedStyles.marginBottom, 10) - ); - } - } - return height; - }, + // height += (h + + // parseInt(computedStyles.marginTop, 10) + + // parseInt(computedStyles.marginBottom, 10) + // ); + // } + // } + // return height; + // }, render() { /* * this.props.collapsible is set in NavBar when an eventKey is supplied. */ - const classes = this.props.collapsible ? this.getCollapsibleClassSet('navbar-collapse') : null; + const classes = this.props.collapsible ? 'navbar-collapse' : null; const renderChildren = this.props.collapsible ? this.renderCollapsibleNavChildren : this.renderChildren; - return ( + let nav = (
{ValidComponentChildren.map(this.props.children, renderChildren)}
); + + if ( this.props.collapsible ){ + return ( + + { nav } + + ); + } else { + return nav; + } }, getChildActiveProp(child) { diff --git a/src/Nav.js b/src/Nav.js index d4873cc2b4..0c8affd216 100644 --- a/src/Nav.js +++ b/src/Nav.js @@ -1,14 +1,13 @@ import React, { cloneElement } from 'react'; import BootstrapMixin from './BootstrapMixin'; -import CollapsibleMixin from './CollapsibleMixin'; +import Collapse from './Collapse'; import classNames from 'classnames'; -import domUtils from './utils/domUtils'; import ValidComponentChildren from './utils/ValidComponentChildren'; import createChainedFunction from './utils/createChainedFunction'; const Nav = React.createClass({ - mixins: [BootstrapMixin, CollapsibleMixin], + mixins: [BootstrapMixin], propTypes: { activeHref: React.PropTypes.string, @@ -27,33 +26,24 @@ const Nav = React.createClass({ getDefaultProps() { return { - bsClass: 'nav' + bsClass: 'nav', + expanded: true }; }, - getCollapsibleDOMNode() { - return React.findDOMNode(this); - }, - - getCollapsibleDimensionValue() { - let node = React.findDOMNode(this.refs.ul); - let height = node.offsetHeight; - let computedStyles = domUtils.getComputedStyles(node); - - return height + parseInt(computedStyles.marginTop, 10) + parseInt(computedStyles.marginBottom, 10); - }, - render() { - const classes = this.props.collapsible ? this.getCollapsibleClassSet('navbar-collapse') : null; + const classes = this.props.collapsible ? 'navbar-collapse' : null; if (this.props.navbar && !this.props.collapsible) { return (this.renderUl()); } return ( - + + + ); }, diff --git a/src/Panel.js b/src/Panel.js index d0bde0f778..d6dd1ae24a 100644 --- a/src/Panel.js +++ b/src/Panel.js @@ -2,10 +2,10 @@ import React, { cloneElement } from 'react'; import classNames from 'classnames'; import BootstrapMixin from './BootstrapMixin'; -import CollapsibleMixin from './CollapsibleMixin'; +import Collapse from './Collapse'; const Panel = React.createClass({ - mixins: [BootstrapMixin, CollapsibleMixin], + mixins: [BootstrapMixin], propTypes: { collapsible: React.PropTypes.bool, @@ -13,6 +13,8 @@ const Panel = React.createClass({ header: React.PropTypes.node, id: React.PropTypes.string, footer: React.PropTypes.node, + defaultExpanded: React.PropTypes.bool, + expanded: React.PropTypes.bool, eventKey: React.PropTypes.any }, @@ -23,6 +25,18 @@ const Panel = React.createClass({ }; }, + getInitialState(){ + let defaultExpanded = this.props.defaultExpanded != null ? + this.props.defaultExpanded : + this.props.expanded != null ? + this.props.expanded : + false; + + return { + expanded: defaultExpanded + }; + }, + handleSelect(e){ e.selected = true; @@ -38,19 +52,11 @@ const Panel = React.createClass({ }, handleToggle(){ - this.setState({expanded:!this.state.expanded}); + this.setState({ expanded: !this.state.expanded}); }, - getCollapsibleDimensionValue() { - return React.findDOMNode(this.refs.panel).scrollHeight; - }, - - getCollapsibleDOMNode() { - if (!this.isMounted() || !this.refs || !this.refs.panel) { - return null; - } - - return React.findDOMNode(this.refs.panel); + isExpanded(){ + return this.props.expanded != null ? this.props.expanded : this.state.expanded; }, render() { @@ -69,13 +75,16 @@ const Panel = React.createClass({ let collapseClass = this.prefixClass('collapse'); return ( -
- {this.renderBody()} -
+ +
+ {this.renderBody()} + +
+
); }, diff --git a/test/CollapseSpec.js b/test/CollapseSpec.js new file mode 100644 index 0000000000..0ac893fcf0 --- /dev/null +++ b/test/CollapseSpec.js @@ -0,0 +1,216 @@ +import React from 'react'; +import ReactTestUtils from 'react/lib/ReactTestUtils'; +import Collapse from '../src/Collapse'; + +describe('Collapse', function () { + + let Component, instance; + + beforeEach(function(){ + + Component = React.createClass({ + render(){ + let { children, ...props } = this.props; + + return ( + this.collapse = r} + getDimensionValue={()=> 15 } + {...props} + > +
+
+ {children} +
+
+
+ ); + } + }); + }); + + it('Should default to collapsed', function () { + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + + assert.ok( + instance.collapse.props.in === false); + }); + + + describe('collapsed', function(){ + + it('Should have collapse class', function () { + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + + assert.ok(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'collapse')); + }); + }); + + describe('from collapsed to expanded', function(){ + let scrollHeightStub; + + beforeEach(function(){ + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + + // since scrollHeight is gonna be 0 detached from the DOM + scrollHeightStub = sinon.stub(instance.collapse, '_getScrollDimensionValue'); + scrollHeightStub.returns('15px'); + }); + + + it('Should have collapsing class', function () { + instance.setProps({ in: true }); + + let node = React.findDOMNode(instance); + + assert.equal(node.className, 'collapsing'); + }); + + it('Should set initial 0px height', function (done) { + let node = React.findDOMNode(instance); + + function onEnter(){ + assert.equal(node.style.height, '0px'); + done(); + } + + assert.equal(node.style.height, ''); + + instance.setProps({ in: true, onEnter }); + }); + + it('Should set node to height', function () { + let node = React.findDOMNode(instance); + + assert.equal(node.styled, undefined); + + instance.setProps({ in: true }); + assert.equal(node.style.height, '15px'); + }); + + it('Should transition from collapsing to not collapsing', function (done) { + let node = React.findDOMNode(instance); + + function onEntered(){ + assert.equal(node.className, 'collapse in'); + done(); + } + + instance.setProps({ in: true, onEntered }); + + assert.equal(node.className, 'collapsing'); + }); + + it('Should clear height after transition complete', function (done) { + let node = React.findDOMNode(instance); + + function onEntered(){ + assert.equal(node.style.height, ''); + done(); + } + + assert.equal(node.style.height, ''); + + instance.setProps({ in: true, onEntered }); + assert.equal(node.style.height, '15px'); + }); + }); + + describe('from expanded to collapsed', function(){ + beforeEach(function(){ + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + }); + + it('Should have collapsing class', function () { + instance.setProps({ in: false }); + let node = React.findDOMNode(instance); + assert.equal(node.className, 'collapsing'); + }); + + it('Should set initial height', function () { + let node = React.findDOMNode(instance); + + function onExit(){ + assert.equal(node.style.height, '15px'); + } + + assert.equal(node.style.height, ''); + instance.setProps({ in: false, onExit }); + }); + + it('Should set node to height', function () { + let node = React.findDOMNode(instance); + assert.equal(node.style.height, ''); + + instance.setProps({ in: false }); + assert.equal(node.style.height, '0px'); + }); + + it('Should transition from collapsing to not collapsing', function (done) { + let node = React.findDOMNode(instance); + + function onExited(){ + assert.equal(node.className, 'collapse'); + done(); + } + + instance.setProps({ in: false, onExited }); + + assert.equal(node.className, 'collapsing'); + }); + + it('Should have 0px height after transition complete', function (done) { + let node = React.findDOMNode(instance); + + function onExited(){ + assert.ok(node.style.height === '0px'); + done(); + } + + assert.equal(node.style.height, ''); + + instance.setProps({ in: false, onExited }); + }); + }); + + describe('expanded', function(){ + + it('Should have collapse and in class', function () { + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + assert.ok(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'collapse in')); + }); + }); + + describe('dimension', function(){ + beforeEach(function(){ + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + }); + + it('Defaults to height', function(){ + assert.equal(instance.collapse._dimension(), 'height'); + }); + + it('Uses getCollapsibleDimension if exists', function(){ + + function dimension(){ + return 'whatevs'; + } + + instance.setProps({ dimension }); + + assert.equal(instance.collapse._dimension(), 'whatevs'); + }); + }); +});