-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
80afaaf
commit ec4b767
Showing
2 changed files
with
320 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
); | ||
}); | ||
}); |