Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add SwitchTransition component #470

Merged
merged 9 commits into from
Jun 28, 2019
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'
);
});
});