Skip to content

Commit c5e379d

Browse files
dimensijquense
authored andcommitted
feat: add SwitchTransition component (#470)
* add switch transition component * change prop type * add some documentations and tests * add export in entry point * update switchtransition for new api
1 parent f1383c5 commit c5e379d

File tree

2 files changed

+320
-0
lines changed

2 files changed

+320
-0
lines changed

src/SwitchTransition.js

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { ENTERED, ENTERING, EXITING } from './Transition'
4+
import TransitionGroupContext from './TransitionGroupContext';
5+
6+
function areChildrenDifferent(oldChildren, newChildren) {
7+
if (oldChildren === newChildren) return false;
8+
if (
9+
React.isValidElement(oldChildren) &&
10+
React.isValidElement(newChildren) &&
11+
oldChildren.key != null &&
12+
oldChildren.key === newChildren.key
13+
) {
14+
return false;
15+
}
16+
return true;
17+
}
18+
19+
/**
20+
* Enum of modes for SwitchTransition component
21+
* @enum { string }
22+
*/
23+
export const modes = {
24+
out: 'out-in',
25+
in: 'in-out'
26+
};
27+
28+
const callHook = (element, name, cb) => (...args) => {
29+
element.props[name] && element.props[name](...args)
30+
cb()
31+
}
32+
33+
const leaveRenders = {
34+
[modes.out]: ({ current, changeState }) =>
35+
React.cloneElement(current, {
36+
in: false,
37+
onExited: callHook(current, 'onExited', () => {
38+
changeState(ENTERING, null);
39+
})
40+
}),
41+
[modes.in]: ({ current, changeState, children }) => [
42+
current,
43+
React.cloneElement(children, {
44+
in: true,
45+
onEntered: callHook(children, 'onEntered', () => {
46+
changeState(ENTERING);
47+
})
48+
})
49+
]
50+
};
51+
52+
const enterRenders = {
53+
[modes.out]: ({ children, changeState }) =>
54+
React.cloneElement(children, {
55+
in: true,
56+
onEntered: callHook(children, 'onEntered', () => {
57+
changeState(ENTERED, React.cloneElement(children, { in: true }));
58+
})
59+
}),
60+
[modes.in]: ({ current, children, changeState }) => [
61+
React.cloneElement(current, {
62+
in: false,
63+
onExited: callHook(current, 'onExited', () => {
64+
changeState(ENTERED, React.cloneElement(children, { in: true }));
65+
})
66+
}),
67+
React.cloneElement(children, {
68+
in: true
69+
})
70+
]
71+
};
72+
73+
/**
74+
* A transition component inspired by the [vue transition modes](https://vuejs.org/v2/guide/transitions.html#Transition-Modes).
75+
* You can use it when you want to control the render between state transitions.
76+
* 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.
77+
*
78+
* If the `out-in` mode is selected, the `SwitchTransition` waits until the old child leaves and then inserts a new child.
79+
* 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
80+
*
81+
* ```jsx
82+
*
83+
* function App() {
84+
* const [state, setState] = useState(false);
85+
* return (
86+
* <SwitchTransition>
87+
* <FadeTransition key={state ? "Goodbye, world!" : "Hello, world!"}
88+
* addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
89+
* classNames='fade' >
90+
* <button onClick={() => setState(state => !state)}>
91+
* {state ? "Goodbye, world!" : "Hello, world!"}
92+
* </button>
93+
* </FadeTransition>
94+
* </SwitchTransition>
95+
* )
96+
* }
97+
* ```
98+
*/
99+
export class SwitchTransition extends React.Component {
100+
state = {
101+
status: ENTERED,
102+
current: null
103+
};
104+
105+
appeared = false;
106+
107+
componentDidMount() {
108+
this.appeared = true;
109+
}
110+
111+
static getDerivedStateFromProps(props, state) {
112+
if (props.children == null) {
113+
return {
114+
current: null
115+
}
116+
}
117+
118+
if (state.status === ENTERING && props.mode === modes.in) {
119+
return {
120+
status: ENTERING
121+
};
122+
}
123+
124+
if (state.current && areChildrenDifferent(state.current, props.children)) {
125+
return {
126+
status: EXITING
127+
};
128+
}
129+
130+
return {
131+
current: React.cloneElement(props.children, {
132+
in: true
133+
})
134+
};
135+
}
136+
137+
changeState = (status, current = this.state.current) => {
138+
this.setState({
139+
status,
140+
current
141+
});
142+
};
143+
144+
render() {
145+
const {
146+
props: { children, mode },
147+
state: { status, current }
148+
} = this;
149+
150+
const data = { children, current, changeState: this.changeState, status };
151+
let component
152+
switch (status) {
153+
case ENTERING:
154+
component = enterRenders[mode](data);
155+
break;
156+
case EXITING:
157+
component = leaveRenders[mode](data)
158+
break;
159+
case ENTERED:
160+
component = current;
161+
}
162+
163+
return (
164+
<TransitionGroupContext.Provider value={{ isMounting: !this.appeared }}>
165+
{component}
166+
</TransitionGroupContext.Provider>
167+
)
168+
}
169+
}
170+
171+
172+
SwitchTransition.propTypes = {
173+
/**
174+
* Transition modes.
175+
* `out-in`: Current element transitions out first, then when complete, the new element transitions in.
176+
* `in-out: New element transitions in first, then when complete, the current element transitions out.`
177+
*
178+
* @type {'out-in'|'in-out'}
179+
*/
180+
mode: PropTypes.oneOf([modes.in, modes.out]),
181+
/**
182+
* Any `Transition` or `CSSTransition` component
183+
*/
184+
children: PropTypes.oneOfType([
185+
PropTypes.element.isRequired,
186+
]),
187+
}
188+
189+
SwitchTransition.defaultProps = {
190+
mode: modes.out
191+
}

test/SwitchTransition-test.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import React from 'react';
2+
3+
import { mount } from 'enzyme';
4+
5+
import Transition, { ENTERED } from '../src/Transition';
6+
import { SwitchTransition, modes } from '../src/SwitchTransition';
7+
8+
describe('SwitchTransition', () => {
9+
let log, Parent;
10+
beforeEach(() => {
11+
log = [];
12+
let events = {
13+
onEnter: (_, m) => log.push(m ? 'appear' : 'enter'),
14+
onEntering: (_, m) => log.push(m ? 'appearing' : 'entering'),
15+
onEntered: (_, m) => log.push(m ? 'appeared' : 'entered'),
16+
onExit: () => log.push('exit'),
17+
onExiting: () => log.push('exiting'),
18+
onExited: () => log.push('exited')
19+
};
20+
21+
Parent = function Parent({ on, rendered = true }) {
22+
return (
23+
<SwitchTransition>
24+
{rendered ? (
25+
<Transition timeout={0} key={on ? 'first' : 'second'} {...events}>
26+
<span />
27+
</Transition>
28+
) : null}
29+
</SwitchTransition>
30+
);
31+
};
32+
});
33+
34+
it('should have default status ENTERED', () => {
35+
const wrapper = mount(
36+
<SwitchTransition>
37+
<Transition timeout={0} key="first">
38+
<span />
39+
</Transition>
40+
</SwitchTransition>
41+
);
42+
43+
expect(wrapper.state('status')).toBe(ENTERED);
44+
});
45+
46+
it('should have default mode: out-in', () => {
47+
const wrapper = mount(
48+
<SwitchTransition>
49+
<Transition timeout={0} key="first">
50+
<span />
51+
</Transition>
52+
</SwitchTransition>
53+
);
54+
55+
expect(wrapper.prop('mode')).toBe(modes.out);
56+
});
57+
58+
it('should work without childs', () => {
59+
expect(() => {
60+
mount(
61+
<SwitchTransition>
62+
<Transition timeout={0} key="first">
63+
<span />
64+
</Transition>
65+
</SwitchTransition>
66+
);
67+
}).not.toThrow();
68+
});
69+
70+
it('should switch between components on change state', () => {
71+
const wrapper = mount(<Parent on={true} />);
72+
73+
jest.useFakeTimers();
74+
expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe(
75+
'first'
76+
);
77+
wrapper.setProps({ on: false });
78+
expect(log).toEqual(['exit', 'exiting']);
79+
jest.runAllTimers();
80+
expect(log).toEqual([
81+
'exit',
82+
'exiting',
83+
'exited',
84+
'enter',
85+
'entering',
86+
'entered'
87+
]);
88+
expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe(
89+
'second'
90+
);
91+
});
92+
93+
it('should switch between null and component', () => {
94+
const wrapper = mount(<Parent on={true} rendered={false} />);
95+
96+
expect(
97+
wrapper.find(SwitchTransition).getElement().props.children
98+
).toBeFalsy();
99+
100+
jest.useFakeTimers();
101+
102+
wrapper.setProps({ rendered: true });
103+
jest.runAllTimers();
104+
expect(log).toEqual(['enter', 'entering', 'entered']);
105+
expect(
106+
wrapper.find(SwitchTransition).getElement().props.children
107+
).toBeTruthy();
108+
expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe(
109+
'first'
110+
);
111+
112+
wrapper.setProps({ on: false, rendered: true });
113+
jest.runAllTimers();
114+
expect(log).toEqual([
115+
'enter',
116+
'entering',
117+
'entered',
118+
'exit',
119+
'exiting',
120+
'exited',
121+
'enter',
122+
'entering',
123+
'entered'
124+
]);
125+
expect(wrapper.find(SwitchTransition).getElement().props.children.key).toBe(
126+
'second'
127+
);
128+
});
129+
});

0 commit comments

Comments
 (0)