From 3a0b4da0ca7c4fd7fa409a168173875c6ada8eb4 Mon Sep 17 00:00:00 2001 From: jquense Date: Sat, 11 Jul 2015 11:12:17 -0400 Subject: [PATCH] Add transition Component --- src/Transition.js | 286 +++++++++++++++++++++++++++++++++++++++++ test/TransitionSpec.js | 231 +++++++++++++++++++++++++++++++++ 2 files changed, 517 insertions(+) create mode 100644 src/Transition.js create mode 100644 test/TransitionSpec.js diff --git a/src/Transition.js b/src/Transition.js new file mode 100644 index 0000000000..ce8eb200cf --- /dev/null +++ b/src/Transition.js @@ -0,0 +1,286 @@ +'use strict'; +import React from 'react'; +import TransitionEvents from './utils/TransitionEvents'; +import classnames from 'classnames'; + +function omit(obj, keys) { + let included = Object.keys(obj).filter( k => keys.indexOf(k) === -1); + let newObj = {}; + + included.forEach( key => newObj[key] = obj[key] ); + return newObj; +} + +function ensureTransitionEnd(node, handler, duration){ + let fired = false; + let done = e => { + if (!fired) { + fired = true; + handler(e); + } + }; + + if ( node ) { + TransitionEvents.addEndEventListener(node, done); + setTimeout(done, duration); + } else { + setTimeout(done, 0); + } +} + +// 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 + +class Transition extends React.Component { + + constructor(props, context){ + super(props, context); + + this.state = { + in: !props.in, + transitioning: false + }; + + this.needsTransition = true; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.in !== this.props.in) { + this.needsTransition = true; + } + } + + componentDidUpdate() { + this.processChild(); + } + + componentWillMount() { + this._mounted = true; + + if (!this.props.transitionAppear) { + this.needsTransition = false; + this.setState({ in: this.props.in }); + } + } + + componentWillUnmount(){ + this._mounted = false; + } + + componentDidMount() { + if (this.props.transitionAppear) { + this.processChild(); + } + } + + processChild(){ + let needsTransition = this.needsTransition; + let enter = this.props.in; + + if (needsTransition) { + this.needsTransition = false; + this[enter ? 'performEnter' : 'performLeave'](); + } + } + + performEnter() { + let maybeNode = React.findDOMNode(this); + + let enter = node => { + node = this.props.transitioningNode(node) || node; + + this.props.onEnter(node); + + this.safeSetState({ in: true, transitioning: true, needInitialRender: false }, ()=> { + + this.props.onEntering(node); + + ensureTransitionEnd(node, () => { + if ( this.state.in ){ + this.safeSetState({ + transitioning: false + }, () => this.props.onEntered(node)); + } + + }, this.props.duration); + }); + }; + + if (maybeNode) { + enter(maybeNode); + } + else if (this.props.unmountOnExit) { + this._ensureNode(enter); + } + } + + performLeave() { + let node = React.findDOMNode(this); + + node = this.props.transitioningNode(node) || node; + + this.props.onExit(node); + + this.setState({ in: false, transitioning: true }, () => { + this.props.onExiting(node); + + ensureTransitionEnd(node, () => { + if ( !this.state.in ){ + this.safeSetState({ transitioning: false }, ()=> this.props.onExited(node)); + } + }, this.props.duration); + }); + } + + _ensureNode(callback) { + + this.setState({ needInitialRender: true }, ()=> { + let node = React.findDOMNode(this); + + triggerBrowserReflow(node); + + callback(node); + }); + } + + safeSetState(newState, cb){ + if (this._mounted) { + this.setState(newState, cb); + } + } + + render() { + let childProps = omit(this.props, Object.keys(Transition.propTypes).concat('children')); + + let child = this.props.children; + let starting = this.state.needInitialRender; + let out = !this.state.in && !this.state.transitioning; + + if ( !child || (this.props.unmountOnExit && out && !starting) ){ + return null; + } + + let classes = ''; + + // for whatever reason classnames() doesn't actually work here, + // maybe because they aren't always single classes? + if (this.state.in && !this.state.transitioning) { + classes = this.props.enteredClassName; + } + + else if (this.state.in && this.state.transitioning) { + classes = this.props.enteringClassName; + } + + else if (!this.state.in && !this.state.transitioning) { + classes = this.props.exitedClassName; + } + + else if (!this.state.in && this.state.transitioning) { + classes = this.props.exitingClassName; + } + + return React.cloneElement(child, { + ...childProps, + className: classnames( + child.props.className + , this.props.className + , classes) + }); + } +} + +Transition.propTypes = { + /** + * Triggers the Enter or Exit animation + */ + in: React.PropTypes.bool, + + /** + * Specify whether the transitioning component should be unmounted (removed from the DOM) once the exit animation finishes. + */ + unmountOnExit: React.PropTypes.bool, + + /** + * Specify whether transitions should run when the Transition component mounts. + */ + transitionAppear: 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, + + /** + * A css class or classes applied once the Component has exited. + */ + exitedClassName: React.PropTypes.string, + /** + * A css class or classes applied while the Component is exiting. + */ + exitingClassName: React.PropTypes.string, + /** + * A css class or classes applied once the Component has entered. + */ + enteredClassName: React.PropTypes.string, + /** + * A css class or classes applied while the Component is entering. + */ + enteringClassName: React.PropTypes.string, + + /** + * A function that returns the DOM node to animate. This Node will have the transition classes applied to it. + * When left out, the Component will use its immediate child. + * + * @private + */ + transitioningNode: React.PropTypes.func, + + /** + * A callback fired just before the "entering" classes are applied + */ + onEnter: React.PropTypes.func, + /** + * A callback fired just after the "entering" classes are applied + */ + onEntering: React.PropTypes.func, + /** + * A callback fired after "enter" classes are applied + */ + onEntered: React.PropTypes.func, + /** + * A callback fired after "exiting" classes are applied + */ + onExit: React.PropTypes.func, + /** + * A callback fired after "exiting" classes are applied + */ + onExiting: React.PropTypes.func, + /** + * A callback fired after "exit" classes are applied + */ + onExited: React.PropTypes.func +}; + +// name the function so it is clearer in the documentation +const noop = ()=>{}; + +Transition.defaultProps = { + in: false, + duration: 300, + unmountOnExit: false, + transitionAppear: false, + transitioningNode: noop, + + onEnter: noop, + onEntering: noop, + onEntered: noop, + + onExit: noop, + onExiting: noop, + onExited: noop +}; + +export default Transition; diff --git a/test/TransitionSpec.js b/test/TransitionSpec.js new file mode 100644 index 0000000000..7945ba8ceb --- /dev/null +++ b/test/TransitionSpec.js @@ -0,0 +1,231 @@ +import React from 'react'; +import ReactTestUtils from 'react/lib/ReactTestUtils'; +import { render } from './helpers'; +import Transition from '../src/Transition'; +//import classNames from 'classnames'; + +describe('Transition', function () { + + + it('should not transition on mount', function(){ + let instance = render( + { throw new Error('should not Enter'); }}> +
+
+ ); + + instance.state.in.should.equal(true); + assert.ok(!instance.state.transitioning); + }); + + it('should transition on mount with transitionAppear', done =>{ + let instance = ReactTestUtils.renderIntoDocument( + done()} + > +
+
+ ); + + instance.state.in.should.equal(true); + instance.state.transitioning.should.equal(true); + }); + + describe('entering', ()=> { + let instance; + + beforeEach(function(){ + instance = render( + +
+ + ); + }); + + it('should fire callbacks', done => { + let onEnter = sinon.spy(); + let onEntering = sinon.spy(); + + instance.state.in.should.equal(false); + + instance = instance.renderWithProps({ + + in: true, + + onEnter, + + onEntering, + + onEntered(){ + assert.ok(onEnter.calledOnce); + assert.ok(onEntering.calledOnce); + assert.ok(onEnter.calledBefore(onEntering)); + done(); + } + }); + }); + + it('should move to each transition state', done => { + let count = 0; + + instance.state.in.should.equal(false); + + instance = instance.renderWithProps({ + + in: true, + + onEnter(){ + count++; + instance.state.in.should.equal(false); + instance.state.transitioning.should.equal(false); + }, + + onEntering(){ + count++; + instance.state.in.should.equal(true); + instance.state.transitioning.should.equal(true); + }, + + onEntered(){ + instance.state.in.should.equal(true); + instance.state.transitioning.should.equal(false); + assert.ok(count === 2); + done(); + } + }); + }); + + it('should apply classes at each transition state', done => { + let count = 0; + + instance.state.in.should.equal(false); + + instance = instance.renderWithProps({ + + in: true, + + onEnter(node){ + count++; + assert.equal(node.className, ''); + }, + + onEntering(node){ + count++; + assert.equal(node.className, 'test-entering'); + }, + + onEntered(node){ + assert.equal(node.className, 'test-enter'); + assert.ok(count === 2); + done(); + } + }); + }); + + }); + + + describe('exiting', ()=> { + let instance; + + beforeEach(function(){ + instance = render( + +
+ + ); + }); + + it('should fire callbacks', done => { + let onExit = sinon.spy(); + let onExiting = sinon.spy(); + + instance.state.in.should.equal(true); + + instance = instance.renderWithProps({ + + in: false, + + onExit, + + onExiting, + + onExited(){ + assert.ok(onExit.calledOnce); + assert.ok(onExiting.calledOnce); + assert.ok(onExit.calledBefore(onExiting)); + done(); + } + }); + }); + + it('should move to each transition state', done => { + let count = 0; + + instance.state.in.should.equal(true); + + instance = instance.renderWithProps({ + + in: false, + + onExit(){ + count++; + instance.state.in.should.equal(true); + instance.state.transitioning.should.equal(false); + }, + + onExiting(){ + count++; + instance.state.in.should.equal(false); + instance.state.transitioning.should.equal(true); + }, + + onExited(){ + instance.state.in.should.equal(false); + instance.state.transitioning.should.equal(false); + //assert.ok(count === 2); + done(); + } + }); + }); + + it('should apply classes at each transition state', done => { + let count = 0; + + instance.state.in.should.equal(true); + + instance = instance.renderWithProps({ + + in: false, + + onExit(node){ + count++; + assert.equal(node.className, ''); + }, + + onExiting(node){ + count++; + assert.equal(node.className, 'test-exiting'); + }, + + onExited(node){ + assert.equal(node.className, 'test-exit'); + assert.ok(count === 2); + done(); + } + }); + }); + + }); + +});