diff --git a/README.md b/README.md index ceb066c..a1354a0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,57 @@ ReactDOM.render( , mountNode); ``` -## API + +## CSSMotion + +### props + +| Property | Type | Default | Description| +| -------- | ---- | ------- | ---------- | +| visible | boolean | true | Display child content or not | +| children | function | | Render props of children content. Example [see below](#sample usage) | +| motionName | string \| [motionNameObjProps](#motionNameObjProps) | | Set the className when motion start | +| motionAppear | boolean | true | Support motion on appear | +| motionEnter | boolean | true | Support motion on enter | +| motionLeave | boolean | true | Support motion on leave | +| onAppearStart | function | | Trigger when appear start | +| onAppearActive | function | | Trigger when appear active | +| onAppearEnd | function | | Trigger when appear end | +| onEnterStart | function | | Trigger when enter start | +| onEnterActive | function | | Trigger when enter active | +| onEnterEnd | function | | Trigger when enter end | +| onLeaveStart | function | | Trigger when leave start | +| onLeaveActive | function | | Trigger when leave active | +| onLeaveEnd | function | | Trigger when leave end | + +#### motionNameObjProps +| Property | Type | +| -------- | ---- | +| appear | string | +| appearActive | string | +| enter | string | +| enterActive | string | +| leave | string | +| leaveActive | string | + +### sample usage + +```jsx +// Return customize style +const onAppearStart = (ele) => ({ height: 0 }); + + + {({ style, className }) => ( +
+ )} + +``` + +## Animate (Deprecated) ### props diff --git a/examples/CSSMotion.html b/examples/CSSMotion.html new file mode 100644 index 0000000..e69de29 diff --git a/examples/CSSMotion.js b/examples/CSSMotion.js new file mode 100644 index 0000000..d085786 --- /dev/null +++ b/examples/CSSMotion.js @@ -0,0 +1,87 @@ +/* eslint no-console:0, react/no-multi-comp:0 */ + +import React from 'react'; +// import PropTypes from 'prop-types'; +import ReactDOM from 'react-dom'; +import { CSSMotion } from 'rc-animate'; +import classNames from 'classnames'; +import './CSSMotion.less'; + +class Demo extends React.Component { + state = { + show: true, + }; + + onTrigger = () => { + this.setState({ + show: !this.state.show, + }); + }; + + onCollapse = () => ({ height: 0 }); + + skipColorTransition = (_, event) => { + // CSSMotion support multiple transition. + // You can return false to prevent motion end when fast transition finished. + if (event.propertyName === 'background-color') { + return false; + } + return true; + }; + + styleGreen = () => ({ + background: 'green', + }); + + render() { + const { show } = this.state; + + return ( +
+ +
+
+

With Transition Class

+ + {({ style, className }) => ( +
+ )} + +
+ +
+

With Animation Class

+ + {({ style, className }) => ( +
+ )} + +
+
+
+ ); + } +} + +ReactDOM.render(, document.getElementById('__react-content')); + +// Remove for IE9 test +// const aaa = document.getElementsByClassName('navbar')[0]; +// aaa.parentNode.removeChild(aaa); diff --git a/examples/CSSMotion.less b/examples/CSSMotion.less new file mode 100644 index 0000000..0e9ed42 --- /dev/null +++ b/examples/CSSMotion.less @@ -0,0 +1,86 @@ +.grid { + display: table; + + > div { + display: table-cell; + min-width: 350px; + } +} + +.demo-block { + display: block; + height: 300px; + width: 300px; + background: red; + overflow: hidden; +} + +.transition { + transition: background .3s, height 1.3s, opacity 1.3s; + + &.transition-appear, + &.transition-enter { + opacity: 0; + } + + &.transition-appear.transition-appear-active, + &.transition-enter.transition-enter-active { + opacity: 1; + } + + &.transition-leave-active { + opacity: 0; + background: green; + } +} + +.animation { + animation-duration: 1.3s; + animation-fill-mode: both; + + &.animation-appear, + &.animation-enter { + animation-name: enter; + animation-fill-mode: both; + animation-play-state: paused; + } + + &.animation-appear.animation-appear-active, + &.animation-enter.animation-enter-active { + animation-name: enter; + animation-play-state: running; + } + + &.animation-leave { + animation-name: leave; + animation-fill-mode: both; + animation-play-state: paused; + + &.animation-leave-active { + animation-name: leave; + animation-play-state: running; + } + } +} + +@keyframes enter { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +@keyframes leave { + from { + transform: scale(1); + opacity: 1; + } + to { + transform: scale(0); + opacity: 0; + } +} diff --git a/index.js b/index.js index a1d1242..02ed4e1 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,4 @@ // do not modify this file import Animate from './src/Animate'; +export CSSMotion from './src/CSSMotion'; export default Animate; diff --git a/src/CSSMotion.jsx b/src/CSSMotion.jsx new file mode 100644 index 0000000..966fc27 --- /dev/null +++ b/src/CSSMotion.jsx @@ -0,0 +1,223 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import classNames from 'classnames'; +import raf from 'raf'; +import { + getTransitionName, + animationEndName, transitionEndName, + supportTransition, +} from './util'; + +const STATUS_NONE = 'none'; +const STATUS_APPEAR = 'appear'; +const STATUS_ENTER = 'enter'; +const STATUS_LEAVE = 'leave'; + +/** + * `transitionSupport` is used for none transition test case. + * Default we use browser transition event support check. + */ +export function genCSSMotion(transitionSupport) { + class CSSMotion extends React.Component { + static propTypes = { + visible: PropTypes.bool, + children: PropTypes.func, + motionName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + motionAppear: PropTypes.bool, + motionEnter: PropTypes.bool, + motionLeave: PropTypes.bool, + onAppearStart: PropTypes.func, + onAppearActive: PropTypes.func, + onAppearEnd: PropTypes.func, + onEnterStart: PropTypes.func, + onEnterActive: PropTypes.func, + onEnterEnd: PropTypes.func, + onLeaveStart: PropTypes.func, + onLeaveActive: PropTypes.func, + onLeaveEnd: PropTypes.func, + }; + + static defaultProps = { + visible: true, + motionEnter: true, + motionAppear: true, + motionLeave: true, + }; + + constructor() { + super(); + + this.state = { + status: STATUS_NONE, + statusActive: false, + newStatus: false, + statusStyle: null, + }; + this.$ele = null; + } + + static getDerivedStateFromProps(props, { prevProps }) { + if (!transitionSupport) return {}; + + const { visible, motionAppear, motionEnter, motionLeave } = props; + const newState = { + prevProps: props, + }; + + // Appear + if (!prevProps && visible && motionAppear) { + newState.status = STATUS_APPEAR; + newState.statusActive = false; + newState.newStatus = true; + } + + // Enter + if (prevProps && !prevProps.visible && visible && motionEnter) { + newState.status = STATUS_ENTER; + newState.statusActive = false; + newState.newStatus = true; + } + + // Leave + if (prevProps && prevProps.visible && !visible && motionLeave) { + newState.status = STATUS_LEAVE; + newState.statusActive = false; + newState.newStatus = true; + } + + return newState; + }; + + componentDidMount() { + this.onDomUpdate(); + } + + componentDidUpdate() { + this.onDomUpdate(); + } + + componentWillUnmount() { + this.removeEventListener(this.$ele); + this._destroyed = true; + } + + onDomUpdate = () => { + const { status, newStatus } = this.state; + const { + onAppearStart, onEnterStart, onLeaveStart, + onAppearActive, onEnterActive, onLeaveActive, + motionAppear, motionEnter, motionLeave, + } = this.props; + + if (!transitionSupport) { + return; + } + + // Event injection + const $ele = ReactDOM.findDOMNode(this); + if (this.$ele !== $ele) { + this.removeEventListener(this.$ele); + this.addEventListener($ele); + this.$ele = $ele; + } + + // Init status + if (newStatus && status === STATUS_APPEAR && motionAppear) { + this.updateStatus(onAppearStart, null, null, () => { + this.updateActiveStatus(onAppearActive, STATUS_APPEAR); + }); + } else if (newStatus && status === STATUS_ENTER && motionEnter) { + this.updateStatus(onEnterStart, null, null, () => { + this.updateActiveStatus(onEnterActive, STATUS_ENTER); + }); + } else if (newStatus && status === STATUS_LEAVE && motionLeave) { + this.updateStatus(onLeaveStart, null, null, () => { + this.updateActiveStatus(onLeaveActive, STATUS_LEAVE); + }); + } + }; + + onMotionEnd = (event) => { + const { status, statusActive } = this.state; + const { onAppearEnd, onEnterEnd, onLeaveEnd } = this.props; + if (status === STATUS_APPEAR && statusActive) { + this.updateStatus(onAppearEnd, { status: STATUS_NONE }, event); + } else if (status === STATUS_ENTER && statusActive) { + this.updateStatus(onEnterEnd, { status: STATUS_NONE }, event); + } else if (status === STATUS_LEAVE && statusActive) { + this.updateStatus(onLeaveEnd, { status: STATUS_NONE }, event); + } + }; + + addEventListener = ($ele) => { + if (!$ele) return; + + $ele.addEventListener(transitionEndName, this.onMotionEnd); + $ele.addEventListener(animationEndName, this.onMotionEnd); + }; + removeEventListener = ($ele) => { + if (!$ele) return; + + $ele.removeEventListener(transitionEndName, this.onMotionEnd); + $ele.removeEventListener(animationEndName, this.onMotionEnd); + }; + + updateStatus = (styleFunc, additionalState, event, callback) => { + const statusStyle = styleFunc ? styleFunc(ReactDOM.findDOMNode(this), event) : null; + + if (statusStyle === false || this._destroyed) return; + + let nextStep; + if (callback) { + nextStep = () => { + raf(callback); + }; + } + + this.setState({ + statusStyle: typeof statusStyle === 'object' ? statusStyle : null, + newStatus: false, + ...additionalState, + }, nextStep); // Trigger before next frame & after `componentDidMount` + }; + + updateActiveStatus = (styleFunc, currentStatus) => { + // `setState` use `postMessage` to trigger at the end of frame. + // Let's use requestAnimationFrame to update new state in next frame. + raf(() => { + const { status } = this.state; + if (status !== currentStatus) return; + + this.updateStatus(styleFunc, { statusActive: true }); + }); + }; + + render() { + const { status, statusActive, statusStyle } = this.state; + const { children, motionName, visible } = this.props; + + if (!children) return null; + + if (status === STATUS_NONE || !transitionSupport) { + return visible ? children({}) : null; + } + + return children({ + className: classNames({ + [getTransitionName(motionName, status)]: status !== STATUS_NONE, + [getTransitionName(motionName, `${status}-active`)]: status !== STATUS_NONE && statusActive, + [motionName]: typeof motionName === 'string', + }), + style: statusStyle, + }); + } + } + + polyfill(CSSMotion); + + return CSSMotion; +} + +export default genCSSMotion(supportTransition); diff --git a/src/util.js b/src/util.js index 508cab1..1f018a1 100644 --- a/src/util.js +++ b/src/util.js @@ -160,4 +160,3 @@ export function getTransitionName(transitionName, transitionType) { return `${transitionName}-${transitionType}`; } - diff --git a/tests/CSSMotion.spec.css b/tests/CSSMotion.spec.css new file mode 100644 index 0000000..ddbedda --- /dev/null +++ b/tests/CSSMotion.spec.css @@ -0,0 +1,71 @@ +.motion-box { + width: 100px; + height: 100px; +} + +/* Transition */ +.motion-box.transition { + transition: all .3s; +} + +.motion-box.transition-appear, +.motion-box.transition-enter { + opacity: 0; +} + +.motion-box.transition-appear-active, +.motion-box.transition-enter-active { + opacity: 1; +} + +/* Animation */ +.motion-box.animation { + animation-duration: .3s; + animation-fill-mode: both; +} + +.motion-box.animation-appear, +.motion-box.animation-enter { + animation-name: enter; + animation-fill-mode: both; + animation-play-state: paused; +} + +.motion-box.animation-appear-active, +.motion-box.animation-enter-active { + animation-name: enter; + animation-play-state: running; +} + +.motion-box.animation-leave { + animation-name: leave; + animation-fill-mode: both; + animation-play-state: paused; +} + +.motion-box.animation-leave-active { + animation-name: leave; + animation-play-state: running; +} + +@keyframes enter { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +@keyframes leave { + from { + transform: scale(1); + opacity: 1; + } + to { + transform: scale(0); + opacity: 0; + } +} diff --git a/tests/CSSMotion.spec.js b/tests/CSSMotion.spec.js new file mode 100644 index 0000000..7a39709 --- /dev/null +++ b/tests/CSSMotion.spec.js @@ -0,0 +1,244 @@ +/* eslint react/no-render-return-value:0, react/prefer-stateless-function:0, react/no-multi-comp:0 */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import TestUtils from 'react-dom/test-utils'; +import expect from 'expect.js'; +import $ from 'jquery'; +import raf from 'raf'; +import CSSMotion, { genCSSMotion } from '../src/CSSMotion'; + +import './CSSMotion.spec.css'; + +describe('motion', () => { + let div; + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + }); + + afterEach(() => { + try { + ReactDOM.unmountComponentAtNode(div); + document.body.removeChild(div); + } catch (e) { + // Do nothing + } + }); + + describe('transition', () => { + const onCollapse = () => ({ height: 0 }); + const actionList = [ + { + name: 'appear', + props: { motionAppear: true, onAppearStart: onCollapse }, + visible: [true], + oriHeight: 0, + tgtHeight: 100, + oriOpacity: 0, + tgtOpacity: 1, + }, + { + name: 'enter', + props: { motionEnter: true, onEnterStart: onCollapse }, + visible: [false, true], + oriHeight: 0, + tgtHeight: 100, + oriOpacity: 0, + tgtOpacity: 1, + }, + { + name: 'leave', + props: { motionLeave: true, onLeaveActive: onCollapse }, + visible: [true, false], + oriHeight: 100, + tgtHeight: 0, + oriOpacity: 1, + tgtOpacity: 0, + }, + ]; + + actionList.forEach(({ name, props, visible, oriHeight, tgtHeight, oriOpacity, tgtOpacity }) => { + class Demo extends React.Component { + state = { + visible: visible[0], + }; + + render() { + return ( + + {({ style, className }) => ( +
+ )} + + ); + } + } + + it(name, (done) => { + ReactDOM.render(, div, function init() { + const nextVisible = visible[1]; + const instance = this; + + const doStartTest = () => { + const $ele = $(div).find('.motion-box'); + + const basicClassName = TestUtils.findRenderedDOMComponentWithClass(instance, 'motion-box').className; + expect(basicClassName).to.contain('transition'); + expect(basicClassName).to.contain(`transition-${name}`); + expect(basicClassName).to.not.contain(`transition-${name}-active`); + + raf(() => { + // After first dom render, merge the style into element + expect($ele.height()).to.be(oriHeight); + expect(Number($ele.css('opacity'))).to.be(oriOpacity); + + setTimeout(() => { + const activeClassName = TestUtils.findRenderedDOMComponentWithClass(instance, 'motion-box').className; + expect(activeClassName).to.contain('transition'); + expect(activeClassName).to.contain(`transition-${name}`); + expect(activeClassName).to.contain(`transition-${name}-active`); + + setTimeout(() => { + if (nextVisible === false) { + expect( + TestUtils.scryRenderedDOMComponentsWithClass(instance, 'motion-box').length + ).to.be(0); + } else { + const endClassName = TestUtils.findRenderedDOMComponentWithClass(instance, 'motion-box').className; + expect(endClassName).to.not.contain('transition'); + expect(endClassName).to.not.contain(`transition-${name}`); + expect(endClassName).to.not.contain(`transition-${name}-active`); + + expect($ele.height()).to.be(tgtHeight); + expect(Number($ele.css('opacity'))).to.be(tgtOpacity); + } + done(); + }, 300); + }, 100); + }); + }; + + // Delay for the visible finished + if (nextVisible !== undefined) { + setTimeout(() => { + instance.setState({ visible: nextVisible }); + doStartTest(); + }, 100); + } else { + doStartTest(); + } + }); + // End of test case + }); + }); + }); + + describe('animation', () => { + const actionList = [ + { + name: 'appear', + props: { motionAppear: true }, + visible: [true], + }, + { + name: 'enter', + props: { motionEnter: true }, + visible: [false, true], + }, + { + name: 'leave', + props: { motionLeave: true }, + visible: [true, false], + }, + ]; + + actionList.forEach(({ name, visible, props }) => { + class Demo extends React.Component { + state = { + visible: visible[0], + }; + + render() { + return ( + + {({ style, className }) => ( +
+ )} + + ); + } + } + + it(name, (done) => { + ReactDOM.render(, div, function init() { + const nextVisible = visible[1]; + const instance = this; + + const doStartTest = () => { + const basicClassName = TestUtils.findRenderedDOMComponentWithClass(instance, 'motion-box').className; + expect(basicClassName).to.contain('animation'); + expect(basicClassName).to.contain(`animation-${name}`); + expect(basicClassName).to.not.contain(`animation-${name}-active`); + + setTimeout(() => { + const activeClassName = TestUtils.findRenderedDOMComponentWithClass(instance, 'motion-box').className; + expect(activeClassName).to.contain('animation'); + expect(activeClassName).to.contain(`animation-${name}`); + expect(activeClassName).to.contain(`animation-${name}-active`); + + // Simulation browser env not support animation. Not check end event + done(); + }, 100); + }; + + // Delay for the visible finished + if (nextVisible !== undefined) { + setTimeout(() => { + instance.setState({ visible: nextVisible }); + doStartTest(); + }, 100); + } else { + doStartTest(); + } + }); + }); + // End of it + }); + }); + + it('no transition', (done) => { + const NoCSSTransition = genCSSMotion(false); + + ReactDOM.render( + + {({ style, className }) => ( +
+ )} + + , div, function init() { + const basicClassName = TestUtils.findRenderedDOMComponentWithClass(this, 'motion-box').className; + expect(basicClassName).to.not.contain('transition'); + expect(basicClassName).to.not.contain('transition-appear'); + expect(basicClassName).to.not.contain('transition-appear-active'); + + done(); + }); + }); +}); diff --git a/tests/index.js b/tests/index.js index 6ec1dac..98a2bf0 100644 --- a/tests/index.js +++ b/tests/index.js @@ -5,3 +5,4 @@ import './single.spec'; import './single-animation.spec'; import './multiple.spec'; import './no.transition.spec'; +import './CSSMotion.spec';