diff --git a/docs/examples/CollapsableParagraph.js b/docs/examples/CollapsableParagraph.js new file mode 100644 index 0000000000..5725be5baf --- /dev/null +++ b/docs/examples/CollapsableParagraph.js @@ -0,0 +1,37 @@ +var CollapsableParagraph = React.createClass({ + mixins: [CollapsableMixin], + + getCollapsableDOMNode: function(){ + return this.refs.panel.getDOMNode(); + }, + + getCollapsableDimensionValue: function(){ + return this.refs.panel.getDOMNode().scrollHeight; + }, + + onHandleToggle: function(e){ + e.preventDefault(); + this.setState({expanded:!this.state.expanded}); + }, + + render: function(){ + var styles = this.getCollapsableClassSet(); + var text = this.isExpanded() ? 'Hide' : 'Show'; + return ( +
+ +
+ {this.props.children} +
+
+ ); + } +}); + +var panelInstance = ( + + Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS. + +); + +React.render(panelInstance, mountNode); diff --git a/docs/src/ComponentsPage.js b/docs/src/ComponentsPage.js index bd55a8e2ca..7a611b42c6 100644 --- a/docs/src/ComponentsPage.js +++ b/docs/src/ComponentsPage.js @@ -207,6 +207,10 @@ var ComponentsPage = React.createClass({

Accordions

<Accordion /> aliases <PanelGroup accordion />.

+ +

Collapsable Mixin

+

CollapsableMixin can be used to create your own components with collapse functionality.

+
diff --git a/docs/src/ReactPlayground.js b/docs/src/ReactPlayground.js index 7ff19f1b02..8a23add10d 100644 --- a/docs/src/ReactPlayground.js +++ b/docs/src/ReactPlayground.js @@ -8,6 +8,7 @@ var Badge = require('../../lib/Badge'); var Button = require('../../lib/Button'); var ButtonGroup = require('../../lib/ButtonGroup'); var ButtonToolbar = require('../../lib/ButtonToolbar'); +var CollapsableMixin = require('../../lib/CollapsableMixin'); var Carousel = require('../../lib/Carousel'); var CarouselItem = require('../../lib/CarouselItem'); var Col = require('../../lib/Col'); diff --git a/src/CollapsableMixin.js b/src/CollapsableMixin.js index 6cd877de33..201ed69fe4 100644 --- a/src/CollapsableMixin.js +++ b/src/CollapsableMixin.js @@ -1,101 +1,149 @@ var React = require('react'); -var TransitionEvents = require('./utils/TransitionEvents'); +var TransitionEvents = require('react/lib/ReactTransitionEvents'); var CollapsableMixin = { propTypes: { - collapsable: React.PropTypes.bool, defaultExpanded: React.PropTypes.bool, expanded: React.PropTypes.bool }, - getInitialState: function () { + getInitialState: function(){ + var defaultExpanded = this.props.defaultExpanded != null ? + this.props.defaultExpanded : + this.props.expanded != null ? + this.props.expanded : + false; + return { - expanded: this.props.defaultExpanded != null ? this.props.defaultExpanded : null, + expanded: defaultExpanded, collapsing: false }; }, - handleTransitionEnd: function () { - this._collapseEnd = true; - this.setState({ - collapsing: false - }); - }, - - componentWillReceiveProps: function (newProps) { - if (this.props.collapsable && newProps.expanded !== this.props.expanded) { - this._collapseEnd = false; - this.setState({ - collapsing: true - }); + componentWillUpdate: function(nextProps, nextState){ + var willExpanded = nextProps.expanded != null ? nextProps.expanded : nextState.expanded; + if (willExpanded === this.isExpanded()) { + return; } - }, - _addEndTransitionListener: function () { + // if the expanded state is being toggled, ensure node has a dimension value + // this is needed for the animation to work and needs to be set before + // the collapsing class is applied (after collapsing is applied the in class + // is removed and the node's dimension will be wrong) + var node = this.getCollapsableDOMNode(); + var dimension = this.dimension(); + var value = '0'; - if (node) { - TransitionEvents.addEndEventListener( - node, - this.handleTransitionEnd - ); + if(!willExpanded){ + value = this.getCollapsableDimensionValue(); } + + node.style[dimension] = value + 'px'; + + this._afterWillUpdate(); }, - _removeEndTransitionListener: function () { - var node = this.getCollapsableDOMNode(); + componentDidUpdate: function(prevProps, prevState){ + // check if expanded is being toggled; if so, set collapsing + this._checkToggleCollapsing(prevProps, prevState); - if (node) { - TransitionEvents.removeEndEventListener( - node, - this.handleTransitionEnd - ); - } + // check if collapsing was turned on; if so, start animation + this._checkStartAnimation(); + }, + + // helps enable test stubs + _afterWillUpdate: function(){ }, - componentDidMount: function () { - this._afterRender(); + _checkStartAnimation: function(){ + if(!this.state.collapsing) { + return; + } + + var node = this.getCollapsableDOMNode(); + var dimension = this.dimension(); + var value = this.getCollapsableDimensionValue(); + + // setting the dimension here starts the transition animation + var result; + if(this.isExpanded()) { + result = value + 'px'; + } else { + result = '0px'; + } + node.style[dimension] = result; }, - componentWillUnmount: function () { - this._removeEndTransitionListener(); + _checkToggleCollapsing: function(prevProps, prevState){ + var wasExpanded = prevProps.expanded != null ? prevProps.expanded : prevState.expanded; + var isExpanded = this.isExpanded(); + if(wasExpanded !== isExpanded){ + if(wasExpanded) { + this._handleCollapse(); + } else { + this._handleExpand(); + } + } }, - componentWillUpdate: function (nextProps) { - var dimension = (typeof this.getCollapsableDimension === 'function') ? - this.getCollapsableDimension() : 'height'; + _handleExpand: function(){ var node = this.getCollapsableDOMNode(); + var dimension = this.dimension(); + + var complete = (function (){ + this._removeEndEventListener(node, complete); + // remove dimension value - this ensures the collapsable item can grow + // in dimension after initial display (such as an image loading) + node.style[dimension] = ''; + this.setState({ + collapsing:false + }); + }).bind(this); + + this._addEndEventListener(node, complete); - this._removeEndTransitionListener(); + this.setState({ + collapsing: true + }); }, - componentDidUpdate: function (prevProps, prevState) { - this._afterRender(); + _handleCollapse: function(){ + var node = this.getCollapsableDOMNode(); + + var complete = (function (){ + this._removeEndEventListener(node, complete); + this.setState({ + collapsing: false + }); + }).bind(this); + + this._addEndEventListener(node, complete); + + this.setState({ + collapsing: true + }); }, - _afterRender: function () { - if (!this.props.collapsable) { - return; - } + // helps enable test stubs + _addEndEventListener: function(node, complete){ + TransitionEvents.addEndEventListener(node, complete); + }, - this._addEndTransitionListener(); - setTimeout(this._updateDimensionAfterRender, 0); + // helps enable test stubs + _removeEndEventListener: function(node, complete){ + TransitionEvents.removeEndEventListener(node, complete); }, - _updateDimensionAfterRender: function () { - var node = this.getCollapsableDOMNode(); - if (node) { - var dimension = (typeof this.getCollapsableDimension === 'function') ? - this.getCollapsableDimension() : 'height'; - node.style[dimension] = this.isExpanded() ? - this.getCollapsableDimensionValue() + 'px' : '0px'; - } + dimension: function(){ + return (typeof this.getCollapsableDimension === 'function') ? + this.getCollapsableDimension() : + 'height'; }, - isExpanded: function () { - return (this.props.expanded != null) ? - this.props.expanded : this.state.expanded; + isExpanded: function(){ + return this.props.expanded != null ? this.props.expanded : this.state.expanded; }, getCollapsableClassSet: function (className) { diff --git a/src/Panel.jsx b/src/Panel.jsx index 703ba75bed..36ed087612 100644 --- a/src/Panel.jsx +++ b/src/Panel.jsx @@ -10,6 +10,7 @@ var Panel = React.createClass({ mixins: [BootstrapMixin, CollapsableMixin], propTypes: { + collapsable: React.PropTypes.bool, onSelect: React.PropTypes.func, header: React.PropTypes.node, footer: React.PropTypes.node, @@ -23,22 +24,22 @@ var Panel = React.createClass({ }; }, - handleSelect: function (e) { + handleSelect: function(e){ + e.selected = true; + if (this.props.onSelect) { - this._isChanging = true; - this.props.onSelect(this.props.eventKey); - this._isChanging = false; + this.props.onSelect(e, this.props.eventKey); + } else { + e.preventDefault(); } - e.preventDefault(); - - this.setState({ - expanded: !this.state.expanded - }); + if (e.selected) { + this.handleToggle(); + } }, - shouldComponentUpdate: function () { - return !this._isChanging; + handleToggle: function(){ + this.setState({expanded:!this.state.expanded}); }, getCollapsableDimensionValue: function () { @@ -69,7 +70,11 @@ var Panel = React.createClass({ renderCollapsableBody: function () { return ( -
+
{this.renderBody()}
); @@ -78,6 +83,7 @@ var Panel = React.createClass({ renderBody: function () { var allChildren = this.props.children; var bodyElements = []; + var panelBodyChildren = []; function getProps() { return {key: bodyElements.length}; @@ -95,24 +101,23 @@ var Panel = React.createClass({ ); } + function maybeRenderPanelBody () { + if (panelBodyChildren.length === 0) { + return; + } + + addPanelBody(panelBodyChildren); + panelBodyChildren = []; + } + // Handle edge cases where we should not iterate through children. - if (!Array.isArray(allChildren) || allChildren.length == 0) { + if (!Array.isArray(allChildren) || allChildren.length === 0) { if (this.shouldRenderFill(allChildren)) { addPanelChild(allChildren); } else { addPanelBody(allChildren); } } else { - var panelBodyChildren = []; - - function maybeRenderPanelBody () { - if (panelBodyChildren.length == 0) { - return; - } - - addPanelBody(panelBodyChildren); - panelBodyChildren = []; - } allChildren.forEach(function(child) { if (this.shouldRenderFill(child)) { @@ -132,7 +137,7 @@ var Panel = React.createClass({ }, shouldRenderFill: function (child) { - return React.isValidElement(child) && child.props.fill != null + return React.isValidElement(child) && child.props.fill != null; }, renderHeading: function () { @@ -168,6 +173,7 @@ var Panel = React.createClass({ {header} diff --git a/src/PanelGroup.jsx b/src/PanelGroup.jsx index eb177b4a4d..1c0d4b1b07 100644 --- a/src/PanelGroup.jsx +++ b/src/PanelGroup.jsx @@ -66,7 +66,9 @@ var PanelGroup = React.createClass({ return !this._isChanging; }, - handleSelect: function (key) { + handleSelect: function (e, key) { + e.preventDefault(); + if (this.props.onSelect) { this._isChanging = true; this.props.onSelect(key); diff --git a/test/CollapsableMixinSpec.jsx b/test/CollapsableMixinSpec.jsx new file mode 100644 index 0000000000..1c613ea800 --- /dev/null +++ b/test/CollapsableMixinSpec.jsx @@ -0,0 +1,220 @@ +/*global describe, it, assert */ + +var React = require('react'); +var ReactTestUtils = require('react/lib/ReactTestUtils'); +var CollapsableMixin = require('../lib/CollapsableMixin'); +var classSet = require('../lib/utils/classSet'); + +describe('CollapsableMixin', function () { + + var Component, instance; + + beforeEach(function(){ + Component = React.createClass({ + mixins: [CollapsableMixin], + + getCollapsableDOMNode: function(){ + return this.refs.panel.getDOMNode(); + }, + + getCollapsableDimensionValue: function(){ + return 15; + }, + + render: function(){ + var styles = this.getCollapsableClassSet(); + return ( +
+
+ {this.props.children} +
+
+ ); + } + }); + }); + + describe('getInitialState', function(){ + it('Should check defaultExpanded', function () { + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + var state = instance.getInitialState(); + assert.ok(state.expanded === true); + }); + + it('Should default collapsing to false', function () { + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + var state = instance.getInitialState(); + assert.ok(state.collapsing === 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(){ + beforeEach(function(){ + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + }); + + it('Should have collapsing class', function () { + instance.setProps({expanded:true}); + var node = instance.getCollapsableDOMNode(); + assert.equal(node.className, 'collapsing'); + }); + + it('Should set initial 0px height', function () { + var node = instance.getCollapsableDOMNode(); + assert.equal(node.style['height'], ''); + + instance._afterWillUpdate = function(){ + assert.equal(node.style['height'], '0px'); + }; + + instance.setProps({expanded:true}); + }); + + it('Should set transition to height', function () { + var node = instance.getCollapsableDOMNode(); + assert.equal(node.style['height'], ''); + + instance.setProps({expanded:true}); + assert.equal(node.style['height'], '15px'); + }); + + it('Should transition from collapsing to not collapsing', function (done) { + instance._addEndEventListener = function(node, complete){ + setTimeout(function(){ + complete(); + assert.ok(!instance.state.collapsing); + done(); + }, 100); + }; + instance.setProps({expanded:true}); + assert.ok(instance.state.collapsing); + }); + + it('Should clear height after transition complete', function (done) { + var node = instance.getCollapsableDOMNode(); + + instance._addEndEventListener = function(node, complete){ + setTimeout(function(){ + complete(); + assert.equal(node.style['height'], ''); + done(); + }, 100); + }; + + assert.equal(node.style['height'], ''); + instance.setProps({expanded:true}); + 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({expanded:false}); + var node = instance.getCollapsableDOMNode(); + assert.equal(node.className, 'collapsing'); + }); + + it('Should set initial height', function () { + var node = instance.getCollapsableDOMNode(); + + instance._afterWillUpdate = function(){ + assert.equal(node.style['height'], '15px'); + }; + + assert.equal(node.style['height'], ''); + instance.setProps({expanded:false}); + }); + + it('Should set transition to height', function () { + var node = instance.getCollapsableDOMNode(); + assert.equal(node.style['height'], ''); + + instance.setProps({expanded:false}); + assert.equal(node.style['height'], '0px'); + }); + + it('Should transition from collapsing to not collapsing', function (done) { + instance._addEndEventListener = function(node, complete){ + setTimeout(function(){ + complete(); + assert.ok(!instance.state.collapsing); + done(); + }, 100); + }; + instance.setProps({expanded:false}); + assert.ok(instance.state.collapsing); + }); + + it('Should have 0px height after transition complete', function (done) { + var node = instance.getCollapsableDOMNode(); + + instance._addEndEventListener = function(node, complete){ + setTimeout(function(){ + complete(); + assert.ok(node.style['height'] === '0px'); + done(); + }, 100); + }; + + assert.equal(node.style['height'], ''); + instance.setProps({expanded:false}); + assert.equal(node.style['height'], '0px'); + }); + }); + + describe('expanded', function(){ + it('Should have collapse and in class', function () { + instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + assert.ok(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'collapse in')); + }); + + it('Should have collapse and in class with defaultExpanded', 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.dimension(), 'height'); + }); + + it('Uses getCollapsableDimension if exists', function(){ + instance.getCollapsableDimension = function(){ + return 'whatevs'; + }; + assert.equal(instance.dimension(), 'whatevs'); + }); + }); +}); diff --git a/test/PanelSpec.jsx b/test/PanelSpec.jsx index 37a93c38d0..a761242204 100644 --- a/test/PanelSpec.jsx +++ b/test/PanelSpec.jsx @@ -113,8 +113,28 @@ describe('Panel', function () { assert.ok(anchor.className.match(/\bcollapsed\b/)); }); + it('Should be aria-expanded=true', function () { + var instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + var collapse = instance.getDOMNode().querySelector('.panel-collapse'); + var anchor = instance.getDOMNode().querySelector('.panel-title a'); + assert.equal(collapse.getAttribute('aria-expanded'), 'true'); + assert.equal(anchor.getAttribute('aria-expanded'), 'true'); + }); + + it('Should be aria-expanded=false', function () { + var instance = ReactTestUtils.renderIntoDocument( + Panel content + ); + var collapse = instance.getDOMNode().querySelector('.panel-collapse'); + var anchor = instance.getDOMNode().querySelector('.panel-title a'); + assert.equal(collapse.getAttribute('aria-expanded'), 'false'); + assert.equal(anchor.getAttribute('aria-expanded'), 'false'); + }); + it('Should call onSelect handler', function (done) { - function handleSelect (key) { + function handleSelect (e, key) { assert.equal(key, '1'); done(); } @@ -174,4 +194,4 @@ describe('Panel', function () { assert.equal(children[0].nodeName, 'TABLE'); assert.notOk(children[0].className.match(/\bpanel-body\b/)); }); -}); \ No newline at end of file +});