Skip to content

Commit

Permalink
feat: add SwitchTransition component (#470)
Browse files Browse the repository at this point in the history
* add switch transition component

* change prop type

* add some documentations and tests

* add export in entry point

* update switchtransition for new api
  • Loading branch information
patrickm68 committed Jun 28, 2019
1 parent 80afaaf commit ec4b767
Show file tree
Hide file tree
Showing 2 changed files with 320 additions and 0 deletions.
191 changes: 191 additions & 0 deletions src/SwitchTransition.js
Original file line number Diff line number Diff line change
@@ -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 (
* <SwitchTransition>
* <FadeTransition key={state ? "Goodbye, world!" : "Hello, world!"}
* addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
* classNames='fade' >
* <button onClick={() => setState(state => !state)}>
* {state ? "Goodbye, world!" : "Hello, world!"}
* </button>
* </FadeTransition>
* </SwitchTransition>
* )
* }
* ```
*/
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 (
<TransitionGroupContext.Provider value={{ isMounting: !this.appeared }}>
{component}
</TransitionGroupContext.Provider>
)
}
}


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
}
129 changes: 129 additions & 0 deletions test/SwitchTransition-test.js
Original file line number Diff line number Diff line change
@@ -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 (
<SwitchTransition>
{rendered ? (
<Transition timeout={0} key={on ? 'first' : 'second'} {...events}>
<span />
</Transition>
) : null}
</SwitchTransition>
);
};
});

it('should have default status ENTERED', () => {
const wrapper = mount(
<SwitchTransition>
<Transition timeout={0} key="first">
<span />
</Transition>
</SwitchTransition>
);

expect(wrapper.state('status')).toBe(ENTERED);
});

it('should have default mode: out-in', () => {
const wrapper = mount(
<SwitchTransition>
<Transition timeout={0} key="first">
<span />
</Transition>
</SwitchTransition>
);

expect(wrapper.prop('mode')).toBe(modes.out);
});

it('should work without childs', () => {
expect(() => {
mount(
<SwitchTransition>
<Transition timeout={0} key="first">
<span />
</Transition>
</SwitchTransition>
);
}).not.toThrow();
});

it('should switch between components on change state', () => {
const wrapper = mount(<Parent on={true} />);

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(<Parent on={true} rendered={false} />);

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'
);
});
});

0 comments on commit ec4b767

Please sign in to comment.