react-view-router
Version:
react-view-router
626 lines (550 loc) • 18.1 kB
JavaScript
/* eslint-disable max-len */
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import config from 'react-transition-group/esm/config';
import { timeoutsShape } from 'react-transition-group/esm/utils/PropTypes';
import TransitionGroupContext from 'react-transition-group/esm/TransitionGroupContext';
export const UNMOUNTED = 'unmounted';
export const EXITED = 'exited';
export const ENTERING = 'entering';
export const ENTERED = 'entered';
export const EXITING = 'exiting';
// const nextTick = (cb) => cb && new Promise(r => r()).then(() => cb());
/**
* The Transition component lets you describe a transition from one component
* state to another _over time_ with a simple declarative API. Most commonly
* it's used to animate the mounting and unmounting of a component, but can also
* be used to describe in-place transition states as well.
*
* ---
*
* **Note**: `Transition` is a platform-agnostic base component. If you're using
* transitions in CSS, you'll probably want to use
* [`CSSTransition`](https://reactcommunity.org/react-transition-group/css-transition)
* instead. It inherits all the features of `Transition`, but contains
* additional features necessary to play nice with CSS transitions (hence the
* name of the component).
*
* ---
*
* By default the `Transition` component does not alter the behavior of the
* component it renders, it only tracks "enter" and "exit" states for the
* components. It's up to you to give meaning and effect to those states. For
* example we can add styles to a component when it enters or exits:
*
* ```jsx
* import { Transition } from 'react-transition-group';
*
* const duration = 300;
*
* const defaultStyle = {
* transition: `opacity ${duration}ms ease-in-out`,
* opacity: 0,
* }
*
* const transitionStyles = {
* entering: { opacity: 1 },
* entered: { opacity: 1 },
* exiting: { opacity: 0 },
* exited: { opacity: 0 },
* };
*
* const Fade = ({ in: inProp }) => (
* <Transition in={inProp} timeout={duration}>
* {state => (
* <div style={{
* ...defaultStyle,
* ...transitionStyles[state]
* }}>
* I'm a fade Transition!
* </div>
* )}
* </Transition>
* );
* ```
*
* There are 4 main states a Transition can be in:
* - `'entering'`
* - `'entered'`
* - `'exiting'`
* - `'exited'`
*
* Transition state is toggled via the `in` prop. When `true` the component
* begins the "Enter" stage. During this stage, the component will shift from
* its current transition state, to `'entering'` for the duration of the
* transition and then to the `'entered'` stage once it's complete. Let's take
* the following example (we'll use the
* [useState](https://reactjs.org/docs/hooks-reference.html#usestate) hook):
*
* ```jsx
* function App() {
* const [inProp, setInProp] = useState(false);
* return (
* <div>
* <Transition in={inProp} timeout={500}>
* {state => (
* // ...
* )}
* </Transition>
* <button onClick={() => setInProp(true)}>
* Click to Enter
* </button>
* </div>
* );
* }
* ```
*
* When the button is clicked the component will shift to the `'entering'` state
* and stay there for 500ms (the value of `timeout`) before it finally switches
* to `'entered'`.
*
* When `in` is `false` the same thing happens except the state moves from
* `'exiting'` to `'exited'`.
*/
class Transition extends React.Component {
static contextType = TransitionGroupContext;
constructor(props, context) {
super(props, context);
const parentGroup = context;
// In the context of a TransitionGroup all enters are really appears
const appear
= parentGroup && !parentGroup.isMounting ? props.enter : props.appear;
let initialStatus;
this.appearStatus = null;
if (props.in) {
if (appear) {
initialStatus = EXITED;
this.appearStatus = ENTERING;
} else {
initialStatus = ENTERED;
}
} else {
if (props.unmountOnExit || props.mountOnEnter) {
initialStatus = UNMOUNTED;
} else {
initialStatus = EXITED;
}
}
this.state = { status: initialStatus };
this.nextCallback = null;
// this._isMounted = false;
}
static getDerivedStateFromProps({ in: nextIn }, prevState) {
if (nextIn && prevState.status === UNMOUNTED) {
return { status: EXITED };
}
return null;
}
// getSnapshotBeforeUpdate(prevProps) {
// let nextStatus = null
// if (prevProps !== this.props) {
// const { status } = this.state
// if (this.props.in) {
// if (status !== ENTERING && status !== ENTERED) {
// nextStatus = ENTERING
// }
// } else {
// if (status === ENTERING || status === ENTERED) {
// nextStatus = EXITING
// }
// }
// }
// return { nextStatus }
// }
componentDidMount() {
// this._isMounted = true;
// nextTick(() => this._isMounted && this.updateStatus(true, this.appearStatus));
this.updateStatus(true, this.appearStatus);
}
componentDidUpdate(prevProps) {
let nextStatus = null;
if (prevProps !== this.props) {
const { status } = this.state;
if (this.props.in) {
if (status !== ENTERING && status !== ENTERED) {
nextStatus = ENTERING;
}
} else {
if (status === ENTERING || status === ENTERED) {
nextStatus = EXITING;
}
}
}
this.updateStatus(false, nextStatus);
}
componentWillUnmount() {
// this._isMounted = false;
this.cancelNextCallback();
}
getTimeouts() {
const { timeout } = this.props;
let exit; let enter; let appear;
exit = enter = appear = timeout;
if (timeout != null && typeof timeout !== 'number') {
exit = timeout.exit;
enter = timeout.enter;
// TODO: remove fallback for next major
appear = timeout.appear !== undefined ? timeout.appear : enter;
}
return { exit, enter, appear };
}
// eslint-disable-next-line default-param-last
updateStatus(mounting = false, nextStatus) {
if (nextStatus !== null) {
// nextStatus will always be ENTERING or EXITING.
this.cancelNextCallback();
if (nextStatus === ENTERING) {
this.performEnter(mounting);
} else {
this.performExit();
}
} else if (this.props.unmountOnExit && this.state.status === EXITED) {
this.setState({ status: UNMOUNTED });
}
}
performEnter(mounting) {
const { enter } = this.props;
const appearing = this.context ? this.context.isMounting : mounting;
const [maybeNode, maybeAppearing] = this.props.nodeRef
? [appearing]
: [ReactDOM.findDOMNode(this), appearing];
const timeouts = this.getTimeouts();
const enterTimeout = appearing ? timeouts.appear : timeouts.enter;
// no enter animation skip right to ENTERED
// if we are mounting and running this it means appear _must_ be set
if ((!mounting && !enter) || config.disabled) {
this.safeSetState({ status: ENTERED }, () => {
this.props.onEntered(maybeNode);
});
return;
}
this.props.onEnter(maybeNode, maybeAppearing);
this.safeSetState({ status: ENTERING }, () => {
this.props.onEntering(maybeNode, maybeAppearing);
this.onTransitionEnd(enterTimeout, () => {
this.safeSetState({ status: ENTERED }, () => {
this.props.onEntered(maybeNode, maybeAppearing);
});
});
});
}
performExit() {
const { exit } = this.props;
const timeouts = this.getTimeouts();
const maybeNode = this.props.nodeRef
? undefined
: ReactDOM.findDOMNode(this);
// no exit animation skip right to EXITED
if (!exit || config.disabled) {
this.safeSetState({ status: EXITED }, () => {
this.props.onExited(maybeNode);
});
return;
}
this.props.onExit(maybeNode);
this.safeSetState({ status: EXITING }, () => {
this.props.onExiting(maybeNode);
this.onTransitionEnd(timeouts.exit, () => {
this.safeSetState({ status: EXITED }, () => {
this.props.onExited(maybeNode);
});
});
});
}
cancelNextCallback() {
if (this.nextCallback !== null) {
this.nextCallback.cancel();
this.nextCallback = null;
}
}
safeSetState(nextState, callback) {
// This shouldn't be necessary, but there are weird race conditions with
// setState callbacks and unmounting in testing, so always make sure that
// we can cancel any pending setState callbacks after we unmount.
callback = this.setNextCallback(callback);
this.setState(nextState, callback);
}
setNextCallback(callback) {
let active = true;
this.nextCallback = event => {
if (active) {
active = false;
this.nextCallback = null;
callback(event);
}
};
this.nextCallback.cancel = () => {
active = false;
};
return this.nextCallback;
}
onTransitionEnd(timeout, handler) {
this.setNextCallback(handler);
const node = this.props.nodeRef
? this.props.nodeRef.current
: ReactDOM.findDOMNode(this);
const doesNotHaveTimeoutOrListener
= timeout == null && !this.props.addEndListener;
if (!node || doesNotHaveTimeoutOrListener) {
setTimeout(this.nextCallback, 0);
return;
}
if (this.props.addEndListener) {
const [maybeNode, maybeNextCallback] = this.props.nodeRef
? [this.nextCallback]
: [node, this.nextCallback];
this.props.addEndListener(maybeNode, maybeNextCallback);
}
if (timeout != null) {
setTimeout(this.nextCallback, timeout);
}
}
render() {
const status = this.state.status;
if (status === UNMOUNTED) {
return null;
}
const {
children,
// filter props for `Transition`
// eslint-disable-next-line no-unused-vars
in: _in,
// eslint-disable-next-line no-unused-vars
mountOnEnter: _mountOnEnter,
// eslint-disable-next-line no-unused-vars
unmountOnExit: _unmountOnExit,
// eslint-disable-next-line no-unused-vars
appear: _appear,
// eslint-disable-next-line no-unused-vars
enter: _enter,
// eslint-disable-next-line no-unused-vars
exit: _exit,
// eslint-disable-next-line no-unused-vars
timeout: _timeout,
// eslint-disable-next-line no-unused-vars
addEndListener: _addEndListener,
// eslint-disable-next-line no-unused-vars
onEnter: _onEnter,
// eslint-disable-next-line no-unused-vars
onEntering: _onEntering,
// eslint-disable-next-line no-unused-vars
onEntered: _onEntered,
// eslint-disable-next-line no-unused-vars
onExit: _onExit,
// eslint-disable-next-line no-unused-vars
onExiting: _onExiting,
// eslint-disable-next-line no-unused-vars
onExited: _onExited,
// eslint-disable-next-line no-unused-vars
nodeRef: _nodeRef,
...childProps
} = this.props;
return React.createElement(
TransitionGroupContext.Provider,
{ value: null },
typeof children === 'function'
? children(status, childProps)
: React.cloneElement(React.Children.only(children), childProps)
);
}
}
Transition.propTypes = {
/**
* A React reference to DOM element that need to transition:
* https://stackoverflow.com/a/51127130/4671932
*
* - When `nodeRef` prop is used, `node` is not passed to callback functions
* (e.g. `onEnter`) because user already has direct access to the node.
* - When changing `key` prop of `Transition` in a `TransitionGroup` a new
* `nodeRef` need to be provided to `Transition` with changed `key` prop
* (see
* [test/CSSTransition-test.js](https://github.com/reactjs/react-transition-group/blob/13435f897b3ab71f6e19d724f145596f5910581c/test/CSSTransition-test.js#L362-L437)).
*/
nodeRef: PropTypes.shape({
current:
typeof Element === 'undefined'
? PropTypes.any
: (propValue, key, componentName, location, propFullName, secret) => {
const value = propValue[key];
return PropTypes.instanceOf(
value && 'ownerDocument' in value
? value.ownerDocument.defaultView.Element
: Element
)(propValue, key, componentName, location, propFullName, secret);
},
}),
/**
* A `function` child can be used instead of a React element. This function is
* called with the current transition status (`'entering'`, `'entered'`,
* `'exiting'`, `'exited'`), which can be used to apply context
* specific props to a component.
*
* ```jsx
* <Transition in={this.state.in} timeout={150}>
* {state => (
* <MyComponent className={`fade fade-${state}`} />
* )}
* </Transition>
* ```
*/
children: PropTypes.oneOfType([
PropTypes.func.isRequired,
PropTypes.element.isRequired,
]).isRequired,
/**
* Show the component; triggers the enter or exit states
*/
in: PropTypes.bool,
/**
* By default the child component is mounted immediately along with
* the parent `Transition` component. If you want to "lazy mount" the component on the
* first `in={true}` you can set `mountOnEnter`. After the first enter transition the component will stay
* mounted, even on "exited", unless you also specify `unmountOnExit`.
*/
mountOnEnter: PropTypes.bool,
/**
* By default the child component stays mounted after it reaches the `'exited'` state.
* Set `unmountOnExit` if you'd prefer to unmount the component after it finishes exiting.
*/
unmountOnExit: PropTypes.bool,
/**
* By default the child component does not perform the enter transition when
* it first mounts, regardless of the value of `in`. If you want this
* behavior, set both `appear` and `in` to `true`.
*
* > **Note**: there are no special appear states like `appearing`/`appeared`, this prop
* > only adds an additional enter transition. However, in the
* > `<CSSTransition>` component that first enter transition does result in
* > additional `.appear-*` classes, that way you can choose to style it
* > differently.
*/
appear: PropTypes.bool,
/**
* Enable or disable enter transitions.
*/
enter: PropTypes.bool,
/**
* Enable or disable exit transitions.
*/
exit: PropTypes.bool,
/**
* The duration of the transition, in milliseconds.
* Required unless `addEndListener` is provided.
*
* You may specify a single timeout for all transitions:
*
* ```jsx
* timeout={500}
* ```
*
* or individually:
*
* ```jsx
* timeout={{
* appear: 500,
* enter: 300,
* exit: 500,
* }}
* ```
*
* - `appear` defaults to the value of `enter`
* - `enter` defaults to `0`
* - `exit` defaults to `0`
*
* @type {number | { enter?: number, exit?: number, appear?: number }}
*/
timeout: (props, ...args) => {
let pt = timeoutsShape;
if (!props.addEndListener) pt = pt.isRequired;
return pt(props, ...args);
},
/**
* Add a custom transition end trigger. Called with the transitioning
* DOM node and a `done` callback. Allows for more fine grained transition end
* logic. Timeouts are still used as a fallback if provided.
*
* **Note**: when `nodeRef` prop is passed, `node` is not passed.
*
* ```jsx
* addEndListener={(node, done) => {
* // use the css transitionend event to mark the finish of a transition
* node.addEventListener('transitionend', done, false);
* }}
* ```
*/
addEndListener: PropTypes.func,
/**
* Callback fired before the "entering" status is applied. An extra parameter
* `isAppearing` is supplied to indicate if the enter stage is occurring on the initial mount
*
* **Note**: when `nodeRef` prop is passed, `node` is not passed.
*
* @type Function(node: HtmlElement, isAppearing: bool) -> void
*/
onEnter: PropTypes.func,
/**
* Callback fired after the "entering" status is applied. An extra parameter
* `isAppearing` is supplied to indicate if the enter stage is occurring on the initial mount
*
* **Note**: when `nodeRef` prop is passed, `node` is not passed.
*
* @type Function(node: HtmlElement, isAppearing: bool)
*/
onEntering: PropTypes.func,
/**
* Callback fired after the "entered" status is applied. An extra parameter
* `isAppearing` is supplied to indicate if the enter stage is occurring on the initial mount
*
* **Note**: when `nodeRef` prop is passed, `node` is not passed.
*
* @type Function(node: HtmlElement, isAppearing: bool) -> void
*/
onEntered: PropTypes.func,
/**
* Callback fired before the "exiting" status is applied.
*
* **Note**: when `nodeRef` prop is passed, `node` is not passed.
*
* @type Function(node: HtmlElement) -> void
*/
onExit: PropTypes.func,
/**
* Callback fired after the "exiting" status is applied.
*
* **Note**: when `nodeRef` prop is passed, `node` is not passed.
*
* @type Function(node: HtmlElement) -> void
*/
onExiting: PropTypes.func,
/**
* Callback fired after the "exited" status is applied.
*
* **Note**: when `nodeRef` prop is passed, `node` is not passed
*
* @type Function(node: HtmlElement) -> void
*/
onExited: PropTypes.func,
};
// Name the function so it is clearer in the documentation
function noop() {}
Transition.defaultProps = {
in: false,
mountOnEnter: false,
unmountOnExit: false,
appear: false,
enter: true,
exit: true,
onEnter: noop,
onEntering: noop,
onEntered: noop,
onExit: noop,
onExiting: noop,
onExited: noop,
};
Transition.UNMOUNTED = UNMOUNTED;
Transition.EXITED = EXITED;
Transition.ENTERING = ENTERING;
Transition.ENTERED = ENTERED;
Transition.EXITING = EXITING;
export default Transition;