diff --git a/src/SwitchTransition.js b/src/SwitchTransition.js new file mode 100644 index 00000000..e7714588 --- /dev/null +++ b/src/SwitchTransition.js @@ -0,0 +1,191 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ENTERED, ENTERING, EXITING } from './Transition' +import TransitionGroupContext from './TransitionGroupContext'; + +function areChildrenDifferent(oldChildren, newChildren) { + if (oldChildren === newChildren) return false; + if ( + React.isValidElement(oldChildren) && + React.isValidElement(newChildren) && + oldChildren.key != null && + oldChildren.key === newChildren.key + ) { + return false; + } + return true; +} + +/** + * Enum of modes for SwitchTransition component + * @enum { string } + */ +export const modes = { + out: 'out-in', + in: 'in-out' +}; + +const callHook = (element, name, cb) => (...args) => { + element.props[name] && element.props[name](...args) + cb() +} + +const leaveRenders = { + [modes.out]: ({ current, changeState }) => + React.cloneElement(current, { + in: false, + onExited: callHook(current, 'onExited', () => { + changeState(ENTERING, null); + }) + }), + [modes.in]: ({ current, changeState, children }) => [ + current, + React.cloneElement(children, { + in: true, + onEntered: callHook(children, 'onEntered', () => { + changeState(ENTERING); + }) + }) + ] +}; + +const enterRenders = { + [modes.out]: ({ children, changeState }) => + React.cloneElement(children, { + in: true, + onEntered: callHook(children, 'onEntered', () => { + changeState(ENTERED, React.cloneElement(children, { in: true })); + }) + }), + [modes.in]: ({ current, children, changeState }) => [ + React.cloneElement(current, { + in: false, + onExited: callHook(current, 'onExited', () => { + changeState(ENTERED, React.cloneElement(children, { in: true })); + }) + }), + React.cloneElement(children, { + in: true + }) + ] +}; + +/** + * A transition component inspired by the [vue transition modes](https://vuejs.org/v2/guide/transitions.html#Transition-Modes). + * You can use it when you want to control the render between state transitions. + * Based on the selected mode and the child's key which is the `Transition` or `CSSTransition` component, the `SwitchTransition` makes a consistent transition between them. + * + * If the `out-in` mode is selected, the `SwitchTransition` waits until the old child leaves and then inserts a new child. + * If the `in-out` mode is selected, the `SwitchTransition` inserts a new child first, waits for the new child to enter and then removes the old child + * + * ```jsx + * + * function App() { + * const [state, setState] = useState(false); + * return ( + * + * node.addEventListener("transitionend", done, false)} + * classNames='fade' > + * + * + * + * ) + * } + * ``` + */ +export class SwitchTransition extends React.Component { + state = { + status: ENTERED, + current: null + }; + + appeared = false; + + componentDidMount() { + this.appeared = true; + } + + static getDerivedStateFromProps(props, state) { + if (props.children == null) { + return { + current: null + } + } + + if (state.status === ENTERING && props.mode === modes.in) { + return { + status: ENTERING + }; + } + + if (state.current && areChildrenDifferent(state.current, props.children)) { + return { + status: EXITING + }; + } + + return { + current: React.cloneElement(props.children, { + in: true + }) + }; + } + + changeState = (status, current = this.state.current) => { + this.setState({ + status, + current + }); + }; + + render() { + const { + props: { children, mode }, + state: { status, current } + } = this; + + const data = { children, current, changeState: this.changeState, status }; + let component + switch (status) { + case ENTERING: + component = enterRenders[mode](data); + break; + case EXITING: + component = leaveRenders[mode](data) + break; + case ENTERED: + component = current; + } + + return ( + + {component} + + ) + } +} + + +SwitchTransition.propTypes = { + /** + * Transition modes. + * `out-in`: Current element transitions out first, then when complete, the new element transitions in. + * `in-out: New element transitions in first, then when complete, the current element transitions out.` + * + * @type {'out-in'|'in-out'} + */ + mode: PropTypes.oneOf([modes.in, modes.out]), + /** + * Any `Transition` or `CSSTransition` component + */ + children: PropTypes.oneOfType([ + PropTypes.element.isRequired, + ]), +} + +SwitchTransition.defaultProps = { + mode: modes.out +} diff --git a/test/SwitchTransition-test.js b/test/SwitchTransition-test.js new file mode 100644 index 00000000..833da0d7 --- /dev/null +++ b/test/SwitchTransition-test.js @@ -0,0 +1,129 @@ +import React from 'react'; + +import { mount } from 'enzyme'; + +import Transition, { ENTERED } from '../src/Transition'; +import { SwitchTransition, modes } from '../src/SwitchTransition'; + +describe('SwitchTransition', () => { + let log, Parent; + beforeEach(() => { + log = []; + let events = { + onEnter: (_, m) => log.push(m ? 'appear' : 'enter'), + onEntering: (_, m) => log.push(m ? 'appearing' : 'entering'), + onEntered: (_, m) => log.push(m ? 'appeared' : 'entered'), + onExit: () => log.push('exit'), + onExiting: () => log.push('exiting'), + onExited: () => log.push('exited') + }; + + Parent = function Parent({ on, rendered = true }) { + return ( + + {rendered ? ( + + + + ) : null} + + ); + }; + }); + + it('should have default status ENTERED', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.state('status')).toBe(ENTERED); + }); + + it('should have default mode: out-in', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.prop('mode')).toBe(modes.out); + }); + + it('should work without childs', () => { + expect(() => { + mount( + + + + + + ); + }).not.toThrow(); + }); + + it('should switch between components on change state', () => { + const wrapper = mount(); + + jest.useFakeTimers(); + expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe( + 'first' + ); + wrapper.setProps({ on: false }); + expect(log).toEqual(['exit', 'exiting']); + jest.runAllTimers(); + expect(log).toEqual([ + 'exit', + 'exiting', + 'exited', + 'enter', + 'entering', + 'entered' + ]); + expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe( + 'second' + ); + }); + + it('should switch between null and component', () => { + const wrapper = mount(); + + expect( + wrapper.find(SwitchTransition).getElement().props.children + ).toBeFalsy(); + + jest.useFakeTimers(); + + wrapper.setProps({ rendered: true }); + jest.runAllTimers(); + expect(log).toEqual(['enter', 'entering', 'entered']); + expect( + wrapper.find(SwitchTransition).getElement().props.children + ).toBeTruthy(); + expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe( + 'first' + ); + + wrapper.setProps({ on: false, rendered: true }); + jest.runAllTimers(); + expect(log).toEqual([ + 'enter', + 'entering', + 'entered', + 'exit', + 'exiting', + 'exited', + 'enter', + 'entering', + 'entered' + ]); + expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe( + 'second' + ); + }); +});