diff --git a/package.json b/package.json
index f9ff7df03f..2a17446444 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,8 @@
"react-redux": "5.0.6",
"redux": "^3.6.0",
"redux-thunk": "2.2.0",
- "reselect": "3.0.1"
+ "reselect": "3.0.1",
+ "uuid": "^3.1.0"
},
"devDependencies": {
"@atlaskit/css-reset": "1.1.5",
diff --git a/src/view/animation.js b/src/view/animation.js
index 5d05640960..6a9d50937f 100644
--- a/src/view/animation.js
+++ b/src/view/animation.js
@@ -23,6 +23,11 @@ export const physics = (() => {
return { standard, fast };
})();
+const transitionCurve = 'cubic-bezier(0.2, 0, 0, 1)';
+const transitionTime = '0.2s';
+
export const css = {
- outOfTheWay: 'transform 0.2s cubic-bezier(0.2, 0, 0, 1)',
+ outOfTheWay: `transform ${transitionTime} ${transitionCurve}`,
+ transitionCurve,
+ transitionTime,
};
diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx
index 24f8a37caf..93e939a1aa 100644
--- a/src/view/draggable/draggable.jsx
+++ b/src/view/draggable/draggable.jsx
@@ -20,7 +20,7 @@ import type {
Provided as DragHandleProvided,
} from '../drag-handle/drag-handle-types';
import getCenterPosition from '../get-center-position';
-import Placeholder from '../placeholder';
+import { StaticPlaceholder as Placeholder } from '../placeholder';
import { droppableIdKey } from '../context-keys';
import { add } from '../../state/position';
import type {
diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js
index 23c84990f6..3c54cfe111 100644
--- a/src/view/droppable/connected-droppable.js
+++ b/src/view/droppable/connected-droppable.js
@@ -79,9 +79,10 @@ export const makeSelector = (): Selector => {
);
const getMapProps = memoizeOne(
- (isDraggingOver: boolean, placeholder: ?Placeholder): MapProps => ({
+ (isDraggingOver: boolean, placeholder: ?Placeholder, phase: ?string): MapProps => ({
isDraggingOver,
placeholder,
+ phase,
})
);
@@ -107,7 +108,7 @@ export const makeSelector = (): Selector => {
if (phase === 'DRAGGING') {
if (!drag) {
console.error('cannot determine dragging over as there is not drag');
- return getMapProps(false, null);
+ return getMapProps(false, null, phase);
}
const isDraggingOver = getIsDraggingOver(id, drag.impact.destination);
@@ -117,13 +118,13 @@ export const makeSelector = (): Selector => {
drag.impact.destination,
draggable
);
- return getMapProps(isDraggingOver, placeholder);
+ return getMapProps(isDraggingOver, placeholder, phase);
}
if (phase === 'DROP_ANIMATING') {
if (!pending) {
console.error('cannot determine dragging over as there is no pending result');
- return getMapProps(false, null);
+ return getMapProps(false, null, phase);
}
const isDraggingOver = getIsDraggingOver(id, pending.impact.destination);
@@ -133,10 +134,10 @@ export const makeSelector = (): Selector => {
pending.result.destination,
draggable
);
- return getMapProps(isDraggingOver, placeholder);
+ return getMapProps(isDraggingOver, placeholder, phase);
}
- return getMapProps(false, null);
+ return getMapProps(false, null, phase);
},
);
};
diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js
index 1015ce4b51..7d3c067d81 100644
--- a/src/view/droppable/droppable-types.js
+++ b/src/view/droppable/droppable-types.js
@@ -31,6 +31,8 @@ export type MapProps = {|
// not the user is dragging over a list that
// is not the source list
placeholder: ?Placeholder,
+ // whether the entire application is currently in the dragging state
+ phase: ?string,
|}
export type OwnProps = {|
diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx
index 95bf4cbf9e..935c17fcae 100644
--- a/src/view/droppable/droppable.jsx
+++ b/src/view/droppable/droppable.jsx
@@ -14,6 +14,8 @@ type Context = {|
[droppableIdKey]: DroppableId
|}
+const defaultPlaceholderDimensions = { height: 0, width: 0 };
+
export default class Droppable extends Component {
/* eslint-disable react/sort-comp */
props: Props
@@ -62,14 +64,16 @@ export default class Droppable extends Component {
}
getPlaceholder() {
- if (!this.props.placeholder) {
- return null;
- }
+ const { phase } = this.props;
+ const isDraggedOver = Boolean(this.props.placeholder);
+ const { height, width } = this.props.placeholder || defaultPlaceholderDimensions;
return (
);
}
diff --git a/src/view/placeholder/StaticPlaceholder.jsx b/src/view/placeholder/StaticPlaceholder.jsx
new file mode 100644
index 0000000000..4b648a4985
--- /dev/null
+++ b/src/view/placeholder/StaticPlaceholder.jsx
@@ -0,0 +1,21 @@
+// @flow
+import React, { PureComponent } from 'react';
+
+type Props = {|
+ height: number,
+ width: number,
+|};
+
+export default class StaticPlaceholder extends PureComponent {
+ props: Props
+
+ render() {
+ const { height, width } = this.props;
+ const style = {
+ height,
+ pointerEvents: 'none',
+ width,
+ };
+ return
;
+ }
+}
diff --git a/src/view/placeholder/index.js b/src/view/placeholder/index.js
index 7d723e8f35..9518992c70 100644
--- a/src/view/placeholder/index.js
+++ b/src/view/placeholder/index.js
@@ -1 +1,2 @@
-export default from './placeholder';
+export default from './Placeholder';
+export { default as StaticPlaceholder } from './StaticPlaceholder';
diff --git a/src/view/placeholder/placeholder.jsx b/src/view/placeholder/placeholder.jsx
index de44f9c1b9..11ae72ea82 100644
--- a/src/view/placeholder/placeholder.jsx
+++ b/src/view/placeholder/placeholder.jsx
@@ -1,20 +1,192 @@
// @flow
import React, { PureComponent } from 'react';
+import v4 from 'uuid/v4';
+import { css as transitionStyles } from '../animation';
+import type { Phase } from '../../types';
+
+type AnimatedProperties = {|
+ /** The height of the placeholder */
+ height: number,
+ /** The width of the placeholder */
+ width: number,
+|};
+
+type Props = {|
+ ...AnimatedProperties,
+ /** Whether the parent droppable is being dragged over */
+ isDraggedOver: boolean,
+ /** The phase of the drag */
+ phase: Phase,
+|};
+
+type State = {|
+ ...AnimatedProperties,
+ /** Whether the placeholder should be animating in */
+ isAnimatingIn: boolean,
+ /** Whether the placeholder should be animating out */
+ isAnimatingOut: boolean,
+|}
+
+const VENDOR_PREFIXES: string[] = ['-ms-', '-moz-', '-webkit-', ''];
+// We inject a stylesheet into the document so that we can dynamically
+// add CSS animations.
+const stylesheet: HTMLStyleElement = document.createElement('style');
+stylesheet.type = 'text/css';
+document.getElementsByTagName('head')[0].appendChild(stylesheet);
+// A global counter keeping track of how many style rules have been
+// added to the stylesheet.
+let globalRuleIndex = 0;
export default class Placeholder extends PureComponent {
- props: {|
- height: number,
- width: number,
- |}
+ // eslint-disable-next-line react/sort-comp
+ props: Props
- render() {
- const style = {
- width: this.props.width,
- height: this.props.height,
+ static defaultProps = {
+ isDraggedOver: false,
+ }
+
+ state: State = {
+ height: this.props.height,
+ isAnimatingIn: false,
+ isAnimatingOut: false,
+ width: this.props.width,
+ }
+
+ // Every time we instantiate a new placeholder we record the index
+ // of its animation rules, then increment the global counter
+ ruleIndex: number = (() => {
+ const ruleIndex = globalRuleIndex;
+ globalRuleIndex += VENDOR_PREFIXES.length;
+ return ruleIndex;
+ })()
+
+ componentWillReceiveProps(newProps: Props) {
+ if (newProps.phase === this.props.phase &&
+ newProps.isDraggedOver === this.props.isDraggedOver) {
+ return;
+ }
+
+ if (newProps.phase === 'DRAGGING') {
+ const height = newProps.height || this.state.height;
+ const width = newProps.width || this.state.width;
+ const isAnimatingIn = newProps.isDraggedOver;
+ const isAnimatingOut = !newProps.isDraggedOver;
+ this.setState({ height, isAnimatingIn, isAnimatingOut, width });
+ }
+
+ // Reset the state once the drop completes
+ if (newProps.phase === 'DROP_COMPLETE') {
+ this.setState({
+ height: 0,
+ isAnimatingIn: false,
+ isAnimatingOut: false,
+ width: 0,
+ });
+ }
+ }
+
+ getAnimationKeyframes = (animationName: string): string[] => {
+ const { height, isAnimatingIn, isAnimatingOut, width } = this.state;
+ const expanded = `height: ${height}px; width: ${width}px;`;
+ const collapsed = 'height: 0; width: 0;';
+ const keyframeSteps = (() => {
+ if (isAnimatingIn) {
+ return `from { ${collapsed} } to { ${expanded} }`;
+ }
+ if (isAnimatingOut) {
+ return `from { ${expanded} } to { ${collapsed} }`;
+ }
+ return '';
+ })();
+
+ return VENDOR_PREFIXES.map(
+ prefix => `@${prefix}keyframes ${animationName} { ${keyframeSteps} }`
+ );
+ }
+
+ createAnimation = (): ?string => {
+ const { sheet } = stylesheet;
+
+ // Stop flow complaining about possibly undefined properties
+ if (!sheet ||
+ !sheet.cssRules ||
+ !sheet.insertRule ||
+ !sheet.deleteRule) {
+ return null;
+ }
+
+ // We need to generate a random name for the animation every time
+ // because you can't dynamically update CSS animations
+ const animationName = `rbdnd-placeholder-animation-${v4()}`;
+ const keyframes = this.getAnimationKeyframes(animationName);
+ // It's easier to manage if we inject a dummy rule when the
+ // browser doesn't support a prefixed version
+ const dummyRule = '.rbdnd-placeholder-donotuse { display: initial; }';
+ const { ruleIndex } = this;
+
+ for (let i = 0; i < VENDOR_PREFIXES.length; i++) {
+ const thisRuleIndex = ruleIndex + i;
+ // `sheet` is a CSSStyleSheet but the definition of HTMLStyleElement indicates that it
+ // contains a StyleSheet which doesn't have all the methods of its child CSSStyleSheet
+ // $ExpectError - property cssRules not found in StyleSheet
+ if (sheet.cssRules[thisRuleIndex]) {
+ // $ExpectError - property deleteRule not found in StyleSheet
+ sheet.deleteRule(thisRuleIndex);
+ }
+ // The browser will throw if it doesn't support a prefixed version of the rule
+ try {
+ // $ExpectError - property insertRule not found in StyleSheet
+ sheet.insertRule(keyframes[i], thisRuleIndex);
+ } catch (err) {
+ // If it doesn't like it we inject a dummy rule instead
+ // $ExpectError - property insertRule not found in StyleSheet
+ sheet.insertRule(dummyRule, thisRuleIndex);
+ }
+ }
+
+ return animationName;
+ }
+
+ getPlaceholderStyle = () => {
+ const { isDraggedOver, phase } = this.props;
+ const { height, width } = this.state;
+
+ const animationName = this.createAnimation();
+
+ const staticStyles = {
+ height,
pointerEvents: 'none',
+ width,
+ };
+
+ // Hold the full height during a drop
+ if (phase === 'DROP_ANIMATING' && isDraggedOver) {
+ return staticStyles;
+ }
+
+ // Animate in/out during a drag
+ if (phase === 'DRAGGING') {
+ return {
+ ...staticStyles,
+ animationName,
+ animationDuration: transitionStyles.transitionTime,
+ animationTimingFunction: transitionStyles.transitionCurve,
+ animationDelay: '0.0s',
+ animationIterationCount: 1,
+ animationDirection: 'normal',
+ animationFillMode: 'forwards',
+ };
+ }
+
+ // Otherwise don't display
+ return {
+ display: 'none',
};
+ }
+
+ render() {
return (
-
+
);
}
}