@invisionag/react-flip-move
Version:
Effortless animation between DOM changes (eg. list reordering) using the FLIP technique.
226 lines (193 loc) • 6.96 kB
JavaScript
// @flow
/**
* React Flip Move | propConverter
* (c) 2016-present Joshua Comeau
*
* Abstracted away a bunch of the messy business with props.
* - props flow types and defaultProps
* - Type conversion (We accept 'string' and 'number' values for duration,
* delay, and other fields, but we actually need them to be ints.)
* - Children conversion (we need the children to be an array. May not always
* be, if a single child is passed in.)
* - Resolving animation presets into their base CSS styles
*/
/* eslint-disable block-scoped-var */
import React, { Component, Children } from 'react';
// eslint-disable-next-line no-duplicate-imports
import type { ComponentType, Element } from 'react';
import {
statelessFunctionalComponentSupplied,
primitiveNodeSupplied,
invalidTypeForTimingProp,
invalidEnterLeavePreset,
} from './error-messages';
import {
appearPresets,
enterPresets,
leavePresets,
defaultPreset,
disablePreset,
} from './enter-leave-presets';
import { isElementAnSFC, omit } from './helpers';
import type {
Animation,
AnimationProp,
Presets,
FlipMoveProps,
ConvertedProps,
DelegatedProps,
} from './typings';
declare var process: {
env: {
NODE_ENV: 'production' | 'development',
},
};
function propConverter(
ComposedComponent: ComponentType<ConvertedProps>,
): ComponentType<FlipMoveProps> {
return class FlipMovePropConverter extends Component<FlipMoveProps> {
static defaultProps = {
easing: 'ease-in-out',
duration: 350,
delay: 0,
staggerDurationBy: 0,
staggerDelayBy: 0,
typeName: 'div',
enterAnimation: defaultPreset,
leaveAnimation: defaultPreset,
disableAllAnimations: false,
getPosition: (node: HTMLElement) => node.getBoundingClientRect(),
maintainContainerHeight: false,
verticalAlignment: 'top',
};
// eslint-disable-next-line class-methods-use-this
checkChildren(children) {
// Skip all console warnings in production.
// Bail early, to avoid unnecessary work.
if (process.env.NODE_ENV === 'production') {
return;
}
// same as React.Node, but without fragments, see https://github.com/facebook/flow/issues/4781
type Child = void | null | boolean | number | string | Element<*>;
// FlipMove does not support stateless functional components.
// Check to see if any supplied components won't work.
// If the child doesn't have a key, it means we aren't animating it.
// It's allowed to be an SFC, since we ignore it.
Children.forEach(children, (child: Child) => {
// null, undefined, and booleans will be filtered out by Children.toArray
if (child == null || typeof child === 'boolean') {
return;
}
if (typeof child !== 'object') {
primitiveNodeSupplied();
return;
}
if (isElementAnSFC(child) && child.key != null) {
statelessFunctionalComponentSupplied();
}
});
}
convertProps(props: FlipMoveProps): ConvertedProps {
const workingProps: ConvertedProps = {
// explicitly bypass the props that don't need conversion
children: props.children,
easing: props.easing,
onStart: props.onStart,
onFinish: props.onFinish,
onStartAll: props.onStartAll,
onFinishAll: props.onFinishAll,
typeName: props.typeName,
disableAllAnimations: props.disableAllAnimations,
getPosition: props.getPosition,
maintainContainerHeight: props.maintainContainerHeight,
verticalAlignment: props.verticalAlignment,
// Do string-to-int conversion for all timing-related props
duration: this.convertTimingProp('duration'),
delay: this.convertTimingProp('delay'),
staggerDurationBy: this.convertTimingProp('staggerDurationBy'),
staggerDelayBy: this.convertTimingProp('staggerDelayBy'),
// Our enter/leave animations can be specified as boolean (default or
// disabled), string (preset name), or object (actual animation values).
// Let's standardize this so that they're always objects
appearAnimation: this.convertAnimationProp(
props.appearAnimation,
appearPresets,
),
enterAnimation: this.convertAnimationProp(
props.enterAnimation,
enterPresets,
),
leaveAnimation: this.convertAnimationProp(
props.leaveAnimation,
leavePresets,
),
delegated: {},
};
this.checkChildren(workingProps.children);
// Gather any additional props;
// they will be delegated to the ReactElement created.
const primaryPropKeys = Object.keys(workingProps);
const delegatedProps: DelegatedProps = omit(this.props, primaryPropKeys);
// The FlipMove container element needs to have a non-static position.
// We use `relative` by default, but it can be overridden by the user.
// Now that we're delegating props, we need to merge this in.
delegatedProps.style = {
position: 'relative',
...delegatedProps.style,
};
workingProps.delegated = delegatedProps;
return workingProps;
}
convertTimingProp(prop: string): number {
const rawValue: string | number = this.props[prop];
const value =
typeof rawValue === 'number' ? rawValue : parseInt(rawValue, 10);
if (isNaN(value)) {
const defaultValue: number = FlipMovePropConverter.defaultProps[prop];
if (process.env.NODE_ENV !== 'production') {
invalidTypeForTimingProp({
prop,
value: rawValue,
defaultValue,
});
}
return defaultValue;
}
return value;
}
// eslint-disable-next-line class-methods-use-this
convertAnimationProp(
animation: ?AnimationProp,
presets: Presets,
): ?Animation {
switch (typeof animation) {
case 'boolean': {
// If it's true, we want to use the default preset.
// If it's false, we want to use the 'none' preset.
return presets[animation ? defaultPreset : disablePreset];
}
case 'string': {
const presetKeys = Object.keys(presets);
if (presetKeys.indexOf(animation) === -1) {
if (process.env.NODE_ENV !== 'production') {
invalidEnterLeavePreset({
value: animation,
acceptableValues: presetKeys.join(', '),
defaultValue: defaultPreset,
});
}
return presets[defaultPreset];
}
return presets[animation];
}
default: {
return animation;
}
}
}
render() {
return <ComposedComponent {...this.convertProps(this.props)} />;
}
};
}
export default propConverter;