diff --git a/.eslintrc b/.eslintrc index d573cc4fe9..57c598119f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -87,6 +87,13 @@ // All blocks must be wrapped in curly braces {} // Preventing if(condition) return; // https://eslint.org/docs/rules/curly - "curly": ["error", "all"] + "curly": ["error", "all"], + + // Allowing Math.pow rather than forcing `**` + // https://eslint.org/docs/rules/no-restricted-properties + "no-restricted-properties": ["off", { + "object": "Math", + "property": "pow" + }] } } \ No newline at end of file diff --git a/README.md b/README.md index ee1ca22180..311f943ccf 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ class App extends Component { )} ))} + {provided.placeholder} )} @@ -390,7 +391,7 @@ type DraggableLocation = {| ### Best practices for `hooks` -**Block updates during a drag** +#### Block updates during a drag It is **highly** recommended that while a user is dragging that you block any state updates that might impact the amount of `Draggable`s and `Droppable`s, or their dimensions. Please listen to `onDragStart` and block updates to the `Draggable`s and `Droppable`s until you receive at `onDragEnd`. @@ -404,12 +405,7 @@ Here are a few poor user experiences that can occur if you change things *during - If you remove the node that the user is dragging the drag will instantly end - If you change the dimension of the dragging node then other things will not move out of the way at the correct time. - -**`onDragStart` and `onDragEnd` pairing** - -We try very hard to ensure that each `onDragStart` event is paired with a single `onDragEnd` event. However, there maybe a rouge situation where this is not the case. If that occurs - it is a bug. Currently there is no mechanism to tell the library to cancel a current drag externally. - -**Style** +#### Add a cursor style and block selection During a drag it is recommended that you add two styles to the body: @@ -420,9 +416,24 @@ During a drag it is recommended that you add two styles to the body: `cursor: [your desired cursor];` is needed because we apply `pointer-events: none;` to the dragging item. This prevents you setting your own cursor style on the Draggable directly based on `snapshot.isDragging` (see `Draggable`). +#### Force focus after a transition between lists + +When an item is moved from one list to a different list it looses browser focus if it had it. This is because `React` creates a new node in this situation. It will not loose focus if transitioned within the same list. The dragging item will always have had browser focus if it is dragging with a keyboard. It is highly recommended that you give the item (which is now in a different list) focus again. You can see an example of how to do this in our stories. Here is an example of how you could do it: + +- `onDragEnd`: move the item into the new list and record the id fo the item that has moved +- When rendering the reordered list pass down a prop which will tell the newly moved item to obtain focus +- In the `componentDidMount` lifecycle call back check if the item needs to gain focus based on its props (such as an `autoFocus` prop) +- If focus is required - call `.focus` on the node. You can obtain the node by using `ReactDOM.findDOMNode` or monkey patching the `provided.innerRef` callback. + +### Other `hooks` information + +**`onDragStart` and `onDragEnd` pairing** + +We try very hard to ensure that each `onDragStart` event is paired with a single `onDragEnd` event. However, there maybe a rouge situation where this is not the case. If that occurs - it is a bug. Currently there is no mechanism to tell the library to cancel a current drag externally. + **Dynamic hooks** -Your *hook* functions will only be captured *once at start up*. Please do not change the function after that. If there is a valid use case for this then dynamic hooks could be supported. However, at this time it is not. +Your *hook* functions will only be captured *once at start up*. Please do not change the function after that. This behaviour will be changed soon to allow dynamic hooks. ## `Droppable` @@ -437,7 +448,8 @@ import { Droppable } from 'react-beautiful-dnd'; ref={provided.innerRef} style={{ backgroundColor: snapshot.isDraggingOver ? 'blue' : 'grey' }} > - I am a droppable! +

I am a droppable!

+ {provided.placeholder} )} ; @@ -468,14 +480,23 @@ The function is provided with two arguments: ```js type DroppableProvided = {| innerRef: (?HTMLElement) => void, + placeholder: ?ReactElement, |} ``` -In order for the droppable to function correctly, **you must** bind the `provided.innerRef` to the highest possible DOM node in the `ReactElement`. We do this in order to avoid needing to use `ReactDOM` to look up your DOM node. +- `provided.innerRef`: In order for the droppable to function correctly, **you must** bind the `provided.innerRef` to the highest possible DOM node in the `ReactElement`. We do this in order to avoid needing to use `ReactDOM` to look up your DOM node. + +- `provided.placeholder`: This is used to create space in the `Droppable` as needed during a drag. This space is needed when a user is dragging over a list that is not the home list. Please be sure to put the placeholder inside of the component that you have provided the ref for. We need to increase the side of the `Droppable` itself. This is different from `Draggable` where the `placeholder` needs to be a *silbing* to the draggable node. ```js - {(provided, snapshot) =>
Good to go
} + {(provided, snapshot) => ( +
+ Good to go + + {provided.placeholder} +
+ )}
; ``` @@ -497,6 +518,8 @@ The `children` function is also provided with a small amount of state relating t style={{ backgroundColor: snapshot.isDraggingOver ? 'blue' : 'grey' }} > I am a droppable! + + {provided.placeholder} )} ; @@ -681,7 +704,7 @@ type NotDraggingStyle = {| |}; ``` -- `provided.placeholder (?ReactElement)` The `Draggable` element has `position: fixed` applied to it while it is dragging. The role of the `placeholder` is to sit in the place that the `Draggable` was during a drag. It is needed to stop the `Droppable` list from collapsing when you drag. It is advised to render it as a sibling to the `Draggable` node. When the library moves to `React` 16 the `placeholder` will be removed from api. +- `provided.placeholder (?ReactElement)` The `Draggable` element has `position: fixed` applied to it while it is dragging. The role of the `placeholder` is to sit in the place that the `Draggable` was during a drag. It is needed to stop the `Droppable` list from collapsing when you drag. It is advised to render it as a sibling to the `Draggable` node. This is unlike `Droppable` where the `placeholder` needs to be *within* the `Droppable` node. When the library moves to `React` 16 the `placeholder` will be removed from api. ```js @@ -856,6 +879,11 @@ type DraggableLocation = {| // Droppable type DroppableProvided = {| innerRef: (?HTMLElement) => void, + placeholder: ?ReactElement, +|} + +type DraggableStateSnapshot = {| + isDraggingOver: boolean, |} // Draggable @@ -865,6 +893,11 @@ type DraggableProvided = {| dragHandleProps: ?DragHandleProvided, placeholder: ?ReactElement, |} + +type DraggableStateSnapshot = {| + isDragging: boolean, +|} + type DraggableStyle = DraggingStyle | NotDraggingStyle type DraggingStyle = {| pointerEvents: 'none', diff --git a/src/state/action-creators.js b/src/state/action-creators.js index 8fd589ccc0..f258c4c68d 100644 --- a/src/state/action-creators.js +++ b/src/state/action-creators.js @@ -16,34 +16,33 @@ import type { InitialDrag, } from '../types'; import noImpact from './no-impact'; -import getNewHomeClientOffset from './get-new-home-client-offset'; +import getNewHomeClientCenter from './get-new-home-client-center'; import { add, subtract, isEqual } from './position'; const origin: Position = { x: 0, y: 0 }; -type ScrollDiffResult = {| - droppable: Position, - window: Position, -|} - -const getScrollDiff = ( +type ScrollDiffArgs = {| initial: InitialDrag, current: CurrentDrag, - droppable: DroppableDimension -): ScrollDiffResult => { + droppable: ?DroppableDimension +|} + +const getScrollDiff = ({ + initial, + current, + droppable, +}: ScrollDiffArgs): Position => { const windowScrollDiff: Position = subtract( initial.windowScroll, current.windowScroll ); - const droppableScrollDiff: Position = subtract( + + const droppableScrollDiff: Position = droppable ? subtract( droppable.scroll.initial, droppable.scroll.current - ); + ) : origin; - return { - window: windowScrollDiff, - droppable: droppableScrollDiff, - }; + return add(windowScrollDiff, droppableScrollDiff); }; export type RequestDimensionsAction = {| @@ -72,6 +71,7 @@ export type CompleteLiftAction = {| client: InitialDragLocation, page: InitialDragLocation, windowScroll: Position, + isScrollAllowed: boolean, |} |} @@ -80,6 +80,7 @@ const completeLift = (id: DraggableId, client: InitialDragLocation, page: InitialDragLocation, windowScroll: Position, + isScrollAllowed: boolean, ): CompleteLiftAction => ({ type: 'COMPLETE_LIFT', payload: { @@ -88,6 +89,7 @@ const completeLift = (id: DraggableId, client, page, windowScroll, + isScrollAllowed, }, }); @@ -130,6 +132,23 @@ export const updateDroppableDimensionScroll = }, }); +export type UpdateDroppableDimensionIsEnabledAction = {| + type: 'UPDATE_DROPPABLE_DIMENSION_IS_ENABLED', + payload: { + id: DroppableId, + isEnabled: boolean, + } +|} + +export const updateDroppableDimensionIsEnabled = + (id: DroppableId, isEnabled: boolean): UpdateDroppableDimensionIsEnabledAction => ({ + type: 'UPDATE_DROPPABLE_DIMENSION_IS_ENABLED', + payload: { + id, + isEnabled, + }, + }); + export type MoveAction = {| type: 'MOVE', payload: {| @@ -190,6 +209,26 @@ export const moveForward = (id: DraggableId): MoveForwardAction => ({ payload: id, }); +export type CrossAxisMoveForwardAction = {| + type: 'CROSS_AXIS_MOVE_FORWARD', + payload: DraggableId +|} + +export const crossAxisMoveForward = (id: DraggableId): CrossAxisMoveForwardAction => ({ + type: 'CROSS_AXIS_MOVE_FORWARD', + payload: id, +}); + +export type CrossAxisMoveBackwardAction = {| + type: 'CROSS_AXIS_MOVE_BACKWARD', + payload: DraggableId +|} + +export const crossAxisMoveBackward = (id: DraggableId): CrossAxisMoveBackwardAction => ({ + type: 'CROSS_AXIS_MOVE_BACKWARD', + payload: id, +}); + type CleanAction = { type: 'CLEAN', payload: null, @@ -269,11 +308,10 @@ export const drop = () => } const { impact, initial, current } = state.drag; - const sourceDroppable: DroppableDimension = - state.dimension.droppable[initial.source.droppableId]; - const destinationDroppable: ?DroppableDimension = impact.destination ? + const droppable: ?DroppableDimension = impact.destination ? state.dimension.droppable[impact.destination.droppableId] : null; + const draggable: DraggableDimension = state.dimension.draggable[current.id]; const result: DropResult = { draggableId: current.id, @@ -282,22 +320,17 @@ export const drop = () => destination: impact.destination, }; - const scrollDiff = getScrollDiff( - initial, - current, - sourceDroppable, - ); - - const newHomeOffset: Position = getNewHomeClientOffset({ + const newCenter: Position = getNewHomeClientCenter({ movement: impact.movement, - clientOffset: current.client.offset, - pageOffset: current.page.offset, - droppableScrollDiff: scrollDiff.droppable, - windowScrollDiff: scrollDiff.window, + draggable, draggables: state.dimension.draggable, - axis: destinationDroppable ? destinationDroppable.axis : null, + destination: droppable, }); + const clientOffset: Position = subtract(newCenter, draggable.client.withMargin.center); + const scrollDiff: Position = getScrollDiff({ initial, current, droppable }); + const newHomeOffset: Position = add(clientOffset, scrollDiff); + // Do not animate if you do not need to. // This will be the case if either you are dragging with a // keyboard or if you manage to nail it just with a mouse. @@ -353,11 +386,11 @@ export const cancel = () => return; } - const scrollDiff = getScrollDiff(initial, current, droppable); + const scrollDiff: Position = getScrollDiff({ initial, current, droppable }); dispatch(animateDrop({ trigger: 'CANCEL', - newHomeOffset: add(scrollDiff.droppable, scrollDiff.window), + newHomeOffset: scrollDiff, impact: noImpact, result, })); @@ -390,6 +423,7 @@ export type LiftAction = {| client: InitialDragLocation, page: InitialDragLocation, windowScroll: Position, + isScrollAllowed: boolean, |} |} @@ -399,6 +433,7 @@ export const lift = (id: DraggableId, client: InitialDragLocation, page: InitialDragLocation, windowScroll: Position, + isScrollAllowed: boolean, ) => (dispatch: Dispatch, getState: Function) => { (() => { const state: State = getState(); @@ -436,7 +471,7 @@ export const lift = (id: DraggableId, if (newState.phase !== 'COLLECTING_DIMENSIONS') { return; } - dispatch(completeLift(id, type, client, page, windowScroll)); + dispatch(completeLift(id, type, client, page, windowScroll, isScrollAllowed)); }); }); }; @@ -449,6 +484,8 @@ export type Action = BeginLiftAction | MoveAction | MoveBackwardAction | MoveForwardAction | + CrossAxisMoveForwardAction | + CrossAxisMoveBackwardAction | DropAnimateAction | DropCompleteAction | CleanAction; diff --git a/src/state/axis.js b/src/state/axis.js index aa1a553fd7..e3bdec7dbb 100644 --- a/src/state/axis.js +++ b/src/state/axis.js @@ -4,15 +4,23 @@ import type { HorizontalAxis, VerticalAxis } from '../types'; export const vertical: VerticalAxis = { direction: 'vertical', line: 'y', + crossLine: 'x', start: 'top', end: 'bottom', size: 'height', + crossAxisStart: 'left', + crossAxisEnd: 'right', + crossAxisSize: 'width', }; export const horizontal: HorizontalAxis = { direction: 'horizontal', line: 'x', + crossLine: 'y', start: 'left', end: 'right', size: 'width', + crossAxisStart: 'top', + crossAxisEnd: 'bottom', + crossAxisSize: 'height', }; diff --git a/src/state/dimension.js b/src/state/dimension.js index aa316860e0..7f627c9e4e 100644 --- a/src/state/dimension.js +++ b/src/state/dimension.js @@ -1,5 +1,6 @@ // @flow import { vertical, horizontal } from './axis'; +import getClientRect from './get-client-rect'; import type { DroppableId, DraggableId, @@ -8,27 +9,13 @@ import type { DroppableDimension, Direction, DimensionFragment, + Spacing, + ClientRect, } from '../types'; -export type ClientRect = {| - top: number, - right: number, - bottom: number, - left: number, - width: number, - height: number, -|} - -export type Margin = {| - top: number, - right: number, - bottom: number, - left: number, -|} - const origin: Position = { x: 0, y: 0 }; -export const noMargin: Margin = { +export const noSpacing: Spacing = { top: 0, right: 0, bottom: 0, @@ -36,50 +23,55 @@ export const noMargin: Margin = { }; const getWithPosition = (clientRect: ClientRect, point: Position): ClientRect => { - const { top, right, bottom, left, width, height } = clientRect; - return { + const { top, right, bottom, left } = clientRect; + return getClientRect({ top: top + point.y, left: left + point.x, bottom: bottom + point.y, right: right + point.x, - height, - width, - }; + }); }; -const getWithMargin = (clientRect: ClientRect, margin: Margin): ClientRect => { - const { top, right, bottom, left, height, width } = clientRect; - return { - top: top + margin.top, - left: left + margin.left, - bottom: bottom + margin.bottom, - right: right + margin.right, - height: height + margin.top + margin.bottom, - width: width + margin.left + margin.right, - }; +const getWithSpacing = (clientRect: ClientRect, spacing: Spacing): ClientRect => { + const { top, right, bottom, left } = clientRect; + return getClientRect({ + top: top + spacing.top, + left: left + spacing.left, + bottom: bottom + spacing.bottom, + right: right + spacing.right, + }); }; const getFragment = ( initial: ClientRect | DimensionFragment, point?: Position = origin, -): DimensionFragment => ({ - top: initial.top + point.y, - left: initial.left + point.x, - bottom: initial.bottom + point.y, - right: initial.right + point.x, - width: initial.width, - height: initial.height, - center: { - x: ((initial.right + point.x) + (initial.left + point.x)) / 2, - y: ((initial.bottom + point.y) + (initial.top + point.y)) / 2, - }, -}); +): DimensionFragment => { + const rect: ClientRect = getClientRect({ + top: initial.top + point.y, + left: initial.left + point.x, + bottom: initial.bottom + point.y, + right: initial.right + point.x, + }); + + return { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: { + x: (rect.right + rect.left) / 2, + y: (rect.bottom + rect.top) / 2, + }, + }; +}; type GetDraggableArgs = {| id: DraggableId, droppableId: DroppableId, clientRect: ClientRect, - margin?: Margin, + margin?: Spacing, windowScroll?: Position, |}; @@ -87,11 +79,10 @@ export const getDraggableDimension = ({ id, droppableId, clientRect, - margin = noMargin, + margin = noSpacing, windowScroll = origin, }: GetDraggableArgs): DraggableDimension => { const withScroll = getWithPosition(clientRect, windowScroll); - const withScrollAndMargin = getWithMargin(withScroll, margin); const dimension: DraggableDimension = { id, @@ -99,12 +90,12 @@ export const getDraggableDimension = ({ // on the viewport client: { withoutMargin: getFragment(clientRect), - withMargin: getFragment(getWithMargin(clientRect, margin)), + withMargin: getFragment(getWithSpacing(clientRect, margin)), }, // with scroll page: { withoutMargin: getFragment(withScroll), - withMargin: getFragment(withScrollAndMargin), + withMargin: getFragment(getWithSpacing(withScroll, margin)), }, }; @@ -115,33 +106,53 @@ type GetDroppableArgs = {| id: DroppableId, clientRect: ClientRect, direction?: Direction, - margin?: Margin, + margin?: Spacing, + padding?: Spacing, windowScroll?: Position, - scroll?: Position, + scroll ?: Position, + // Whether or not the droppable is currently enabled (can change at during a drag) + // defaults to true + isEnabled?: boolean, |} +const add = (spacing1: Spacing, spacing2: Spacing): Spacing => ({ + top: spacing1.top + spacing2.top, + left: spacing1.left + spacing2.left, + right: spacing1.right + spacing2.right, + bottom: spacing1.bottom + spacing2.bottom, +}); + export const getDroppableDimension = ({ id, clientRect, direction = 'vertical', - margin = noMargin, + margin = noSpacing, + padding = noSpacing, windowScroll = origin, scroll = origin, + isEnabled = true, }: GetDroppableArgs): DroppableDimension => { + const withMargin = getWithSpacing(clientRect, margin); const withWindowScroll = getWithPosition(clientRect, windowScroll); - const withWindowScrollAndMargin = getWithMargin(withWindowScroll, margin); const dimension: DroppableDimension = { id, + isEnabled, axis: direction === 'vertical' ? vertical : horizontal, scroll: { initial: scroll, // when we start the current scroll is the initial scroll current: scroll, }, + client: { + withoutMargin: getFragment(clientRect), + withMargin: getFragment(withMargin), + withMarginAndPadding: getFragment(getWithSpacing(withMargin, padding)), + }, page: { withoutMargin: getFragment(withWindowScroll), - withMargin: getFragment(withWindowScrollAndMargin), + withMargin: getFragment(getWithSpacing(withWindowScroll, margin)), + withMarginAndPadding: getFragment(getWithSpacing(withWindowScroll, add(margin, padding))), }, }; diff --git a/src/state/get-client-rect.js b/src/state/get-client-rect.js new file mode 100644 index 0000000000..2c579383a5 --- /dev/null +++ b/src/state/get-client-rect.js @@ -0,0 +1,15 @@ +// @flow +import type { Spacing, ClientRect } from '../types'; + +// Ideally we would just use the Spacing type here - but flow gets confused when +// dynamically creating a Spacing object from an axis +type ShouldBeSpacing = Object | Spacing + +export default ({ top, right, bottom, left }: ShouldBeSpacing): ClientRect => ({ + top, + right, + bottom, + left, + width: (right - left), + height: (bottom - top), +}); diff --git a/src/state/get-drag-impact.js b/src/state/get-drag-impact.js index 1bab8637a0..a4970c3209 100644 --- a/src/state/get-drag-impact.js +++ b/src/state/get-drag-impact.js @@ -15,7 +15,25 @@ import type { DraggableId, import { patch } from './position'; import getDroppableOver from './get-droppable-over'; import getDraggablesInsideDroppable from './get-draggables-inside-droppable'; -import noImpact from './no-impact'; +import noImpact, { noMovement } from './no-impact'; + +// Calculates the net scroll diff along the main axis +// between two droppables with internal scrolling +const getDroppablesScrollDiff = ({ + sourceDroppable, + destinationDroppable, + line, +}: { + sourceDroppable: DroppableDimension, + destinationDroppable: DroppableDimension, + line: 'x' | 'y', +}): number => { + const sourceScrollDiff = sourceDroppable.scroll.initial[line] - + sourceDroppable.scroll.current[line]; + const destinationScrollDiff = destinationDroppable.scroll.initial[line] - + destinationDroppable.scroll.current[line]; + return destinationScrollDiff - sourceScrollDiff; +}; // It is the responsibility of this function // to return the impact of a drag @@ -25,7 +43,9 @@ type ImpactArgs = {| page: Position, // used for comparison with other dimensions withinDroppable: WithinDroppable, + // item being dragged draggableId: DraggableId, + // all dimensions in system draggables: DraggableDimensionMap, droppables: DroppableDimensionMap |} @@ -46,20 +66,30 @@ export default ({ return noImpact; } - const newCenter = withinDroppable.center; - const draggingDimension: DraggableDimension = draggables[draggableId]; - const droppableDimension: DroppableDimension = droppables[droppableId]; + const droppable: DroppableDimension = droppables[droppableId]; + const axis: Axis = droppable.axis; + + if (!droppable.isEnabled) { + return { + movement: noMovement, + direction: axis.direction, + destination: null, + }; + } const insideDroppable: DraggableDimension[] = getDraggablesInsideDroppable( - droppableDimension, + droppable, draggables, ); - const axis: Axis = droppableDimension.axis; + const newCenter: Position = withinDroppable.center; + const draggingDimension: DraggableDimension = draggables[draggableId]; + const isWithinHomeDroppable = draggingDimension.droppableId === droppableId; // not considering margin so that items move based on visible edges const draggableCenter: Position = draggingDimension.page.withoutMargin.center; const isBeyondStartPosition: boolean = newCenter[axis.line] - draggableCenter[axis.line] > 0; + const shouldDisplaceItemsForward = isWithinHomeDroppable ? isBeyondStartPosition : false; const moved: DraggableId[] = insideDroppable .filter((dimension: DraggableDimension): boolean => { @@ -70,6 +100,17 @@ export default ({ const fragment: DimensionFragment = dimension.page.withoutMargin; + // If we're over a new droppable items will be displaced + // if they sit ahead of the dragging item + if (!isWithinHomeDroppable) { + const scrollDiff = getDroppablesScrollDiff({ + sourceDroppable: droppables[draggingDimension.droppableId], + destinationDroppable: droppable, + line: axis.line, + }); + return (newCenter[axis.line] - scrollDiff) < fragment[axis.end]; + } + if (isBeyondStartPosition) { // 1. item needs to start ahead of the moving item // 2. the dragging item has moved over it @@ -90,8 +131,20 @@ export default ({ }) .map((dimension: DraggableDimension): DroppableId => dimension.id); + // Need to ensure that we always order by the closest impacted item + const ordered: DraggableId[] = (() => { + if (!isWithinHomeDroppable) { + return moved; + } + return isBeyondStartPosition ? moved.reverse() : moved; + })(); + const startIndex = insideDroppable.indexOf(draggingDimension); const index: number = (() => { + if (!isWithinHomeDroppable) { + return insideDroppable.length - moved.length; + } + if (!moved.length) { return startIndex; } @@ -112,8 +165,8 @@ export default ({ const movement: DragMovement = { amount, - draggables: moved, - isBeyondStartPosition, + draggables: ordered, + isBeyondStartPosition: shouldDisplaceItemsForward, }; const impact: DragImpact = { diff --git a/src/state/get-draggables-inside-droppable.js b/src/state/get-draggables-inside-droppable.js index eced228a18..8bced4371b 100644 --- a/src/state/get-draggables-inside-droppable.js +++ b/src/state/get-draggables-inside-droppable.js @@ -1,25 +1,25 @@ // @flow import memoizeOne from 'memoize-one'; import type { + DraggableId, DraggableDimension, DroppableDimension, DraggableDimensionMap, - DraggableId, } from '../types'; export default memoizeOne( - (droppableDimension: DroppableDimension, - draggableDimensions: DraggableDimensionMap, + (droppable: DroppableDimension, + draggables: DraggableDimensionMap, ): DraggableDimension[] => - Object.keys(draggableDimensions) - .map((key: DraggableId): DraggableDimension => draggableDimensions[key]) - .filter((dimension: DraggableDimension): boolean => - dimension.droppableId === droppableDimension.id - ) + Object.keys(draggables) + .map((id: DraggableId): DraggableDimension => draggables[id]) + .filter((draggable: DraggableDimension): boolean => ( + droppable.id === draggable.droppableId + )) // Dimensions are not guarenteed to be ordered in the same order as keys // So we need to sort them so they are in the correct order - .sort((a: DraggableDimension, b: DraggableDimension): number => - a.page.withoutMargin.center[droppableDimension.axis.line] - - b.page.withoutMargin.center[droppableDimension.axis.line] - ) - ); + .sort((a: DraggableDimension, b: DraggableDimension): number => ( + a.page.withoutMargin.center[droppable.axis.line] - + b.page.withoutMargin.center[droppable.axis.line] + )) +); diff --git a/src/state/get-droppable-over.js b/src/state/get-droppable-over.js index d3add29fad..05e127aa47 100644 --- a/src/state/get-droppable-over.js +++ b/src/state/get-droppable-over.js @@ -1,13 +1,22 @@ // @flow -import type { DroppableId, Position, DroppableDimensionMap } from '../types'; -import isInsideDroppable from './is-inside-droppable'; +import { isPointWithin } from './is-within-visible-bounds-of-droppable'; +import type { + DroppableId, + Position, + DroppableDimensionMap, + DroppableDimension, +} from '../types'; export default ( target: Position, droppables: DroppableDimensionMap, ): ?DroppableId => { - const maybeId: ?DroppableId = Object.keys(droppables) - .find(key => isInsideDroppable(target, droppables[key])); + const maybe: ?DroppableDimension = + Object.keys(droppables) + .map((id: DroppableId): DroppableDimension => droppables[id]) + .find((droppable: DroppableDimension): boolean => ( + isPointWithin(droppable)(target) + )); - return maybeId || null; + return maybe ? maybe.id : null; }; diff --git a/src/state/get-initial-impact.js b/src/state/get-initial-impact.js new file mode 100644 index 0000000000..77b0317069 --- /dev/null +++ b/src/state/get-initial-impact.js @@ -0,0 +1,47 @@ +// @flow +import getDraggablesInsideDroppable from './get-draggables-inside-droppable'; +import { noMovement } from './no-impact'; +import type { + DraggableLocation, + DraggableDimension, + DraggableDimensionMap, + DroppableDimension, + DragImpact, +} from '../types'; + +type Args = {| + draggable: DraggableDimension, + droppable: DroppableDimension, + draggables: DraggableDimensionMap +|} + +export default ({ + draggable, + droppable, + draggables, +}: Args): ?DragImpact => { + const insideDroppable: DraggableDimension[] = getDraggablesInsideDroppable( + droppable, + draggables, + ); + + const homeIndex: number = insideDroppable.indexOf(draggable); + + if (homeIndex === -1) { + console.error('lifting a draggable that is not inside a droppable'); + return null; + } + + const home: DraggableLocation = { + index: homeIndex, + droppableId: droppable.id, + }; + + const impact: DragImpact = { + movement: noMovement, + direction: droppable.axis.direction, + destination: home, + }; + + return impact; +}; diff --git a/src/state/get-new-home-client-center.js b/src/state/get-new-home-client-center.js new file mode 100644 index 0000000000..d4e4a7090c --- /dev/null +++ b/src/state/get-new-home-client-center.js @@ -0,0 +1,112 @@ +// @flow +import type { + Axis, + DimensionFragment, + DraggableDimension, + DraggableDimensionMap, + DragMovement, + DroppableDimension, + Position, +} from '../types'; +import moveToEdge from './move-to-edge'; +import getDraggablesInsideDroppable from './get-draggables-inside-droppable'; + +type NewHomeArgs = {| + movement: DragMovement, + draggable: DraggableDimension, + // all draggables in the system + draggables: DraggableDimensionMap, + destination: ?DroppableDimension, +|}; + +// Returns the client offset required to move an item from its +// original client position to its final resting position +export default ({ + movement, + draggable, + draggables, + destination, +}: NewHomeArgs): Position => { + const homeCenter: Position = draggable.client.withMargin.center; + + // not dropping anywhere + if (destination == null) { + return homeCenter; + } + + const { draggables: movedDraggables, isBeyondStartPosition } = movement; + const axis: Axis = destination.axis; + + const isWithinHomeDroppable: boolean = destination.id === draggable.droppableId; + + // dropping back into home index + if (isWithinHomeDroppable && !movedDraggables.length) { + return homeCenter; + } + + // All the draggables in the destination (even the ones that haven't moved) + const draggablesInDestination: DraggableDimension[] = getDraggablesInsideDroppable( + destination, draggables + ); + + // Find the dimension we need to compare the dragged item with + const destinationFragment: DimensionFragment = (() => { + if (isWithinHomeDroppable) { + return draggables[movedDraggables[0]].client.withMargin; + } + + // Not in home list + + if (movedDraggables.length) { + return draggables[movedDraggables[0]].client.withMargin; + } + + // If we're dragging to the last place in a new droppable + // which has items in it (but which haven't moved) + if (draggablesInDestination.length) { + return draggablesInDestination[ + draggablesInDestination.length - 1 + ].client.withMargin; + } + + // Otherwise, return the dimension of the empty foreign droppable + // $ExpectError - flow does not correctly type this as non optional + return destination.client.withMarginAndPadding; + })(); + + const { sourceEdge, destinationEdge } = (() => { + if (isWithinHomeDroppable) { + if (isBeyondStartPosition) { + // move below the target + return { sourceEdge: 'end', destinationEdge: 'end' }; + } + + // move above the target + return { sourceEdge: 'start', destinationEdge: 'start' }; + } + + // not within our home droppable + + // If we're moving in after the last draggable + // we want to move the draggable below the last item + if (!movedDraggables.length && draggablesInDestination.length) { + return { sourceEdge: 'start', destinationEdge: 'end' }; + } + + // move above the target + return { sourceEdge: 'start', destinationEdge: 'start' }; + })(); + + const source: DimensionFragment = draggable.client.withMargin; + + // This is the draggable's new home + const targetCenter: Position = moveToEdge({ + source, + sourceEdge, + destination: destinationFragment, + destinationEdge, + destinationAxis: axis, + }); + + return targetCenter; +}; diff --git a/src/state/get-new-home-client-offset.js b/src/state/get-new-home-client-offset.js deleted file mode 100644 index 7bb3ad5eaf..0000000000 --- a/src/state/get-new-home-client-offset.js +++ /dev/null @@ -1,70 +0,0 @@ -// @flow -import type { - DragMovement, - Position, - DraggableDimension, - DraggableDimensionMap, - DraggableId, - Axis, -} from '../types'; -import { add, subtract, patch } from './position'; - -type NewHomeArgs = {| - movement: DragMovement, - clientOffset: Position, - pageOffset: Position, - droppableScrollDiff: Position, - windowScrollDiff: Position, - draggables: DraggableDimensionMap, - // axis of the destination droppable - axis: ?Axis, -|} - -type ClientOffset = Position; - -// Returns the client offset required to move an item from its -// original client position to its final resting position -export default ({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff, - windowScrollDiff, - draggables, - axis, -}: NewHomeArgs): ClientOffset => { - // Just animate back to where it started - if (!movement.draggables.length) { - return add(droppableScrollDiff, windowScrollDiff); - } - - if (!axis) { - console.error('should not have any movement if there is no axis'); - return add(droppableScrollDiff, windowScrollDiff); - } - - // Currently not considering horizontal movement - const distance: number = movement.draggables.reduce( - (previous: number, draggableId: DraggableId): number => { - const dimension: DraggableDimension = draggables[draggableId]; - // $ExpectError - for some reason flow is not liking axis.size - return previous + dimension.page.withMargin[axis.size]; - }, 0); - - const signed: number = movement.isBeyondStartPosition ? distance : -distance; - - // How much distance the item needs to travel to be in its new home - // from where it started - const amount = patch(axis.line, signed); - - // How far away it is on the page from where it needs to be - const diff: Position = subtract(amount, pageOffset); - - // The final client offset - const client: Position = add(diff, clientOffset); - - // Accounting for container scroll - const withScroll: Position = add(client, droppableScrollDiff); - - return withScroll; -}; diff --git a/src/state/is-inside-droppable.js b/src/state/is-inside-droppable.js deleted file mode 100644 index aedd732521..0000000000 --- a/src/state/is-inside-droppable.js +++ /dev/null @@ -1,16 +0,0 @@ -// @flow -import type { - Position, - DroppableDimension, - DimensionFragment, -} from '../types'; - -export default (target: Position, dimension: DroppableDimension): boolean => { - const fragment: DimensionFragment = dimension.page.withMargin; - const { top, right, bottom, left } = fragment; - - return target.x >= left && - target.x <= right && - target.y >= top && - target.y <= bottom; -}; diff --git a/src/state/is-within-visible-bounds-of-droppable.js b/src/state/is-within-visible-bounds-of-droppable.js new file mode 100644 index 0000000000..64bac99a05 --- /dev/null +++ b/src/state/is-within-visible-bounds-of-droppable.js @@ -0,0 +1,38 @@ +// @flow +import isWithin from './is-within'; +import type { + Position, + DraggableDimension, + DroppableDimension, + DimensionFragment, +} from '../types'; + +export const isPointWithin = (droppable: DroppableDimension) => { + const { top, right, bottom, left } = droppable.page.withMargin; + + const isWithinHorizontal = isWithin(left, right); + const isWithinVertical = isWithin(top, bottom); + + return (point: Position): boolean => ( + isWithinHorizontal(point.x) && + isWithinVertical(point.y) + ); +}; + +export const isDraggableWithin = (droppable: DroppableDimension) => { + const { top, right, bottom, left } = droppable.page.withMargin; + + // There are some extremely minor inaccuracy in the calculations of margins (~0.001) + // To allow for this inaccuracy we make the dimension slightly bigger for this calculation + const isWithinHorizontal = isWithin(left - 1, right + 1); + const isWithinVertical = isWithin(top - 1, bottom + 1); + + return (draggable: DraggableDimension): boolean => { + const fragment: DimensionFragment = draggable.page.withMargin; + + return isWithinHorizontal(fragment.left) && + isWithinHorizontal(fragment.right) && + isWithinVertical(fragment.top) && + isWithinVertical(fragment.bottom); + }; +}; diff --git a/src/state/is-within.js b/src/state/is-within.js new file mode 100644 index 0000000000..89f54f1138 --- /dev/null +++ b/src/state/is-within.js @@ -0,0 +1,6 @@ +// @flow + +// is a value between two other values + +export default (lowerBound: number, upperBound: number): ((number) => boolean) => + (value: number): boolean => value <= upperBound && value >= lowerBound; diff --git a/src/state/jump-to-next-index.js b/src/state/jump-to-next-index.js deleted file mode 100644 index 4538227a70..0000000000 --- a/src/state/jump-to-next-index.js +++ /dev/null @@ -1,121 +0,0 @@ -// @flow -import memoizeOne from 'memoize-one'; -import getDraggablesInsideDroppable from './get-draggables-inside-droppable'; -import { patch } from './position'; -import type { - DraggableLocation, - DraggableDimension, - DroppableDimension, - DraggableDimensionMap, - DroppableDimensionMap, - Position, - DraggableId, - Axis, - DragImpact, -} from '../types'; - -const getIndex = memoizeOne( - (draggables: DraggableDimension[], - target: DraggableDimension - ): number => draggables.indexOf(target) -); - -type JumpToNextArgs = {| - isMovingForward: boolean, - draggableId: DraggableId, - impact: DragImpact, - draggables: DraggableDimensionMap, - droppables: DroppableDimensionMap, -|} - -export type JumpToNextResult = {| - diff: Position, - impact: DragImpact, -|} - -export default ({ - isMovingForward, - draggableId, - impact, - draggables, - droppables, -}: JumpToNextArgs): ?JumpToNextResult => { - if (!impact.destination) { - console.error('cannot move forward when there is not previous destination'); - return null; - } - - const location: DraggableLocation = impact.destination; - const droppable: DroppableDimension = droppables[location.droppableId]; - const draggable: DraggableDimension = draggables[draggableId]; - const currentIndex: number = location.index; - const axis: Axis = droppable.axis; - - const insideDroppable: DraggableDimension[] = getDraggablesInsideDroppable( - droppable, - draggables, - ); - - const startIndex: number = getIndex(insideDroppable, draggable); - - if (startIndex === -1) { - console.error('could not find draggable inside current droppable'); - return null; - } - - // cannot move beyond the last item - if (isMovingForward && currentIndex === insideDroppable.length - 1) { - return null; - } - - // cannot move before the first item - if (!isMovingForward && currentIndex === 0) { - return null; - } - - const atCurrentIndex: DraggableDimension = insideDroppable[currentIndex]; - const nextIndex = isMovingForward ? currentIndex + 1 : currentIndex - 1; - const atNextIndex: DraggableDimension = insideDroppable[nextIndex]; - - const isMovingTowardStart = (isMovingForward && nextIndex <= startIndex) || - (!isMovingForward && nextIndex >= startIndex); - - const distance: number = isMovingTowardStart ? - atCurrentIndex.page.withMargin[axis.size] : - atNextIndex.page.withMargin[axis.size]; - - const signed: number = isMovingForward ? distance : -distance; - - const diff: Position = patch(axis.line, signed); - - // Calculate DragImpact - - // 1. If moving back towards where we started - // we need to remove the latest addition - // 2. If we are moving away from where we started, - // we need to add the next draggable to the impact - const moved: DraggableId[] = isMovingTowardStart ? - impact.movement.draggables.slice(0, impact.movement.draggables.length - 1) : - [...impact.movement.draggables, atNextIndex.id]; - - const newImpact: DragImpact = { - movement: { - draggables: moved, - // The amount of movement will always be the size of the dragging item - amount: patch(axis.line, draggable.page.withMargin[axis.size]), - isBeyondStartPosition: nextIndex > startIndex, - }, - destination: { - droppableId: droppable.id, - index: nextIndex, - }, - direction: droppable.axis.direction, - }; - - const result: JumpToNextResult = { - diff, impact: newImpact, - }; - - return result; -}; - diff --git a/src/state/move-cross-axis/get-best-cross-axis-droppable.js b/src/state/move-cross-axis/get-best-cross-axis-droppable.js new file mode 100644 index 0000000000..d404f18767 --- /dev/null +++ b/src/state/move-cross-axis/get-best-cross-axis-droppable.js @@ -0,0 +1,145 @@ +// @flow +import { closest } from '../position'; +import isWithin from '../is-within'; +import type { + Axis, + Position, + DroppableId, + DimensionFragment, + DroppableDimension, + DroppableDimensionMap, +} from '../../types'; + +const getCorners = (droppable: DroppableDimension): Position[] => { + const fragment: DimensionFragment = droppable.page.withMargin; + + return [ + { x: fragment.left, y: fragment.top }, + { x: fragment.right, y: fragment.top }, + { x: fragment.left, y: fragment.bottom }, + { x: fragment.right, y: fragment.bottom }, + ]; +}; + +type GetBestDroppableArgs = {| + isMovingForward: boolean, + // the current position of the dragging item + pageCenter: Position, + // the home of the draggable + source: DroppableDimension, + // all the droppables in the system + droppables: DroppableDimensionMap, +|} + +export default ({ + isMovingForward, + pageCenter, + source, + droppables, +}: GetBestDroppableArgs): ?DroppableDimension => { + const axis: Axis = source.axis; + + const candidates: DroppableDimension[] = Object.keys(droppables) + .map((id: DroppableId) => droppables[id]) + // Remove the source droppable from the list + .filter((droppable: DroppableDimension): boolean => droppable !== source) + // Remove any options that are not enabled + .filter((droppable: DroppableDimension): boolean => droppable.isEnabled) + // Get only droppables that are on the desired side + .filter((droppable: DroppableDimension): boolean => { + if (isMovingForward) { + // is the droppable in front of the source on the cross axis? + return source.page.withMargin[axis.crossAxisEnd] <= + droppable.page.withMargin[axis.crossAxisStart]; + } + // is the droppable behind the source on the cross axis? + return droppable.page.withMargin[axis.crossAxisEnd] <= + source.page.withMargin[axis.crossAxisStart]; + }) + // Must have some overlap on the main axis + .filter((droppable: DroppableDimension): boolean => { + const sourceFragment: DimensionFragment = source.page.withMargin; + const destinationFragment: DimensionFragment = droppable.page.withMargin; + + const isBetweenSourceBounds = isWithin( + sourceFragment[axis.start], + sourceFragment[axis.end] + ); + const isBetweenDestinationBounds = isWithin( + destinationFragment[axis.start], + destinationFragment[axis.end] + ); + + return isBetweenSourceBounds(destinationFragment[axis.start]) || + isBetweenSourceBounds(destinationFragment[axis.end]) || + isBetweenDestinationBounds(sourceFragment[axis.start]) || + isBetweenDestinationBounds(sourceFragment[axis.end]); + }) + // Sort on the cross axis + .sort((a: DroppableDimension, b: DroppableDimension) => { + const first: number = a.page.withMargin[axis.crossAxisStart]; + const second: number = b.page.withMargin[axis.crossAxisStart]; + + if (isMovingForward) { + return first - second; + } + return second - first; + }) + // Find the droppables that have the same cross axis value as the first item + .filter((droppable: DroppableDimension, index: number, array: DroppableDimension[]): boolean => + droppable.page.withMargin[axis.crossAxisStart] === + array[0].page.withMargin[axis.crossAxisStart] + ); + + // no possible candidates + if (!candidates.length) { + return null; + } + + // only one result - all done! + if (candidates.length === 1) { + return candidates[0]; + } + + // At this point we have a number of candidates that + // all have the same axis.crossAxisStart value. + + // Check to see if the center position is within the size of a Droppable on the main axis + const contains: DroppableDimension[] = candidates + .filter((droppable: DroppableDimension) => { + const isWithinDroppable = isWithin( + droppable.page.withMargin[axis.start], + droppable.page.withMargin[axis.end] + ); + return isWithinDroppable(pageCenter[axis.line]); + }); + + if (contains.length === 1) { + return contains[0]; + } + + // The center point of the draggable falls on the boundary between two droppables + if (contains.length > 1) { + // sort on the main axis and choose the first + return contains.sort((a: DroppableDimension, b: DroppableDimension) => ( + a.page.withMargin[axis.start] - b.page.withMargin[axis.start] + ))[0]; + } + + // The center is not contained within any droppable + // 1. Find the candidate that has the closest corner + // 2. If there is a tie - choose the one that is first on the main axis + return candidates.sort((a: DroppableDimension, b: DroppableDimension) => { + const first = closest(pageCenter, getCorners(a)); + const second = closest(pageCenter, getCorners(b)); + + // if the distances are not equal - choose the shortest + if (first !== second) { + return first - second; + } + + // They both have the same distance - + // choose the one that is first on the main axis + return a.page.withMargin[axis.start] - b.page.withMargin[axis.start]; + })[0]; +}; diff --git a/src/state/move-cross-axis/get-closest-draggable.js b/src/state/move-cross-axis/get-closest-draggable.js new file mode 100644 index 0000000000..bb53c44c54 --- /dev/null +++ b/src/state/move-cross-axis/get-closest-draggable.js @@ -0,0 +1,57 @@ +// @flow +import { distance } from '../position'; +import { isDraggableWithin } from '../is-within-visible-bounds-of-droppable'; +import type { + Axis, + Position, + DraggableDimension, + DroppableDimension, +} from '../../types'; + +type Args = {| + axis: Axis, + pageCenter: Position, + // the droppable that is being moved to + destination: DroppableDimension, + // the droppables inside the destination + insideDestination: DraggableDimension[], +|} + +export default ({ + axis, + pageCenter, + destination, + insideDestination, +}: Args): ?DraggableDimension => { + // Empty list - bail out + if (!insideDestination.length) { + return null; + } + + const isWithinDestination = isDraggableWithin(destination); + + const result: DraggableDimension[] = insideDestination + // Remove any options that are hidden by overflow + // Whole draggable must be visible to move to it + .filter(isWithinDestination) + .sort((a: DraggableDimension, b: DraggableDimension): number => { + const distanceToA = distance(pageCenter, a.page.withMargin.center); + const distanceToB = distance(pageCenter, b.page.withMargin.center); + + // if a is closer - return a + if (distanceToA < distanceToB) { + return -1; + } + + // if b is closer - return b + if (distanceToB < distanceToA) { + return 1; + } + + // if the distance to a and b are the same: + // return the one that appears first on the main axis + return a.page.withMargin[axis.start] - b.page.withMargin[axis.start]; + }); + + return result.length ? result[0] : null; +}; diff --git a/src/state/move-cross-axis/index.js b/src/state/move-cross-axis/index.js new file mode 100644 index 0000000000..69d712889c --- /dev/null +++ b/src/state/move-cross-axis/index.js @@ -0,0 +1,84 @@ +// @flow +import getBestCrossAxisDroppable from './get-best-cross-axis-droppable'; +import getClosestDraggable from './get-closest-draggable'; +import moveToNewDroppable from './move-to-new-droppable/'; +import getDraggablesInsideDroppable from '../get-draggables-inside-droppable'; +import type { Result } from './move-cross-axis-types'; +import type { + DraggableId, + DroppableId, + Position, + DroppableDimension, + DraggableDimension, + DraggableDimensionMap, + DroppableDimensionMap, + DraggableLocation, +} from '../../types'; + +type Args = {| + isMovingForward: boolean, + // the current page center of the dragging item + pageCenter: Position, + // the dragging item + draggableId: DraggableId, + // the droppable the dragging item is in + droppableId: DroppableId, + // the original location of the draggable + home: DraggableLocation, + // all the dimensions in the system + draggables: DraggableDimensionMap, + droppables: DroppableDimensionMap, +|} + +export default ({ + isMovingForward, + pageCenter, + draggableId, + droppableId, + home, + draggables, + droppables, + }: Args): ?Result => { + const draggable: DraggableDimension = draggables[draggableId]; + const source: DroppableDimension = droppables[droppableId]; + + // not considering the container scroll changes as container scrolling cancels a keyboard drag + + const destination: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward, + pageCenter, + source, + droppables, + }); + + // nothing available to move to + if (!destination) { + return null; + } + + const insideDestination: DraggableDimension[] = getDraggablesInsideDroppable( + destination, draggables + ); + + const target: ?DraggableDimension = getClosestDraggable({ + axis: destination.axis, + pageCenter, + destination, + insideDestination, + }); + + // Draggables available, but none are candidates for movement (eg none are visible) + // Cannot move into the list + if (insideDestination.length && !target) { + return null; + } + + return moveToNewDroppable({ + pageCenter, + destination, + draggable, + target, + insideDestination, + home, + }); +}; diff --git a/src/state/move-cross-axis/move-cross-axis-types.js b/src/state/move-cross-axis/move-cross-axis-types.js new file mode 100644 index 0000000000..e9957e9bb5 --- /dev/null +++ b/src/state/move-cross-axis/move-cross-axis-types.js @@ -0,0 +1,8 @@ +import type { Position, DragImpact } from '../../types'; + +export type Result = {| + // how far the draggable needs to move to be in its new home + pageCenter: Position, + // The impact of the movement + impact: DragImpact, +|} diff --git a/src/state/move-cross-axis/move-to-new-droppable/index.js b/src/state/move-cross-axis/move-to-new-droppable/index.js new file mode 100644 index 0000000000..b073788c77 --- /dev/null +++ b/src/state/move-cross-axis/move-to-new-droppable/index.js @@ -0,0 +1,63 @@ +// @flow +import toHomeList from './to-home-list'; +import toForeignList from './to-foreign-list'; +import { patch } from '../../position'; +import type { Result } from '../move-cross-axis-types'; +import type { + Position, + DraggableDimension, + DroppableDimension, + DraggableLocation, +} from '../../../types'; + +type Args = {| + // the current center position of the draggable + pageCenter: Position, + // the draggable that is dragging and needs to move + draggable: DraggableDimension, + // what the draggable is moving towards + // can be null if the destination is empty + target: ?DraggableDimension, + // the droppable the draggable is moving to + destination: DroppableDimension, + // all the draggables inside the destination + insideDestination: DraggableDimension[], + // the source location of the draggable + home: DraggableLocation, +|} + +export default ({ + pageCenter, + destination, + draggable, + target, + home, + insideDestination, +}: Args): ?Result => { + const amount: Position = patch( + destination.axis.line, + draggable.page.withMargin[destination.axis.size] + ); + + // moving back to the home list + if (destination.id === draggable.droppableId) { + return toHomeList({ + amount, + originalIndex: home.index, + target, + insideDroppable: insideDestination, + draggable, + droppable: destination, + }); + } + + // moving to a foreign list + return toForeignList({ + amount, + pageCenter, + target, + insideDroppable: insideDestination, + draggable, + droppable: destination, + }); +}; diff --git a/src/state/move-cross-axis/move-to-new-droppable/to-foreign-list.js b/src/state/move-cross-axis/move-to-new-droppable/to-foreign-list.js new file mode 100644 index 0000000000..18aaa3d2b9 --- /dev/null +++ b/src/state/move-cross-axis/move-to-new-droppable/to-foreign-list.js @@ -0,0 +1,111 @@ +// @flow +import moveToEdge from '../../move-to-edge'; +import type { Result } from '../move-cross-axis-types'; +import type { + Axis, + Position, + DragImpact, + DraggableId, + DraggableDimension, + DroppableDimension, +} from '../../../types'; + +type Args = {| + amount: Position, + pageCenter: Position, + target: ?DraggableDimension, + insideDroppable: DraggableDimension[], + draggable: DraggableDimension, + droppable: DroppableDimension, +|} + +export default ({ + amount, + pageCenter, + target, + insideDroppable, + draggable, + droppable, +}: Args): ?Result => { + const axis: Axis = droppable.axis; + const isGoingBeforeTarget: boolean = Boolean(target && + pageCenter[droppable.axis.line] < target.page.withMargin.center[droppable.axis.line]); + + // Moving to an empty list + + if (!target) { + // Move to start edge of the destination + // based on the axis of the destination + + const newCenter: Position = moveToEdge({ + source: draggable.page.withoutMargin, + sourceEdge: 'start', + destination: droppable.page.withMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + const newImpact: DragImpact = { + movement: { + draggables: [], + amount, + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: droppable.id, + index: 0, + }, + }; + + return { + pageCenter: newCenter, + impact: newImpact, + }; + } + + // Moving to a populated list + + const targetIndex: number = insideDroppable.indexOf(target); + const proposedIndex: number = isGoingBeforeTarget ? targetIndex : targetIndex + 1; + + if (targetIndex === -1) { + console.error('could not find target inside destination'); + return null; + } + + const newCenter: Position = moveToEdge({ + // Aligning to visible top of draggable + source: draggable.page.withoutMargin, + sourceEdge: 'start', + destination: target.page.withMargin, + destinationEdge: isGoingBeforeTarget ? 'start' : 'end', + destinationAxis: axis, + }); + + // Can only displace forward when moving into a foreign list + // if going before: move everything down including the target + // if going after: move everything down excluding the target + + const needsToMove: DraggableId[] = insideDroppable + .slice(proposedIndex, insideDroppable.length) + .map((dimension: DraggableDimension): DraggableId => dimension.id); + + const newImpact: DragImpact = { + movement: { + draggables: needsToMove, + amount, + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: droppable.id, + index: proposedIndex, + }, + }; + + return { + pageCenter: newCenter, + impact: newImpact, + }; +}; diff --git a/src/state/move-cross-axis/move-to-new-droppable/to-home-list.js b/src/state/move-cross-axis/move-to-new-droppable/to-home-list.js new file mode 100644 index 0000000000..421895058e --- /dev/null +++ b/src/state/move-cross-axis/move-to-new-droppable/to-home-list.js @@ -0,0 +1,121 @@ +// @flow +import moveToEdge from '../../move-to-edge'; +import type { Edge } from '../../move-to-edge'; +import type { Result } from '../move-cross-axis-types'; +import type { + Axis, + Position, + DragImpact, + DraggableId, + DraggableDimension, + DroppableDimension, +} from '../../../types'; + +type Args = {| + amount: Position, + originalIndex: number, + target: ?DraggableDimension, + insideDroppable: DraggableDimension[], + draggable: DraggableDimension, + droppable: DroppableDimension, +|} + +export default ({ + amount, + originalIndex, + target, + insideDroppable, + draggable, + droppable, +}: Args): ?Result => { + if (!target) { + console.error('there will always be a target in the original list'); + return null; + } + + const axis: Axis = droppable.axis; + const targetIndex: number = insideDroppable.indexOf(target); + + if (targetIndex === -1) { + console.error('unable to find target in destination droppable'); + return null; + } + + // Moving back to original index + // Super simple - just move it back to the original center with no impact + if (targetIndex === originalIndex) { + const newCenter: Position = draggable.page.withoutMargin.center; + const newImpact: DragImpact = { + movement: { + draggables: [], + amount, + isBeyondStartPosition: false, + }, + direction: droppable.axis.direction, + destination: { + droppableId: droppable.id, + index: originalIndex, + }, + }; + + return { + pageCenter: newCenter, + impact: newImpact, + }; + } + + // When moving *before* where the item started: + // We align the dragging item top of the target + // and move everything from the target to the original position forwards + + // When moving *after* where the item started: + // We align the dragging item to the end of the target + // and move everything from the target to the original position backwards + + const isMovingPastOriginalIndex = targetIndex > originalIndex; + const edge: Edge = isMovingPastOriginalIndex ? 'end' : 'start'; + + const newCenter: Position = moveToEdge({ + source: draggable.page.withoutMargin, + sourceEdge: edge, + destination: isMovingPastOriginalIndex ? target.page.withoutMargin : target.page.withMargin, + destinationEdge: edge, + destinationAxis: axis, + }); + + const needsToMove: DraggableId[] = (() => { + if (!isMovingPastOriginalIndex) { + return insideDroppable.slice(targetIndex, originalIndex); + } + + // We are aligning to the bottom of the target and moving everything + // back to the original index backwards + + // We want everything after the original index to move + const from: number = originalIndex + 1; + // We need the target to move backwards + const to: number = targetIndex + 1; + + // Need to ensure that the list is sorted with the closest item being first + return insideDroppable.slice(from, to).reverse(); + })().map((d: DraggableDimension): DraggableId => d.id); + + const newImpact: DragImpact = { + movement: { + draggables: needsToMove, + amount, + // TODO: not sure what this should be + isBeyondStartPosition: isMovingPastOriginalIndex, + }, + direction: axis.direction, + destination: { + droppableId: droppable.id, + index: targetIndex, + }, + }; + + return { + pageCenter: newCenter, + impact: newImpact, + }; +}; diff --git a/src/state/move-to-edge.js b/src/state/move-to-edge.js new file mode 100644 index 0000000000..40db92eaa6 --- /dev/null +++ b/src/state/move-to-edge.js @@ -0,0 +1,62 @@ +// @flow +import { absolute, add, patch, subtract } from './position'; +import type { + Axis, + Position, + DimensionFragment, +} from '../types'; + +export type Edge = 'start' | 'end'; + +type Args = {| + source: DimensionFragment, + sourceEdge: Edge, + destination: DimensionFragment, + destinationEdge: Edge, + destinationAxis: Axis, +|} + +// Being clear that this function returns the new center position +type CenterPosition = Position; + +// This function will return the center position required to move +// a draggable to the edge of a dimension fragment (could be a droppable or draggable). +// The center position will be aligned to the axis.crossAxisStart value rather than +// the center position of the destination. This allows for generally a better +// experience when moving between lists of different cross axis size. +// The size difference can be caused by the presence or absence of scroll bars + +export default ({ + source, + sourceEdge, + destination, + destinationEdge, + destinationAxis, +}: Args): CenterPosition => { + const getCorner = (fragment: DimensionFragment): Position => patch( + destinationAxis.line, + // it does not really matter what edge we use here + // as the difference to the center from edges will be the same + fragment[destinationAxis[destinationEdge]], + fragment[destinationAxis.crossAxisStart] + ); + + // 1. Find the intersection corner point + // 2. add the difference between that point and the center of the dimension + const corner: Position = getCorner(destination); + + // the difference between the center of the draggable and its corner + const centerDiff = absolute(subtract( + source.center, + getCorner(source) + )); + + const signed: Position = patch( + destinationAxis.line, + // if moving to the end edge - we need to pull the source backwards + (sourceEdge === 'end' ? -1 : 1) * centerDiff[destinationAxis.line], + centerDiff[destinationAxis.crossLine], + ); + + return add(corner, signed); +}; diff --git a/src/state/move-to-next-index/in-foreign-list.js b/src/state/move-to-next-index/in-foreign-list.js new file mode 100644 index 0000000000..35f3d6e628 --- /dev/null +++ b/src/state/move-to-next-index/in-foreign-list.js @@ -0,0 +1,121 @@ +// @flow +import getDraggablesInsideDroppable from '../get-draggables-inside-droppable'; +import { isPointWithin } from '../is-within-visible-bounds-of-droppable'; +import { patch } from '../position'; +import moveToEdge from '../move-to-edge'; +import type { Edge } from '../move-to-edge'; +import type { Args, Result } from './move-to-next-index-types'; +import type { + DraggableLocation, + DraggableDimension, + Position, + Axis, + DragImpact, + DraggableId, +} from '../../types'; + +export default ({ + isMovingForward, + draggableId, + impact, + droppable, + draggables, +}: Args): ?Result => { + if (!impact.destination) { + console.error('cannot move to next index when there is not previous destination'); + return null; + } + + const location: DraggableLocation = impact.destination; + const draggable: DraggableDimension = draggables[draggableId]; + const axis: Axis = droppable.axis; + + const insideForeignDroppable: DraggableDimension[] = getDraggablesInsideDroppable( + droppable, + draggables, + ); + + const currentIndex: number = location.index; + const proposedIndex: number = isMovingForward ? currentIndex + 1 : currentIndex - 1; + const lastIndex: number = insideForeignDroppable.length - 1; + + // draggable is allowed to exceed the foreign droppables count by 1 + if (proposedIndex > insideForeignDroppable.length) { + return null; + } + + // Cannot move before the first item + if (proposedIndex < 0) { + return null; + } + + // Always moving relative to the draggable at the current index + const movingRelativeTo: DraggableDimension = insideForeignDroppable[ + // We want to move relative to the proposed index + // or if we are going beyond to the end of the list - use that index + Math.min(proposedIndex, lastIndex) + ]; + + const isMovingPastLastIndex: boolean = proposedIndex > lastIndex; + const sourceEdge: Edge = 'start'; + const destinationEdge: Edge = (() => { + // moving past the last item + // in this case we are moving relative to the last item + // as there is nothing at the proposed index. + if (isMovingPastLastIndex) { + return 'end'; + } + + return 'start'; + })(); + + const newCenter: Position = moveToEdge({ + source: draggable.page.withoutMargin, + sourceEdge, + destination: movingRelativeTo.page.withMargin, + destinationEdge, + destinationAxis: droppable.axis, + }); + + const isVisible: boolean = (() => { + // Moving into placeholder position + // Usually this would be outside of the visible bounds + if (isMovingPastLastIndex) { + return true; + } + + return isPointWithin(droppable)(newCenter); + })(); + + if (!isVisible) { + return null; + } + + // When we are in foreign list we are only displacing items forward + // This list is always sorted by the closest impacted draggable + const moved: DraggableId[] = isMovingForward ? + // Stop displacing the closest draggable forward + impact.movement.draggables.slice(1, impact.movement.draggables.length) : + // Add the draggable that we are moving into the place of + [movingRelativeTo.id, ...impact.movement.draggables]; + + const newImpact: DragImpact = { + movement: { + draggables: moved, + // The amount of movement will always be the size of the dragging item + amount: patch(axis.line, draggable.page.withMargin[axis.size]), + // When we are in foreign list we are only displacing items forward + isBeyondStartPosition: false, + }, + destination: { + droppableId: droppable.id, + index: proposedIndex, + }, + direction: droppable.axis.direction, + }; + + return { + pageCenter: newCenter, + impact: newImpact, + }; +}; diff --git a/src/state/move-to-next-index/in-home-list.js b/src/state/move-to-next-index/in-home-list.js new file mode 100644 index 0000000000..e33e68b9e1 --- /dev/null +++ b/src/state/move-to-next-index/in-home-list.js @@ -0,0 +1,122 @@ +// @flow +import memoizeOne from 'memoize-one'; +import getDraggablesInsideDroppable from '../get-draggables-inside-droppable'; +import { isPointWithin } from '../is-within-visible-bounds-of-droppable'; +import { patch } from '../position'; +import moveToEdge from '../move-to-edge'; +import type { Edge } from '../move-to-edge'; +import type { Args, Result } from './move-to-next-index-types'; +import type { + DraggableLocation, + DraggableDimension, + Position, + DraggableId, + Axis, + DragImpact, +} from '../../types'; + +const getIndex = memoizeOne( + (draggables: DraggableDimension[], + target: DraggableDimension + ): number => draggables.indexOf(target) +); + +export default ({ + isMovingForward, + draggableId, + impact, + droppable, + draggables, +}: Args): ?Result => { + if (!impact.destination) { + console.error('cannot move to next index when there is not previous destination'); + return null; + } + + const location: DraggableLocation = impact.destination; + const draggable: DraggableDimension = draggables[draggableId]; + const axis: Axis = droppable.axis; + + const insideDroppable: DraggableDimension[] = getDraggablesInsideDroppable( + droppable, + draggables, + ); + + const startIndex: number = getIndex(insideDroppable, draggable); + const currentIndex: number = location.index; + const proposedIndex = isMovingForward ? currentIndex + 1 : currentIndex - 1; + + if (startIndex === -1) { + console.error('could not find draggable inside current droppable'); + return null; + } + + // cannot move forward beyond the last item + if (proposedIndex > insideDroppable.length - 1) { + return null; + } + + // cannot move before the first item + if (proposedIndex < 0) { + return null; + } + + const destination: DraggableDimension = insideDroppable[proposedIndex]; + const isMovingTowardStart = (isMovingForward && proposedIndex <= startIndex) || + (!isMovingForward && proposedIndex >= startIndex); + + const edge: Edge = (() => { + // is moving away from the start + if (!isMovingTowardStart) { + return isMovingForward ? 'end' : 'start'; + } + // is moving back towards the start + return isMovingForward ? 'start' : 'end'; + })(); + + const newCenter: Position = moveToEdge({ + source: draggable.page.withoutMargin, + sourceEdge: edge, + destination: destination.page.withoutMargin, + destinationEdge: edge, + destinationAxis: droppable.axis, + }); + + // Currently not supporting moving a draggable outside the visibility bounds of a droppable + + const isVisible: boolean = isPointWithin(droppable)(newCenter); + + if (!isVisible) { + return null; + } + + // Calculate DragImpact + + // List is sorted where the items closest to where the draggable is currently go first + const moved: DraggableId[] = isMovingTowardStart ? + // remove the most recently impacted + impact.movement.draggables.slice(1, impact.movement.draggables.length) : + // add the destination as the most recently impacted + [destination.id, ...impact.movement.draggables]; + + const newImpact: DragImpact = { + movement: { + draggables: moved, + // The amount of movement will always be the size of the dragging item + amount: patch(axis.line, draggable.page.withMargin[axis.size]), + isBeyondStartPosition: proposedIndex > startIndex, + }, + destination: { + droppableId: droppable.id, + index: proposedIndex, + }, + direction: droppable.axis.direction, + }; + + const result: Result = { + pageCenter: newCenter, + impact: newImpact, + }; + + return result; +}; diff --git a/src/state/move-to-next-index/index.js b/src/state/move-to-next-index/index.js new file mode 100644 index 0000000000..345045d473 --- /dev/null +++ b/src/state/move-to-next-index/index.js @@ -0,0 +1,23 @@ +// @flow +import inHomeList from './in-home-list'; +import inForeignList from './in-foreign-list'; +import type { Args, Result } from './move-to-next-index-types'; +import type { DraggableDimension } from '../../types'; + +export default (args: Args): ?Result => { + const { draggableId, draggables, droppable } = args; + + const draggable: DraggableDimension = draggables[draggableId]; + const isInHomeList: boolean = draggable.droppableId === droppable.id; + + // Cannot move in list if the list is not enabled (can still cross axis move) + if (!droppable.isEnabled) { + return null; + } + + if (isInHomeList) { + return inHomeList(args); + } + + return inForeignList(args); +}; diff --git a/src/state/move-to-next-index/move-to-next-index-types.js b/src/state/move-to-next-index/move-to-next-index-types.js new file mode 100644 index 0000000000..c7f11857e7 --- /dev/null +++ b/src/state/move-to-next-index/move-to-next-index-types.js @@ -0,0 +1,23 @@ +// @flow +import type { + DraggableId, + Position, + DragImpact, + DroppableDimension, + DraggableDimensionMap, +} from '../../types'; + +export type Args = {| + isMovingForward: boolean, + draggableId: DraggableId, + impact: DragImpact, + droppable: DroppableDimension, + draggables: DraggableDimensionMap, +|} + +export type Result = {| + // the new page center position of the element + pageCenter: Position, + // the impact of the movement + impact: DragImpact, +|} diff --git a/src/state/no-impact.js b/src/state/no-impact.js index 91fae6c597..e9d44c3ad6 100644 --- a/src/state/no-impact.js +++ b/src/state/no-impact.js @@ -3,7 +3,7 @@ import type { DragMovement, DragImpact, Position } from '../types'; const origin: Position = { x: 0, y: 0 }; -const noMovement: DragMovement = { +export const noMovement: DragMovement = { draggables: [], amount: origin, isBeyondStartPosition: false, diff --git a/src/state/position.js b/src/state/position.js index 045d4307f9..0b28eaf86e 100644 --- a/src/state/position.js +++ b/src/state/position.js @@ -20,8 +20,34 @@ export const negate = (point: Position): Position => ({ y: point.y !== 0 ? -point.y : 0, }); -export const patch = (line: 'x' | 'y', value: number): Position => ({ - x: line === 'x' ? value : 0, - y: line === 'y' ? value : 0, +export const absolute = (point: Position): Position => ({ + x: Math.abs(point.x), + y: Math.abs(point.y), }); +// Allows you to build a position from values. +// Really useful when working with the Axis type +// patch('x', 5) = { x: 5, y: 0 } +// patch('y', 5, 1) = { x: 1, y: 5 } +export const patch = ( + line: 'x' | 'y', + value: number, + otherValue?: number = 0 +): Position => ({ + // set the value of 'x', or 'y' + [line]: value, + // set the value of the other line + [line === 'x' ? 'y' : 'x']: otherValue, +}); + +// Returns the distance between two points +// https://www.mathsisfun.com/algebra/distance-2-points.html +export const distance = (point1: Position, point2: Position): number => + Math.sqrt( + Math.pow((point2.x - point1.x), 2) + + Math.pow((point2.y - point1.y), 2) + ); + +// When given a list of points, it finds the smallest distance to any point +export const closest = (target: Position, points: Position[]): number => + Math.min(...points.map((point: Position) => distance(target, point))); diff --git a/src/state/reducer.js b/src/state/reducer.js index 7436aa1dee..24fc304779 100644 --- a/src/state/reducer.js +++ b/src/state/reducer.js @@ -5,7 +5,9 @@ import type { TypeId, State, DraggableDimension, DroppableDimension, + DraggableDimensionMap, DroppableId, + DraggableId, DimensionState, DragImpact, DragState, @@ -19,11 +21,13 @@ import type { TypeId, Position, WithinDroppable, } from '../types'; +import getInitialImpact from './get-initial-impact'; import { add, subtract, negate } from './position'; import getDragImpact from './get-drag-impact'; -import jumpToNextIndex from './jump-to-next-index'; -import type { JumpToNextResult } from './jump-to-next-index'; -import getDroppableOver from './get-droppable-over'; +import moveToNextIndex from './move-to-next-index/'; +import type { Result as MoveToNextResult } from './move-to-next-index/move-to-next-index-types'; +import type { Result as MoveCrossAxisResult } from './move-cross-axis/move-cross-axis-types'; +import moveCrossAxis from './move-cross-axis/'; const noDimensions: DimensionState = { request: null, @@ -112,6 +116,7 @@ const move = ({ const current: CurrentDrag = { id: previous.id, type: previous.type, + isScrollAllowed: previous.isScrollAllowed, client, page, withinDroppable, @@ -226,28 +231,28 @@ export default (state: State = clean('IDLE'), action: Action): State => { return state; } - const { id, type, client, page, windowScroll } = action.payload; + const { id, type, client, page, windowScroll, isScrollAllowed } = action.payload; + const draggables: DraggableDimensionMap = state.dimension.draggable; + const draggable: DraggableDimension = state.dimension.draggable[id]; + const droppable: DroppableDimension = state.dimension.droppable[draggable.droppableId]; - // no scroll diff yet so withinDroppable is just the center position - const withinDroppable: WithinDroppable = { - center: page.center, - }; - - const impact: DragImpact = getDragImpact({ - page: page.selection, - withinDroppable, - draggableId: id, - draggables: state.dimension.draggable, - droppables: state.dimension.droppable, + const impact: ?DragImpact = getInitialImpact({ + draggable, + droppable, + draggables, }); - const source: ?DraggableLocation = impact.destination; - - if (!source) { - console.error('lifting a draggable that is not inside a droppable'); + if (!impact || !impact.destination) { + console.error('invalid lift state'); return clean(); } + const source: DraggableLocation = impact.destination; + + const withinDroppable: WithinDroppable = { + center: page.center, + }; + const initial: InitialDrag = { source, client, @@ -271,6 +276,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { }, withinDroppable, windowScroll, + isScrollAllowed, shouldAnimate: false, }; @@ -296,6 +302,14 @@ export default (state: State = clean('IDLE'), action: Action): State => { return clean(); } + // Currently not supporting container scrolling while dragging with a keyboard + // We do not store whether we are dragging with a keyboard in the state but this flag + // does this trick. Ideally this check would not exist. + // Kill the drag instantly + if (!state.drag.current.isScrollAllowed) { + return clean(); + } + const { id, offset } = action.payload; const target: ?DroppableDimension = state.dimension.droppable[id]; @@ -337,6 +351,41 @@ export default (state: State = clean('IDLE'), action: Action): State => { }); } + if (action.type === 'UPDATE_DROPPABLE_DIMENSION_IS_ENABLED') { + if (!Object.keys(state.dimension.droppable).length) { + return state; + } + + const { id, isEnabled } = action.payload; + const target = state.dimension.droppable[id]; + + if (!target) { + console.error('cannot update enabled flag on droppable that does not have a dimension'); + return clean(); + } + + if (target.isEnabled === isEnabled) { + console.warn(`trying to set droppable isEnabled to ${isEnabled} but it is already ${isEnabled}`); + return state; + } + + const updatedDroppableDimension = { + ...target, + isEnabled, + }; + + return { + ...state, + dimension: { + ...state.dimension, + droppable: { + ...state.dimension.droppable, + [id]: updatedDroppableDimension, + }, + }, + }; + } + if (action.type === 'MOVE') { const { client, page, windowScroll } = action.payload; return move({ @@ -396,12 +445,21 @@ export default (state: State = clean('IDLE'), action: Action): State => { const existing: DragState = state.drag; const isMovingForward: boolean = action.type === 'MOVE_FORWARD'; - const result: ?JumpToNextResult = jumpToNextIndex({ + if (!existing.impact.destination) { + console.error('cannot move if there is no previous destination'); + return clean(); + } + + const droppable: DroppableDimension = state.dimension.droppable[ + existing.impact.destination.droppableId + ]; + + const result: ?MoveToNextResult = moveToNextIndex({ isMovingForward, draggableId: existing.current.id, impact: existing.impact, + droppable, draggables: state.dimension.draggable, - droppables: state.dimension.droppable, }); // cannot move anyway (at the beginning or end of a list) @@ -409,28 +467,63 @@ export default (state: State = clean('IDLE'), action: Action): State => { return state; } - const diff: Position = result.diff; const impact: DragImpact = result.impact; + const page: Position = result.pageCenter; + const client: Position = subtract(page, existing.current.windowScroll); - const page: Position = add(existing.current.page.selection, diff); - const client: Position = add(existing.current.client.selection, diff); + return move({ + state, + impact, + clientSelection: client, + pageSelection: page, + shouldAnimate: true, + }); + } - // current limitation: cannot go beyond visible border of list - const droppableId: ?DroppableId = getDroppableOver( - page, state.dimension.droppable, - ); + if (action.type === 'CROSS_AXIS_MOVE_FORWARD' || action.type === 'CROSS_AXIS_MOVE_BACKWARD') { + if (state.phase !== 'DRAGGING') { + console.error('cannot move cross axis when not dragging'); + return clean(); + } + + if (!state.drag) { + console.error('cannot move cross axis if there is no drag information'); + return clean(); + } + + if (!state.drag.impact.destination) { + console.error('cannot move cross axis if not in a droppable'); + return clean(); + } - if (!droppableId) { - // eslint-disable-next-line no-console - console.info('currently not supporting moving a draggable outside the visibility bounds of a droppable'); + const current: CurrentDrag = state.drag.current; + const draggableId: DraggableId = current.id; + const center: Position = current.page.center; + const droppableId: DroppableId = state.drag.impact.destination.droppableId; + const home: DraggableLocation = state.drag.initial.source; + + const result: ?MoveCrossAxisResult = moveCrossAxis({ + isMovingForward: action.type === 'CROSS_AXIS_MOVE_FORWARD', + pageCenter: center, + draggableId, + droppableId, + home, + draggables: state.dimension.draggable, + droppables: state.dimension.droppable, + }); + + if (!result) { return state; } + const page: Position = result.pageCenter; + const client: Position = subtract(page, current.windowScroll); + return move({ state, - impact, clientSelection: client, pageSelection: page, + impact: result.impact, shouldAnimate: true, }); } diff --git a/src/state/selectors.js b/src/state/selectors.js index ef6fc0e51c..7d0a36a8c3 100644 --- a/src/state/selectors.js +++ b/src/state/selectors.js @@ -1,7 +1,10 @@ // @flow +import { createSelector } from 'reselect'; import type { PendingDrop, DragState, + DraggableDimension, + DraggableDimensionMap, Phase, State, } from '../types'; @@ -16,3 +19,43 @@ export const pendingDropSelector = (state: State): ?PendingDrop => { }; export const dragSelector = (state: State): ?DragState => state.drag; + +const draggableMapSelector = (state: State): DraggableDimensionMap => state.dimension.draggable; + +export const draggingDraggableSelector = createSelector([ + phaseSelector, + dragSelector, + pendingDropSelector, + draggableMapSelector, +], (phase: Phase, + drag: ?DragState, + pending: ?PendingDrop, + draggables: DraggableDimensionMap + ): ?DraggableDimension => { + if (phase === 'DRAGGING') { + if (!drag) { + console.error('cannot get placeholder dimensions as there is an invalid drag state'); + return null; + } + + const draggable: DraggableDimension = draggables[drag.current.id]; + return draggable; + } + + if (phase === 'DROP_ANIMATING') { + if (!pending) { + console.error('cannot get placeholder dimensions as there is an invalid drag state'); + return null; + } + + if (!pending.result.destination) { + return null; + } + + const draggable: DraggableDimension = draggables[pending.result.draggableId]; + return draggable; + } + + return null; +} +); diff --git a/src/types.js b/src/types.js index c93f270bc6..d81bd5c17b 100644 --- a/src/types.js +++ b/src/types.js @@ -13,60 +13,92 @@ export type Position = {| y: number, |}; +export type Spacing = {| + top: number, + right: number, + bottom: number, + left: number, +|} + +export type ClientRect = {| + top: number, + right: number, + bottom: number, + left: number, + width: number, + height: number, +|} + export type Direction = 'horizontal' | 'vertical'; export type VerticalAxis = {| direction: 'vertical', line: 'y', + crossLine: 'x', start: 'top', end: 'bottom', size: 'height', + crossAxisStart: 'left', + crossAxisEnd: 'right', + crossAxisSize: 'width', |} export type HorizontalAxis = {| direction: 'horizontal', line: 'x', + crossLine: 'y', start: 'left', end: 'right', size: 'width', + crossAxisStart: 'top', + crossAxisEnd: 'bottom', + crossAxisSize: 'height', |} export type Axis = VerticalAxis | HorizontalAxis export type DimensionFragment = {| - top: number, - left: number, - bottom: number, - right: number, - width: number, - height: number, + ...ClientRect, center: Position, |} export type DraggableDimension = {| id: DraggableId, droppableId: DroppableId, - page: {| + // relative to the viewport when the drag started + client: {| withMargin: DimensionFragment, withoutMargin: DimensionFragment, |}, - client: {| + // relative to the whole page + page: {| withMargin: DimensionFragment, withoutMargin: DimensionFragment, - |} + |}, |} export type DroppableDimension = {| id: DroppableId, axis: Axis, + isEnabled: boolean, scroll: {| initial: Position, current: Position, |}, + // relative to the current viewport + client: {| + withMargin: DimensionFragment, + withoutMargin: DimensionFragment, + // the area in which content presses up against + withMarginAndPadding: DimensionFragment, + |}, + // relative to the whole page page: {| withMargin: DimensionFragment, withoutMargin: DimensionFragment, - |} + // the area in which content presses up against + withMarginAndPadding: DimensionFragment, + |}, |} export type DraggableLocation = {| droppableId: DroppableId, @@ -77,6 +109,8 @@ export type DraggableDimensionMap = { [key: DraggableId]: DraggableDimension }; export type DroppableDimensionMap = { [key: DroppableId]: DroppableDimension }; export type DragMovement = {| + // The draggables that need to move in response to a drag. + // Ordered by closest draggable to the *current* location of the dragging item draggables: DraggableId[], amount: Position, // is moving forward relative to the starting position @@ -87,7 +121,7 @@ export type DragImpact = {| movement: DragMovement, // the direction of the Droppable you are over direction: ?Direction, - destination: ?DraggableLocation + destination: ?DraggableLocation, |} export type InitialDragLocation = {| @@ -101,9 +135,9 @@ export type WithinDroppable = {| export type InitialDrag = {| source: DraggableLocation, - // viewport + // relative to the viewport when the drag started client: InitialDragLocation, - // viewport + window scroll + // viewport + window scroll (position relative to 0, 0) page: InitialDragLocation, // Storing scroll directly to support movement during a window scroll. // Value required for comparison with current scroll @@ -125,6 +159,8 @@ export type CurrentDragLocation = {| export type CurrentDrag = {| id: DraggableId, type: TypeId, + // whether scrolling is allowed - otherwise a scroll will cancel the drag + isScrollAllowed: boolean, // viewport client: CurrentDragLocation, // viewport + scroll @@ -134,6 +170,7 @@ export type CurrentDrag = {| windowScroll: Position, // viewport + scroll + droppable scroll withinDroppable: WithinDroppable, + // whether or not movements should be animated shouldAnimate: boolean, |} @@ -150,7 +187,7 @@ export type DropResult = {| type: TypeId, source: DraggableLocation, // may not have any destination (drag to nowhere) - destination: ?DraggableLocation + destination: ?DraggableLocation, |} export type DragState = {| diff --git a/src/view/dimension-types.js b/src/view/dimension-types.js deleted file mode 100644 index 926f1eb7d1..0000000000 --- a/src/view/dimension-types.js +++ /dev/null @@ -1,15 +0,0 @@ -export type ClientRect = {| - top: number, - right: number, - bottom: number, - left: number, - width: number, - height: number, -|} - -export type Margin = {| - top: number, - right: number, - bottom: number, - left: number, -|} diff --git a/src/view/drag-handle/drag-handle-types.js b/src/view/drag-handle/drag-handle-types.js index adaa7a84b2..f618db0260 100644 --- a/src/view/drag-handle/drag-handle-types.js +++ b/src/view/drag-handle/drag-handle-types.js @@ -9,6 +9,8 @@ export type Callbacks = {| onWindowScroll: (diff: Position) => void, onMoveForward: () => void, onMoveBackward: () => void, + onCrossAxisMoveForward: () => void, + onCrossAxisMoveBackward: () => void, onDrop: () => void, onCancel: () => void, |} diff --git a/src/view/drag-handle/drag-handle.jsx b/src/view/drag-handle/drag-handle.jsx index 147a91a42f..edd5430bd4 100644 --- a/src/view/drag-handle/drag-handle.jsx +++ b/src/view/drag-handle/drag-handle.jsx @@ -2,7 +2,7 @@ import { Component } from 'react'; import invariant from 'invariant'; import memoizeOne from 'memoize-one'; -import rafScheduler from 'raf-schd'; +import rafSchedule from 'raf-schd'; // Using keyCode's for consistent event pattern matching between // React synthetic events as well as raw browser events. import * as keyCodes from '../key-codes'; @@ -29,6 +29,11 @@ type State = { pending: ?Position, }; +type ExecuteBasedOnDirection = {| + vertical: () => void, + horizontal: () => void, +|} + export default class DragHandle extends Component { /* eslint-disable react/sort-comp */ @@ -60,19 +65,27 @@ export default class DragHandle extends Component { }); // scheduled functions - scheduleMove = rafScheduler((point: Position) => { + scheduleMove = rafSchedule((point: Position) => { this.ifDragging(() => this.memoizedMove(point.x, point.y)); }); - scheduleMoveForward = rafScheduler(() => { + scheduleMoveForward = rafSchedule(() => { this.ifDragging(this.props.callbacks.onMoveForward); }) - scheduleMoveBackward = rafScheduler(() => { + scheduleMoveBackward = rafSchedule(() => { this.ifDragging(this.props.callbacks.onMoveBackward); }); - scheduleWindowScrollMove = rafScheduler(() => { + scheduleCrossAxisMoveForward = rafSchedule(() => { + this.ifDragging(this.props.callbacks.onCrossAxisMoveForward); + }) + + scheduleCrossAxisMoveBackward = rafSchedule(() => { + this.ifDragging(this.props.callbacks.onCrossAxisMoveBackward); + }); + + scheduleWindowScrollMove = rafSchedule(() => { this.ifDragging(this.props.callbacks.onWindowScroll); }); /* eslint-enable react/sort-comp */ @@ -237,6 +250,17 @@ export default class DragHandle extends Component { this.startPendingMouseDrag(point); }; + executeBasedOnDirection = (fns: ExecuteBasedOnDirection) => { + if (!this.props.direction) { + console.error('cannot move based on direction when none is provided'); + this.stopDragging(() => this.props.callbacks.onCancel()); + return; + } + + // eslint-disable-next-line no-unused-expressions + this.props.direction === 'vertical' ? fns.vertical() : fns.horizontal(); + } + // window keyboard events are bound during a keyboard drag // or after the user presses the mouse down onWindowKeydown = (event: KeyboardEvent): void => { @@ -298,30 +322,39 @@ export default class DragHandle extends Component { return; } - if (this.props.direction === 'vertical') { - if (event.keyCode === keyCodes.arrowDown) { - event.preventDefault(); - this.scheduleMoveForward(); - } - - if (event.keyCode === keyCodes.arrowUp) { - event.preventDefault(); - this.scheduleMoveBackward(); - } + if (event.keyCode === keyCodes.arrowDown) { + event.preventDefault(); + this.executeBasedOnDirection({ + vertical: this.scheduleMoveForward, + horizontal: this.scheduleCrossAxisMoveForward, + }); + return; + } + if (event.keyCode === keyCodes.arrowUp) { + event.preventDefault(); + this.executeBasedOnDirection({ + vertical: this.scheduleMoveBackward, + horizontal: this.scheduleCrossAxisMoveBackward, + }); return; } - // horizontal dragging if (event.keyCode === keyCodes.arrowRight) { event.preventDefault(); - this.scheduleMoveForward(); + this.executeBasedOnDirection({ + vertical: this.scheduleCrossAxisMoveForward, + horizontal: this.scheduleMoveForward, + }); return; } if (event.keyCode === keyCodes.arrowLeft) { event.preventDefault(); - this.scheduleMoveBackward(); + this.executeBasedOnDirection({ + vertical: this.scheduleCrossAxisMoveBackward, + horizontal: this.scheduleMoveBackward, + }); } } diff --git a/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx b/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx index 6d425533a2..d0a42dbb43 100644 --- a/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx +++ b/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx @@ -3,9 +3,7 @@ import { Component } from 'react'; import invariant from 'invariant'; import getWindowScrollPosition from '../get-window-scroll-position'; import { getDraggableDimension } from '../../state/dimension'; -// eslint-disable-next-line no-duplicate-imports -import type { Margin } from '../../state/dimension'; -import type { DraggableDimension } from '../../types'; +import type { DraggableDimension, Spacing } from '../../types'; import type { Props } from './draggable-dimension-publisher-types'; export default class DraggableDimensionPublisher extends Component { @@ -23,7 +21,7 @@ export default class DraggableDimensionPublisher extends Component { const style = window.getComputedStyle(targetRef); - const margin: Margin = { + const margin: Spacing = { top: parseInt(style.marginTop, 10), right: parseInt(style.marginRight, 10), bottom: parseInt(style.marginBottom, 10), diff --git a/src/view/draggable/connected-draggable.js b/src/view/draggable/connected-draggable.js index 2f8b0a1c13..214de089e4 100644 --- a/src/view/draggable/connected-draggable.js +++ b/src/view/draggable/connected-draggable.js @@ -15,6 +15,8 @@ import { move as moveAction, moveForward as moveForwardAction, moveBackward as moveBackwardAction, + crossAxisMoveForward as crossAxisMoveForwardAction, + crossAxisMoveBackward as crossAxisMoveBackwardAction, drop as dropAction, cancel as cancelAction, dropAnimationFinished as dropAnimationFinishedAction, @@ -228,8 +230,10 @@ const makeMapStateToProps = () => { const mapDispatchToProps: DispatchProps = { lift: liftAction, move: moveAction, - moveBackward: moveBackwardAction, moveForward: moveForwardAction, + moveBackward: moveBackwardAction, + crossAxisMoveForward: crossAxisMoveForwardAction, + crossAxisMoveBackward: crossAxisMoveBackwardAction, moveByWindowScroll: moveByWindowScrollAction, drop: dropAction, dropAnimationFinished: dropAnimationFinishedAction, diff --git a/src/view/draggable/draggable-types.js b/src/view/draggable/draggable-types.js index c4a673dfe7..db32e3aaae 100644 --- a/src/view/draggable/draggable-types.js +++ b/src/view/draggable/draggable-types.js @@ -19,6 +19,8 @@ import { moveByWindowScroll, moveForward, moveBackward, + crossAxisMoveForward, + crossAxisMoveBackward, drop, cancel, dropAnimationFinished, @@ -97,6 +99,8 @@ export type DispatchProps = { moveByWindowScroll: PropType, moveForward: PropType, moveBackward: PropType, + crossAxisMoveForward: PropType, + crossAxisMoveBackward: PropType, drop: PropType, cancel: PropType, dropAnimationFinished: PropType, diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 661727400f..0551ac2e41 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 Placeholder from '../placeholder'; import { droppableIdKey } from '../context-keys'; import { add } from '../../state/position'; import type { @@ -69,7 +69,7 @@ export default class Draggable extends Component { constructor(props: Props, context: mixed) { super(props, context); - this.callbacks = { + const callbacks: DragHandleCallbacks = { onLift: this.onLift, onMove: this.onMove, onDrop: this.onDrop, @@ -77,8 +77,12 @@ export default class Draggable extends Component { onKeyLift: this.onKeyLift, onMoveBackward: this.onMoveBackward, onMoveForward: this.onMoveForward, + onCrossAxisMoveForward: this.onCrossAxisMoveForward, + onCrossAxisMoveBackward: this.onCrossAxisMoveBackward, onWindowScroll: this.onWindowScroll, }; + + this.callbacks = callbacks; } // This should already be handled gracefully in DragHandle. @@ -117,7 +121,10 @@ export default class Draggable extends Component { center: add(client.center, windowScroll), }; - lift(draggableId, type, client, page, windowScroll); + // Allowing scrolling with a mouse when lifting with a mouse + const isScrollAllowed = true; + + lift(draggableId, type, client, page, windowScroll, isScrollAllowed); } onKeyLift = () => { @@ -138,8 +145,10 @@ export default class Draggable extends Component { selection: add(center, windowScroll), center: add(center, windowScroll), }; + // not allowing scrolling with a mouse when lifting with a keyboard + const isScrollAllowed = false; - lift(draggableId, type, client, page, windowScroll); + lift(draggableId, type, client, page, windowScroll, isScrollAllowed); } onMove = (client: Position) => { @@ -168,6 +177,16 @@ export default class Draggable extends Component { this.props.moveBackward(this.props.draggableId); } + onCrossAxisMoveForward = () => { + this.throwIfCannotDrag(); + this.props.crossAxisMoveForward(this.props.draggableId); + } + + onCrossAxisMoveBackward = () => { + this.throwIfCannotDrag(); + this.props.crossAxisMoveBackward(this.props.draggableId); + } + onWindowScroll = () => { this.throwIfCannotDrag(); const windowScroll = getWindowScrollPosition(); diff --git a/src/view/droppable-dimension-publisher/connected-droppable-dimension-publisher.js b/src/view/droppable-dimension-publisher/connected-droppable-dimension-publisher.js index 092f6b6d9e..dc69804947 100644 --- a/src/view/droppable-dimension-publisher/connected-droppable-dimension-publisher.js +++ b/src/view/droppable-dimension-publisher/connected-droppable-dimension-publisher.js @@ -14,6 +14,7 @@ import DroppableDimensionPublisher from './droppable-dimension-publisher'; import { publishDroppableDimension, updateDroppableDimensionScroll, + updateDroppableDimensionIsEnabled, } from '../../state/action-creators'; const requestDimensionSelector = @@ -43,6 +44,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps: DispatchProps = { publish: publishDroppableDimension, updateScroll: updateDroppableDimensionScroll, + updateIsEnabled: updateDroppableDimensionIsEnabled, }; export default connect( diff --git a/src/view/droppable-dimension-publisher/droppable-dimension-publisher-types.js b/src/view/droppable-dimension-publisher/droppable-dimension-publisher-types.js index cfa53daf5d..b48c0e1704 100644 --- a/src/view/droppable-dimension-publisher/droppable-dimension-publisher-types.js +++ b/src/view/droppable-dimension-publisher/droppable-dimension-publisher-types.js @@ -1,11 +1,15 @@ // @flow +import type { PropType } from 'babel-plugin-react-flow-props-to-prop-types'; +import { + publishDroppableDimension, + updateDroppableDimensionIsEnabled, + updateDroppableDimensionScroll, +} from '../../state/action-creators'; import type { - DroppableDimension, DroppableId, TypeId, ReactElement, HTMLElement, - Position, Direction, } from '../../types'; @@ -14,19 +18,25 @@ export type MapProps = {| |} export type DispatchProps = {| - publish: (dimension: DroppableDimension) => mixed, - updateScroll: (id: DroppableId, offset: Position) => mixed, + publish: PropType, + updateIsEnabled: PropType, + updateScroll: PropType, |} export type OwnProps = {| droppableId: DroppableId, direction: Direction, + isDropDisabled: boolean, type: TypeId, targetRef: ?HTMLElement, children?: ReactElement, |} -export type Props = MapProps & DispatchProps & OwnProps; +export type Props = { + ...MapProps, + ...DispatchProps, + ...OwnProps +} // Having issues getting the correct reselect type // export type Selector = OutputSelector; diff --git a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx b/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx index 84a11ab5be..4a1a813106 100644 --- a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx +++ b/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx @@ -4,11 +4,17 @@ import invariant from 'invariant'; import rafScheduler from 'raf-schd'; import memoizeOne from 'memoize-one'; import getWindowScrollPosition from '../get-window-scroll-position'; +import getClientRect from '../../state/get-client-rect'; import { getDroppableDimension } from '../../state/dimension'; import getClosestScrollable from '../get-closest-scrollable'; // eslint-disable-next-line no-duplicate-imports -import type { Margin } from '../../state/dimension'; -import type { DroppableDimension, Position, HTMLElement } from '../../types'; +import type { + DroppableDimension, + Position, + HTMLElement, + ClientRect, + Spacing, +} from '../../types'; import type { Props } from './droppable-dimension-publisher-types'; const origin: Position = { x: 0, y: 0 }; @@ -34,25 +40,66 @@ export default class DroppableDimensionPublisher extends Component { } getDimension = (): DroppableDimension => { - const { droppableId, direction, targetRef } = this.props; + const { droppableId, direction, isDropDisabled, targetRef } = this.props; invariant(targetRef, 'DimensionPublisher cannot calculate a dimension when not attached to the DOM'); + const scroll: Position = this.getScrollOffset(); const style = window.getComputedStyle(targetRef); - const margin: Margin = { + // keeping it simple and always using the margin of the droppable + + const margin: Spacing = { top: parseInt(style.marginTop, 10), right: parseInt(style.marginRight, 10), bottom: parseInt(style.marginBottom, 10), left: parseInt(style.marginLeft, 10), }; + const padding: Spacing = { + top: parseInt(style.paddingTop, 10), + right: parseInt(style.paddingRight, 10), + bottom: parseInt(style.paddingBottom, 10), + left: parseInt(style.paddingLeft, 10), + }; + + const clientRect: ClientRect = (() => { + const current: ClientRect = targetRef.getBoundingClientRect(); + + if (!this.closestScrollable) { + return current; + } + + if (this.closestScrollable === targetRef) { + return current; + } + + // We need to trim the dimension by the visible area of the scroll container + + // Adjust the current dimension with the parents scroll + const top = current.top + scroll.y; + const bottom = current.bottom + scroll.y; + const left = current.left + scroll.x; + const right = current.right + scroll.x; + + // Trim the dimension by the size of the parent + + const parent: ClientRect = this.closestScrollable.getBoundingClientRect(); + return getClientRect({ + top: Math.max(top, parent.top), + left: Math.max(left, parent.left), + right: Math.min(right, parent.right), + bottom: Math.min(bottom, parent.bottom), + }); + })(); const dimension: DroppableDimension = getDroppableDimension({ id: droppableId, direction, - clientRect: targetRef.getBoundingClientRect(), + clientRect, margin, + padding, windowScroll: getWindowScrollPosition(), - scroll: this.getScrollOffset(), + scroll, + isEnabled: !isDropDisabled, }); return dimension; @@ -105,29 +152,44 @@ export default class DroppableDimensionPublisher extends Component { this.closestScrollable.removeEventListener('scroll', this.onClosestScroll); } - // TODO: componentDidUpdate? componentWillReceiveProps(nextProps: Props) { - if (nextProps.targetRef !== this.props.targetRef) { - if (this.isWatchingScroll) { - console.warn('changing targetRef while watching scroll!'); - this.unwatchScroll(); - } - } - // Because the dimension publisher wraps children - it might render even when its props do // not change. We need to ensure that it does not publish when it should not. - const shouldPublish = !this.props.shouldPublish && nextProps.shouldPublish; + + const shouldStartPublishing = !this.props.shouldPublish && nextProps.shouldPublish; + const alreadyPublishing = this.props.shouldPublish && nextProps.shouldPublish; + const stopPublishing = this.props.shouldPublish && !nextProps.shouldPublish; // should no longer watch for scrolling - if (!nextProps.shouldPublish) { + if (stopPublishing) { this.unwatchScroll(); return; } - if (!shouldPublish) { + if (alreadyPublishing) { + // if ref changes and watching scroll - unwatch the scroll + if (nextProps.targetRef !== this.props.targetRef) { + if (this.isWatchingScroll) { + console.warn('changing targetRef while watching scroll!'); + this.unwatchScroll(); + } + } + + // publish any changes to the disabled flag + if (nextProps.isDropDisabled !== this.props.isDropDisabled) { + this.props.updateIsEnabled(this.props.droppableId, !nextProps.isDropDisabled); + } + return; } + // This will be the default when nothing is happening + if (!shouldStartPublishing) { + return; + } + + // Need to start publishing + // discovering the closest scrollable for a drag this.closestScrollable = getClosestScrollable(this.props.targetRef); this.props.publish(this.getDimension()); diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index 0275d3a36c..dd9367401e 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -3,7 +3,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import memoizeOne from 'memoize-one'; import { storeKey } from '../context-keys'; -import { dragSelector, pendingDropSelector, phaseSelector } from '../../state/selectors'; +import { + dragSelector, + pendingDropSelector, + phaseSelector, + draggingDraggableSelector, +} from '../../state/selectors'; import Droppable from './droppable'; import type { Phase, @@ -12,11 +17,13 @@ import type { State, DroppableId, DraggableLocation, + DraggableDimension, } from '../../types'; import type { OwnProps, MapProps, Selector, + Placeholder, } from './droppable-types'; export const makeSelector = (): Selector => { @@ -34,48 +41,102 @@ export const makeSelector = (): Selector => { }, ); - const getMapProps = memoizeOne((isDraggingOver: boolean): MapProps => ({ - isDraggingOver, + const memoizedPlaceholder = memoizeOne((width: number, height: number): Placeholder => ({ + width, height, })); + const getPlaceholder = memoizeOne( + (id: DroppableId, + source: DraggableLocation, + destination: ?DraggableLocation, + draggable: ?DraggableDimension + ): ?Placeholder => { + if (!destination) { + return null; + } + // no placeholder needed for this droppable + if (destination.droppableId !== id) { + return null; + } + + // no placeholder needed when dragging over the source list + if (source.droppableId === destination.droppableId) { + return null; + } + + if (!draggable) { + return null; + } + + const placeholder: Placeholder = memoizedPlaceholder( + draggable.page.withMargin.width, + draggable.page.withMargin.height, + ); + + return placeholder; + } + ); + + const getMapProps = memoizeOne( + (isDraggingOver: boolean, placeholder: ?Placeholder): MapProps => ({ + isDraggingOver, + placeholder, + }) + ); + return createSelector( [phaseSelector, dragSelector, + draggingDraggableSelector, pendingDropSelector, idSelector, isDropDisabledSelector, ], (phase: Phase, drag: ?DragState, + draggable: ?DraggableDimension, pending: ?PendingDrop, id: DroppableId, isDropDisabled: boolean, ): MapProps => { if (isDropDisabled) { - return getMapProps(false); + return getMapProps(false, null); } if (phase === 'DRAGGING') { if (!drag) { console.error('cannot determine dragging over as there is not drag'); - return getMapProps(false); + return getMapProps(false, null); } const isDraggingOver = getIsDraggingOver(id, drag.impact.destination); - return getMapProps(isDraggingOver); + + const placeholder: ?Placeholder = getPlaceholder( + id, + drag.initial.source, + drag.impact.destination, + draggable + ); + return getMapProps(isDraggingOver, placeholder); } if (phase === 'DROP_ANIMATING') { if (!pending) { console.error('cannot determine dragging over as there is no pending result'); - return getMapProps(false); + return getMapProps(false, null); } const isDraggingOver = getIsDraggingOver(id, pending.impact.destination); - return getMapProps(isDraggingOver); + const placeholder: ?Placeholder = getPlaceholder( + id, + pending.result.source, + pending.result.destination, + draggable + ); + return getMapProps(isDraggingOver, placeholder); } - return getMapProps(false); + return getMapProps(false, null); }, ); }; diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index f12ff8fa50..1015ce4b51 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -11,8 +11,14 @@ import type { Direction, } from '../../types'; +export type Placeholder = {| + height: number, + width: number, +|} + export type Provided = {| innerRef: (?HTMLElement) => void, + placeholder: ?ReactElement, |} export type StateSnapshot = {| @@ -21,6 +27,10 @@ export type StateSnapshot = {| export type MapProps = {| isDraggingOver: boolean, + // placeholder is used to hold space when + // not the user is dragging over a list that + // is not the source list + placeholder: ?Placeholder, |} export type OwnProps = {| diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index 10d99413ac..61c73495a5 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import type { Props, Provided, StateSnapshot, DefaultProps } from './droppable-types'; import type { DroppableId, HTMLElement } from '../../types'; import DroppableDimensionPublisher from '../droppable-dimension-publisher/'; +import Placeholder from '../placeholder/'; import { droppableIdKey } from '../context-keys'; type State = {| @@ -60,22 +61,45 @@ export default class Droppable extends Component { }); } + getPlaceholder() { + if (!this.props.placeholder) { + return null; + } + + return ( + + ); + } + render() { + const { + children, + direction, + droppableId, + isDraggingOver, + isDropDisabled, + type, + } = this.props; const provided: Provided = { innerRef: this.setRef, + placeholder: this.getPlaceholder(), }; const snapshot: StateSnapshot = { - isDraggingOver: this.props.isDraggingOver, + isDraggingOver, }; return ( - {this.props.children(provided, snapshot)} + {children(provided, snapshot)} ); } diff --git a/src/view/placeholder/index.js b/src/view/placeholder/index.js new file mode 100644 index 0000000000..eab54fa1b0 --- /dev/null +++ b/src/view/placeholder/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './placeholder'; diff --git a/src/view/draggable/placeholder.jsx b/src/view/placeholder/placeholder.jsx similarity index 100% rename from src/view/draggable/placeholder.jsx rename to src/view/placeholder/placeholder.jsx diff --git a/stories/2-single-horizontal-story.js b/stories/2-single-horizontal-story.js index a0f2084603..b9a4e93591 100644 --- a/stories/2-single-horizontal-story.js +++ b/stories/2-single-horizontal-story.js @@ -3,10 +3,10 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import styled from 'styled-components'; import AuthorApp from './src/horizontal/author-app'; -import { authors, getAuthors } from './src/data'; -import type { Author } from './src/types'; +import { quotes, getQuotes } from './src/data'; +import type { Quote } from './src/types'; -const bigData: Author[] = getAuthors(30); +const bigData: Quote[] = getQuotes(30); const WideWindow = styled.div` width: 120vw; @@ -14,13 +14,13 @@ const WideWindow = styled.div` storiesOf('single horizontal list', module) .add('simple example', () => ( - + )) .add('with overflow scroll', () => ( - + )) .add('with window scroll and overflow scroll', () => ( - + )); diff --git a/stories/3-complex-vertical-list-story.js b/stories/3-complex-vertical-list-story.js index cb3310afef..78d542d421 100644 --- a/stories/3-complex-vertical-list-story.js +++ b/stories/3-complex-vertical-list-story.js @@ -3,13 +3,11 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import NestedQuoteApp from './src/vertical-nested/quote-app'; import GroupedQuoteApp from './src/vertical-grouped/quote-app'; -import { authorWithQuotes } from './src/data'; +import { authorQuoteMap } from './src/data'; storiesOf('complex vertical list', module) .add('grouped', () => ( - + )) // this is kind of strange - but hey, if you want to! .add('nested vertical lists', () => ( diff --git a/stories/4-board-story.js b/stories/4-board-story.js index 5515b5065d..51f1e3679d 100644 --- a/stories/4-board-story.js +++ b/stories/4-board-story.js @@ -2,9 +2,9 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import Board from './src/board/board'; -import { authorWithQuotes } from './src/data'; +import { authorQuoteMap } from './src/data'; storiesOf('board', module) .add('task board', () => ( - + )); diff --git a/stories/5-multiple-vertical-lists-story.js b/stories/5-multiple-vertical-lists-story.js new file mode 100644 index 0000000000..f5a3eff09c --- /dev/null +++ b/stories/5-multiple-vertical-lists-story.js @@ -0,0 +1,23 @@ +// @flow +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import QuoteApp from './src/multiple-vertical/quote-app'; +import { getQuotes } from './src/data'; +import type { QuoteMap } from './src/types'; + +const alpha: string = 'alpha'; +const beta: string = 'beta'; +const gamma: string = 'gamma'; +const delta: string = 'delta'; + +const quoteMap: QuoteMap = { + [alpha]: getQuotes(20), + [beta]: getQuotes(3), + [gamma]: getQuotes(20), + [delta]: getQuotes(0), +}; + +storiesOf('multiple vertical lists', module) + .add('stress test', () => ( + + )); diff --git a/stories/6-multiple-horizontal-lists-story.js b/stories/6-multiple-horizontal-lists-story.js new file mode 100644 index 0000000000..4346efd141 --- /dev/null +++ b/stories/6-multiple-horizontal-lists-story.js @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import QuoteApp from './src/multiple-horizontal/quote-app'; +import { getQuotes } from './src/data'; +import type { QuoteMap } from './src/types'; + +const alpha: string = 'alpha'; +const beta: string = 'beta'; +const gamma: string = 'gamma'; + +const quoteMap: QuoteMap = { + [alpha]: getQuotes(20), + [beta]: getQuotes(18), + [gamma]: getQuotes(22), +}; + +storiesOf('multiple horizontal lists', module) + .add('stress test', () => ( + + )); diff --git a/stories/src/board/board.jsx b/stories/src/board/board.jsx index 604457b027..6cef129fb6 100644 --- a/stories/src/board/board.jsx +++ b/stories/src/board/board.jsx @@ -4,7 +4,7 @@ import styled, { injectGlobal } from 'styled-components'; import { action } from '@storybook/addon-actions'; import Column from './column'; import { colors } from '../constants'; -import reorder, { reorderGroup } from '../reorder'; +import reorder, { reorderQuoteMap } from '../reorder'; import { DragDropContext, Droppable } from '../../../src/'; import type { DropResult, @@ -12,7 +12,7 @@ import type { DraggableLocation, DroppableProvided, } from '../../../src/'; -import type { AuthorWithQuotes } from '../types'; +import type { QuoteMap } from '../types'; const isDraggingClassName = 'is-dragging'; @@ -29,11 +29,13 @@ const Container = styled.div` `; type Props = {| - initial: AuthorWithQuotes[], + initial: QuoteMap, |} type State = {| - columns: AuthorWithQuotes[], + columns: QuoteMap, + ordered: string[], + autoFocusQuoteId: ?string, |} export default class Board extends Component { @@ -43,6 +45,8 @@ export default class Board extends Component { state: State = { columns: this.props.initial, + ordered: Object.keys(this.props.initial), + autoFocusQuoteId: null, } /* eslint-enable react/sort-comp */ @@ -60,6 +64,10 @@ export default class Board extends Component { publishOnDragStart(initial); // $ExpectError - body wont be null document.body.classList.add(isDraggingClassName); + + this.setState({ + autoFocusQuoteId: null, + }); } onDragEnd = (result: DropResult) => { @@ -76,34 +84,36 @@ export default class Board extends Component { const destination: DraggableLocation = result.destination; // reordering column - if (result.type === 'AUTHOR') { - const columns: AuthorWithQuotes[] = reorder( - this.state.columns, + if (result.type === 'COLUMN') { + const ordered: string[] = reorder( + this.state.ordered, source.index, destination.index ); this.setState({ - columns, + ordered, }); return; } - const columns: ?AuthorWithQuotes[] = reorderGroup( - this.state.columns, result - ); - - if (!columns) { - return; - } + const data = reorderQuoteMap({ + quoteMap: this.state.columns, + source, + destination, + }); this.setState({ - columns, + columns: data.quoteMap, + autoFocusQuoteId: data.autoFocusQuoteId, }); } render() { + const columns: QuoteMap = this.state.columns; + const ordered: string[] = this.state.ordered; + return ( {(provided: DroppableProvided) => ( - {this.state.columns.map((column: AuthorWithQuotes) => ( - + {ordered.map((key: string) => ( + ))} )} diff --git a/stories/src/board/column.jsx b/stories/src/board/column.jsx index 6611db3373..05c4fd8497 100644 --- a/stories/src/board/column.jsx +++ b/stories/src/board/column.jsx @@ -4,8 +4,9 @@ import styled from 'styled-components'; import { grid, colors, borderRadius } from '../constants'; import { Draggable } from '../../../src/'; import type { DraggableProvided, DraggableStateSnapshot } from '../../../src/'; -import CardList from '../vertical/quote-list'; -import type { AuthorWithQuotes } from '../types'; +import QuoteList from '../primatives/quote-list'; +import Title from '../primatives/title'; +import type { Quote } from '../types'; const Wrapper = styled.div` display: flex; @@ -32,22 +33,18 @@ const Header = styled.div` } `; -const Title = styled.h4` - padding: ${grid}px; - cursor: grab; - transition: background-color ease 0.2s; - flex-grow: 1; - user-select: none; -`; - export default class Column extends Component { props: {| - column: AuthorWithQuotes + title: string, + quotes: Quote[], + autoFocusQuoteId: ?string, |} + render() { - const column: AuthorWithQuotes = this.props.column; + const title: string = this.props.title; + const quotes: Quote[] = this.props.quotes; return ( - + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( - {column.author.name} + {title} - {provided.placeholder} diff --git a/stories/src/data.js b/stories/src/data.js index 22a8775ed9..96a6964198 100644 --- a/stories/src/data.js +++ b/stories/src/data.js @@ -1,5 +1,5 @@ // @flow -import type { Author, Quote, AuthorWithQuotes } from './types'; +import type { Author, Quote, QuoteMap } from './types'; const jake: Author = { id: '1', @@ -79,14 +79,31 @@ export const quotes: Quote[] = [ content: 'Don\'t you always call sweatpants \'give up on life pants,\' Jake?', author: finn, }, + { + id: '10', + content: 'I should not have drunk that much tea!', + author: princess, + }, + { + id: '11', + content: 'Please! I need the real you!', + author: princess, + }, + { + id: '12', + content: 'Haven\'t slept for a solid 83 hours, but, yeah, I\'m good.', + author: princess, + }, ]; +let idCount: number = 0; + export const getQuotes = (count: number): Quote[] => - Array.from({ length: count }, (v, k) => k).map((val: number) => { + Array.from({ length: count }, (v, k) => k).map(() => { const random: Quote = quotes[Math.floor(Math.random() * quotes.length)]; const custom: Quote = { - id: `${val}`, + id: `quote-${idCount++}`, content: random.content, author: random.author, }; @@ -95,11 +112,11 @@ export const getQuotes = (count: number): Quote[] => }); export const getAuthors = (count: number): Author[] => - Array.from({ length: count }, (v, k) => k).map((val: number) => { + Array.from({ length: count }, (v, k) => k).map(() => { const random: Author = authors[Math.floor(Math.random() * authors.length)]; const custom: Author = { - id: `${val}`, + id: `author-${idCount++}`, name: random.name, avatarUrl: random.avatarUrl, url: random.url, @@ -111,8 +128,8 @@ export const getAuthors = (count: number): Author[] => const getByAuthor = (author: Author, items: Quote[]): Quote[] => items.filter((quote: Quote) => quote.author === author); -export const authorWithQuotes: AuthorWithQuotes[] = - authors.map((author: Author): AuthorWithQuotes => ({ - author, - quotes: getByAuthor(author, quotes), - })); +export const authorQuoteMap: QuoteMap = + authors.reduce((previous: QuoteMap, author: Author) => ({ + ...previous, + [author.name]: getByAuthor(author, quotes), + }), {}); diff --git a/stories/src/horizontal/author-app.jsx b/stories/src/horizontal/author-app.jsx index a0bfaf88a5..a21b914bd8 100644 --- a/stories/src/horizontal/author-app.jsx +++ b/stories/src/horizontal/author-app.jsx @@ -3,25 +3,26 @@ import React, { Component } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { action } from '@storybook/addon-actions'; import { DragDropContext } from '../../../src/'; -import type { DropResult, DragStart } from '../../../src'; -import AuthorList from './author-list'; -import AuthorItem from './author-item'; +import type { + DropResult, + DragStart, +} from '../../../src'; +import type { Quote } from '../types'; +import AuthorList from '../primatives/author-list'; import reorder from '../reorder'; import { colors, grid } from '../constants'; -import type { Author } from '../types'; -import type { Overflow } from './types'; const isDraggingClassName = 'is-dragging'; const publishOnDragStart = action('onDragStart'); const publishOnDragEnd = action('onDragEnd'); type Props = {| - initial: Author[], - overflow?: Overflow, + initial: Quote[], + internalScroll?: boolean, |} type State = {| - authors: Author[], + quotes: Quote[], |} const Root = styled.div` @@ -35,7 +36,7 @@ export default class AuthorApp extends Component { state: State state: State = { - authors: this.props.initial, + quotes: this.props.initial, } /* eslint-enable react/sort-comp */ @@ -65,14 +66,14 @@ export default class AuthorApp extends Component { return; } - const authors = reorder( - this.state.authors, + const quotes = reorder( + this.state.quotes, result.source.index, result.destination.index ); this.setState({ - authors, + quotes, }); } @@ -83,14 +84,11 @@ export default class AuthorApp extends Component { onDragEnd={this.onDragEnd} > - - {this.state.authors.map((author: Author) => ( - - ))} - + ); diff --git a/stories/src/horizontal/author-list.jsx b/stories/src/horizontal/author-list.jsx deleted file mode 100644 index d965d7b1f1..0000000000 --- a/stories/src/horizontal/author-list.jsx +++ /dev/null @@ -1,42 +0,0 @@ -// @flow -import React, { Component } from 'react'; -import styled from 'styled-components'; -import { Droppable } from '../../../src/'; -import type { DroppableProvided, DroppableStateSnapshot } from '../../../src/'; -import type { Overflow } from './types'; - -const Container = styled.div` - display: flex; - overflow: ${({ overflow }) => overflow}; -`; - -export default class AuthorList extends Component { - props: {| - listId: string, - overflow?: Overflow, - children?: any, - |} - - static defaultProps = { - overflow: 'visible', - } - - render() { - return ( - - {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( - - {this.props.children} - - )} - - ); - } -} diff --git a/stories/src/horizontal/types.js b/stories/src/horizontal/types.js deleted file mode 100644 index ca8b9349e0..0000000000 --- a/stories/src/horizontal/types.js +++ /dev/null @@ -1,2 +0,0 @@ -// @flow -export type Overflow = 'auto' | 'scroll' | 'visible'; diff --git a/stories/src/multiple-horizontal/quote-app.jsx b/stories/src/multiple-horizontal/quote-app.jsx new file mode 100644 index 0000000000..0807b0536f --- /dev/null +++ b/stories/src/multiple-horizontal/quote-app.jsx @@ -0,0 +1,101 @@ +// @flow +import React, { Component } from 'react'; +import styled, { injectGlobal } from 'styled-components'; +import { action } from '@storybook/addon-actions'; +import { DragDropContext } from '../../../src/'; +import AuthorList from '../primatives/author-list'; +import { colors, grid } from '../constants'; +import { reorderQuoteMap } from '../reorder'; +import type { ReorderQuoteMapResult } from '../reorder'; +import type { QuoteMap } from '../types'; +import type { DropResult, DragStart } from '../../../src/types'; + +const publishOnDragStart = action('onDragStart'); +const publishOnDragEnd = action('onDragEnd'); + +const Root = styled.div` + background-color: ${colors.blue.deep}; + box-sizing: border-box; + padding: ${grid * 2}px; + min-height: 100vh; + + /* flexbox */ + display: flex; + flex-direction: column; +`; + +const isDraggingClassName = 'is-dragging'; + +type Props = {| + initial: QuoteMap, +|} + +type State = ReorderQuoteMapResult; + +export default class QuoteApp extends Component { + /* eslint-disable react/sort-comp */ + props: Props + state: State + + state: State = { + quoteMap: this.props.initial, + autoFocusQuoteId: null, + }; + /* eslint-enable react/sort-comp */ + + onDragStart = (initial: DragStart) => { + publishOnDragStart(initial); + // $ExpectError - body could be null? + document.body.classList.add(isDraggingClassName); + } + + onDragEnd = (result: DropResult) => { + publishOnDragEnd(result); + // $ExpectError - body could be null? + document.body.classList.remove(isDraggingClassName); + + // // dropped outside the list + if (!result.destination) { + return; + } + + this.setState(reorderQuoteMap({ + quoteMap: this.state.quoteMap, + source: result.source, + destination: result.destination, + })); + } + + componentDidMount() { + // eslint-disable-next-line no-unused-expressions + injectGlobal` + body.${isDraggingClassName} { + cursor: grabbing; + user-select: none; + } + `; + } + + render() { + const { quoteMap, autoFocusQuoteId } = this.state; + + return ( + + + {Object.keys(quoteMap).map((key: string) => ( + + ))} + + + ); + } +} diff --git a/stories/src/multiple-vertical/quote-app.jsx b/stories/src/multiple-vertical/quote-app.jsx new file mode 100644 index 0000000000..190d3dc7a0 --- /dev/null +++ b/stories/src/multiple-vertical/quote-app.jsx @@ -0,0 +1,163 @@ +// @flow +import React, { Component } from 'react'; +import styled, { injectGlobal } from 'styled-components'; +import { action } from '@storybook/addon-actions'; +import { DragDropContext } from '../../../src/'; +import QuoteList from '../primatives/quote-list'; +import { colors, grid } from '../constants'; +import { reorderQuoteMap } from '../reorder'; +import type { ReorderQuoteMapResult } from '../reorder'; +import type { QuoteMap } from '../types'; +import type { DropResult, DragStart, DraggableLocation } from '../../../src/types'; + +const publishOnDragStart = action('onDragStart'); +const publishOnDragEnd = action('onDragEnd'); + +const Root = styled.div` + background-color: ${colors.blue.deep}; + box-sizing: border-box; + padding: ${grid * 2}px; + min-height: 100vh; + + /* flexbox */ + display: flex; + justify-content: center; + align-items: flex-start; +`; + +const Column = styled.div` + margin: 0 ${grid * 2}px; +`; + +const PushDown = styled.div` + height: 200px; +`; + +const isDraggingClassName = 'is-dragging'; + +type Props = {| + initial: QuoteMap, +|} + +type State = ReorderQuoteMapResult + +export default class QuoteApp extends Component { + /* eslint-disable react/sort-comp */ + props: Props + state: State + + state: State = { + quoteMap: this.props.initial, + autoFocusQuoteId: null, + }; + /* eslint-enable react/sort-comp */ + + onDragStart = (initial: DragStart) => { + publishOnDragStart(initial); + // this.setState({ + // disabledDroppable: this.getDisabledDroppable(initial.source.droppableId), + // }); + // $ExpectError - body could be null? + document.body.classList.add(isDraggingClassName); + } + + onDragEnd = (result: DropResult) => { + publishOnDragEnd(result); + // $ExpectError - body could be null? + document.body.classList.remove(isDraggingClassName); + + // dropped nowhere + if (!result.destination) { + return; + } + + const source: DraggableLocation = result.source; + const destination: DraggableLocation = result.destination; + + this.setState(reorderQuoteMap({ + quoteMap: this.state.quoteMap, + source, + destination, + })); + } + + componentDidMount() { + // eslint-disable-next-line no-unused-expressions + injectGlobal` + body.${isDraggingClassName} { + cursor: grabbing; + user-select: none; + } + `; + } + + // TODO + getDisabledDroppable = (sourceDroppable: ?string) => { + if (!sourceDroppable) { + return null; + } + + const droppables: string[] = ['alpha', 'beta', 'gamma', 'delta']; + const sourceIndex = droppables.indexOf(sourceDroppable); + const disabledDroppableIndex = (sourceIndex + 1) % droppables.length; + + return droppables[disabledDroppableIndex]; + } + + render() { + const { quoteMap, autoFocusQuoteId } = this.state; + const disabledDroppable = 'TODO'; + + return ( + + + + + + + + + + + + + + + + ); + } +} diff --git a/stories/src/horizontal/author-item.jsx b/stories/src/primatives/author-item.jsx similarity index 55% rename from stories/src/horizontal/author-item.jsx rename to stories/src/primatives/author-item.jsx index 10dae79ba8..c826a25e25 100644 --- a/stories/src/horizontal/author-item.jsx +++ b/stories/src/primatives/author-item.jsx @@ -1,11 +1,13 @@ // @flow import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; import styled from 'styled-components'; import { colors, grid } from '../constants'; -import { Draggable } from '../../../src/'; import type { DraggableProvided, DraggableStateSnapshot } from '../../../src/'; import type { Author } from '../types'; +type HTMLElement = any; + const Avatar = styled.img` width: 60px; height: 60px; @@ -28,27 +30,36 @@ const Avatar = styled.img` export default class AuthorItem extends Component { props: {| - author: Author + author: Author, + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + autoFocus?: boolean, |} + componentDidMount() { + if (!this.props.autoFocus) { + return; + } + + // eslint-disable-next-line react/no-find-dom-node + const node: HTMLElement = ReactDOM.findDOMNode(this); + node.focus(); + } + render() { const author: Author = this.props.author; + const provided: DraggableProvided = this.props.provided; + const snapshot: DraggableStateSnapshot = this.props.snapshot; + return ( - - {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( -
- provided.innerRef(ref)} - style={provided.draggableStyle} - {...provided.dragHandleProps} - src={author.avatarUrl} - alt={author.name} - isDragging={snapshot.isDragging} - /> - {provided.placeholder} -
- )} -
+ provided.innerRef(ref)} + style={provided.draggableStyle} + {...provided.dragHandleProps} + src={author.avatarUrl} + alt={author.name} + isDragging={snapshot.isDragging} + /> ); } } diff --git a/stories/src/primatives/author-list.jsx b/stories/src/primatives/author-list.jsx new file mode 100644 index 0000000000..f05306f26a --- /dev/null +++ b/stories/src/primatives/author-list.jsx @@ -0,0 +1,101 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; +import { Droppable, Draggable } from '../../../src'; +import Author from '../primatives/author-item'; +import { grid, colors } from '../constants'; +import type { Quote } from '../types'; +import type { + DroppableProvided, + DroppableStateSnapshot, + DraggableProvided, + DraggableStateSnapshot, +} from '../../../src/'; + +const Wrapper = styled.div` + background-color: ${({ isDraggingOver }) => (isDraggingOver ? colors.blue.lighter : colors.blue.light)}; + display: flex; + flex-direction: column; + padding: ${grid}px; + user-select: none; + transition: background-color 0.1s ease; + margin: ${grid}px 0; + + overflow: auto; +`; + +const DropZone = styled.div` + /* stop the list collapsing when empty */ + min-width: 600px; + display: flex; +`; + +const ScrollContainer = styled.div` + overflow: auto; +`; + +const Container = styled.div` + /* flex child */ + flex-grow: 1; + + /* flex parent */ + display: flex; + flex-direction: column; +`; + +export default class AuthorList extends Component { + props: {| + quotes: Quote[], + listId: string, + listType?: string, + internalScroll?: boolean, + autoFocusQuoteId?: ?string, + |} + + renderBoard = (dropProvided: DroppableProvided) => { + const { listType, quotes } = this.props; + + return ( + + + {quotes.map((quote: Quote) => ( + + {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => ( +
+ + {dragProvided.placeholder} +
+ )} +
+ ))} + {dropProvided.placeholder} +
+
+ ); + } + + render() { + const { listId, listType, internalScroll } = this.props; + + return ( + + {(dropProvided: DroppableProvided, dropSnapshot: DroppableStateSnapshot) => ( + + {internalScroll ? ( + + {this.renderBoard(dropProvided)} + + ) : ( + this.renderBoard(dropProvided) + )} + + )} + + ); + } +} diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index 3d456dcebe..defcf772f3 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -1,5 +1,6 @@ // @flow import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; import styled from 'styled-components'; import { borderRadius, colors, grid } from '../constants'; import type { Quote } from '../types'; @@ -9,8 +10,11 @@ type Props = { quote: Quote, isDragging: boolean, provided: DraggableProvided, + autoFocus?: boolean, } +type HTMLElement = any; + const Container = styled.a` border-radius: ${borderRadius}px; border: 1px solid grey; @@ -90,6 +94,16 @@ flex-grow: 1; export default class QuoteItem extends Component { props: Props + componentDidMount() { + if (!this.props.autoFocus) { + return; + } + + // eslint-disable-next-line react/no-find-dom-node + const node: HTMLElement = ReactDOM.findDOMNode(this); + node.focus(); + } + render() { const { quote, isDragging, provided } = this.props; diff --git a/stories/src/primatives/quote-list.jsx b/stories/src/primatives/quote-list.jsx new file mode 100644 index 0000000000..58f3f1adfd --- /dev/null +++ b/stories/src/primatives/quote-list.jsx @@ -0,0 +1,108 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; +import { Droppable, Draggable } from '../../../src'; +import QuoteItem from '../primatives/quote-item'; +import { grid, colors } from '../constants'; +import Title from '../primatives/title'; +import type { Quote } from '../types'; +import type { + DroppableProvided, + DroppableStateSnapshot, + DraggableProvided, + DraggableStateSnapshot, +} from '../../../src/'; + +const Wrapper = styled.div` + background-color: ${({ isDraggingOver }) => (isDraggingOver ? colors.blue.lighter : colors.blue.light)}; + display: flex; + flex-direction: column; + opacity: ${({ isDropDisabled }) => (isDropDisabled ? 0.5 : 'inherit')}; + padding: ${grid}px; + padding-bottom: 0; + transition: background-color 0.1s ease, opacity 0.1s ease; + user-select: none; + width: 250px; +`; + +const DropZone = styled.div` + /* stop the list collapsing when empty */ + min-height: 250px; +`; + +const ScrollContainer = styled.div` + overflow: auto; + max-height: 800px; +`; + +const Container = styled.div``; + +export default class QuoteList extends Component { + props: {| + listId: string, + quotes: Quote[], + title?: string, + listType?: string, + internalScroll?: boolean, + isDropDisabled ?: boolean, + style?: Object, + // may not be provided - and might be null + autoFocusQuoteId?: ?string, + |} + + renderQuotes = (dropProvided: DroppableProvided) => { + const { listType, quotes } = this.props; + const title = this.props.title ? ( + {this.props.title} + ) : null; + + return ( + + {title} + + {quotes.map((quote: Quote) => ( + + {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => ( +
+ + {dragProvided.placeholder} +
+ )} +
+ ))} + {dropProvided.placeholder} +
+
+ ); + } + + render() { + const { listId, listType, internalScroll, isDropDisabled, style } = this.props; + + return ( + + {(dropProvided: DroppableProvided, dropSnapshot: DroppableStateSnapshot) => ( + + {internalScroll ? ( + + {this.renderQuotes(dropProvided)} + + ) : ( + this.renderQuotes(dropProvided) + )} + + )} + + ); + } +} diff --git a/stories/src/primatives/title.jsx b/stories/src/primatives/title.jsx new file mode 100644 index 0000000000..751e41e662 --- /dev/null +++ b/stories/src/primatives/title.jsx @@ -0,0 +1,11 @@ +// @flow +import styled from 'styled-components'; +import { grid } from '../constants'; + +export default styled.h4` + padding: ${grid}px; + cursor: grab; + transition: background-color ease 0.2s; + flex-grow: 1; + user-select: none; +`; diff --git a/stories/src/reorder.js b/stories/src/reorder.js index 9e6faec1be..7aaf29e896 100644 --- a/stories/src/reorder.js +++ b/stories/src/reorder.js @@ -1,6 +1,6 @@ // @flow -import type { AuthorWithQuotes } from './types'; -import type { DropResult, DraggableLocation } from '../../src/types'; +import type { Quote, QuoteMap } from './types'; +import type { DraggableLocation } from '../../src/types'; // a little function to help us with reordering the result const reorder = ( @@ -16,39 +16,60 @@ const reorder = ( export default reorder; -export const reorderGroup = ( - groups: AuthorWithQuotes[], - result: DropResult -): ?AuthorWithQuotes[] => { - if (!result.destination) { - return null; - } +type ReorderQuoteMapArgs = {| + quoteMap: QuoteMap, + source: DraggableLocation, + destination: DraggableLocation, +|} - const source: DraggableLocation = result.source; - const destination: DraggableLocation = result.destination; +export type ReorderQuoteMapResult = {| + quoteMap: QuoteMap, + autoFocusQuoteId: ?string, +|} - const group: ?AuthorWithQuotes = groups.filter( - (item: AuthorWithQuotes) => item.author.id === result.type - )[0]; +export const reorderQuoteMap = ({ + quoteMap, + source, + destination, +}: ReorderQuoteMapArgs): ReorderQuoteMapResult => { + const current: Quote[] = [...quoteMap[source.droppableId]]; + const next: Quote[] = [...quoteMap[destination.droppableId]]; + const target: Quote = current[source.index]; - if (!group) { - console.error('could not find group', result.type, groups); - return null; + // moving to same list + if (source.droppableId === destination.droppableId) { + const reordered: Quote[] = reorder( + current, + source.index, + destination.index, + ); + const result: QuoteMap = { + ...quoteMap, + [source.droppableId]: reordered, + }; + return { + quoteMap: result, + // not auto focusing in own list + autoFocusQuoteId: null, + }; } - const quotes = reorder( - group.quotes, - source.index, - destination.index - ); + // moving to different list - const updated: AuthorWithQuotes = { - author: group.author, - quotes, - }; + // remove from original + current.splice(source.index, 1); + // insert into next + next.splice(destination.index, 0, target); - const newGroups: AuthorWithQuotes[] = Array.from(groups); - newGroups[groups.indexOf(group)] = updated; + const result: QuoteMap = { + ...quoteMap, + [source.droppableId]: current, + [destination.droppableId]: next, + }; - return newGroups; -}; + return { + quoteMap: result, + autoFocusQuoteId: target.id, + }; +} +; diff --git a/stories/src/types.js b/stories/src/types.js index bbcb178cd7..c9612b5f35 100644 --- a/stories/src/types.js +++ b/stories/src/types.js @@ -19,7 +19,6 @@ export type Dragging = {| location: DraggableLocation, |} -export type AuthorWithQuotes = {| - author: Author, - quotes: Quote[], -|} +export type QuoteMap = { + [key: string]: Quote[] +} diff --git a/stories/src/vertical-grouped/quote-app.jsx b/stories/src/vertical-grouped/quote-app.jsx index 1c6ddaccf6..d520365a92 100644 --- a/stories/src/vertical-grouped/quote-app.jsx +++ b/stories/src/vertical-grouped/quote-app.jsx @@ -3,10 +3,10 @@ import React, { Component } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { action } from '@storybook/addon-actions'; import { DragDropContext } from '../../../src/'; -import QuoteList from '../vertical/quote-list'; +import QuoteList from '../primatives/quote-list'; import { colors, grid } from '../constants'; -import { reorderGroup } from '../reorder'; -import type { AuthorWithQuotes } from '../types'; +import { reorderQuoteMap } from '../reorder'; +import type { QuoteMap } from '../types'; import type { DropResult, DragStart } from '../../../src/types'; const publishOnDragStart = action('onDragStart'); @@ -40,11 +40,11 @@ const Title = styled.h4` const isDraggingClassName = 'is-dragging'; type Props = {| - initial: AuthorWithQuotes[], + initial: QuoteMap, |} type State = {| - groups: AuthorWithQuotes[], + quoteMap: QuoteMap, |} export default class QuoteApp extends Component { @@ -53,7 +53,7 @@ export default class QuoteApp extends Component { state: State state: State = { - groups: this.props.initial, + quoteMap: this.props.initial, }; /* eslint-enable react/sort-comp */ @@ -68,17 +68,17 @@ export default class QuoteApp extends Component { // $ExpectError - body could be null? document.body.classList.remove(isDraggingClassName); - const groups: ?AuthorWithQuotes[] = reorderGroup( - this.state.groups, result - ); - - if (!groups) { + if (!result.destination) { return; } - this.setState({ - groups, + const { quoteMap } = reorderQuoteMap({ + quoteMap: this.state.quoteMap, + source: result.source, + destination: result.destination, }); + + this.setState({ quoteMap }); } componentDidMount() { @@ -92,7 +92,7 @@ export default class QuoteApp extends Component { } render() { - const { groups } = this.state; + const { quoteMap } = this.state; return ( - {groups.map((group: AuthorWithQuotes) => ( - - {group.author.name} + {Object.keys(quoteMap).map((key: string) => ( + + {key} ))} diff --git a/stories/src/vertical-nested/quote-list.jsx b/stories/src/vertical-nested/quote-list.jsx index a4027c835a..8e62a51c36 100644 --- a/stories/src/vertical-nested/quote-list.jsx +++ b/stories/src/vertical-nested/quote-list.jsx @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import styled from 'styled-components'; import { Droppable, Draggable } from '../../../src'; import QuoteItem from '../primatives/quote-item'; +import Title from '../primatives/title'; import { grid, colors } from '../constants'; import type { Quote } from '../types'; import type { NestedQuoteList } from './types'; @@ -35,10 +36,6 @@ const NestedContainer = Container.extend` } `; -const Title = styled.h4` - margin-bottom: ${grid}px; -`; - export default class QuoteList extends Component { props: {| list: NestedQuoteList diff --git a/stories/src/vertical/quote-app.jsx b/stories/src/vertical/quote-app.jsx index 77d4a41544..3d1bf9d5a5 100644 --- a/stories/src/vertical/quote-app.jsx +++ b/stories/src/vertical/quote-app.jsx @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { action } from '@storybook/addon-actions'; import { DragDropContext } from '../../../src/'; -import QuoteList from './quote-list'; +import QuoteList from '../primatives/quote-list'; import { colors, grid } from '../constants'; import reorder from '../reorder'; import type { Quote } from '../types'; diff --git a/stories/src/vertical/quote-list.jsx b/stories/src/vertical/quote-list.jsx deleted file mode 100644 index b9c93090ea..0000000000 --- a/stories/src/vertical/quote-list.jsx +++ /dev/null @@ -1,64 +0,0 @@ -// @flow -import React, { Component } from 'react'; -import styled from 'styled-components'; -import QuoteItem from '../primatives/quote-item'; -import { grid, colors } from '../constants'; -import { Droppable, Draggable } from '../../../src'; -import type { - DraggableProvided, - DraggableStateSnapshot, - DroppableProvided, - DroppableStateSnapshot, -} from '../../../src/'; -import type { Quote } from '../types'; - -const Container = styled.div` - background-color: ${({ isDraggingOver }) => (isDraggingOver ? colors.blue.lighter : colors.blue.light)}; - display: flex; - flex-direction: column; - padding: ${grid}px; - padding-bottom: 0; - user-select: none; - transition: background-color 0.1s ease; - width: 250px; -`; - -export default class QuoteList extends Component { - props: {| - listId: string, - quotes: Quote[], - listType?: string, - style?: Object, - |} - - render() { - const { listId, listType, style, quotes } = this.props; - return ( - - {(dropProvided: DroppableProvided, dropSnapshot: DroppableStateSnapshot) => ( - - {quotes.map((quote: Quote) => ( - - {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => ( -
- - {dragProvided.placeholder} -
- )} -
- ))} -
- )} -
- ); - } -} diff --git a/test/unit/integration/hooks-integration.spec.js b/test/unit/integration/hooks-integration.spec.js index da049eae58..b2ebcec22c 100644 --- a/test/unit/integration/hooks-integration.spec.js +++ b/test/unit/integration/hooks-integration.spec.js @@ -4,7 +4,7 @@ import { mount } from 'enzyme'; import { DragDropContext, Draggable, Droppable } from '../../../src/'; import { sloppyClickThreshold } from '../../../src/view/drag-handle/drag-handle'; import { dispatchWindowMouseEvent, dispatchWindowKeyDownEvent, mouseEvent } from '../../utils/user-input-util'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import type { Hooks, DraggableLocation, @@ -48,6 +48,10 @@ describe('hooks integration', () => { marginRight: '0', marginBottom: '0', marginLeft: '0', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', })); return mount( @@ -61,13 +65,16 @@ describe('hooks integration', () => {

Droppable

{(draggableProvided: DraggableProvided) => ( -
-

Draggable

+
+
+

Draggable

+
+ {draggableProvided.placeholder}
)} diff --git a/test/unit/state/dimension.spec.js b/test/unit/state/dimension.spec.js index b7586847e3..452f645350 100644 --- a/test/unit/state/dimension.spec.js +++ b/test/unit/state/dimension.spec.js @@ -4,9 +4,10 @@ import { getDroppableDimension, } from '../../../src/state/dimension'; import { vertical, horizontal } from '../../../src/state/axis'; -// eslint-disable-next-line no-duplicate-imports -import type { Margin, ClientRect } from '../../../src/state/dimension'; +import getClientRect from '../../../src/state/get-client-rect'; import type { + ClientRect, + Spacing, DraggableId, DroppableId, Position, @@ -26,14 +27,22 @@ const clientRect: ClientRect = { width: 90, height: 80, }; -const margin: Margin = { +const margin: Spacing = { top: 1, right: 2, bottom: 3, left: 4, }; +const padding: Spacing = { + top: 5, right: 6, bottom: 7, left: 8, +}; const windowScroll: Position = { x: 50, y: 80, }; +const getCenter = (rect: ClientRect): Position => ({ + x: (rect.left + rect.right) / 2, + y: (rect.top + rect.bottom) / 2, +}); + describe('dimension', () => { describe('draggable dimension', () => { const dimension: DraggableDimension = getDraggableDimension({ @@ -44,7 +53,7 @@ describe('dimension', () => { windowScroll, }); - describe('client coordinates', () => { + describe('without scroll (client)', () => { it('should return a portion that does not account for margins', () => { const fragment: DimensionFragment = { top: clientRect.top, @@ -62,23 +71,27 @@ describe('dimension', () => { }); it('should return a portion that considers margins', () => { - const fragment: DimensionFragment = { + const rect: ClientRect = getClientRect({ top: clientRect.top + margin.top, right: clientRect.right + margin.right, bottom: clientRect.bottom + margin.bottom, left: clientRect.left + margin.left, - width: clientRect.width + margin.left + margin.right, - height: clientRect.height + margin.top + margin.bottom, - center: { - x: (clientRect.left + margin.left + clientRect.right + margin.right) / 2, - y: (clientRect.top + margin.top + clientRect.bottom + margin.bottom) / 2, - }, + }); + + const fragment: DimensionFragment = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: getCenter(rect), }; expect(dimension.client.withMargin).toEqual(fragment); }); }); - describe('page coordination', () => { + describe('with scroll (page)', () => { it('should return a portion that does not account for margins', () => { const top: number = clientRect.top + windowScroll.y; const right: number = clientRect.right + windowScroll.x; @@ -101,22 +114,21 @@ describe('dimension', () => { }); it('should return a portion that considers margins', () => { - const top: number = clientRect.top + margin.top + windowScroll.y; - const right: number = clientRect.right + margin.right + windowScroll.x; - const bottom: number = clientRect.bottom + margin.bottom + windowScroll.y; - const left: number = clientRect.left + margin.left + windowScroll.x; + const rect: ClientRect = getClientRect({ + top: clientRect.top + margin.top + windowScroll.y, + right: clientRect.right + margin.right + windowScroll.x, + bottom: clientRect.bottom + margin.bottom + windowScroll.y, + left: clientRect.left + margin.left + windowScroll.x, + }); const fragment: DimensionFragment = { - top, - right, - bottom, - left, - width: clientRect.width + margin.left + margin.right, - height: clientRect.height + margin.top + margin.bottom, - center: { - x: (left + right) / 2, - y: (top + bottom) / 2, - }, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: getCenter(rect), }; expect(dimension.page.withMargin).toEqual(fragment); @@ -134,6 +146,7 @@ describe('dimension', () => { id: droppableId, clientRect, margin, + padding, windowScroll, scroll, }); @@ -178,48 +191,127 @@ describe('dimension', () => { expect(withHorizontal.axis).toBe(horizontal); }); - it('should return a portion that does not consider margins', () => { - const top: number = clientRect.top + windowScroll.y; - const left: number = clientRect.left + windowScroll.x; - const bottom: number = clientRect.bottom + windowScroll.y; - const right: number = clientRect.right + windowScroll.x; - - const fragment: DimensionFragment = { - top, - right, - bottom, - left, - width: clientRect.width, - height: clientRect.height, - center: { - x: (left + right) / 2, - y: (top + bottom) / 2, - }, - }; - - expect(dimension.page.withoutMargin).toEqual(fragment); + describe('without scroll (client)', () => { + it('should return a portion that does not consider margins', () => { + const fragment: DimensionFragment = { + top: clientRect.top, + right: clientRect.right, + bottom: clientRect.bottom, + left: clientRect.left, + width: clientRect.width, + height: clientRect.height, + center: getCenter(clientRect), + }; + + expect(dimension.client.withoutMargin).toEqual(fragment); + }); + + it('should return a portion that does consider margins', () => { + const rect: ClientRect = getClientRect({ + top: clientRect.top + margin.top, + left: clientRect.left + margin.left, + bottom: clientRect.bottom + margin.bottom, + right: clientRect.right + margin.right, + }); + + const fragment: DimensionFragment = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: getCenter(rect), + }; + + expect(dimension.client.withMargin).toEqual(fragment); + }); + + it('should return a portion that considers margins and padding', () => { + const rect: ClientRect = getClientRect({ + top: clientRect.top + margin.top + padding.top, + left: clientRect.left + margin.left + padding.left, + bottom: clientRect.bottom + margin.bottom + padding.bottom, + right: clientRect.right + margin.right + padding.right, + }); + + const fragment: DimensionFragment = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: getCenter(rect), + }; + + expect(dimension.client.withMarginAndPadding).toEqual(fragment); + }); }); - it('should return a portion that does consider margins', () => { - const top: number = clientRect.top + windowScroll.y + margin.top; - const left: number = clientRect.left + windowScroll.x + margin.left; - const bottom: number = clientRect.bottom + windowScroll.y + margin.bottom; - const right: number = clientRect.right + windowScroll.x + margin.right; - - const fragment: DimensionFragment = { - top, - right, - bottom, - left, - width: clientRect.width + margin.left + margin.right, - height: clientRect.height + margin.top + margin.bottom, - center: { - x: (left + right) / 2, - y: (top + bottom) / 2, - }, - }; - - expect(dimension.page.withMargin).toEqual(fragment); + describe('with scroll (page)', () => { + it('should return a portion that does not consider margins', () => { + const rect: ClientRect = getClientRect({ + top: clientRect.top + windowScroll.y, + left: clientRect.left + windowScroll.x, + bottom: clientRect.bottom + windowScroll.y, + right: clientRect.right + windowScroll.x, + }); + + const fragment: DimensionFragment = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: getCenter(rect), + }; + + expect(dimension.page.withoutMargin).toEqual(fragment); + }); + + it('should return a portion that does consider margins', () => { + const rect: ClientRect = getClientRect({ + top: clientRect.top + windowScroll.y + margin.top, + left: clientRect.left + windowScroll.x + margin.left, + bottom: clientRect.bottom + windowScroll.y + margin.bottom, + right: clientRect.right + windowScroll.x + margin.right, + }); + + const fragment: DimensionFragment = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: getCenter(rect), + }; + + expect(dimension.page.withMargin).toEqual(fragment); + }); + + it('should return a portion that considers margins and padding', () => { + const rect: ClientRect = getClientRect({ + top: clientRect.top + windowScroll.y + margin.top + padding.top, + left: clientRect.left + windowScroll.x + margin.left + padding.left, + bottom: clientRect.bottom + windowScroll.y + margin.bottom + padding.bottom, + right: clientRect.right + windowScroll.x + margin.right + padding.right, + }); + + const fragment: DimensionFragment = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + center: getCenter(rect), + }; + + expect(dimension.page.withMarginAndPadding).toEqual(fragment); + }); }); }); }); diff --git a/test/unit/state/get-drag-impact.spec.js b/test/unit/state/get-drag-impact.spec.js index 2cc6dc4829..448543cd06 100644 --- a/test/unit/state/get-drag-impact.spec.js +++ b/test/unit/state/get-drag-impact.spec.js @@ -5,8 +5,10 @@ import { } from '../../../src/state/dimension'; // eslint-disable-next-line no-duplicate-imports import getDragImpact from '../../../src/state/get-drag-impact'; -import noImpact from '../../../src/state/no-impact'; -import getClientRect from '../../utils/get-client-rect'; +import noImpact, { noMovement } from '../../../src/state/no-impact'; +import getClientRect from '../../../src/state/get-client-rect'; +import getDroppableWithDraggables from '../../utils/get-droppable-with-draggables'; +import { add, patch } from '../../../src/state/position'; import type { WithinDroppable, DroppableId, @@ -83,7 +85,7 @@ describe('get drag impact', () => { }; it('should return no movement when not dragging over anything', () => { - // dragging up above the list + // dragging up above the list const page: Position = { x: droppable.page.withMargin.left, y: droppable.page.withMargin.top - 100, @@ -107,7 +109,7 @@ describe('get drag impact', () => { describe('moving forward', () => { describe('not moved far enough', () => { it('should return the starting position', () => { - // moving forward - but not enough + // moving forward - but not enough const page: Position = { x: draggable2.page.withoutMargin.center.x, y: draggable2.page.withoutMargin.center.y + 1, @@ -141,7 +143,7 @@ describe('get drag impact', () => { }); describe('moving past one item', () => { - // moving forward past the top of the next item + // moving forward past the top of the next item const page: Position = { x: draggable1.page.withoutMargin.center.x, y: draggable2.page.withoutMargin.top + 1, @@ -190,7 +192,7 @@ describe('get drag impact', () => { }); describe('moving past two items', () => { - // moving forward past the top of the third item + // moving forward past the top of the third item const page: Position = { x: draggable1.page.withoutMargin.center.x, y: draggable3.page.withoutMargin.top + 1, @@ -233,13 +235,13 @@ describe('get drag impact', () => { expect(impact.movement.amount).toEqual(expected); }); - it('should return the items that need to be moved', () => { - expect(impact.movement.draggables).toEqual([draggable2.id, draggable3.id]); + it('should return the items that need to be moved (sorted by the closest to the draggables current location)', () => { + expect(impact.movement.draggables).toEqual([draggable3.id, draggable2.id]); }); }); describe('moving past one item when the dragging item is not the first in the list', () => { - // moving the second item forward past the top of the third item + // moving the second item forward past the top of the third item const page: Position = { x: draggable2.page.withoutMargin.center.x, y: draggable3.page.withMargin.top + 1, @@ -288,7 +290,7 @@ describe('get drag impact', () => { }); describe('moving past an item due to change in droppable scroll', () => { - // using the center position of the draggable as the selection point + // using the center position of the draggable as the selection point const page: Position = draggable1.page.withMargin.center; const withinDroppable: WithinDroppable = { // just over the top of the second item @@ -574,6 +576,42 @@ describe('get drag impact', () => { }); }); }); + + describe('moving over disabled list', () => { + it('should return an empty impact', () => { + // moving forward past the top of the next item + const page: Position = { + x: draggable1.page.withoutMargin.center.x, + y: draggable2.page.withoutMargin.top + 1, + }; + const withinDroppable: WithinDroppable = { + center: page, + }; + // $ExpectError - using spread + const disabled: DroppableDimension = { + ...droppable, + isEnabled: false, + }; + const custom: DroppableDimensionMap = { + [disabled.id]: disabled, + }; + const expected: DragImpact = { + movement: noMovement, + direction: droppable.axis.direction, + destination: null, + }; + + const impact: DragImpact = getDragImpact({ + page, + withinDroppable, + draggableId: draggable1.id, + draggables, + droppables: custom, + }); + + expect(impact).toEqual(expected); + }); + }); }); // same tests as vertical - but moving on the horizontal plane @@ -789,8 +827,8 @@ describe('get drag impact', () => { expect(impact.movement.amount).toEqual(expected); }); - it('should return the items that need to be moved', () => { - expect(impact.movement.draggables).toEqual([draggable2.id, draggable3.id]); + it('should return the items that need to be moved (sorted by closest impacted)', () => { + expect(impact.movement.draggables).toEqual([draggable3.id, draggable2.id]); }); }); @@ -1024,7 +1062,7 @@ describe('get drag impact', () => { expect(impact.movement.amount).toEqual(expected); }); - it('should return the items that need to be moved', () => { + it('should return the items that need to be moved (sorted by closest to the draggables current position)', () => { expect(impact.movement.draggables).toEqual([draggable1.id, draggable2.id]); }); }); @@ -1130,5 +1168,249 @@ describe('get drag impact', () => { }); }); }); + + describe('moving over disabled list', () => { + it('should return an empty impact', () => { + // moving forward past the right of the next item + const page: Position = { + x: draggable2.page.withoutMargin.left + 1, + y: draggable1.page.withoutMargin.center.y, + }; + const withinDroppable: WithinDroppable = { + center: page, + }; + // $ExpectError - using flow + const disabled: DroppableDimension = { + ...droppable, + isEnabled: false, + }; + const custom: DroppableDimensionMap = { + [disabled.id]: disabled, + }; + const expected: DragImpact = { + movement: noMovement, + direction: droppable.axis.direction, + destination: null, + }; + + const impact: DragImpact = getDragImpact({ + page, + withinDroppable, + draggableId: draggable1.id, + draggables, + droppables: custom, + }); + + expect(impact).toEqual(expected); + }); + }); + }); + + describe('moving between lists', () => { + const homeDroppable = getDroppableWithDraggables({ + droppableId: 'drop-home', + droppableRect: { top: 0, left: 0, bottom: 600, right: 100 }, + draggableRects: [ + { top: 0, left: 0, bottom: 100, right: 100 }, + { top: 101, left: 0, bottom: 300, right: 100 }, + { top: 301, left: 0, bottom: 600, right: 100 }, + ], + }); + + const destinationDroppable = getDroppableWithDraggables({ + droppableId: 'drop-destination', + droppableRect: { top: 100, left: 110, bottom: 800, right: 210 }, + draggableRects: [ + { top: 100, left: 110, bottom: 400, right: 210 }, + { top: 401, left: 110, bottom: 600, right: 210 }, + { top: 601, left: 110, bottom: 700, right: 210 }, + ], + }); + + const droppables = { + [homeDroppable.droppableId]: homeDroppable.droppable, + [destinationDroppable.droppableId]: destinationDroppable.droppable, + }; + + const draggables = { + ...homeDroppable.draggables, + ...destinationDroppable.draggables, + }; + + const draggableId = homeDroppable.draggableIds[0]; + const draggedItem = homeDroppable.draggables[draggableId]; + + describe('moving outside a droppable', () => { + const page = { + x: homeDroppable.droppable.page.withMargin.center.x, + y: homeDroppable.droppable.page.withMargin.height + 1, + }; + const withinDroppable = { center: page }; + const impact = getDragImpact({ + page, + withinDroppable, + draggableId, + draggables, + droppables, + }); + + it('should not return a destination', () => { + expect(impact.destination).toBe(null); + }); + it('should not return a movement amount', () => { + expect(impact.movement.amount).toEqual(origin); + }); + it('should not displace any items', () => { + expect(impact.movement.draggables.length).toBe(0); + }); + }); + + describe('moving to the start of a foreign droppable', () => { + const page = { + x: destinationDroppable.droppable.page.withMargin.center.x, + y: destinationDroppable.droppable.page.withMargin.top + 1, + }; + const withinDroppable = { center: page }; + const impact = getDragImpact({ + page, + withinDroppable, + draggableId, + draggables, + droppables, + }); + + it('should return the destination droppable', () => { + expect(impact.destination && impact.destination.droppableId) + .toBe(destinationDroppable.droppableId); + }); + it('should return an index of 0 (first position)', () => { + expect(impact.destination && impact.destination.index).toEqual(0); + }); + it('should indicate that items must be displaced forwards', () => { + expect(impact.movement.isBeyondStartPosition).toBe(false); + }); + it('should indicate that items need to be displaced by the height of the dragged item', () => { + const expected = patch('y', draggedItem.page.withMargin.height); + expect(impact.movement.amount).toEqual(expected); + }); + it('should displace all items in the destination droppable', () => { + expect(impact.movement.draggables).toEqual(destinationDroppable.draggableIds); + }); + }); + + describe('moving to the second position of a foreign droppable', () => { + const page = { + x: destinationDroppable.droppable.page.withMargin.center.x, + y: destinationDroppable.draggables[ + destinationDroppable.draggableIds[1] + ].page.withMargin.top + 1, + }; + const withinDroppable = { center: page }; + const impact = getDragImpact({ + page, + withinDroppable, + draggableId, + draggables, + droppables, + }); + + it('should return the destination droppable', () => { + expect(impact.destination && impact.destination.droppableId) + .toBe(destinationDroppable.droppableId); + }); + it('should return an index of 1 (second position)', () => { + expect(impact.destination && impact.destination.index).toEqual(1); + }); + it('should indicate that items must be displaced forwards', () => { + expect(impact.movement.isBeyondStartPosition).toBe(false); + }); + it('should indicate that items need to be displaced by the height of the dragged item', () => { + const expected = patch('y', draggedItem.page.withMargin.height); + expect(impact.movement.amount).toEqual(expected); + }); + it('should displace all items in the destination droppable except the first', () => { + expect(impact.movement.draggables).toEqual( + destinationDroppable.draggableIds.slice(1 - destinationDroppable.draggableIds.length) + ); + }); + }); + + describe('moving to the end of a foreign droppable', () => { + const page = { + x: destinationDroppable.droppable.page.withMargin.center.x, + y: destinationDroppable.droppable.page.withMargin.bottom - 1, + }; + const withinDroppable = { center: page }; + const impact = getDragImpact({ + page, + withinDroppable, + draggableId, + draggables, + droppables, + }); + + it('should return the destination droppable', () => { + expect(impact.destination && impact.destination.droppableId) + .toBe(destinationDroppable.droppableId); + }); + it('should return an index equal to the number of draggables in the destination droppable', () => { + expect(impact.destination && impact.destination.index) + .toEqual(destinationDroppable.draggableIds.length); + }); + it('should indicate that items must be displaced forwards', () => { + expect(impact.movement.isBeyondStartPosition).toBe(false); + }); + it('should indicate that items need to be displaced by the height of the dragged item', () => { + const expected = patch('y', draggedItem.page.withMargin.height); + expect(impact.movement.amount).toEqual(expected); + }); + it('should not displace any items', () => { + expect(impact.movement.draggables.length).toBe(0); + }); + }); + + describe('when the foreign droppable is scrolled', () => { + // top of the first item + const page = { + x: destinationDroppable.droppable.page.withMargin.center.x, + y: destinationDroppable.droppable.page.withMargin.top + 1, + }; + + // scroll past the first item + const center = add(page, { + x: 0, + y: destinationDroppable.draggables[ + destinationDroppable.draggableIds[0] + ].page.withMargin.height, + }); + const withinDroppable = { center }; + const impact = getDragImpact({ + page, + withinDroppable, + draggableId, + draggables, + droppables, + }); + + it('should return the destination droppable', () => { + expect(impact.destination && impact.destination.droppableId) + .toBe(destinationDroppable.droppableId); + }); + it('should account for scrolling when calculating the index', () => { + expect(impact.destination && impact.destination.index).toEqual(1); + }); + it('should indicate that items must be displaced forwards', () => { + expect(impact.movement.isBeyondStartPosition).toBe(false); + }); + it('should indicate that items need to be displaced by the height of the dragged item', () => { + const expected = patch('y', draggedItem.page.withMargin.height); + expect(impact.movement.amount).toEqual(expected); + }); + it('should account for scrolling when determining which items are being displaced', () => { + expect(impact.movement.draggables).toEqual( + destinationDroppable.draggableIds.slice(1 - destinationDroppable.draggableIds.length) + ); + }); + }); }); }); diff --git a/test/unit/state/get-draggables-inside-droppable.spec.js b/test/unit/state/get-draggables-inside-droppable.spec.js index f4b95d1101..27e5ea840f 100644 --- a/test/unit/state/get-draggables-inside-droppable.spec.js +++ b/test/unit/state/get-draggables-inside-droppable.spec.js @@ -1,7 +1,7 @@ // @flow import getDraggablesInsideDroppable from '../../../src/state/get-draggables-inside-droppable'; import { getDraggableDimension, getDroppableDimension } from '../../../src/state/dimension'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import type { DroppableId, DraggableDimension, @@ -94,6 +94,4 @@ describe('get draggables inside a droppable', () => { expect(result).toEqual([inside1, inside2, inside3]); }); - - // other edge cases tested in get-inside-dimension }); diff --git a/test/unit/state/get-droppable-over.spec.js b/test/unit/state/get-droppable-over.spec.js index eecd40c29c..13703c7a0c 100644 --- a/test/unit/state/get-droppable-over.spec.js +++ b/test/unit/state/get-droppable-over.spec.js @@ -1,7 +1,7 @@ // @flow import getDroppableOver from '../../../src/state/get-droppable-over'; import { getDroppableDimension } from '../../../src/state/dimension'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import type { DroppableDimension, DroppableDimensionMap, diff --git a/test/unit/state/get-initial-impact.spec.js b/test/unit/state/get-initial-impact.spec.js new file mode 100644 index 0000000000..77cdca666b --- /dev/null +++ b/test/unit/state/get-initial-impact.spec.js @@ -0,0 +1,76 @@ +// @flow +import getInitialImpact from '../../../src/state/get-initial-impact'; +import getDroppableWithDraggables from '../../utils/get-droppable-with-draggables'; +import { getDraggableDimension } from '../../../src/state/dimension'; +import getClientRect from '../../../src/state/get-client-rect'; +import { noMovement } from '../../../src/state/no-impact'; +import type { Result as Data } from '../../utils/get-droppable-with-draggables'; +import type { + DraggableDimensionMap, + DraggableDimension, + DroppableDimension, + DragImpact, +} from '../../../src/types'; + +const data: Data = getDroppableWithDraggables({ + droppableId: 'droppable', + droppableRect: { top: 0, left: 0, right: 100, bottom: 100 }, + draggableRects: [ + { top: 0, left: 0, right: 100, bottom: 20 }, + { top: 20, left: 0, right: 100, bottom: 40 }, + { top: 40, left: 0, right: 100, bottom: 60 }, + { top: 60, left: 0, right: 100, bottom: 80 }, + ], +}); + +const droppable: DroppableDimension = data.droppable; +const draggables: DraggableDimensionMap = data.draggables; + +describe('get initial impact', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + it('should return null if the draggable cannot be found in the droppable', () => { + const mystery = getDraggableDimension({ + id: 'mystery', + droppableId: 'some-cool-id', + clientRect: getClientRect({ + top: 0, left: 100, right: 200, bottom: 20, + }), + }); + + const result: ?DragImpact = getInitialImpact({ + draggable: mystery, + droppable: data.droppable, + draggables: data.draggables, + }); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalled(); + }); + + it('should return the initial location', () => { + data.draggableDimensions.forEach((draggable: DraggableDimension, index: number) => { + const expected: DragImpact = { + movement: noMovement, + direction: droppable.axis.direction, + destination: { + droppableId: droppable.id, + index, + }, + }; + const impact: ?DragImpact = getInitialImpact({ + draggable, + droppable, + draggables, + }); + + expect(impact).toEqual(expected); + }); + }); +}); diff --git a/test/unit/state/get-new-home-client-center.spec.js b/test/unit/state/get-new-home-client-center.spec.js new file mode 100644 index 0000000000..38873882b6 --- /dev/null +++ b/test/unit/state/get-new-home-client-center.spec.js @@ -0,0 +1,301 @@ +// @flow +import getNewHomeClientCenter from '../../../src/state/get-new-home-client-center'; +import { noMovement } from '../../../src/state/no-impact'; +import { patch } from '../../../src/state/position'; +import { getDroppableDimension, getDraggableDimension } from '../../../src/state/dimension'; +import { vertical, horizontal } from '../../../src/state/axis'; +import getClientRect from '../../../src/state/get-client-rect'; +import moveToEdge from '../../../src/state/move-to-edge'; +import type { + Axis, + DragMovement, + Position, + DraggableDimension, + DroppableDimension, + DraggableDimensionMap, +} from '../../../src/types'; + +describe('get new home client center', () => { + [vertical, horizontal].forEach((axis: Axis) => { + describe(`dropping on ${axis.direction} list`, () => { + const crossAxisStart: number = 0; + const crossAxisEnd: number = 100; + + const home: DroppableDimension = getDroppableDimension({ + id: 'home', + direction: axis.direction, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.end]: 100, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + }), + }); + + // size 10 + const inHome1: DraggableDimension = getDraggableDimension({ + id: 'inHome1', + droppableId: home.id, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.end]: 10, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + }), + }); + + // size 20 + const inHome2: DraggableDimension = getDraggableDimension({ + id: 'inHome2', + droppableId: home.id, + clientRect: getClientRect({ + [axis.start]: 10, + [axis.end]: 30, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + }), + }); + + // size 30 + const inHome3: DraggableDimension = getDraggableDimension({ + id: 'inHome3', + droppableId: home.id, + clientRect: getClientRect({ + [axis.start]: 30, + [axis.end]: 60, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + }), + }); + + const draggables: DraggableDimensionMap = { + [inHome1.id]: inHome1, + [inHome2.id]: inHome2, + [inHome3.id]: inHome3, + }; + + const inHome1Size: Position = patch(axis.line, inHome1.page.withMargin[axis.size]); + + it('should return the original center dropped on no destination', () => { + const result: Position = getNewHomeClientCenter({ + movement: noMovement, + draggables, + draggable: inHome1, + destination: null, + }); + + expect(result).toEqual(inHome1.client.withMargin.center); + }); + + describe('dropping in home list', () => { + it('should return the original center if moving back into the same spot', () => { + const newCenter: Position = getNewHomeClientCenter({ + movement: noMovement, + draggables, + draggable: inHome1, + destination: home, + }); + + expect(newCenter).toEqual(inHome1.client.withMargin.center); + }); + + describe('is moving forward (is always beyond start position)', () => { + // moving the first item forward past the third item + it('should move after the closest impacted draggable', () => { + const targetCenter: Position = moveToEdge({ + source: inHome1.client.withMargin, + sourceEdge: 'end', + destination: inHome3.client.withMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + // the movement from the last drag + const movement: DragMovement = { + // ordered by closest to impacted + draggables: [inHome3.id, inHome2.id], + amount: inHome1Size, + isBeyondStartPosition: true, + }; + + const newCenter = getNewHomeClientCenter({ + movement, + draggables, + draggable: inHome1, + destination: home, + }); + + expect(newCenter).toEqual(targetCenter); + }); + }); + + describe('is moving backward (is always not beyond start position)', () => { + // moving inHome3 back past inHome1 + it('should move before the closest impacted draggable', () => { + const targetCenter: Position = moveToEdge({ + source: inHome3.client.withMargin, + sourceEdge: 'start', + destination: inHome1.client.withMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + // the movement from the last drag + const movement: DragMovement = { + // ordered by closest to impacted + draggables: [inHome1.id, inHome2.id], + amount: inHome1Size, + // is not beyond start position - going backwards + isBeyondStartPosition: false, + }; + + const newCenter = getNewHomeClientCenter({ + movement, + draggables, + draggable: inHome3, + destination: home, + }); + + expect(newCenter).toEqual(targetCenter); + }); + }); + }); + + describe('dropping in foreign list', () => { + const foreignCrossAxisStart: number = 100; + const foreignCrossAxisEnd: number = 200; + const foreign: DroppableDimension = getDroppableDimension({ + id: 'foreign', + direction: axis.direction, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.end]: 100, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + }), + }); + + // size 10 + const inForeign1: DraggableDimension = getDraggableDimension({ + id: 'inForeign1', + droppableId: foreign.id, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.end]: 10, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + }), + }); + // size 20 + const inForeign2: DraggableDimension = getDraggableDimension({ + id: 'inForeign2', + droppableId: foreign.id, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.end]: 10, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + }), + }); + + const withForeign: DraggableDimensionMap = { + ...draggables, + [inForeign1.id]: inForeign1, + [inForeign2.id]: inForeign2, + }; + + describe('is moving into a populated list', () => { + it('should move above the target', () => { + const targetCenter: Position = moveToEdge({ + source: inHome1.client.withMargin, + sourceEdge: 'start', + destination: inForeign1.client.withMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + // the movement from the last drag + const movement: DragMovement = { + // ordered by closest to impacted + draggables: [inForeign1.id, inForeign2.id], + amount: inHome1Size, + // not relevant when moving into new list + isBeyondStartPosition: false, + }; + + const newCenter = getNewHomeClientCenter({ + movement, + draggables: withForeign, + draggable: inHome1, + destination: foreign, + }); + + expect(newCenter).toEqual(targetCenter); + }); + }); + + describe('is moving to end of a list', () => { + it('should draggable below the last item in the list', () => { + const targetCenter: Position = moveToEdge({ + source: inHome1.client.withMargin, + sourceEdge: 'start', + // will target the last in the foreign droppable + destination: inForeign2.client.withMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + // the movement from the last drag + const movement: DragMovement = { + // nothing has moved (going to end of list) + draggables: [], + amount: inHome1Size, + // not relevant when moving into new list + isBeyondStartPosition: false, + }; + + const newCenter = getNewHomeClientCenter({ + movement, + draggables: withForeign, + draggable: inHome1, + destination: foreign, + }); + + expect(newCenter).toEqual(targetCenter); + }); + }); + + describe('is moving to empty list', () => { + it('should move to the start of the list', () => { + const empty: DroppableDimension = getDroppableDimension({ + id: 'empty', + clientRect: getClientRect({ + top: 1000, bottom: 2000, left: 1000, right: 1000, + }), + }); + + const targetCenter: Position = moveToEdge({ + source: inHome1.client.withMargin, + sourceEdge: 'start', + destination: empty.client.withMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + // the movement from the last drag + const movement: DragMovement = { + draggables: [], + amount: inHome1Size, + // not relevant when moving into new list + isBeyondStartPosition: false, + }; + + const newCenter = getNewHomeClientCenter({ + movement, + draggables: withForeign, + draggable: inHome1, + destination: empty, + }); + + expect(newCenter).toEqual(targetCenter); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/state/get-new-home-client-offset.spec.js b/test/unit/state/get-new-home-client-offset.spec.js deleted file mode 100644 index 0bb2c2a67f..0000000000 --- a/test/unit/state/get-new-home-client-offset.spec.js +++ /dev/null @@ -1,761 +0,0 @@ -// @flow -import getNewHomeClientOffset from '../../../src/state/get-new-home-client-offset'; -import noImpact from '../../../src/state/no-impact'; -import { getDraggableDimension } from '../../../src/state/dimension'; -import { add, subtract } from '../../../src/state/position'; -import getClientRect from '../../utils/get-client-rect'; -import { vertical, horizontal } from '../../../src/state/axis'; -import type { - DroppableId, - DragMovement, - Position, - DraggableDimension, - DraggableDimensionMap, -} from '../../../src/types'; - -const origin: Position = { x: 0, y: 0 }; -const droppableId: DroppableId = 'drop-1'; - -describe('get new home client offset', () => { - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => { }); - }); - - afterEach(() => { - console.error.mockRestore(); - }); - - describe('vertical', () => { - const draggable1: DraggableDimension = getDraggableDimension({ - id: 'drag-1', - droppableId, - clientRect: getClientRect({ - top: 0, - left: 0, - bottom: 100, - right: 100, - }), - }); - - // huge height: 199 - const draggable2: DraggableDimension = getDraggableDimension({ - id: 'drag-2', - droppableId, - clientRect: getClientRect({ - top: 101, - left: 0, - bottom: 300, - right: 100, - }), - }); - - // height: 299 - const draggable3: DraggableDimension = getDraggableDimension({ - id: 'drag-3', - droppableId, - clientRect: getClientRect({ - top: 301, - left: 0, - bottom: 600, - right: 100, - }), - }); - - const draggables: DraggableDimensionMap = { - [draggable1.id]: draggable1, - [draggable2.id]: draggable2, - [draggable3.id]: draggable3, - }; - - it('should return the total scroll diff if nothing has moved', () => { - const offset: Position = { - x: 100, - y: 200, - }; - const droppableScrollDiff: Position = { - x: 20, - y: 10, - }; - const windowScrollDiff: Position = { - x: 30, - y: 20, - }; - - const result: Position = getNewHomeClientOffset({ - movement: noImpact.movement, - clientOffset: offset, - pageOffset: offset, - droppableScrollDiff, - windowScrollDiff, - draggables, - axis: vertical, - }); - - expect(result).toEqual(add(droppableScrollDiff, windowScrollDiff)); - }); - - it('should return the total scroll diff is no axis is provided', () => { - const offset: Position = { - x: 100, - y: 200, - }; - const droppableScrollDiff: Position = { - x: 20, - y: 10, - }; - const windowScrollDiff: Position = { - x: 30, - y: 20, - }; - // There should be no movement without an axis - // This is an error situation - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: 0, - y: draggable1.page.withMargin.height, - }, - isBeyondStartPosition: true, - }; - - const result: Position = getNewHomeClientOffset({ - movement, - clientOffset: offset, - pageOffset: offset, - droppableScrollDiff, - windowScrollDiff, - draggables, - axis: null, - }); - - expect(result).toEqual(add(droppableScrollDiff, windowScrollDiff)); - // this is an error situation - expect(console.error).toHaveBeenCalled(); - }); - - describe('moving forward', () => { - // Moving the first item down into the third position - - // Where the users cursor is - const selection: Position = { - x: draggable1.client.withoutMargin.center.x, - y: draggable3.client.withoutMargin.top + 1, - }; - - // moving the first item down past the third item - it('should account for the current client location of the dragging item', () => { - // The offset needed to get to the selection. - const clientOffset: Position = subtract(selection, draggable1.client.withoutMargin.center); - - // this test does not exercise page movement - const pageOffset: Position = clientOffset; - - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: 0, - y: draggable1.page.withMargin.height, - }, - isBeyondStartPosition: true, - }; - - // How much distance the item needs to travel to be in its new home - // from where it started - const verticalChange = { - x: 0, - y: draggable2.page.withMargin.height + draggable3.page.withMargin.height, - }; - // How far away it is from where it needs to end up - const diff: Position = subtract(verticalChange, pageOffset); - // this is the final client offset - const expected = add(clientOffset, diff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff: origin, - draggables, - axis: vertical, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the first item down past the third using only container scroll - it('should account for any changes in the droppables scroll container', () => { - const clientOffset: Position = origin; - const pageOffset: Position = origin; - const droppableScrollDiff: Position = subtract( - selection, - draggable1.page.withoutMargin.center - ); - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: 0, - y: draggable1.page.withMargin.height, - }, - isBeyondStartPosition: true, - }; - // this is where it needs to end up - const verticalChange = { - x: 0, - y: draggable2.page.withMargin.height + draggable3.page.withMargin.height, - }; - // this is how far away it is from where it needs to end up - const diff: Position = subtract(verticalChange, pageOffset); - // this is the final client offset - const expected = add(diff, droppableScrollDiff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff, - windowScrollDiff: origin, - draggables, - axis: vertical, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the first item down past the third using only window scroll - it('should account for any changes in the window scroll', () => { - const clientOffset: Position = origin; - const pageOffset: Position = { - x: 10, - y: 200, - }; - const droppableScrollDiff = origin; - const windowScrollDiff = pageOffset; - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: 0, - y: draggable1.page.withMargin.height, - }, - isBeyondStartPosition: true, - }; - // this is where it needs to end up - const verticalChange = { - x: 0, - y: draggable2.page.withMargin.height + draggable3.page.withMargin.height, - }; - // this is how far away it is from where it needs to end up - const diff: Position = subtract(verticalChange, pageOffset); - // this is the final client offset - const expected = add(diff, droppableScrollDiff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff, - draggables, - axis: vertical, - }); - - expect(newHomeOffset).toEqual(expected); - }); - }); - - describe('moving backward', () => { - // Moving the third item back into the first position - - // Where the users cursor is - const selection: Position = { - x: draggable3.client.withoutMargin.center.x, - y: draggable1.client.withoutMargin.bottom - 1, - }; - - // moving the third item backwards past the first and second item - it('should account for the current client location of the dragging item', () => { - // The offset needed to get to the selection. - const clientOffset: Position = subtract(selection, draggable3.client.withoutMargin.center); - - // this test does not exercise page movement - const pageOffset: Position = clientOffset; - - const movement: DragMovement = { - draggables: [draggable1.id, draggable2.id], - amount: { - x: 0, - y: draggable3.page.withMargin.height, - }, - isBeyondStartPosition: false, - }; - - // How much distance the item needs to travel to be in its new home - // from where it started - const verticalChange = { - x: 0, - y: -(draggable1.page.withMargin.height + draggable2.page.withMargin.height), - }; - // How far away it is from where it needs to end up - const diff: Position = subtract(verticalChange, pageOffset); - // this is the final client offset - const expected = add(clientOffset, diff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff: origin, - draggables, - axis: vertical, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the third item back past the first and second item using only window scroll - it('should account for the current page location of the dragging item', () => { - // have not moved the item on the screen at all - const clientOffset: Position = origin; - // the window has scrolled to get it to the selection point - const pageOffset: Position = subtract(selection, draggable3.page.withoutMargin.center); - const movement: DragMovement = { - draggables: [draggable1.id, draggable2.id], - amount: { - x: 0, - y: draggable3.page.withMargin.height, - }, - isBeyondStartPosition: false, - }; - // How much distance the item needs to travel to be in its new home - // from where it started - const verticalChange = { - x: 0, - y: -(draggable1.page.withMargin.height + draggable2.page.withMargin.height), - }; - // How far away it is from where it needs to end up - const diff: Position = subtract(verticalChange, pageOffset); - // this is the final client offset - const expected = add(clientOffset, diff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff: origin, - draggables, - axis: vertical, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the third item backwards past the first and second item using only container scroll - it('should account for any changes in the droppables scroll container', () => { - const clientOffset: Position = origin; - const pageOffset: Position = origin; - const droppableScrollDiff: Position = subtract( - selection, - draggable3.page.withoutMargin.center - ); - const movement: DragMovement = { - draggables: [draggable1.id, draggable2.id], - amount: { - x: 0, - y: draggable3.page.withMargin.height, - }, - isBeyondStartPosition: false, - }; - // this is where it needs to end up - const verticalChange = { - x: 0, - y: -(draggable1.page.withMargin.height + draggable2.page.withMargin.height), - }; - // this is how far away it is from where it needs to end up - const diff: Position = subtract(verticalChange, pageOffset); - // this is the final client offset - const expected = add(diff, droppableScrollDiff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff, - windowScrollDiff: origin, - draggables, - axis: vertical, - }); - - expect(newHomeOffset).toEqual(expected); - }); - }); - }); - - describe('horizontal', () => { - const draggable1: DraggableDimension = getDraggableDimension({ - id: 'drag-1', - droppableId, - clientRect: getClientRect({ - top: 0, - left: 0, - bottom: 100, - right: 100, - }), - }); - - // width: 199 - const draggable2: DraggableDimension = getDraggableDimension({ - id: 'drag-2', - droppableId, - clientRect: getClientRect({ - top: 0, - left: 101, - bottom: 100, - right: 300, - }), - }); - - // height: 299 - const draggable3: DraggableDimension = getDraggableDimension({ - id: 'drag-3', - droppableId, - clientRect: getClientRect({ - top: 0, - left: 500, - bottom: 100, - right: 301, - }), - }); - - const draggables: DraggableDimensionMap = { - [draggable1.id]: draggable1, - [draggable2.id]: draggable2, - [draggable3.id]: draggable3, - }; - - it('should return to the total scroll diff if nothing has moved', () => { - const offset: Position = { - x: 100, - y: 200, - }; - const droppableScrollDiff: Position = { - x: 20, - y: 10, - }; - const windowScrollDiff: Position = { - x: 30, - y: 20, - }; - - const result: Position = getNewHomeClientOffset({ - movement: noImpact.movement, - clientOffset: offset, - pageOffset: offset, - droppableScrollDiff, - windowScrollDiff, - draggables, - axis: horizontal, - }); - - expect(result).toEqual(add(droppableScrollDiff, windowScrollDiff)); - }); - - it('should return the total scroll diff is no axis is provided', () => { - const offset: Position = { - x: 100, - y: 200, - }; - const droppableScrollDiff: Position = { - x: 20, - y: 10, - }; - const windowScrollDiff: Position = { - x: 30, - y: 20, - }; - // There should be no movement without an axis - // This is an error situation - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: draggable1.page.withMargin.width, - y: 0, - }, - isBeyondStartPosition: true, - }; - - const result: Position = getNewHomeClientOffset({ - movement, - clientOffset: offset, - pageOffset: offset, - droppableScrollDiff, - windowScrollDiff, - draggables, - axis: null, - }); - - expect(result).toEqual(add(droppableScrollDiff, windowScrollDiff)); - // this is an error situation - expect(console.error).toHaveBeenCalled(); - }); - - describe('moving forward', () => { - // Moving the first item forward into the third position - - // Where the users cursor is - const selection: Position = { - x: draggable3.client.withoutMargin.left + 1, - y: draggable1.client.withoutMargin.center.y, - }; - - // moving the first item down past the third item - it('should account for the current client location of the dragging item', () => { - // The offset needed to get to the selection. - const clientOffset: Position = subtract(selection, draggable1.client.withoutMargin.center); - - // this test does not exercise page movement - const pageOffset: Position = clientOffset; - - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: draggable1.page.withMargin.width, - y: 0, - }, - isBeyondStartPosition: true, - }; - - // How much distance the item needs to travel to be in its new home - // from where it started - const horizontalChange = { - x: draggable2.page.withMargin.width + draggable3.page.withMargin.width, - y: 0, - }; - // How far away it is from where it needs to end up - const diff: Position = subtract(horizontalChange, pageOffset); - // this is the final client offset - const expected = add(clientOffset, diff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff: origin, - draggables, - axis: horizontal, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the first item down past the third using only container scroll - it('should account for any changes in the droppables scroll container', () => { - const clientOffset: Position = origin; - const pageOffset: Position = origin; - const droppableScrollDiff: Position = subtract( - selection, - draggable1.page.withoutMargin.center - ); - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: draggable1.page.withMargin.width, - y: 0, - }, - isBeyondStartPosition: true, - }; - // this is where it needs to end up - const horizontalChange = { - x: draggable2.page.withMargin.width + draggable3.page.withMargin.width, - y: 0, - }; - // this is how far away it is from where it needs to end up - const diff: Position = subtract(horizontalChange, pageOffset); - // this is the final client offset - const expected = add(diff, droppableScrollDiff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff, - windowScrollDiff: origin, - draggables, - axis: horizontal, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the first item forward past the third using only window scroll - it('should account for any changes in the window scroll', () => { - const clientOffset: Position = origin; - const pageOffset: Position = { - x: 10, - y: 200, - }; - const droppableScrollDiff = origin; - const windowScrollDiff = pageOffset; - const movement: DragMovement = { - draggables: [draggable2.id, draggable3.id], - amount: { - x: draggable1.page.withMargin.width, - y: 0, - }, - isBeyondStartPosition: true, - }; - // this is where it needs to end up - const horizontalChange = { - x: draggable2.page.withMargin.width + draggable3.page.withMargin.width, - y: 0, - }; - // this is how far away it is from where it needs to end up - const diff: Position = subtract(horizontalChange, pageOffset); - // this is the final client offset - const expected = add(diff, droppableScrollDiff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff, - draggables, - axis: horizontal, - }); - - expect(newHomeOffset).toEqual(expected); - }); - }); - - describe('moving backward', () => { - // Moving the third item back into the first position - - // Where the users cursor is - const selection: Position = { - x: draggable1.client.withoutMargin.right - 1, - y: draggable3.client.withoutMargin.center.y, - }; - - // moving the third item back past the first and second item - it('should account for the current client location of the dragging item', () => { - // The offset needed to get to the selection. - const clientOffset: Position = subtract(selection, draggable3.client.withoutMargin.center); - - // this test does not exercise page movement - const pageOffset: Position = clientOffset; - - const movement: DragMovement = { - draggables: [draggable1.id, draggable2.id], - amount: { - x: draggable3.page.withMargin.width, - y: 0, - }, - isBeyondStartPosition: false, - }; - - // How much distance the item needs to travel to be in its new home - // from where it started - const horizontalChange = { - x: -(draggable1.page.withMargin.width + draggable2.page.withMargin.width), - y: 0, - }; - // How far away it is from where it needs to end up - const diff: Position = subtract(horizontalChange, pageOffset); - // this is the final client offset - const expected = add(clientOffset, diff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff: origin, - draggables, - axis: horizontal, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the third item back past the first and second item using only window scroll - it('should account for the current page location of the dragging item', () => { - // have not moved the item on the screen at all - const clientOffset: Position = origin; - // the window has scrolled to get it to the selection point - const pageOffset: Position = subtract(selection, draggable3.page.withoutMargin.center); - const movement: DragMovement = { - draggables: [draggable1.id, draggable2.id], - amount: { - x: draggable3.page.withMargin.width, - y: 0, - }, - isBeyondStartPosition: false, - }; - // How much distance the item needs to travel to be in its new home - // from where it started - const horizontalChange = { - x: -(draggable1.page.withMargin.width + draggable2.page.withMargin.width), - y: 0, - }; - // How far away it is from where it needs to end up - const diff: Position = subtract(horizontalChange, pageOffset); - // this is the final client offset - const expected = add(clientOffset, diff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff: origin, - windowScrollDiff: origin, - draggables, - axis: horizontal, - }); - - expect(newHomeOffset).toEqual(expected); - }); - - // moving the first item down past the third using only container scroll - it('should account for any changes in the droppables scroll container', () => { - const clientOffset: Position = origin; - const pageOffset: Position = origin; - const droppableScrollDiff: Position = subtract( - selection, - draggable3.page.withoutMargin.center - ); - const movement: DragMovement = { - draggables: [draggable1.id, draggable2.id], - amount: { - x: draggable3.page.withMargin.width, - y: 0, - }, - isBeyondStartPosition: false, - }; - // this is where it needs to end up - const horizontalChange = { - x: -(draggable1.page.withMargin.width + draggable2.page.withMargin.width), - y: 0, - }; - // this is how far away it is from where it needs to end up - const diff: Position = subtract(horizontalChange, pageOffset); - // this is the final client offset - const expected = add(diff, droppableScrollDiff); - - const newHomeOffset = getNewHomeClientOffset({ - movement, - clientOffset, - pageOffset, - droppableScrollDiff, - windowScrollDiff: origin, - draggables, - axis: horizontal, - }); - - expect(newHomeOffset).toEqual(expected); - }); - }); - }); -}); diff --git a/test/unit/state/hook-middleware.spec.js b/test/unit/state/hook-middleware.spec.js index a04dc4e2d8..42a600d1d0 100644 --- a/test/unit/state/hook-middleware.spec.js +++ b/test/unit/state/hook-middleware.spec.js @@ -2,7 +2,7 @@ import middleware from '../../../src/state/hook-middleware'; import { getDraggableDimension, getDroppableDimension } from '../../../src/state/dimension'; import { clean } from '../../../src/state/action-creators'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import noImpact from '../../../src/state/no-impact'; import type { DraggableId, @@ -124,6 +124,7 @@ const state = (() => { center: currentClient.center, }, shouldAnimate: true, + isScrollAllowed: true, }, impact: noImpact, }; diff --git a/test/unit/state/is-within-visible-bounds-of-droppable.spec.js b/test/unit/state/is-within-visible-bounds-of-droppable.spec.js new file mode 100644 index 0000000000..43826498e8 --- /dev/null +++ b/test/unit/state/is-within-visible-bounds-of-droppable.spec.js @@ -0,0 +1,132 @@ +// @flow +import { isPointWithin, isDraggableWithin } from '../../../src/state/is-within-visible-bounds-of-droppable'; +import { getDroppableDimension } from '../../../src/state/dimension'; +import { add, subtract } from '../../../src/state/position'; +import getClientRect from '../../../src/state/get-client-rect'; +import getDroppableWithDraggables from '../../utils/get-droppable-with-draggables'; +import type { Result } from '../../utils/get-droppable-with-draggables'; +import type { + Position, + DraggableDimension, +} from '../../../src/types'; + +describe('is within visible bounds of a droppable', () => { + const droppable = getDroppableDimension({ + id: 'droppable', + clientRect: getClientRect({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + }); + + describe('is point within', () => { + const isWithinDroppable = isPointWithin(droppable); + const { top, left, right, bottom } = droppable.page.withMargin; + + it('should return true if a point is within a droppable', () => { + expect(isWithinDroppable(droppable.page.withMargin.center)).toBe(true); + }); + + it('should return true if a point is on any of the droppable boundaries', () => { + const corners = [ + { x: left, y: top }, + { x: left, y: bottom }, + { x: right, y: top }, + { x: right, y: bottom }, + ]; + + corners.forEach((corner: Position) => { + expect(isWithinDroppable(corner)).toBe(true); + }); + }); + + it('should return false if the point is not within the droppable on any side', () => { + const outside = [ + subtract({ x: left, y: top }, { x: 0, y: 10 }), // too far top + subtract({ x: left, y: bottom }, { x: 10, y: 0 }), // too far left + add({ x: right, y: top }, { x: 10, y: 0 }), // too far right + add({ x: right, y: bottom }, { x: 0, y: 10 }), // too far bottom + ]; + + outside.forEach((point: Position) => { + expect(isWithinDroppable(point)).toBe(false); + }); + }); + + it('should be based on the page coordinates of the droppable', () => { + const windowScroll: Position = { + x: 200, y: 200, + }; + const custom = getDroppableDimension({ + id: 'with-scroll', + windowScroll, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + }); + const isWithinCustom = isPointWithin(custom); + + // custom points + expect(isWithinCustom({ x: 10, y: 10 })).toBe(false); + expect(isWithinCustom({ x: 210, y: 210 })).toBe(true); + + // checking with the center position of the dimension itself + expect(isWithinCustom(custom.client.withMargin.center)).toBe(false); + expect(isWithinCustom(custom.page.withMargin.center)).toBe(true); + }); + }); + + describe('is draggable within', () => { + it('should return true if the draggable is within the droppable', () => { + const result: Result = getDroppableWithDraggables({ + droppableRect: { top: 0, left: 0, bottom: 100, right: 100 }, + draggableRects: [ + // on the boundaries + { top: 0, left: 0, bottom: 100, right: 100 }, + ], + }); + const isWithinDroppable = isDraggableWithin(result.droppable); + + expect(isWithinDroppable(result.draggableDimensions[0])).toBe(true); + }); + + it('should return false if there is overlap on any side', () => { + const result: Result = getDroppableWithDraggables({ + droppableRect: { top: 0, left: 0, bottom: 100, right: 100 }, + draggableRects: [ + { top: -10, left: 0, bottom: 20, right: 100 }, // too far top + { top: 0, left: -10, bottom: 20, right: 100 }, // too far left + { top: 0, left: 0, bottom: 20, right: 110 }, // too far right + { top: 0, left: 0, bottom: 120, right: 100 }, // too far bottom + ], + }); + const isWithinDroppable = isDraggableWithin(result.droppable); + + result.draggableDimensions.forEach((draggable: DraggableDimension) => { + expect(isWithinDroppable(draggable)).toBe(false); + }); + }); + + it('should allow a small affordance to compensate for margin capturing inaccuracy', () => { + const result: Result = getDroppableWithDraggables({ + droppableRect: { top: 0, left: 0, bottom: 100, right: 100 }, + draggableRects: [ + { top: -1, left: 0, bottom: 20, right: 100 }, // not too far top + { top: 0, left: -1, bottom: 20, right: 100 }, // not too far left + { top: 0, left: 0, bottom: 20, right: 101 }, // not too far right + { top: 0, left: 0, bottom: 101, right: 100 }, // not too far bottom + ], + }); + const isWithinDroppable = isDraggableWithin(result.droppable); + + result.draggableDimensions.forEach((draggable: DraggableDimension) => { + expect(isWithinDroppable(draggable)).toBe(true); + }); + }); + }); +}); diff --git a/test/unit/state/is-within.spec.js b/test/unit/state/is-within.spec.js new file mode 100644 index 0000000000..679e4d8a46 --- /dev/null +++ b/test/unit/state/is-within.spec.js @@ -0,0 +1,28 @@ +// @flow +import isWithin from '../../../src/state/is-within'; + +describe('is within', () => { + const lowerBound: number = 5; + const upperBound: number = 10; + const execute = isWithin(5, 10); + + it('should return true when the value is between the bounds', () => { + expect(execute(lowerBound + 1)).toBe(true); + }); + + it('should return true when the value is equal to the lower bound', () => { + expect(execute(lowerBound)).toBe(true); + }); + + it('should return true when the value is equal to the upper bound', () => { + expect(execute(upperBound)).toBe(true); + }); + + it('should return false when the value is less then the lower bound', () => { + expect(execute(lowerBound - 1)).toBe(false); + }); + + it('should return false when the value is greater than the upper bound', () => { + expect(execute(upperBound + 1)).toBe(false); + }); +}); diff --git a/test/unit/state/jump-to-next-index.spec.js b/test/unit/state/jump-to-next-index.spec.js deleted file mode 100644 index 08343d29c1..0000000000 --- a/test/unit/state/jump-to-next-index.spec.js +++ /dev/null @@ -1,639 +0,0 @@ -// @flow -import jumpToNextIndex from '../../../src/state/jump-to-next-index'; -import type { JumpToNextResult } from '../../../src/state/jump-to-next-index'; -import { getDraggableDimension, getDroppableDimension } from '../../../src/state/dimension'; -import getClientRect from '../../utils/get-client-rect'; -import { patch } from '../../../src/state/position'; -import { vertical, horizontal } from '../../../src/state/axis'; -import type { - Axis, - DragMovement, - DragImpact, - DroppableId, - DraggableDimension, - DroppableDimension, - DraggableDimensionMap, - DroppableDimensionMap, - DraggableLocation, - Position, -} from '../../../src/types'; - -const droppableId: DroppableId = 'drop-1'; - -describe('jump to next index', () => { - [vertical, horizontal].forEach((axis: Axis) => { - const droppable: DroppableDimension = getDroppableDimension({ - id: droppableId, - direction: axis.direction, - clientRect: getClientRect({ - top: 0, - left: 0, - bottom: 1000, - right: 1000, - }), - }); - - // size: 100 - const draggable1: DraggableDimension = getDraggableDimension({ - id: 'draggable1', - droppableId, - clientRect: getClientRect({ - top: 0, - left: 0, - bottom: 100, - right: 100, - }), - }); - - // size: 199 - const draggable2: DraggableDimension = getDraggableDimension({ - id: 'draggable2', - droppableId, - clientRect: getClientRect({ - top: 101, - left: 101, - bottom: 300, - right: 300, - }), - }); - - // size: 299 - const draggable3: DraggableDimension = getDraggableDimension({ - id: 'draggable3', - droppableId, - clientRect: getClientRect({ - top: 301, - left: 301, - bottom: 600, - right: 600, - }), - }); - - const droppables: DroppableDimensionMap = { - [droppable.id]: droppable, - }; - - const draggables: DraggableDimensionMap = { - [draggable1.id]: draggable1, - [draggable2.id]: draggable2, - [draggable3.id]: draggable3, - }; - describe(`on the ${axis.direction} axis`, () => { - describe('jump forward', () => { - it('should return null if cannot move forward', () => { - const impact: DragImpact = { - movement: { - draggables: [], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - direction: axis.direction, - destination: { - index: 2, - droppableId: droppable.id, - }, - }; - - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: true, - draggableId: draggable3.id, - impact, - draggables, - droppables, - }); - - expect(result).toBe(null); - }); - - describe('is moving toward start position', () => { - describe('dragging item forward to starting position', () => { - // dragging the second item (draggable2), which has previously - // been moved backwards and is now in the first position - const impact: DragImpact = { - movement: { - draggables: [draggable1.id], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - direction: axis.direction, - destination: { - index: 0, - droppableId: droppable.id, - }, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: true, - draggableId: draggable2.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result of jumpToNextIndex'); - } - - it('should return the size of the dimension in the current index as the diff', () => { - const size: Position = patch( - axis.line, - draggable1.page.withMargin[axis.size] - ); - - expect(result.diff).toEqual(size); - }); - - it('should return an empty impact', () => { - const expected: DragImpact = { - movement: { - draggables: [], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - destination: { - droppableId: droppable.id, - index: 1, - }, - direction: axis.direction, - }; - - expect(result.impact).toEqual(expected); - }); - }); - - describe('dragging forwards, but not beyond the starting position', () => { - // draggable3 has moved backwards past draggable2 and draggable1 - const impact: DragImpact = { - movement: { - // second and first item have already moved - draggables: [draggable2.id, draggable1.id], - amount: patch(axis.line, draggable3.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - direction: axis.direction, - // draggable3 is now in the first position - destination: { - droppableId: droppable.id, - index: 0, - }, - }; - // moving draggable3 forward one position - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: true, - draggableId: draggable3.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return the size of the moving dimension (draggable1)', () => { - const expected: Position = patch( - axis.line, - draggable1.page.withMargin[axis.size], - ); - expect(result.diff).toEqual(expected); - }); - - it('should remove the first dimension from the impact', () => { - const expected: DragImpact = { - movement: { - draggables: [draggable2.id], - amount: patch(axis.line, draggable3.page.withMargin[axis.size]), - // is still behind where it started - isBeyondStartPosition: false, - }, - direction: axis.direction, - // is now in the second position - destination: { - droppableId: droppable.id, - index: 1, - }, - }; - - expect(result.impact).toEqual(expected); - }); - }); - }); - - describe('is moving away from start position', () => { - describe('dragging first item forward one position', () => { - // dragging the first item forward into the second position - const movement: DragMovement = { - draggables: [], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }; - const destination: DraggableLocation = { - index: 0, - droppableId: droppable.id, - }; - const impact: DragImpact = { - movement, - direction: axis.direction, - destination, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: true, - draggableId: draggable1.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return the size of the second dimension as the diff', () => { - const expected: Position = patch(axis.line, draggable2.page.withMargin[axis.size]); - - expect(result.diff).toEqual(expected); - }); - - it('should move the item into the second spot and move the second item out of the way', () => { - const expected: DragImpact = { - movement: { - draggables: [draggable2.id], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - direction: axis.direction, - // is now in the second position - destination: { - droppableId: droppable.id, - index: 1, - }, - }; - - expect(result.impact).toEqual(expected); - }); - }); - - describe('dragging second item forward one position', () => { - const movement: DragMovement = { - draggables: [], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }; - const destination: DraggableLocation = { - index: 1, - droppableId: droppable.id, - }; - const impact: DragImpact = { - movement, - direction: axis.direction, - destination, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: true, - draggableId: draggable2.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return the size of the third dimension as the diff', () => { - const expected: Position = patch( - axis.line, - draggable3.page.withMargin[axis.size], - ); - expect(result.diff).toEqual(expected); - }); - - it('should move the dragging item into the third spot and move the third item out of the way', () => { - const expected: DragImpact = { - movement: { - draggables: [draggable3.id], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - direction: axis.direction, - // is now in the second position - destination: { - droppableId: droppable.id, - index: 2, - }, - }; - - expect(result.impact).toEqual(expected); - }); - }); - - describe('dragging first item forward one position after already moving it forward once', () => { - const impact: DragImpact = { - movement: { - // second item has already moved - draggables: [draggable2.id], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - direction: axis.direction, - // draggable1 is now in the second position - destination: { - droppableId: droppable.id, - index: 1, - }, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: true, - draggableId: draggable1.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return the size of the third dimension', () => { - // next dimension from the current index is draggable3 - const expected: Position = patch( - axis.line, - draggable3.page.withMargin[axis.size], - ); - - expect(result.diff).toEqual(expected); - }); - - it('should move the third item out of the way', () => { - const expected: DragImpact = { - movement: { - // adding draggable3 to the list - draggables: [draggable2.id, draggable3.id], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - direction: axis.direction, - // is now in the second position - destination: { - droppableId: droppable.id, - index: 2, - }, - }; - - expect(result.impact).toEqual(expected); - }); - }); - }); - }); - - describe('jump backward', () => { - it('should return null if cannot move backward', () => { - const impact: DragImpact = { - movement: { - draggables: [], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - direction: axis.direction, - destination: { - index: 0, - droppableId: droppable.id, - }, - }; - - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: false, - draggableId: draggable1.id, - impact, - draggables, - droppables, - }); - - expect(result).toBe(null); - }); - - describe('is moving towards the start position', () => { - describe('moving back to original position', () => { - // dragged the second item (draggable2) forward once, and is now - // moving backwards towards the start again - const impact: DragImpact = { - movement: { - draggables: [draggable3.id], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - direction: axis.direction, - destination: { - index: 2, - droppableId: droppable.id, - }, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: false, - draggableId: draggable2.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return the size of the dimension that will move back to its home', () => { - const expected: Position = patch( - axis.line, - // amount is negative because we are moving backwards - -draggable3.page.withMargin[axis.size], - ); - - expect(result.diff).toEqual(expected); - }); - - it('should return an empty impact', () => { - const expected: DragImpact = { - movement: { - draggables: [], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - destination: { - droppableId: droppable.id, - index: 1, - }, - direction: axis.direction, - }; - - expect(result.impact).toEqual(expected); - }); - }); - - describe('moving back, but not far enough to be at the start yet', () => { - // dragged the first item: - // forward twice so it is in the third position - // then moving backward so it is in the second position - const impact: DragImpact = { - movement: { - draggables: [draggable2.id, draggable3.id], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - direction: axis.direction, - destination: { - index: 2, - droppableId: droppable.id, - }, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: false, - draggableId: draggable1.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return the size of the dimension that will move back to its home', () => { - const expected: Position = patch( - axis.line, - // amount is negative because we are moving backwards - -draggable3.page.withMargin[axis.size], - ); - - expect(result.diff).toEqual(expected); - }); - - it('should remove the third draggable from the drag impact', () => { - const expected: DragImpact = { - movement: { - // draggable3 has been removed - draggables: [draggable2.id], - amount: patch(axis.line, draggable1.page.withMargin[axis.size]), - isBeyondStartPosition: true, - }, - destination: { - droppableId: droppable.id, - index: 1, - }, - direction: axis.direction, - }; - - expect(result.impact).toEqual(expected); - }); - }); - }); - - describe('is moving away from start position', () => { - describe('dragging the second item back to the first position', () => { - // no impact yet - const impact: DragImpact = { - movement: { - draggables: [], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - destination: { - droppableId: droppable.id, - index: 1, - }, - direction: axis.direction, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: false, - draggableId: draggable2.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return negated size of draggable1 as the diff', () => { - const expected: Position = patch( - axis.line, - -draggable1.page.withMargin[axis.size], - ); - - expect(result.diff).toEqual(expected); - }); - - it('should add the first draggable to the drag impact', () => { - const expected: DragImpact = { - movement: { - draggables: [draggable1.id], - amount: patch(axis.line, draggable2.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - destination: { - droppableId: droppable.id, - // is now in the first position - index: 0, - }, - direction: axis.direction, - }; - - expect(result.impact).toEqual(expected); - }); - }); - - describe('dragging the third item back to the second position', () => { - const impact: DragImpact = { - movement: { - draggables: [], - amount: patch(axis.line, draggable3.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - destination: { - droppableId: droppable.id, - index: 2, - }, - direction: axis.direction, - }; - const result: ?JumpToNextResult = jumpToNextIndex({ - isMovingForward: false, - draggableId: draggable3.id, - impact, - draggables, - droppables, - }); - - if (!result) { - throw new Error('invalid result'); - } - - it('should return the negated size of draggable2 as the diff', () => { - const expected: Position = patch( - axis.line, - -draggable2.page.withMargin[axis.size], - ); - - expect(result.diff).toEqual(expected); - }); - - it('should add the second draggable to the drag impact', () => { - const expected: DragImpact = { - movement: { - draggables: [draggable2.id], - amount: patch(axis.line, draggable3.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - destination: { - droppableId: droppable.id, - // is now in the second position - index: 1, - }, - direction: axis.direction, - }; - - expect(result.impact).toEqual(expected); - }); - }); - }); - }); - }); - }); -}); diff --git a/test/unit/state/move-cross-axis/get-best-cross-axis-droppable.spec.js b/test/unit/state/move-cross-axis/get-best-cross-axis-droppable.spec.js new file mode 100644 index 0000000000..ee212c83f8 --- /dev/null +++ b/test/unit/state/move-cross-axis/get-best-cross-axis-droppable.spec.js @@ -0,0 +1,627 @@ +// @flow +import getBestCrossAxisDroppable from '../../../../src/state/move-cross-axis/get-best-cross-axis-droppable'; +import { getDroppableDimension } from '../../../../src/state/dimension'; +import getClientRect from '../../../../src/state/get-client-rect'; +import { add } from '../../../../src/state/position'; +import { horizontal, vertical } from '../../../../src/state/axis'; +import type { + Axis, + Position, + DroppableDimension, + DroppableDimensionMap, +} from '../../../../src/types'; + +describe('get best cross axis droppable', () => { + describe('on the vertical axis', () => { + const axis: Axis = vertical; + + it('should return the first droppable on the cross axis when moving forward', () => { + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 20, + right: 30, + bottom: 10, + }), + }); + const forward = getDroppableDimension({ + id: 'forward', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 30, + right: 40, + bottom: 10, + }), + }); + const droppables: DroppableDimensionMap = { + [source.id]: source, + [forward.id]: forward, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(forward); + }); + + it('should return the first droppable on the cross axis when moving backward', () => { + const behind = getDroppableDimension({ + id: 'behind', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 20, + right: 30, + bottom: 10, + }), + }); + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 30, + right: 40, + bottom: 10, + }), + }); + const droppables: DroppableDimensionMap = { + [behind.id]: behind, + [source.id]: source, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + // moving backwards + isMovingForward: false, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(behind); + }); + + it('should exclude options that are not in the desired direction', () => { + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 20, + right: 30, + bottom: 10, + }), + }); + const behind = getDroppableDimension({ + id: 'behind', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 10, + bottom: 10, + }), + }); + const droppables: DroppableDimensionMap = { + [behind.id]: behind, + [source.id]: source, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + expect(result).toBe(null); + + // checking that it would have been returned if was moving in the other direction + const result2: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: false, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + expect(result2).toBe(behind); + }); + + it('should exclude options that are not enabled', () => { + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 20, + right: 30, + bottom: 10, + }), + }); + const disabled = getDroppableDimension({ + id: 'disabled', + isEnabled: false, + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 30, + right: 40, + bottom: 10, + }), + }); + const droppables: DroppableDimensionMap = { + [source.id]: source, + [disabled.id]: disabled, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(null); + }); + + it('should exclude options that do not overlap on the main axis', () => { + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 20, + right: 30, + bottom: 10, + }), + }); + const noOverlap = getDroppableDimension({ + id: 'noOverlap', + direction: axis.direction, + clientRect: getClientRect({ + // top is below where the source ended + top: 11, + left: 30, + right: 40, + bottom: 20, + }), + }); + + const droppables: DroppableDimensionMap = { + [source.id]: source, + [noOverlap.id]: noOverlap, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(null); + }); + + describe('more than one option share the same crossAxisStart value', () => { + // this happens when two lists sit on top of one another + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 20, + bottom: 100, + }), + }); + const sibling1 = getDroppableDimension({ + id: 'sibling1', + direction: axis.direction, + clientRect: getClientRect({ + // not the same top value as source + top: 20, + // shares the left edge with the source + left: 20, + right: 40, + bottom: 40, + }), + }); + const sibling2 = getDroppableDimension({ + id: 'sibling2', + direction: axis.direction, + clientRect: getClientRect({ + // shares the bottom edge with sibling1 + top: 40, + // shares the left edge with the source + left: 20, + right: 40, + bottom: 60, + }), + }); + const droppables: DroppableDimensionMap = { + [source.id]: source, + [sibling1.id]: sibling1, + [sibling2.id]: sibling2, + }; + + it('should return a droppable where the center position (axis.line) of the draggable draggable sits within the size of a droppable', () => { + // sitting inside source - but within the size of sibling2 on the main axis + const center: Position = { + y: 50, + x: 10, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center, + source, + droppables, + }); + + expect(result).toBe(sibling2); + }); + + describe('center point is not contained within a droppable', () => { + it('should return the droppable that has the closest corner', () => { + // Choosing a point that is above the first sibling + const center: Position = { + // above sibling 1 + y: 10, + x: 10, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center, + source, + droppables, + }); + + expect(result).toBe(sibling1); + }); + + it('should choose the droppable that is furthest back (closest to {x: 0, y: 0} on the screen) in the event of a tie', () => { + // Choosing a point that is above the first sibling + const center: Position = { + // this line is shared between sibling1 and sibling2 + y: 40, + x: 10, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center, + source, + droppables, + }); + + expect(result).toBe(sibling1); + + // checking that center position was selected correctly + const center2: Position = add(center, { x: 0, y: 1 }); + const result2: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center2, + source, + droppables, + }); + expect(result2).toBe(sibling2); + }); + }); + }); + }); + + describe('on the horizontal axis', () => { + const axis: Axis = horizontal; + + it('should return the first droppable on the cross axis when moving forward', () => { + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 20, + bottom: 20, + }), + }); + const forward = getDroppableDimension({ + id: 'forward', + direction: axis.direction, + clientRect: getClientRect({ + top: 20, + left: 0, + right: 20, + bottom: 30, + }), + }); + const droppables: DroppableDimensionMap = { + [source.id]: source, + [forward.id]: forward, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(forward); + }); + + it('should return the first droppable on the cross axis when moving backward', () => { + const behind = getDroppableDimension({ + id: 'behind', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 20, + bottom: 10, + }), + }); + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 10, + left: 0, + right: 20, + bottom: 20, + }), + }); + const droppables: DroppableDimensionMap = { + [behind.id]: behind, + [source.id]: source, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + // moving backwards + isMovingForward: false, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(behind); + }); + + it('should exclude options that are not in the desired direction', () => { + const behind = getDroppableDimension({ + id: 'behind', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 20, + bottom: 10, + }), + }); + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 10, + left: 0, + right: 20, + bottom: 20, + }), + }); + const droppables: DroppableDimensionMap = { + [behind.id]: behind, + [source.id]: source, + }; + + // now moving in the other direction + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + expect(result).toBe(null); + + // Ensuring that normally it would be returned if moving in the right direction + const result2: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: false, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + expect(result2).toBe(behind); + }); + + it('should exclude options that are not enabled', () => { + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 20, + right: 30, + bottom: 10, + }), + }); + const disabled = getDroppableDimension({ + id: 'disabled', + isEnabled: false, + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 30, + right: 40, + bottom: 10, + }), + }); + const droppables: DroppableDimensionMap = { + [source.id]: source, + [disabled.id]: disabled, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(null); + }); + + it('should exclude options that do not overlap on the main axis', () => { + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 20, + bottom: 10, + }), + }); + const noOverlap = getDroppableDimension({ + id: 'noOverlap', + direction: axis.direction, + clientRect: getClientRect({ + // comes after the source + top: 10, + // but its left value is > the rigt of the source + left: 30, + right: 40, + bottom: 20, + }), + }); + + const droppables: DroppableDimensionMap = { + [source.id]: source, + [noOverlap.id]: noOverlap, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: source.page.withMargin.center, + source, + droppables, + }); + + expect(result).toBe(null); + }); + + describe('more than one option share the same crossAxisStart value', () => { + // this happens when two lists sit side by side + const source = getDroppableDimension({ + id: 'source', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + right: 100, + bottom: 10, + }), + }); + const sibling1 = getDroppableDimension({ + id: 'sibling1', + direction: axis.direction, + clientRect: getClientRect({ + // shares an edge with the source + top: 10, + // shares the left edge with the source + left: 20, + right: 40, + bottom: 20, + }), + }); + const sibling2 = getDroppableDimension({ + id: 'sibling2', + direction: axis.direction, + clientRect: getClientRect({ + // shares an edge with the source + top: 10, + // shares the left edge with the source + left: 40, + right: 60, + bottom: 20, + }), + }); + const droppables: DroppableDimensionMap = { + [source.id]: source, + [sibling1.id]: sibling1, + [sibling2.id]: sibling2, + }; + + it('should return a droppable where the center position (axis.line) of the draggable draggable sits within the size of a droppable', () => { + // sitting inside source - but within the size of sibling2 on the main axis + const center: Position = { + y: 5, + x: 50, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center, + source, + droppables, + }); + + expect(result).toBe(sibling2); + }); + + describe('center point is not contained within a droppable', () => { + it('should return the droppable that has the closest corner', () => { + // Choosing a point that is before the first sibling + const center: Position = { + // above sibling 1 + y: 5, + // before the left value of sibling 1 + x: 10, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center, + source, + droppables, + }); + + expect(result).toBe(sibling1); + }); + + it('should choose the droppable that is furthest back (closest to {x: 0, y: 0} on the screen) in the event of a tie', () => { + // Choosing a point that is above the first sibling + const center: Position = { + y: 5, + // this line is shared between sibling1 and sibling2 + x: 40, + }; + + const result: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center, + source, + droppables, + }); + + expect(result).toBe(sibling1); + + // checking that center point is correct + + const center2: Position = add(center, { y: 0, x: 1 }); + const result2: ?DroppableDimension = getBestCrossAxisDroppable({ + isMovingForward: true, + pageCenter: center2, + source, + droppables, + }); + + expect(result2).toBe(sibling2); + }); + }); + }); + }); +}); diff --git a/test/unit/state/move-cross-axis/get-closest-draggable.spec.js b/test/unit/state/move-cross-axis/get-closest-draggable.spec.js new file mode 100644 index 0000000000..676b2573dd --- /dev/null +++ b/test/unit/state/move-cross-axis/get-closest-draggable.spec.js @@ -0,0 +1,243 @@ +// @flow +import getClosestDraggable from '../../../../src/state/move-cross-axis/get-closest-draggable'; +import { getDroppableDimension, getDraggableDimension } from '../../../../src/state/dimension'; +import { add, distance, patch } from '../../../../src/state/position'; +import { horizontal, vertical } from '../../../../src/state/axis'; +import getClientRect from '../../../../src/state/get-client-rect'; +import type { + Axis, + Position, + DraggableDimension, + DroppableDimension, +} from '../../../../src/types'; + +describe('get closest draggable', () => { + [vertical, horizontal].forEach((axis: Axis) => { + const start: number = 0; + const end: number = 100; + const crossAxisStart: number = 0; + const crossAxisEnd: number = 20; + + const droppable: DroppableDimension = getDroppableDimension({ + id: 'droppable', + clientRect: getClientRect({ + [axis.start]: start, + [axis.end]: end, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + }), + }); + + // first item bleeds backwards past the start of the droppable + const partialHiddenBackwards: DraggableDimension = getDraggableDimension({ + id: 'partialHiddenBackwards', + droppableId: droppable.id, + clientRect: getClientRect({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: -10, // -10 + [axis.end]: 20, + }), + }); + + const visible1: DraggableDimension = getDraggableDimension({ + id: 'visible1', + droppableId: droppable.id, + clientRect: getClientRect({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 20, + [axis.end]: 40, + }), + }); + + const visible2: DraggableDimension = getDraggableDimension({ + id: 'visible2', + droppableId: droppable.id, + clientRect: getClientRect({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 40, + [axis.end]: 60, + }), + }); + + // bleeds over the end of the visible boundary + const partiallyHiddenForwards: DraggableDimension = getDraggableDimension({ + id: 'partiallyHiddenForwards', + droppableId: droppable.id, + clientRect: getClientRect({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 60, + [axis.end]: 120, + }), + }); + + // totally invisible + const hidden: DraggableDimension = getDraggableDimension({ + id: 'hidden', + droppableId: droppable.id, + clientRect: getClientRect({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 120, + [axis.end]: 140, + }), + }); + + const insideDestination: DraggableDimension[] = [ + partialHiddenBackwards, + visible1, + visible2, + partiallyHiddenForwards, + hidden, + ]; + + it('should return the closest draggable', () => { + // closet to visible1 + const center1: Position = patch( + axis.line, visible1.page.withoutMargin.center[axis.line], 100 + ); + const result1: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center1, + destination: droppable, + insideDestination, + }); + expect(result1).toBe(visible1); + + // closest to visible2 + const center2: Position = patch( + axis.line, visible2.page.withoutMargin.center[axis.line], 100 + ); + const result2: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center2, + destination: droppable, + insideDestination, + }); + expect(result2).toBe(visible2); + }); + + it('should return null if there are no draggables in the droppable', () => { + const center: Position = { + x: 100, + y: 100, + }; + + const result: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center, + destination: droppable, + insideDestination: [], + }); + + expect(result).toBe(null); + }); + + describe('removal of draggables that are not entirely within the current visible bounds of a droppable', () => { + it('should ignore draggables that have backwards partial visiblility', () => { + // point would usually be closest to visible1 - + // but it is outside of the visible bounds of the droppable + const center: Position = patch( + axis.line, partialHiddenBackwards.page.withoutMargin.center[axis.line], 100 + ); + + const result: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center, + destination: droppable, + insideDestination, + }); + + expect(result).toBe(visible1); + }); + + it('should ignore draggables that have forward partial visiblility', () => { + const center: Position = patch( + axis.line, partiallyHiddenForwards.page.withoutMargin.center[axis.line], 100 + ); + + const result: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center, + destination: droppable, + insideDestination, + }); + + expect(result).toBe(visible2); + }); + + it('should ignore draggables that have no visiblity', () => { + const center: Position = patch( + axis.line, hidden.page.withoutMargin.center[axis.line], 100 + ); + + const result: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center, + destination: droppable, + insideDestination, + }); + + expect(result).toBe(visible2); + }); + + it('should return null if there are no visible targets', () => { + const notVisible: DraggableDimension[] = [ + partialHiddenBackwards, + partiallyHiddenForwards, + hidden, + ]; + const center: Position = { + x: 0, + y: 0, + }; + + const result: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center, + destination: droppable, + insideDestination: notVisible, + }); + + expect(result).toBe(null); + }); + }); + + it('should return the draggable that is first on the main axis in the event of a tie', () => { + // in this case the distance between visible1 and visible2 is the same + const center: Position = patch( + axis.line, + // this is shared edge + visible2.page.withoutMargin[axis.start], + 100 + ); + + const result: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center, + destination: droppable, + insideDestination, + }); + + expect(result).toBe(visible1); + + // validating test assumptions + + // 1. that they have equal distances + expect(distance(center, visible1.page.withoutMargin.center)) + .toEqual(distance(center, visible2.page.withoutMargin.center)); + + // 2. if we move beyond the edge visible2 will be selected + const result2: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: add(center, patch(axis.line, 1)), + destination: droppable, + insideDestination, + }); + expect(result2).toBe(visible2); + }); + }); +}); diff --git a/test/unit/state/move-cross-axis/move-cross-axis.spec.js b/test/unit/state/move-cross-axis/move-cross-axis.spec.js new file mode 100644 index 0000000000..05ff26ee3c --- /dev/null +++ b/test/unit/state/move-cross-axis/move-cross-axis.spec.js @@ -0,0 +1,98 @@ +// @flow +import moveCrossAxis from '../../../../src/state/move-cross-axis/'; +import type { Result } from '../../../../src/state/move-cross-axis/move-cross-axis-types'; +import getDroppableWithDraggables from '../../../utils/get-droppable-with-draggables'; +import type { Result as Data } from '../../../utils/get-droppable-with-draggables'; +import type { + DraggableDimension, + DraggableDimensionMap, + DroppableDimensionMap, +} from '../../../../src/types'; + +// The functionality of move-cross-axis is covered by other files in this folder. +// This spec file is directed any any logic in move-cross-axis/index.js + +describe('move cross axis', () => { + const home: Data = getDroppableWithDraggables({ + droppableId: 'home', + droppableRect: { top: 0, left: 0, right: 100, bottom: 100 }, + draggableRects: [ + { top: 0, left: 0, right: 100, bottom: 20 }, + ], + }); + + it('should return null if there are draggables in a destination list but none are visible', () => { + const foreign: Data = getDroppableWithDraggables({ + droppableId: 'foreign', + // to the right of home + droppableRect: { top: 0, left: 100, right: 200, bottom: 100 }, + draggableRects: [ + // bigger than that visible rect + { top: 0, left: 100, right: 200, bottom: 110 }, + ], + }); + const draggables: DraggableDimensionMap = { + ...home.draggables, + ...foreign.draggables, + }; + const droppables: DroppableDimensionMap = { + [home.droppable.id]: home.droppable, + [foreign.droppable.id]: foreign.droppable, + }; + const draggable: DraggableDimension = home.draggableDimensions[0]; + + const result: ?Result = moveCrossAxis({ + isMovingForward: true, + pageCenter: draggable.page.withMargin.center, + draggableId: draggable.id, + droppableId: home.droppable.id, + home: { + droppableId: home.droppable.id, + index: 0, + }, + draggables, + droppables, + }); + + expect(result).toBe(null); + }); + + // this test is a validation that the previous test is working correctly + it('should return a droppable if its children are visible (and all other criteria are met', () => { + // adding visible child to foreign + const foreign: Data = getDroppableWithDraggables({ + droppableId: 'foreign', + // to the right of home + droppableRect: { top: 0, left: 100, right: 200, bottom: 100 }, + draggableRects: [ + // child is visible + { top: 0, left: 100, right: 200, bottom: 90 }, + ], + }); + const draggables: DraggableDimensionMap = { + ...home.draggables, + ...foreign.draggables, + }; + const droppables: DroppableDimensionMap = { + [home.droppable.id]: home.droppable, + [foreign.droppable.id]: foreign.droppable, + }; + const draggable: DraggableDimension = home.draggableDimensions[0]; + + const result: ?Result = moveCrossAxis({ + isMovingForward: true, + pageCenter: draggable.page.withMargin.center, + draggableId: draggable.id, + droppableId: home.droppable.id, + home: { + droppableId: home.droppable.id, + index: 0, + }, + draggables, + droppables, + }); + + // not asserting anything about the behaviour - just that something was returned + expect(result).toBeTruthy(); + }); +}); diff --git a/test/unit/state/move-cross-axis/move-to-new-droppable.spec.js b/test/unit/state/move-cross-axis/move-to-new-droppable.spec.js new file mode 100644 index 0000000000..de4137fe64 --- /dev/null +++ b/test/unit/state/move-cross-axis/move-to-new-droppable.spec.js @@ -0,0 +1,534 @@ +// @flow +import moveToNewDroppable from '../../../../src/state/move-cross-axis/move-to-new-droppable/'; +import type { Result } from '../../../../src/state/move-cross-axis/move-cross-axis-types'; +import { getDraggableDimension, getDroppableDimension } from '../../../../src/state/dimension'; +import getClientRect from '../../../../src/state/get-client-rect'; +import moveToEdge from '../../../../src/state/move-to-edge'; +import { patch } from '../../../../src/state/position'; +import { horizontal, vertical } from '../../../../src/state/axis'; +import type { + Axis, + Spacing, + DragImpact, + DraggableDimension, + DroppableDimension, + Position, +} from '../../../../src/types'; + +describe('move to new droppable', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + const noMargin: Spacing = { top: 0, left: 0, bottom: 0, right: 0 }; + + [vertical, horizontal].forEach((axis: Axis) => { + describe(`on ${axis.direction} axis`, () => { + const margin = { + ...noMargin, + [axis.end]: 10, + }; + + const crossAxisStart: number = 0; + const crossAxisEnd: number = 100; + + const home: DroppableDimension = getDroppableDimension({ + id: 'home', + direction: axis.direction, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.end]: 200, + }), + }); + // size: 10 + const inHome1: DraggableDimension = getDraggableDimension({ + id: 'inhome1', + droppableId: home.id, + margin, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.end]: 10, + }), + }); + // size: 20 + const inHome2: DraggableDimension = getDraggableDimension({ + id: 'inhome2', + droppableId: home.id, + // pushed forward by margin of inHome1 + margin, + clientRect: getClientRect({ + [axis.start]: 20, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.end]: 50, + }), + }); + // size: 30 + const inHome3: DraggableDimension = getDraggableDimension({ + id: 'inhome3', + droppableId: home.id, + margin, + // pushed forward by margin of inHome2 + clientRect: getClientRect({ + [axis.start]: 60, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.end]: 90, + }), + }); + // size: 40 + const inHome4: DraggableDimension = getDraggableDimension({ + id: 'inhome4', + droppableId: home.id, + // pushed forward by margin of inHome3 + margin, + clientRect: getClientRect({ + [axis.start]: 100, + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.end]: 140, + }), + }); + + // TODO: get working with horizonital axis + describe('to home list', () => { + const dontCare: Position = { x: 0, y: 0 }; + const draggables: DraggableDimension[] = [ + inHome1, inHome2, inHome3, inHome4, + ]; + + it('should return null and log an error if no target is found', () => { + // this should never happen but just being safe + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: inHome1, + target: null, + destination: home, + insideDestination: draggables, + home: { + index: 0, + droppableId: home.id, + }, + }); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalled(); + }); + + it('should return null and log an error if the target is not inside the droppable', () => { + const invalid: DraggableDimension = getDraggableDimension({ + id: 'invalid', + droppableId: 'some-other-droppable', + clientRect: getClientRect({ + top: 1000, + left: 1000, + bottom: 1100, + right: 1100, + }), + }); + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: draggables[0], + target: invalid, + destination: home, + insideDestination: draggables, + home: { + index: 0, + droppableId: home.id, + }, + }); + + expect(result).toBe(null); + expect(console.error).toHaveBeenCalled(); + }); + + describe('moving back into original index', () => { + // the second draggable is moving back into its home + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: inHome2, + target: inHome2, + destination: home, + insideDestination: draggables, + home: { + index: 1, + droppableId: home.id, + }, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should return the original center without margin', () => { + expect(result.pageCenter).toBe(inHome2.page.withoutMargin.center); + expect(result.pageCenter).not.toEqual(inHome2.page.withMargin.center); + }); + + it('should return an empty impact with the original location', () => { + const expected: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, inHome2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: home.id, + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('moving before the original index', () => { + // moving inHome4 into the inHome2 position + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: inHome4, + target: inHome2, + destination: home, + insideDestination: draggables, + home: { + index: 3, + droppableId: home.id, + }, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should align to the start of the target', () => { + const expected: Position = moveToEdge({ + source: inHome4.page.withoutMargin, + sourceEdge: 'start', + destination: inHome2.page.withMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should move the everything from the target index to the original index forward', () => { + const expected: DragImpact = { + movement: { + // ordered by closest impacted + draggables: [inHome2.id, inHome3.id], + amount: patch(axis.line, inHome4.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: home.id, + // original index of target + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('moving after the original index', () => { + // moving inHome1 into the inHome4 position + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: inHome1, + target: inHome4, + destination: home, + insideDestination: draggables, + home: { + index: 0, + droppableId: home.id, + }, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + describe('center', () => { + it('should align to the bottom of the target', () => { + const expected: Position = moveToEdge({ + source: inHome1.page.withoutMargin, + sourceEdge: 'end', + destination: inHome4.page.withoutMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + }); + + it('should move the everything from the target index to the original index forward', () => { + const expected: DragImpact = { + movement: { + // ordered by closest impacted + draggables: [inHome4.id, inHome3.id, inHome2.id], + amount: patch(axis.line, inHome1.page.withMargin[axis.size]), + // is moving beyond start position + isBeyondStartPosition: true, + }, + direction: axis.direction, + destination: { + droppableId: home.id, + // original index of target + index: 3, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + }); + + describe('to foreign list', () => { + const foreignCrossAxisStart: number = 100; + const foreignCrossAxisEnd: number = 200; + + const foreign: DroppableDimension = getDroppableDimension({ + id: 'foreign', + direction: axis.direction, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + [axis.end]: 200, + }), + }); + // size: 10 + const inForeign1: DraggableDimension = getDraggableDimension({ + id: 'inForeign1', + droppableId: foreign.id, + margin, + clientRect: getClientRect({ + [axis.start]: 0, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + [axis.end]: 10, + }), + }); + // size: 20 + const inForeign2: DraggableDimension = getDraggableDimension({ + id: 'inForeign2', + droppableId: foreign.id, + // pushed forward by margin of inForeign1 + margin, + clientRect: getClientRect({ + [axis.start]: 20, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + [axis.end]: 50, + }), + }); + // size: 30 + const inForeign3: DraggableDimension = getDraggableDimension({ + id: 'inForeign3', + droppableId: foreign.id, + margin, + // pushed forward by margin of inForeign2 + clientRect: getClientRect({ + [axis.start]: 60, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + [axis.end]: 90, + }), + }); + // size: 40 + const inForeign4: DraggableDimension = getDraggableDimension({ + id: 'inForeign4', + droppableId: foreign.id, + margin, + // pushed forward by margin of inForeign3 + clientRect: getClientRect({ + [axis.start]: 100, + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + [axis.end]: 140, + }), + }); + + const draggables: DraggableDimension[] = [ + inForeign1, inForeign2, inForeign3, inForeign4, + ]; + + it('should return null when the target is not within the list - cannot really happen', () => { + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome1.page.withMargin.center, + draggable: inHome1, + target: inHome2, + destination: foreign, + insideDestination: draggables, + home: { + index: 0, + droppableId: home.id, + }, + }); + + expect(result).toBe(null); + }); + + describe('moving into an unpopulated list', () => { + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome1.page.withMargin.center, + draggable: inHome1, + target: null, + destination: foreign, + insideDestination: [], + home: { + index: 0, + droppableId: home.id, + }, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move to the start edge of the droppable', () => { + const expected: Position = moveToEdge({ + source: inHome1.page.withoutMargin, + sourceEdge: 'start', + destination: foreign.page.withMargin, + destinationEdge: 'start', + destinationAxis: foreign.axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should return an empty impact', () => { + const expected: DragImpact = { + movement: { + draggables: [], + amount: patch(foreign.axis.line, inHome1.page.withMargin[foreign.axis.size]), + isBeyondStartPosition: false, + }, + direction: foreign.axis.direction, + destination: { + droppableId: foreign.id, + index: 0, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('is moving before the target', () => { + // moving home1 into the second position of the list + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome1.page.withMargin.center, + draggable: inHome1, + target: inForeign2, + destination: foreign, + insideDestination: draggables, + home: { + index: 0, + droppableId: home.id, + }, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move before the target', () => { + const expected: Position = moveToEdge({ + source: inHome1.page.withoutMargin, + sourceEdge: 'start', + destination: inForeign2.page.withMargin, + destinationEdge: 'start', + destinationAxis: foreign.axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should move the target and everything below it forward', () => { + const expected: DragImpact = { + movement: { + // ordered by closest impacted + draggables: [inForeign2.id, inForeign3.id, inForeign4.id], + amount: patch(foreign.axis.line, inHome1.page.withMargin[foreign.axis.size]), + isBeyondStartPosition: false, + }, + direction: foreign.axis.direction, + destination: { + droppableId: foreign.id, + // index of foreign2 + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('is moving after the target', () => { + // moving home4 into the second position of the foreign list + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome4.page.withMargin.center, + draggable: inHome4, + target: inForeign2, + destination: foreign, + insideDestination: draggables, + home: { + index: 3, + droppableId: home.id, + }, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move after the target', () => { + const expected = moveToEdge({ + source: inHome4.page.withoutMargin, + sourceEdge: 'start', + destination: inForeign2.page.withMargin, + // going after + destinationEdge: 'end', + destinationAxis: foreign.axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should move everything after the proposed index forward', () => { + const expected: DragImpact = { + movement: { + // ordered by closest impacted + draggables: [inForeign3.id, inForeign4.id], + amount: patch(foreign.axis.line, inHome4.page.withMargin[foreign.axis.size]), + isBeyondStartPosition: false, + }, + direction: foreign.axis.direction, + destination: { + droppableId: foreign.id, + // going after target, so index is target index + 1 + index: 2, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/state/move-to-edge.spec.js b/test/unit/state/move-to-edge.spec.js new file mode 100644 index 0000000000..10ed9fc01a --- /dev/null +++ b/test/unit/state/move-to-edge.spec.js @@ -0,0 +1,272 @@ +// @flow +import { + add, + absolute, + isEqual, + patch, + subtract, +} from '../../../src/state/position'; +import getFragment from '../../utils/get-fragment'; +import getClientRect from '../../../src/state/get-client-rect'; +import moveToEdge from '../../../src/state/move-to-edge'; +import { vertical, horizontal } from '../../../src/state/axis'; +import type { + Axis, + Position, + DimensionFragment, +} from '../../../src/types'; + +// behind the destination +// width: 40, height: 20 +const behind: DimensionFragment = getFragment(getClientRect({ + top: 0, + left: 0, + right: 40, + bottom: 20, +})); + +// in front of the destination +// width: 50, height: 10 +const infront: DimensionFragment = getFragment(getClientRect({ + top: 120, + left: 150, + right: 200, + bottom: 130, +})); + +// width: 50, height: 60 +const destination: DimensionFragment = getFragment(getClientRect({ + top: 50, + left: 50, + right: 100, + bottom: 110, +})); + +// All results are aligned on the crossAxisStart + +const pullBackwardsOnMainAxis = (axis: Axis) => (point: Position) => patch( + axis.line, + -point[axis.line], + point[axis.crossLine] +); + +// returns the absolute difference of the center position +// to one of the corners on the axis.end. Choosing axis.end is arbitrary +const getCenterDiff = (axis: Axis) => (source: DimensionFragment): Position => { + const corner = patch( + axis.line, source[axis.end], source[axis.crossAxisStart] + ); + + const diff = absolute(subtract(source.center, corner)); + + (() => { + // a little check to ensure that our assumption that the distance between the edges + // and the axis.end is the same + const otherCorner = patch( + axis.line, source[axis.end], source[axis.crossAxisEnd] + ); + const otherDiff = absolute(subtract(source.center, otherCorner)); + + if (!isEqual(diff, otherDiff)) { + throw new Error('invalidation position assumption'); + } + })(); + + return diff; +}; + +describe('move to edge', () => { + [behind, infront].forEach((source: DimensionFragment) => { + describe(`source is ${source === behind ? 'behind' : 'infront of'} destination`, () => { + describe('moving to a vertical list', () => { + const pullUpwards = pullBackwardsOnMainAxis(vertical); + const centerDiff = getCenterDiff(vertical)(source); + + describe('destination start edge', () => { + const destinationTopCorner: Position = { + x: destination.left, + y: destination.top, + }; + + describe('to source end edge', () => { + it('should move the source above the destination', () => { + const newCenter: Position = add( + pullUpwards(centerDiff), + destinationTopCorner + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'end', + destination, + destinationEdge: 'start', + destinationAxis: vertical, + }); + + expect(result).toEqual(newCenter); + }); + }); + + describe('to source start edge', () => { + it('should move below the top of the destination', () => { + const newCenter: Position = add( + centerDiff, + destinationTopCorner, + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'start', + destination, + destinationEdge: 'start', + destinationAxis: vertical, + }); + + expect(result).toEqual(newCenter); + }); + }); + }); + + describe('destination end edge', () => { + const destinationBottomCorner: Position = { + x: destination.left, + y: destination.bottom, + }; + + describe('to source end edge', () => { + it('should move above the bottom of the destination', () => { + const newCenter: Position = add( + pullUpwards(centerDiff), + destinationBottomCorner, + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'end', + destination, + destinationEdge: 'end', + destinationAxis: vertical, + }); + + expect(result).toEqual(newCenter); + }); + }); + + describe('to source start edge', () => { + it('should move below the destination', () => { + const newCenter: Position = add( + centerDiff, + destinationBottomCorner, + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'start', + destination, + destinationEdge: 'end', + destinationAxis: vertical, + }); + + expect(result).toEqual(newCenter); + }); + }); + }); + }); + + describe('moving to a horizontal list', () => { + const pullLeft = pullBackwardsOnMainAxis(horizontal); + const centerDiff = getCenterDiff(horizontal)(source); + + describe('destination start edge', () => { + const destinationTopCorner: Position = { + x: destination.left, // axis.start + y: destination.top, // axis.crossAxisStart + }; + + describe('to source end edge', () => { + it('should move the source to the left of destination start edge', () => { + const newCenter: Position = add( + pullLeft(centerDiff), + destinationTopCorner + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'end', + destination, + destinationEdge: 'start', + destinationAxis: horizontal, + }); + + expect(result).toEqual(newCenter); + }); + }); + + describe('to source start edge', () => { + it('should move to the right of the destination start edge', () => { + const newCenter: Position = add( + centerDiff, + destinationTopCorner, + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'start', + destination, + destinationEdge: 'start', + destinationAxis: horizontal, + }); + + expect(result).toEqual(newCenter); + }); + }); + }); + + describe('destination end edge', () => { + const destinationTopRightCorner: Position = { + x: destination.right, // axis.end + y: destination.top, // axis.crossAxisStart + }; + + describe('to source end edge', () => { + it('should move to the left of right side of the destination', () => { + const newCenter: Position = add( + pullLeft(centerDiff), + destinationTopRightCorner, + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'end', + destination, + destinationEdge: 'end', + destinationAxis: horizontal, + }); + + expect(result).toEqual(newCenter); + }); + }); + + describe('to source start edge', () => { + it('should move to the right of the destination', () => { + const newCenter: Position = add( + centerDiff, + destinationTopRightCorner, + ); + + const result: Position = moveToEdge({ + source, + sourceEdge: 'start', + destination, + destinationEdge: 'end', + destinationAxis: horizontal, + }); + + expect(result).toEqual(newCenter); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/state/move-to-next-index.spec.js b/test/unit/state/move-to-next-index.spec.js new file mode 100644 index 0000000000..a268a7aa01 --- /dev/null +++ b/test/unit/state/move-to-next-index.spec.js @@ -0,0 +1,1106 @@ +// @flow +import moveToNextIndex from '../../../src/state/move-to-next-index/'; +import type { Result } from '../../../src/state/move-to-next-index/move-to-next-index-types'; +import { getDraggableDimension, getDroppableDimension } from '../../../src/state/dimension'; +import getClientRect from '../../../src/state/get-client-rect'; +import moveToEdge from '../../../src/state/move-to-edge'; +import { patch } from '../../../src/state/position'; +import { vertical, horizontal } from '../../../src/state/axis'; +import type { + Axis, + DragMovement, + DragImpact, + DraggableDimension, + DroppableDimension, + DraggableDimensionMap, + DraggableLocation, + Position, +} from '../../../src/types'; + +describe('move to next index', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + [vertical, horizontal].forEach((axis: Axis) => { + describe(`on the ${axis.direction} axis`, () => { + const home: DroppableDimension = getDroppableDimension({ + id: 'home', + direction: axis.direction, + clientRect: getClientRect({ + top: 0, + left: 0, + bottom: 1000, + right: 1000, + }), + }); + + // size: 100 + const home1: DraggableDimension = getDraggableDimension({ + id: 'home1', + droppableId: home.id, + clientRect: getClientRect({ + top: 0, + left: 0, + bottom: 100, + right: 100, + }), + }); + + // size: 199 + const home2: DraggableDimension = getDraggableDimension({ + id: 'home2', + droppableId: home.id, + clientRect: getClientRect({ + top: 101, + left: 101, + bottom: 300, + right: 300, + }), + }); + + // size: 299 + const home3: DraggableDimension = getDraggableDimension({ + id: 'home3', + droppableId: home.id, + clientRect: getClientRect({ + top: 301, + left: 301, + bottom: 600, + right: 600, + }), + }); + + // foreign droppable + const foreign: DroppableDimension = getDroppableDimension({ + id: 'foreign', + direction: axis.direction, + clientRect: getClientRect({ + top: 1001, + left: 1001, + bottom: 2000, + right: 2000, + }), + }); + + // size: 99 + const foreign1: DraggableDimension = getDraggableDimension({ + id: 'foreign1', + droppableId: foreign.id, + clientRect: getClientRect({ + top: 1001, + left: 1001, + bottom: 1100, + right: 1100, + }), + }); + + // size: 199 + const foreign2: DraggableDimension = getDraggableDimension({ + id: 'foreign2', + droppableId: foreign.id, + clientRect: getClientRect({ + top: 1101, + left: 1101, + bottom: 1300, + right: 1300, + }), + }); + + // size: 299 + const foreign3: DraggableDimension = getDraggableDimension({ + id: 'foreign3', + droppableId: foreign.id, + clientRect: getClientRect({ + top: 1301, + left: 1301, + bottom: 1600, + right: 1600, + }), + }); + + const draggables: DraggableDimensionMap = { + [home1.id]: home1, + [home2.id]: home2, + [home3.id]: home3, + [foreign1.id]: foreign1, + [foreign2.id]: foreign2, + [foreign3.id]: foreign3, + }; + + it('should return null if the droppable is disabled', () => { + const disabled: DroppableDimension = getDroppableDimension({ + id: 'disabled', + isEnabled: false, + direction: axis.direction, + clientRect: getClientRect({ + top: 2001, + left: 2001, + bottom: 3000, + right: 3000, + }), + }); + const impact: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: disabled.id, + index: 0, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + droppable: disabled, + draggables, + }); + + expect(result).toEqual(null); + }); + + it('should return null if there was no previous destination', () => { + const impact: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + // no previous destination - should not happen when dragging with a keyboard + destination: null, + }; + + const result1: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + expect(result1).toEqual(null); + expect(console.error).toHaveBeenCalledTimes(1); + + const result2: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + expect(result2).toEqual(null); + expect(console.error).toHaveBeenCalledTimes(2); + }); + + describe('in home list', () => { + describe('moving forwards', () => { + it('should return null if cannot move forward', () => { + const impact: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + index: 2, + droppableId: home.id, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home3.id, + impact, + droppable: home, + draggables, + }); + + expect(result).toBe(null); + }); + + describe('is moving away from start position', () => { + describe('dragging first item forward one position', () => { + // dragging the first item forward into the second position + const movement: DragMovement = { + draggables: [], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }; + const destination: DraggableLocation = { + index: 0, + droppableId: home.id, + }; + const impact: DragImpact = { + movement, + direction: axis.direction, + destination, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move the end of the dragging item to the end of the next item', () => { + const expected: Position = moveToEdge({ + source: home1.page.withoutMargin, + sourceEdge: 'end', + destination: home2.page.withoutMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should move the item into the second spot and move the second item out of the way', () => { + const expected: DragImpact = { + movement: { + draggables: [home2.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + // is now in the second position + destination: { + droppableId: home.id, + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('dragging second item forward one position', () => { + const movement: DragMovement = { + draggables: [], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }; + const destination: DraggableLocation = { + index: 1, + droppableId: home.id, + }; + const impact: DragImpact = { + movement, + direction: axis.direction, + destination, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home2.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move the end of the dragging item to the end of the next item', () => { + const expected: Position = moveToEdge({ + source: home2.page.withoutMargin, + sourceEdge: 'end', + destination: home3.page.withoutMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should move the dragging item into the third spot and move the third item out of the way', () => { + const expected: DragImpact = { + movement: { + draggables: [home3.id], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + // is now in the second position + destination: { + droppableId: home.id, + index: 2, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('dragging first item forward one position after already moving it forward once', () => { + const impact: DragImpact = { + movement: { + // second item has already moved + draggables: [home2.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + // draggable1 is now in the second position + destination: { + droppableId: home.id, + index: 1, + }, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move the end of the dragging item to the end of the next item', () => { + // next dimension from the current index is draggable3 + const expected: Position = moveToEdge({ + source: home1.page.withoutMargin, + sourceEdge: 'end', + destination: home3.page.withoutMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should move the third item out of the way', () => { + const expected: DragImpact = { + movement: { + // adding draggable3 to the list + // list is sorted by the the closest to the current item + draggables: [home3.id, home2.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + // is now in the second position + destination: { + droppableId: home.id, + index: 2, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + }); + + describe('is moving toward start position', () => { + describe('dragging item forward to starting position', () => { + // dragging the second item (draggable2), which has previously + // been moved backwards and is now in the first position + const impact: DragImpact = { + movement: { + draggables: [home1.id], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + index: 0, + droppableId: home.id, + }, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home2.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result of moveToNextIndex'); + } + + it('should move the start of the dragging item to the end of the previous item (which its original position)', () => { + const expected: Position = moveToEdge({ + source: home2.page.withoutMargin, + sourceEdge: 'start', + destination: home2.page.withoutMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + // is now back at its original position + expect(result.pageCenter).toEqual(home2.page.withoutMargin.center); + }); + + it('should return an empty impact', () => { + const expected: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + destination: { + droppableId: home.id, + index: 1, + }, + direction: axis.direction, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('dragging forwards, but not beyond the starting position', () => { + // draggable3 has moved backwards past draggable2 and draggable1 + const impact: DragImpact = { + movement: { + // second and first item have already moved + // sorted by the draggable that is closest to where the dragging item is + draggables: [home1.id, home2.id], + amount: patch(axis.line, home3.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + // draggable3 is now in the first position + destination: { + droppableId: home.id, + index: 0, + }, + }; + // moving draggable3 forward one position + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home3.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move to the start of the draggable item to the start position of the destination draggable', () => { + const expected: Position = moveToEdge({ + source: home3.page.withoutMargin, + sourceEdge: 'start', + destination: home2.page.withoutMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should remove the first dimension from the impact', () => { + const expected: DragImpact = { + movement: { + draggables: [home2.id], + amount: patch(axis.line, home3.page.withMargin[axis.size]), + // is still behind where it started + isBeyondStartPosition: false, + }, + direction: axis.direction, + // is now in the second position + destination: { + droppableId: home.id, + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + }); + }); + + describe('moving backwards', () => { + it('should return null if cannot move backward', () => { + const impact: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + index: 0, + droppableId: home.id, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home1.id, + impact, + draggables, + droppable: home, + }); + + expect(result).toBe(null); + }); + + describe('is moving away from start position', () => { + describe('dragging the second item back to the first position', () => { + // no impact yet + const impact: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + destination: { + droppableId: home.id, + index: 1, + }, + direction: axis.direction, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home2.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move the start of the draggable to the start of the previous draggable', () => { + const expected: Position = moveToEdge({ + source: home2.page.withoutMargin, + sourceEdge: 'start', + destination: home1.page.withoutMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should add the first draggable to the drag impact', () => { + const expected: DragImpact = { + movement: { + draggables: [home1.id], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + destination: { + droppableId: home.id, + // is now in the first position + index: 0, + }, + direction: axis.direction, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('dragging the third item back to the second position', () => { + const impact: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home3.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + destination: { + droppableId: home.id, + index: 2, + }, + direction: axis.direction, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home3.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move the start of the draggable to the start of the previous draggable', () => { + const expected: Position = moveToEdge({ + source: home3.page.withoutMargin, + sourceEdge: 'start', + destination: home2.page.withoutMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should add the second draggable to the drag impact', () => { + const expected: DragImpact = { + movement: { + draggables: [home2.id], + amount: patch(axis.line, home3.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + destination: { + droppableId: home.id, + // is now in the second position + index: 1, + }, + direction: axis.direction, + }; + + expect(result.impact).toEqual(expected); + }); + }); + }); + + describe('is moving towards the start position', () => { + describe('moving back to original position', () => { + // dragged the second item (draggable2) forward once, and is now + // moving backwards towards the start again + const impact: DragImpact = { + movement: { + draggables: [home3.id], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + destination: { + index: 2, + droppableId: home.id, + }, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home2.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move the end of the draggable to the end of the next draggable (which is its original position)', () => { + const expected: Position = moveToEdge({ + source: home2.page.withoutMargin, + sourceEdge: 'end', + // destination is itself as moving back to home + destination: home2.page.withoutMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + // moved back to its original position + expect(result.pageCenter).toEqual(home2.page.withoutMargin.center); + }); + + it('should return an empty impact', () => { + const expected: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + destination: { + droppableId: home.id, + index: 1, + }, + direction: axis.direction, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('moving back, but not far enough to be at the start yet', () => { + // dragged the first item: + // forward twice so it is in the third position + // then moving backward so it is in the second position + const impact: DragImpact = { + movement: { + // sorted by closest to where the draggable currently is + draggables: [home3.id, home2.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + destination: { + index: 2, + droppableId: home.id, + }, + }; + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home1.id, + impact, + draggables, + droppable: home, + }); + + if (!result) { + throw new Error('invalid result'); + } + + it('should move the end of the draggable to the end of the previous draggable', () => { + const expected: Position = moveToEdge({ + source: home1.page.withoutMargin, + sourceEdge: 'end', + destination: home2.page.withoutMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should remove the third draggable from the drag impact', () => { + const expected: DragImpact = { + movement: { + // draggable3 has been removed + draggables: [home2.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + destination: { + droppableId: home.id, + index: 1, + }, + direction: axis.direction, + }; + + expect(result.impact).toEqual(expected); + }); + }); + }); + }); + }); + + describe('in foreign list', () => { + describe('moving forwards', () => { + describe('moving forward one position', () => { + // moved home1 into the first position of the foreign list + const impact: DragImpact = { + movement: { + // Ordered by the closest impacted. + // Because we have moved into the first position it will be ordered 1-2-3 + draggables: [foreign1.id, foreign2.id, foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + // Always false when in another list + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + // it is now in the foreign droppable in the first position + droppableId: foreign.id, + index: 0, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move to the start edge of the dragging item to the start of foreign2', () => { + const expected = moveToEdge({ + source: home1.page.withoutMargin, + sourceEdge: 'start', + destination: foreign2.page.withMargin, + destinationEdge: 'start', + destinationAxis: foreign.axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should remove foreign1 when moving forward', () => { + const expected: DragImpact = { + movement: { + draggables: [foreign2.id, foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: foreign.id, + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('moving into last position of the list', () => { + // moved home1 into the second last position of the list + const impact: DragImpact = { + movement: { + // Ordered by the closest impacted. + draggables: [foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + // Always false when in another list + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + // it is now in the foreign droppable in the third position + droppableId: foreign.id, + index: 2, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move to the start edge of the dragging item to the end of foreign1', () => { + const expected = moveToEdge({ + source: home1.page.withoutMargin, + sourceEdge: 'start', + destination: foreign3.page.withMargin, + destinationEdge: 'end', + destinationAxis: foreign.axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should remove foreign3 when moving forward', () => { + const expected: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: foreign.id, + // bigger than the original list - in the forth position + index: 3, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + it('should return null if attempting to move beyond end of the list', () => { + // home1 is now in the last position of the list + const impact: DragImpact = { + movement: { + draggables: [], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: foreign.id, + // bigger than the original list - in the forth position + index: 3, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: true, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + expect(result).toBe(null); + }); + }); + + describe('moving backwards', () => { + it('should return null if attempting to move backwards beyond the start of the list', () => { + // moved home1 into the first position of the foreign list + const impact: DragImpact = { + movement: { + // Ordered by the closest impacted. + // Because we have moved into the first position it will be ordered 1-2-3 + draggables: [foreign1.id, foreign2.id, foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + // Always false when in another list + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + // it is now in the foreign droppable in the first position + droppableId: foreign.id, + index: 0, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + expect(result).toBe(null); + }); + + describe('moving backwards one position in list', () => { + // home1 is in the third position for foreign (one before the last) + const impact: DragImpact = { + movement: { + // Ordered by the closest impacted. + draggables: [foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: foreign.id, + index: 2, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move to the start edge of foreign2', () => { + const expected: Position = moveToEdge({ + source: home1.page.withoutMargin, + sourceEdge: 'start', + destination: foreign2.page.withoutMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should add foreign2 to the drag impact', () => { + const expected: DragImpact = { + movement: { + // Ordered by the closest impacted. + draggables: [foreign2.id, foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: foreign.id, + // moved backwards + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); + }); + + describe('moving backwards into the first position of the list', () => { + // currently home1 is in the second position in front of foreign1 + const impact: DragImpact = { + movement: { + // Ordered by the closest impacted. + draggables: [foreign2.id, foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: foreign.id, + index: 1, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: home1.id, + impact, + droppable: foreign, + draggables, + }); + + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move the start edge of home1 to the start edge of foreign1', () => { + const expected: Position = moveToEdge({ + source: home1.page.withoutMargin, + sourceEdge: 'start', + destination: foreign1.page.withoutMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should add foreign1 to the impact', () => { + const expected: DragImpact = { + movement: { + draggables: [foreign1.id, foreign2.id, foreign3.id], + amount: patch(axis.line, home1.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: foreign.id, + // now in the first position + index: 0, + }, + }; + expect(result.impact).toEqual(expected); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/state/position.spec.js b/test/unit/state/position.spec.js index dde74ee3d7..9f9c7ac614 100644 --- a/test/unit/state/position.spec.js +++ b/test/unit/state/position.spec.js @@ -1,5 +1,13 @@ // @flow -import { add, subtract, isEqual, negate, patch } from '../../../src/state/position'; +import { + add, + subtract, + isEqual, + negate, + patch, + distance, + closest, +} from '../../../src/state/position'; import type { Position } from '../../../src/types'; const point1: Position = { @@ -10,6 +18,7 @@ const point2: Position = { x: 2, y: 1, }; +const origin: Position = { x: 0, y: 0 }; describe('position', () => { describe('add', () => { @@ -63,5 +72,57 @@ describe('position', () => { it('should patch a position with a x value', () => { expect(patch('y', 5)).toEqual({ x: 0, y: 5 }); }); + + it('should allow patching of the non primary line', () => { + expect(patch('x', 5, 1)).toEqual({ x: 5, y: 1 }); + expect(patch('y', 5, 1)).toEqual({ x: 1, y: 5 }); + }); + }); + + describe('distance', () => { + describe('on the same axis', () => { + it('should return the distance between two positive values', () => { + const a = { x: 0, y: 2 }; + const b = { x: 0, y: 5 }; + expect(distance(a, b)).toEqual(3); + }); + + it('should return the distance between two negative values', () => { + const a = { x: 0, y: -2 }; + const b = { x: 0, y: -5 }; + expect(distance(a, b)).toEqual(3); + }); + + it('should return the distance between a positive and negative value', () => { + const a = { x: 0, y: -2 }; + const b = { x: 0, y: 3 }; + expect(distance(a, b)).toEqual(5); + }); + }); + + describe('with axis shift', () => { + it('should account for a shift in plane', () => { + // a '3, 4, 5' triangle + // https://www.mathsisfun.com/pythagoras.html + const target = { x: 3, y: 4 }; + expect(distance(origin, target)).toEqual(5); + }); + + it('should account for a negative shift in plane', () => { + // a reverse '3, 4, 5' triangle shifted down to (-1, -1) + const customOrigin = { x: -1, y: -1 }; + const target = { x: -4, y: -5 }; + expect(distance(customOrigin, target)).toEqual(5); + }); + }); + }); + + describe('closest', () => { + it('should return the closest distance from a series of options', () => { + const option1 = { x: 1, y: 1 }; + const option2 = { x: 2, y: 2 }; + + expect(closest(origin, [option1, option2])).toEqual(distance(origin, option1)); + }); }); }); diff --git a/test/unit/view/connected-draggable.spec.js b/test/unit/view/connected-draggable.spec.js index 57808ac280..2981470ce6 100644 --- a/test/unit/view/connected-draggable.spec.js +++ b/test/unit/view/connected-draggable.spec.js @@ -6,7 +6,7 @@ import Draggable, { makeSelector } from '../../../src/view/draggable/connected-d import { getDraggableDimension } from '../../../src/state/dimension'; import noImpact from '../../../src/state/no-impact'; import { combine, withStore, withDroppableId } from '../../utils/get-context-options'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import { add } from '../../../src/state/position'; import type { CurrentDrag, @@ -99,6 +99,7 @@ const make = (() => { center: page.center, }, shouldAnimate: true, + isScrollAllowed: true, }; const state: DragState = { diff --git a/test/unit/view/connected-droppable-dimension-publisher.spec.js b/test/unit/view/connected-droppable-dimension-publisher.spec.js index 2aa874e986..79d9b9f6f3 100644 --- a/test/unit/view/connected-droppable-dimension-publisher.spec.js +++ b/test/unit/view/connected-droppable-dimension-publisher.spec.js @@ -10,7 +10,7 @@ const shouldPublishMapProps: MapProps = { shouldPublish: true, }; -describe('Dimension publisher - connected', () => { +describe('Connected droppable dimension publisher', () => { it('should return the default props when not requested to publish dimensions', () => { const selector = makeSelector(); diff --git a/test/unit/view/connected-droppable.spec.js b/test/unit/view/connected-droppable.spec.js index 3eed72be00..6d2c6f899b 100644 --- a/test/unit/view/connected-droppable.spec.js +++ b/test/unit/view/connected-droppable.spec.js @@ -6,7 +6,7 @@ import Droppable, { makeSelector } from '../../../src/view/droppable/connected-d import noImpact from '../../../src/state/no-impact'; import { getDraggableDimension } from '../../../src/state/dimension'; import { withStore } from '../../utils/get-context-options'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import type { Phase, DragState, @@ -19,64 +19,79 @@ import type { DropResult, CurrentDrag, DraggableDimension, + DraggableLocation, InitialDragLocation, CurrentDragLocation, } from '../../../src/types'; -import type { MapProps, Provided, Selector } from '../../../src/view/droppable/droppable-types'; +import type { + MapProps, + Provided, + Selector, + Placeholder, +} from '../../../src/view/droppable/droppable-types'; type ExecuteArgs = {| - id: DroppableId, + droppableId: DroppableId, phase: Phase, drag: ?DragState, pending: ?PendingDrop, - isDropDisabled?: boolean + draggable: ?DraggableDimension, + // being simple and defaulting to false (droppable is enabled) + isDropDisabled?: boolean, |} const execute = (selector: Selector) => - ({ phase, drag, pending, id, isDropDisabled = false }: ExecuteArgs) => + ({ phase, drag, draggable, pending, droppableId, isDropDisabled = false }: ExecuteArgs) => selector.resultFunc( - phase, drag, pending, id, isDropDisabled, + phase, drag, draggable, pending, droppableId, isDropDisabled, ); const defaultMapProps: MapProps = { isDraggingOver: false, + placeholder: null, }; -const droppableId: DroppableId = 'drop-1'; +const homeDroppableId: DroppableId = 'home-id'; +const foreignDroppableId: DroppableId = 'foreign-droppable'; const draggableId: DraggableId = 'drag-1'; const origin: Position = { x: 0, y: 0 }; type DragArgs = {| - isDraggingOver: boolean + isDraggingOver: false | 'home' | 'foreign' |} -const perform = (() => { - const dimension: DraggableDimension = getDraggableDimension({ - id: draggableId, - droppableId, - clientRect: getClientRect({ - top: 100, - left: 0, - right: 100, - bottom: 200, - }), - }); +const draggable: DraggableDimension = getDraggableDimension({ + id: draggableId, + droppableId: homeDroppableId, + clientRect: getClientRect({ + top: 100, + left: 0, + right: 100, + bottom: 200, + }), +}); +const placeholder: Placeholder = { + width: draggable.page.withoutMargin.width, + height: draggable.page.withoutMargin.height, +}; + +const perform = (() => { const initial: InitialDrag = (() => { const client: InitialDragLocation = { - selection: dimension.client.withoutMargin.center, - center: dimension.client.withoutMargin.center, + selection: draggable.client.withoutMargin.center, + center: draggable.client.withoutMargin.center, }; const page: InitialDragLocation = { - selection: dimension.page.withoutMargin.center, - center: dimension.page.withoutMargin.center, + selection: draggable.page.withoutMargin.center, + center: draggable.page.withoutMargin.center, }; const value: InitialDrag = { source: { index: 0, - droppableId, + droppableId: homeDroppableId, }, client, page, @@ -85,6 +100,7 @@ const perform = (() => { center: page.center, }, }; + return value; })(); @@ -111,30 +127,59 @@ const perform = (() => { center: page.center, }, shouldAnimate: true, + isScrollAllowed: true, }; return value; })(); - const dragOverImpact: DragImpact = { + const homeDestination: DraggableLocation = { + index: initial.source.index + 1, + droppableId: homeDroppableId, + }; + const foreignDestination: DraggableLocation = { + index: 0, + droppableId: foreignDroppableId, + }; + + const dragOverHomeImpact: DragImpact = { movement: { draggables: [draggableId], amount: { - y: dimension.page.withMargin.height, + y: draggable.page.withMargin.height, x: 0, }, isBeyondStartPosition: true, }, direction: 'vertical', - destination: { - index: initial.source.index + 1, - droppableId, + destination: homeDestination, + }; + + const dragOverForeignImpact: DragImpact = { + movement: { + draggables: [], + amount: { + y: draggable.page.withMargin.height, + x: 0, + }, + isBeyondStartPosition: false, }, + direction: 'vertical', + destination: foreignDestination, }; const drag = ({ isDraggingOver }: DragArgs): DragState => { + const impact: DragImpact = (() => { + if (isDraggingOver === 'home') { + return dragOverHomeImpact; + } + if (isDraggingOver === 'foreign') { + return dragOverForeignImpact; + } + return noImpact; + })(); const state: DragState = { current, - impact: isDraggingOver ? dragOverImpact : noImpact, + impact, initial, }; return state; @@ -142,25 +187,34 @@ const perform = (() => { const drop = ({ isDraggingOver }: DragArgs): PendingDrop => { // some made up position - const newHomeOffset: Position = { + const dontCare: Position = { x: 100, y: 20, }; + const destination: ?DraggableLocation = (() => { + if (isDraggingOver === 'home') { + return homeDestination; + } + if (isDraggingOver === 'foreign') { + return foreignDestination; + } + return null; + })(); + + const impact: DragImpact = drag({ isDraggingOver }).impact; + const result: DropResult = { draggableId, type: 'TYPE', source: initial.source, - destination: { - index: initial.source.index + 1, - droppableId: initial.source.droppableId, - }, + destination, }; const pending: PendingDrop = { trigger: 'DROP', - newHomeOffset, - impact: isDraggingOver ? dragOverImpact : noImpact, + newHomeOffset: dontCare, + impact, result, }; @@ -189,7 +243,8 @@ describe('Droppable - connected', () => { phase, drag: null, pending: null, - id: droppableId, + draggable: null, + droppableId: homeDroppableId, isDropDisabled: true, }); @@ -205,19 +260,49 @@ describe('Droppable - connected', () => { phase, drag: null, pending: null, - id: droppableId, + draggable: null, + droppableId: homeDroppableId, + isDropDisabled: true, }); const second: MapProps = execute(selector)({ phase, drag: null, pending: null, - id: droppableId, + draggable: null, + droppableId: homeDroppableId, + isDropDisabled: true, }); // checking object equality expect(first).toBe(second); }); }); + + it('should not break memoization between phases', () => { + let previous: MapProps; + const selector = makeSelector(); + + phases.forEach((phase: Phase) => { + const result: MapProps = execute(selector)({ + phase, + drag: null, + pending: null, + draggable: null, + droppableId: homeDroppableId, + isDropDisabled: true, + }); + + // seed previous + if (!previous) { + previous = result; + return; + } + + // checking object equality + expect(result).toBe(previous); + expect(result).toEqual(defaultMapProps); + }); + }); }); describe('while dragging', () => { @@ -225,25 +310,28 @@ describe('Droppable - connected', () => { const props: MapProps = execute(makeSelector())({ phase: 'DRAGGING', drag: null, + draggable: null, pending: null, - id: droppableId, + droppableId: homeDroppableId, }); expect(props).toEqual(defaultMapProps); expect(console.error).toHaveBeenCalled(); }); - describe('dragging over', () => { + describe('over home droppable', () => { it('should return that it is dragging over', () => { const expected: MapProps = { isDraggingOver: true, + placeholder: null, }; const props: MapProps = execute(makeSelector())({ phase: 'DRAGGING', - drag: perform.drag({ isDraggingOver: true }), + drag: perform.drag({ isDraggingOver: 'home' }), + draggable, pending: null, - id: droppableId, + droppableId: homeDroppableId, }); expect(props).toEqual(expected); @@ -253,19 +341,22 @@ describe('Droppable - connected', () => { const selector = makeSelector(); const expected: MapProps = { isDraggingOver: true, + placeholder: null, }; const props1: MapProps = execute(selector)({ phase: 'DRAGGING', - drag: perform.drag({ isDraggingOver: true }), + drag: perform.drag({ isDraggingOver: 'home' }), pending: null, - id: droppableId, + draggable, + droppableId: homeDroppableId, }); const props2: MapProps = execute(selector)({ phase: 'DRAGGING', - drag: perform.drag({ isDraggingOver: true }), + drag: perform.drag({ isDraggingOver: 'home' }), pending: null, - id: droppableId, + draggable, + droppableId: homeDroppableId, }); // checking object equality @@ -275,17 +366,67 @@ describe('Droppable - connected', () => { }); }); + describe('over foreign droppable', () => { + it('should return that it is dragging over and a placeholder', () => { + const expected: MapProps = { + isDraggingOver: true, + placeholder, + }; + + const props: MapProps = execute(makeSelector())({ + phase: 'DRAGGING', + drag: perform.drag({ isDraggingOver: 'foreign' }), + draggable, + pending: null, + droppableId: foreignDroppableId, + }); + + expect(props).toEqual(expected); + }); + + it('should not break memoization on multiple drags', () => { + const selector = makeSelector(); + const expected: MapProps = { + isDraggingOver: true, + placeholder, + }; + + const props1: MapProps = execute(selector)({ + phase: 'DRAGGING', + drag: perform.drag({ isDraggingOver: 'foreign' }), + pending: null, + draggable, + droppableId: foreignDroppableId, + }); + const props2: MapProps = execute(selector)({ + phase: 'DRAGGING', + drag: perform.drag({ isDraggingOver: 'foreign' }), + pending: null, + draggable, + droppableId: foreignDroppableId, + }); + + // checking object equality + expect(props1).toBe(props2); + expect(props1.placeholder).toBe(props2.placeholder); + expect(props1).toEqual(expected); + expect(props2).toEqual(expected); + }); + }); + describe('not dragging over', () => { it('should return that it is not dragging over', () => { const expected: MapProps = { isDraggingOver: false, + placeholder: null, }; const props: MapProps = execute(makeSelector())({ phase: 'DRAGGING', drag: perform.drag({ isDraggingOver: false }), pending: null, - id: droppableId, + draggable, + droppableId: homeDroppableId, }); expect(props).toEqual(expected); @@ -295,19 +436,22 @@ describe('Droppable - connected', () => { const selector = makeSelector(); const expected: MapProps = { isDraggingOver: false, + placeholder: null, }; const props1: MapProps = execute(selector)({ phase: 'DRAGGING', drag: perform.drag({ isDraggingOver: false }), pending: null, - id: droppableId, + draggable, + droppableId: homeDroppableId, }); const props2: MapProps = execute(selector)({ phase: 'DRAGGING', drag: perform.drag({ isDraggingOver: false }), pending: null, - id: droppableId, + draggable, + droppableId: homeDroppableId, }); // checking object equality @@ -324,24 +468,74 @@ describe('Droppable - connected', () => { phase: 'DROP_ANIMATING', drag: null, pending: null, - id: droppableId, + draggable, + droppableId: homeDroppableId, }); expect(props).toEqual(defaultMapProps); expect(console.error).toHaveBeenCalled(); }); - describe('dragging over', () => { + describe('was dragging over home droppable', () => { it('should return that it is dragging over', () => { const expected: MapProps = { isDraggingOver: true, + placeholder: null, + }; + + const props: MapProps = execute(makeSelector())({ + phase: 'DROP_ANIMATING', + drag: null, + draggable, + pending: perform.drop({ isDraggingOver: 'home' }), + droppableId: homeDroppableId, + }); + + expect(props).toEqual(expected); + }); + + it('should not break memoization from a previous DRAGGING phase', () => { + const selector = makeSelector(); + const expected: MapProps = { + isDraggingOver: true, + placeholder: null, + }; + + const dragging: MapProps = execute(selector)({ + phase: 'DRAGGING', + drag: perform.drag({ isDraggingOver: 'home' }), + pending: null, + draggable, + droppableId: homeDroppableId, + }); + const dropAnimating: MapProps = execute(selector)({ + phase: 'DROP_ANIMATING', + drag: null, + pending: perform.drop({ isDraggingOver: 'home' }), + draggable, + droppableId: homeDroppableId, + }); + + expect(dragging).toEqual(expected); + expect(dropAnimating).toEqual(expected); + // checking object equality + expect(dragging).toBe(dropAnimating); + }); + }); + + describe('was dragging over foreign droppable', () => { + it('should return that it is dragging over and provide a placeholder', () => { + const expected: MapProps = { + isDraggingOver: true, + placeholder, }; const props: MapProps = execute(makeSelector())({ phase: 'DROP_ANIMATING', drag: null, - pending: perform.drop({ isDraggingOver: true }), - id: droppableId, + pending: perform.drop({ isDraggingOver: 'foreign' }), + draggable, + droppableId: foreignDroppableId, }); expect(props).toEqual(expected); @@ -351,19 +545,22 @@ describe('Droppable - connected', () => { const selector = makeSelector(); const expected: MapProps = { isDraggingOver: true, + placeholder, }; const dragging: MapProps = execute(selector)({ phase: 'DRAGGING', - drag: perform.drag({ isDraggingOver: true }), + drag: perform.drag({ isDraggingOver: 'foreign' }), pending: null, - id: droppableId, + draggable, + droppableId: foreignDroppableId, }); const dropAnimating: MapProps = execute(selector)({ phase: 'DROP_ANIMATING', drag: null, - pending: perform.drop({ isDraggingOver: true }), - id: droppableId, + pending: perform.drop({ isDraggingOver: 'foreign' }), + draggable, + droppableId: foreignDroppableId, }); expect(dragging).toEqual(expected); @@ -377,13 +574,15 @@ describe('Droppable - connected', () => { it('should return that it is not dragging over', () => { const expected: MapProps = { isDraggingOver: false, + placeholder: null, }; const props: MapProps = execute(makeSelector())({ phase: 'DROP_ANIMATING', drag: null, + draggable, pending: perform.drop({ isDraggingOver: false }), - id: droppableId, + droppableId: homeDroppableId, }); expect(props).toEqual(expected); @@ -393,19 +592,22 @@ describe('Droppable - connected', () => { const selector = makeSelector(); const expected: MapProps = { isDraggingOver: false, + placeholder: null, }; const dragging: MapProps = execute(selector)({ phase: 'DRAGGING', drag: perform.drag({ isDraggingOver: false }), pending: null, - id: droppableId, + draggable, + droppableId: homeDroppableId, }); const dropAnimating: MapProps = execute(selector)({ phase: 'DROP_ANIMATING', drag: null, pending: perform.drop({ isDraggingOver: false }), - id: droppableId, + draggable, + droppableId: homeDroppableId, }); expect(dragging).toEqual(expected); @@ -417,17 +619,18 @@ describe('Droppable - connected', () => { }); describe('other phases', () => { - const other: Phase[] = ['IDLE', 'COLLECTING_DIMENSIONS', 'DROP_COMPLETE']; + const others: Phase[] = ['IDLE', 'COLLECTING_DIMENSIONS', 'DROP_COMPLETE']; it('should return the default props', () => { const selector = makeSelector(); - other.forEach((phase: Phase): void => { + others.forEach((phase: Phase): void => { const props: MapProps = execute(selector)({ phase, drag: null, pending: null, - id: droppableId, + draggable: null, + droppableId: homeDroppableId, }); expect(props).toEqual(defaultMapProps); @@ -437,18 +640,20 @@ describe('Droppable - connected', () => { it('should not break memoization on multiple calls', () => { const selector = makeSelector(); - other.forEach((phase: Phase): void => { + others.forEach((phase: Phase): void => { const first: MapProps = execute(selector)({ phase, drag: null, pending: null, - id: droppableId, + draggable: null, + droppableId: homeDroppableId, }); const second: MapProps = execute(selector)({ phase, drag: null, pending: null, - id: droppableId, + draggable: null, + droppableId: homeDroppableId, }); expect(first).toEqual(defaultMapProps); diff --git a/test/unit/view/drag-handle.spec.js b/test/unit/view/drag-handle.spec.js index f067487337..d34f534e0c 100644 --- a/test/unit/view/drag-handle.spec.js +++ b/test/unit/view/drag-handle.spec.js @@ -21,6 +21,8 @@ const getStubCallbacks = (): Callbacks => ({ onMove: jest.fn(), onMoveForward: jest.fn(), onMoveBackward: jest.fn(), + onCrossAxisMoveForward: jest.fn(), + onCrossAxisMoveBackward: jest.fn(), onDrop: jest.fn(), onCancel: jest.fn(), onWindowScroll: jest.fn(), @@ -32,6 +34,8 @@ type CallBacksCalledFn = {| onMove?: number, onMoveForward?: number, onMoveBackward?: number, + onCrossAxisMoveForward ?: number, + onCrossAxisMoveBackward?: number, onDrop?: number, onCancel ?: number, onWindowScroll ?: number, @@ -43,6 +47,8 @@ const callbacksCalled = (callbacks: Callbacks) => ({ onMove = 0, onMoveForward = 0, onMoveBackward = 0, + onCrossAxisMoveForward = 0, + onCrossAxisMoveBackward = 0, onDrop = 0, onCancel = 0, }: CallBacksCalledFn = {}) => @@ -52,7 +58,9 @@ const callbacksCalled = (callbacks: Callbacks) => ({ callbacks.onMoveForward.mock.calls.length === onMoveForward && callbacks.onMoveBackward.mock.calls.length === onMoveBackward && callbacks.onDrop.mock.calls.length === onDrop && - callbacks.onCancel.mock.calls.length === onCancel; + callbacks.onCancel.mock.calls.length === onCancel && + callbacks.onCrossAxisMoveForward.mock.calls.length === onCrossAxisMoveForward && + callbacks.onCrossAxisMoveBackward.mock.calls.length === onCrossAxisMoveBackward; const whereAnyCallbacksCalled = (callbacks: Callbacks) => !callbacksCalled(callbacks)(); @@ -1104,25 +1112,25 @@ describe('drag handle', () => { })).toBe(true); }); - it('should not move backward when the user presses LeftArrow', () => { + it('should request to move to a droppable on the left when the user presses LeftArrow', () => { pressSpacebar(wrapper); windowArrowLeft(); requestAnimationFrame.step(); expect(callbacksCalled(callbacks)({ onKeyLift: 1, - onMoveBackward: 0, + onCrossAxisMoveBackward: 1, })).toBe(true); }); - it('should not move forward when the user presses RightArrow', () => { + it('should request to move to a droppable on the right when the user presses RightArrow', () => { pressSpacebar(wrapper); windowArrowRight(); requestAnimationFrame.step(); expect(callbacksCalled(callbacks)({ onKeyLift: 1, - onMoveForward: 0, + onCrossAxisMoveForward: 1, })).toBe(true); }); }); @@ -1152,49 +1160,47 @@ describe('drag handle', () => { customWrapper.unmount(); }); - it('should not move backward when the user presses ArrowUp', () => { + it('should move backward when the user presses LeftArrow', () => { pressSpacebar(customWrapper); - // try move backward - windowArrowUp(); + windowArrowLeft(); requestAnimationFrame.step(); expect(callbacksCalled(customCallbacks)({ onKeyLift: 1, - onMoveBackward: 0, + onMoveBackward: 1, })).toBe(true); }); - it('should not move forward when the user presses ArrowDown', () => { + it('should move forward when the user presses RightArrow', () => { pressSpacebar(customWrapper); - // try move forward - windowArrowDown(); + windowArrowRight(); requestAnimationFrame.step(); expect(callbacksCalled(customCallbacks)({ onKeyLift: 1, - onMoveForward: 0, + onMoveForward: 1, })).toBe(true); }); - it('should move backward when the user presses LeftArrow', () => { + it('should request a backward cross axis move when the user presses ArrowUp', () => { pressSpacebar(customWrapper); - windowArrowLeft(); + windowArrowUp(); requestAnimationFrame.step(); expect(callbacksCalled(customCallbacks)({ onKeyLift: 1, - onMoveBackward: 1, + onCrossAxisMoveBackward: 1, })).toBe(true); }); - it('should move forward when the user presses RightArrow', () => { + it('should request a forward cross axis move when the user presses ArrowDown', () => { pressSpacebar(customWrapper); - windowArrowRight(); + windowArrowDown(); requestAnimationFrame.step(); expect(callbacksCalled(customCallbacks)({ onKeyLift: 1, - onMoveForward: 1, + onCrossAxisMoveForward: 1, })).toBe(true); }); }); diff --git a/test/unit/view/unconnected-draggable-dimension-publisher.spec.js b/test/unit/view/unconnected-draggable-dimension-publisher.spec.js index e5ba43f1b5..a3930635ab 100644 --- a/test/unit/view/unconnected-draggable-dimension-publisher.spec.js +++ b/test/unit/view/unconnected-draggable-dimension-publisher.spec.js @@ -3,11 +3,11 @@ import React, { Component } from 'react'; import { mount } from 'enzyme'; import DraggableDimensionPublisher from '../../../src/view/draggable-dimension-publisher/draggable-dimension-publisher'; import { getDraggableDimension } from '../../../src/state/dimension'; -// eslint-disable-next-line no-duplicate-imports -import type { ClientRect, Margin } from '../../../src/state/dimension'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import setWindowScroll from '../../utils/set-window-scroll'; import type { + Spacing, + ClientRect, Position, DraggableId, DroppableId, @@ -28,11 +28,15 @@ const dimension: DraggableDimension = getDraggableDimension({ }), }); -const noComputedMargin = { +const noSpacing = { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', }; class Item extends Component { @@ -105,7 +109,7 @@ describe('DraggableDimensionPublisher', () => { height: dimension.page.withoutMargin.height, width: dimension.page.withoutMargin.width, })); - jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noComputedMargin); + jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noSpacing); const wrapper = mount(); wrapper.setProps({ @@ -117,7 +121,7 @@ describe('DraggableDimensionPublisher', () => { }); it('should consider any margins when calculating dimensions', () => { - const margin: Margin = { + const margin: Spacing = { top: 10, right: 30, bottom: 40, @@ -181,7 +185,7 @@ describe('DraggableDimensionPublisher', () => { windowScroll, }); jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => clientRect); - jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noComputedMargin); + jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noSpacing); setWindowScroll(windowScroll); const wrapper = mount(); diff --git a/test/unit/view/unconnected-draggable.spec.js b/test/unit/view/unconnected-draggable.spec.js index f151e38204..8372719222 100644 --- a/test/unit/view/unconnected-draggable.spec.js +++ b/test/unit/view/unconnected-draggable.spec.js @@ -7,7 +7,7 @@ import type { ReactWrapper } from 'enzyme'; import Draggable, { zIndexOptions } from '../../../src/view/draggable/draggable'; import DragHandle, { sloppyClickThreshold } from '../../../src/view/drag-handle/drag-handle'; import Moveable from '../../../src/view/moveable/'; -import Placeholder from '../../../src/view/draggable/placeholder'; +import Placeholder from '../../../src/view/placeholder'; import { css } from '../../../src/view/animation'; import { add, subtract } from '../../../src/state/position'; import type { @@ -28,7 +28,7 @@ import type { InitialDragLocation, } from '../../../src/types'; import { getDraggableDimension } from '../../../src/state/dimension'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import { combine, withStore, withDroppableId } from '../../utils/get-context-options'; import { dispatchWindowMouseEvent, mouseEvent } from '../../utils/user-input-util'; import setWindowScroll from '../../utils/set-window-scroll'; @@ -77,6 +77,8 @@ const getDispatchPropsStub = (): DispatchProps => ({ moveByWindowScroll: jest.fn(), moveForward: jest.fn(), moveBackward: jest.fn(), + crossAxisMoveForward: jest.fn(), + crossAxisMoveBackward: jest.fn(), drop: jest.fn(), cancel: jest.fn(), dropAnimationFinished: jest.fn(), @@ -712,6 +714,84 @@ describe('Draggable - unconnected', () => { }); }); + describe('onCrossAxisMoveForward', () => { + it('should throw if dragging is disabled', () => { + const wrapper = mountDraggable({ + ownProps: disabledOwnProps, + mapProps: draggingMapProps, + }); + + const tryMove = () => + wrapper.find(DragHandle).props().callbacks.onCrossAxisMoveForward(draggableId); + + expect(tryMove).toThrow(); + }); + + it('should throw if not attached to the DOM', () => { + const wrapper = mountDraggable({ + mapProps: draggingMapProps, + }); + + wrapper.unmount(); + + const tryMove = () => + wrapper.find(DragHandle).props().callbacks.onCrossAxisMoveForward(draggableId); + + expect(tryMove).toThrow(); + }); + + it('should call the cross axis move forward action', () => { + const dispatchProps = getDispatchPropsStub(); + const wrapper = mountDraggable({ + mapProps: draggingMapProps, + dispatchProps, + }); + + wrapper.find(DragHandle).props().callbacks.onCrossAxisMoveForward(draggableId); + + expect(dispatchProps.crossAxisMoveForward).toBeCalledWith(draggableId); + }); + }); + + describe('onCrossAxisMoveBackward', () => { + it('should throw if dragging is disabled', () => { + const wrapper = mountDraggable({ + ownProps: disabledOwnProps, + mapProps: draggingMapProps, + }); + + const tryMove = () => + wrapper.find(DragHandle).props().callbacks.onCrossAxisMoveBackward(draggableId); + + expect(tryMove).toThrow(); + }); + + it('should throw if not attached to the DOM', () => { + const wrapper = mountDraggable({ + mapProps: draggingMapProps, + }); + + wrapper.unmount(); + + const tryMove = () => + wrapper.find(DragHandle).props().callbacks.onCrossAxisMoveBackward(draggableId); + + expect(tryMove).toThrow(); + }); + + it('should call the move cross axis backwards action', () => { + const dispatchProps = getDispatchPropsStub(); + const wrapper = mountDraggable({ + mapProps: draggingMapProps, + dispatchProps, + }); + + wrapper.find(DragHandle).props().callbacks.onCrossAxisMoveBackward(draggableId); + + expect(dispatchProps.crossAxisMoveBackward).toBeCalledWith(draggableId); + }); + }); + describe('onCancel', () => { it('should call the cancel dispatch prop', () => { const dispatchProps = getDispatchPropsStub(); diff --git a/test/unit/view/unconnected-droppable-dimension-publisher.spec.js b/test/unit/view/unconnected-droppable-dimension-publisher.spec.js index b2ae39ba61..4057ac1f9b 100644 --- a/test/unit/view/unconnected-droppable-dimension-publisher.spec.js +++ b/test/unit/view/unconnected-droppable-dimension-publisher.spec.js @@ -1,21 +1,23 @@ // @flow +/* eslint-disable react/no-multi-comp */ import React, { Component } from 'react'; import { mount } from 'enzyme'; import DroppableDimensionPublisher from '../../../src/view/droppable-dimension-publisher/droppable-dimension-publisher'; import { getDroppableDimension } from '../../../src/state/dimension'; -// eslint-disable-next-line no-duplicate-imports -import type { Margin, ClientRect } from '../../../src/state/dimension'; -import getClientRect from '../../utils/get-client-rect'; +import getClientRect from '../../../src/state/get-client-rect'; import setWindowScroll from '../../utils/set-window-scroll'; import type { + ClientRect, + Spacing, DroppableId, DroppableDimension, HTMLElement, Position, + ReactElement, } from '../../../src/types'; const droppableId: DroppableId = 'drop-1'; -const dimension: DroppableDimension = getDroppableDimension({ +const droppable: DroppableDimension = getDroppableDimension({ id: droppableId, clientRect: getClientRect({ top: 0, @@ -24,13 +26,37 @@ const dimension: DroppableDimension = getDroppableDimension({ left: 0, }), }); +const origin: Position = { x: 0, y: 0 }; + +const noMargin = { + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', +}; +const noPadding = { + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', +}; + +const noSpacing = { + ...noMargin, + ...noPadding, +}; class ScrollableItem extends Component { /* eslint-disable react/sort-comp */ props: { + // dispatch props publish: (dimension: DroppableDimension) => void, updateScroll: (id: DroppableId, offset: Position) => void, + updateIsEnabled: (id: DroppableId, isEnabled: boolean) => void, + // map props (default: false) shouldPublish?: boolean, + // scrollable item prop (default: false) + isDropDisabled?: boolean, } state: {| @@ -49,13 +75,15 @@ class ScrollableItem extends Component { render() { return ( - // $ExpectError - for an unknown reason flow is having a hard time with this
{ x: window.pageXOffset, y: window.pageYOffset, }; + let publish; + let updateScroll; + let updateIsEnabled; + let dispatchProps; + let wrapper; + + beforeEach(() => { + publish = jest.fn(); + updateScroll = jest.fn(); + updateIsEnabled = jest.fn(); + dispatchProps = { + publish, updateScroll, updateIsEnabled, + }; + }); afterEach(() => { // clean up any stubs @@ -85,18 +127,16 @@ describe('DraggableDimensionPublisher', () => { window.getComputedStyle.mockRestore(); } setWindowScroll(originalWindowScroll, { shouldPublish: false }); + + if (wrapper) { + wrapper.unmount(); + } }); describe('dimension publishing', () => { it('should not publish if not asked to', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); - - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ @@ -105,53 +145,38 @@ describe('DraggableDimensionPublisher', () => { expect(publish).not.toHaveBeenCalled(); expect(updateScroll).not.toHaveBeenCalled(); - - wrapper.unmount(); + expect(updateIsEnabled).not.toHaveBeenCalled(); }); it('should publish the dimensions of the target', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - top: dimension.page.withoutMargin.top, - bottom: dimension.page.withoutMargin.bottom, - left: dimension.page.withoutMargin.left, - right: dimension.page.withoutMargin.right, - height: dimension.page.withoutMargin.height, - width: dimension.page.withoutMargin.width, - })); - jest.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ - marginTop: '0', - marginRight: '0', - marginBottom: '0', - marginLeft: '0', + top: droppable.page.withoutMargin.top, + bottom: droppable.page.withoutMargin.bottom, + left: droppable.page.withoutMargin.left, + right: droppable.page.withoutMargin.right, + height: droppable.page.withoutMargin.height, + width: droppable.page.withoutMargin.width, })); + jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noSpacing); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, }); - expect(publish).toBeCalledWith(dimension); + expect(publish).toBeCalledWith(droppable); expect(publish).toHaveBeenCalledTimes(1); - - wrapper.unmount(); }); it('should consider any margins when calculating dimensions', () => { - const margin: Margin = { + const margin: Spacing = { top: 10, right: 30, bottom: 40, left: 50, }; - const publish = jest.fn(); - const updateScroll = jest.fn(); const expected: DroppableDimension = getDroppableDimension({ id: droppableId, clientRect: getClientRect({ @@ -163,38 +188,32 @@ describe('DraggableDimensionPublisher', () => { margin, }); jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - top: dimension.page.withoutMargin.top, - bottom: dimension.page.withoutMargin.bottom, - left: dimension.page.withoutMargin.left, - right: dimension.page.withoutMargin.right, - height: dimension.page.withoutMargin.height, - width: dimension.page.withoutMargin.width, + top: droppable.page.withoutMargin.top, + bottom: droppable.page.withoutMargin.bottom, + left: droppable.page.withoutMargin.left, + right: droppable.page.withoutMargin.right, + height: droppable.page.withoutMargin.height, + width: droppable.page.withoutMargin.width, })); jest.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ marginTop: `${margin.top}`, marginRight: `${margin.right}`, marginBottom: `${margin.bottom}`, marginLeft: `${margin.left}`, + ...noPadding, })); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, }); expect(publish).toBeCalledWith(expected); - - wrapper.unmount(); }); it('should consider the window scroll when calculating dimensions', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); const windowScroll: Position = { x: 500, y: 1000, @@ -212,17 +231,9 @@ describe('DraggableDimensionPublisher', () => { windowScroll, }); jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => clientRect); - jest.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ - marginTop: '0', - marginRight: '0', - marginBottom: '0', - marginLeft: '0', - })); - const wrapper = mount( - , + jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noSpacing); + wrapper = mount( + , ); wrapper.setProps({ @@ -230,13 +241,9 @@ describe('DraggableDimensionPublisher', () => { }); expect(publish).toHaveBeenCalledWith(expected); - - wrapper.unmount(); }); it('should consider the closest scrollable when calculating dimensions', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); const closestScroll: Position = { x: 500, y: 1000, @@ -261,16 +268,10 @@ describe('DraggableDimensionPublisher', () => { })); jest.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ overflowY: 'scroll', - marginTop: '0', - marginRight: '0', - marginBottom: '0', - marginLeft: '0', + ...noSpacing, })); - const wrapper = mount( - , + wrapper = mount( + , ); // setting initial scroll const container: HTMLElement = wrapper.getDOMNode(); @@ -282,28 +283,21 @@ describe('DraggableDimensionPublisher', () => { }); expect(publish).toHaveBeenCalledWith(expected); - - wrapper.unmount(); }); it('should not publish unless it is freshly required to do so', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - top: dimension.page.withMargin.top, - bottom: dimension.page.withMargin.bottom, - left: dimension.page.withMargin.left, - right: dimension.page.withMargin.right, - height: dimension.page.withMargin.height, - width: dimension.page.withMargin.width, + top: droppable.page.withMargin.top, + bottom: droppable.page.withMargin.bottom, + left: droppable.page.withMargin.left, + right: droppable.page.withMargin.right, + height: droppable.page.withMargin.height, + width: droppable.page.withMargin.width, })); // initial publish - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, @@ -327,20 +321,369 @@ describe('DraggableDimensionPublisher', () => { // just being extra safe: expect(updateScroll).not.toHaveBeenCalled(); + }); - wrapper.unmount(); + describe('dimension clipping', () => { + type ItemProps = { + publish: (dimension: DroppableDimension) => void, + updateScroll: (id: DroppableId, offset: Position) => void, + updateIsEnabled: (id: DroppableId, isEnabled: boolean) => void, + shouldPublish?: boolean, + }; + + class ScrollParent extends Component { + props: { + children: ?ReactElement + } + + render() { + return ( +
+ {this.props.children} +
+ ); + } + } + + class Item extends Component { + /* eslint-disable react/sort-comp */ + props: ItemProps + + state: {| + ref: ?HTMLElement + |} + + state = { + ref: null, + } + + setRef = (ref: ?HTMLElement) => { + this.setState({ + ref, + }); + } + + render() { + return ( +
+ +
hello world
+
+
+ ); + } + } + + class App extends Component { + props: ItemProps + + render() { + return ( + + + + ); + } + } + + type ExecuteArgs = {| + droppableRect: ClientRect, + scrollParentRect: ClientRect, + scrollParentScroll?: Position, + |} + + const execute = ({ + droppableRect, + scrollParentRect, + scrollParentScroll = origin, + }: ExecuteArgs) => { + wrapper = mount( + + ); + + const scrollParentNode: HTMLElement = wrapper.getDOMNode(); + + if (!scrollParentNode.classList.contains('scroll-parent')) { + throw new Error('scroll parent node not obtained correctly'); + } + + const droppableNode: HTMLElement = scrollParentNode.querySelector('.item'); + + scrollParentNode.scrollLeft = scrollParentScroll.x; + scrollParentNode.scrollTop = scrollParentScroll.y; + + jest.spyOn(scrollParentNode, 'getBoundingClientRect').mockImplementationOnce(() => scrollParentRect); + jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementationOnce(() => droppableRect); + + jest.spyOn(window, 'getComputedStyle').mockImplementation((el) => { + if (el === droppableNode) { + return noSpacing; + } + + if (el === scrollParentNode) { + return { + ...noSpacing, + overflow: 'auto', + }; + } + + throw new Error('unknown el'); + }); + + wrapper.setProps({ + shouldPublish: true, + }); + }; + + it('should clip the dimension by the size of the scroll parent', () => { + const droppableRect: ClientRect = getClientRect({ + top: 0, + bottom: 100, + left: 0, + right: 100, + }); + // smaller by 10px in every direction + const scrollParentRect: ClientRect = getClientRect({ + top: 10, + bottom: 90, + left: 10, + right: 90, + }); + const expected = getDroppableDimension({ + id: droppableId, + // because it is smaller in every direction + // the result will be the scroll parent rect + clientRect: scrollParentRect, + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + + describe('dimension clipping by edge', () => { + const base = { + top: 0, + bottom: 100, + left: 0, + right: 100, + }; + const droppableRect: ClientRect = getClientRect(base); + + describe('cut off by scroll container', () => { + it('should choose the biggest top value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + top: 10, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect({ + ...base, + top: 10, + }), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('should choose the biggest left value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + left: 10, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect({ + ...base, + left: 10, + }), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('should choose the smallest right value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + right: 90, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect({ + ...base, + right: 90, + }), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('should choose the smallest bottom value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + bottom: 90, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect({ + ...base, + bottom: 90, + }), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + }); + + describe('cut off by droppable rect', () => { + it('should choose the biggest top value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + top: -10, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect(base), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('should choose the biggest left value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + left: -10, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect(base), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('should choose the smallest right value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + right: 110, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect(base), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('should choose the smallest bottom value', () => { + const scrollParentRect: ClientRect = getClientRect({ + ...base, + bottom: 110, + }); + const expected = getDroppableDimension({ + id: droppableId, + clientRect: getClientRect(base), + }); + + execute({ droppableRect, scrollParentRect }); + + // the trimmed rect + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('should take into account the parents scroll when clipping', () => { + const base = { + top: 0, + bottom: 100, + left: 0, + right: 100, + }; + const scrollParentScroll: Position = { + x: 100, + y: 100, + }; + // rect will have the scroll subtracted when measurements are taken + const droppableRect: ClientRect = getClientRect({ + top: base.top - scrollParentScroll.y, + bottom: base.bottom - scrollParentScroll.y, + left: base.left - scrollParentScroll.x, + right: base.right - scrollParentScroll.x, + }); + const scrollParentRect: ClientRect = getClientRect(base); + const expected: DroppableDimension = getDroppableDimension({ + id: droppableId, + scroll: scrollParentScroll, + clientRect: getClientRect(base), + }); + + execute({ droppableRect, scrollParentRect, scrollParentScroll }); + + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); }); }); describe('scroll watching', () => { it('should not publish any scroll changes unless told it can publish', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); - const wrapper = mount( - , + wrapper = mount( + , ); const container: HTMLElement = wrapper.getDOMNode(); @@ -358,18 +701,11 @@ describe('DraggableDimensionPublisher', () => { requestAnimationFrame.flush(); expect(updateScroll).not.toHaveBeenCalled(); - - wrapper.unmount(); }); it('should publish the closest scrollable scroll offset', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, @@ -389,18 +725,11 @@ describe('DraggableDimensionPublisher', () => { expect(updateScroll.mock.calls[0]).toEqual([ droppableId, { x: 500, y: 1000 }, ]); - - wrapper.unmount(); }); it('should throttle multiple scrolls into a animation frame', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, @@ -428,19 +757,12 @@ describe('DraggableDimensionPublisher', () => { // also checking that no loose frames are stored up requestAnimationFrame.flush(); expect(updateScroll).toHaveBeenCalledTimes(1); - - wrapper.unmount(); }); it('should not fire a scroll if the value has not changed since the previous frame', () => { // this can happen if you scroll backward and forward super quick - const publish = jest.fn(); - const updateScroll = jest.fn(); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, @@ -470,19 +792,12 @@ describe('DraggableDimensionPublisher', () => { requestAnimationFrame.step(); expect(updateScroll).toHaveBeenCalledTimes(1); - - wrapper.unmount(); }); it('should stop watching scroll when no longer required to publish', () => { // this can happen if you scroll backward and forward super quick - const publish = jest.fn(); - const updateScroll = jest.fn(); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, @@ -507,18 +822,11 @@ describe('DraggableDimensionPublisher', () => { // let any frames go that want to requestAnimationFrame.flush(); expect(updateScroll).toHaveBeenCalledTimes(1); - - wrapper.unmount(); }); it('should not publish a scroll update after requested not to update while an animation frame is occurring', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, @@ -545,18 +853,11 @@ describe('DraggableDimensionPublisher', () => { requestAnimationFrame.flush(); expect(updateScroll).toHaveBeenCalledTimes(1); - - wrapper.unmount(); }); it('should stop watching for scroll events when the component is unmounted', () => { - const publish = jest.fn(); - const updateScroll = jest.fn(); - const wrapper = mount( - , + wrapper = mount( + , ); wrapper.setProps({ shouldPublish: true, @@ -572,7 +873,7 @@ describe('DraggableDimensionPublisher', () => { wrapper.unmount(); - // second event + // second event - will not fire any updates container.scrollTop = 1001; container.scrollLeft = 501; container.dispatchEvent(new Event('scroll')); @@ -580,4 +881,126 @@ describe('DraggableDimensionPublisher', () => { expect(updateScroll).toHaveBeenCalledTimes(1); }); }); + + describe('is enabled flag publishing', () => { + beforeEach(() => { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ + top: droppable.page.withoutMargin.top, + bottom: droppable.page.withoutMargin.bottom, + left: droppable.page.withoutMargin.left, + right: droppable.page.withoutMargin.right, + height: droppable.page.withoutMargin.height, + width: droppable.page.withoutMargin.width, + })); + jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noSpacing); + }); + + it('should publish whether the droppable is enabled when requested to publish', () => { + describe('enabled on mount', () => { + it('should publish that the dimension is enabled', () => { + wrapper = mount( + , + ); + wrapper.setProps({ + shouldPublish: true, + }); + + expect(publish).toBeCalledWith(droppable); + expect(publish).toHaveBeenCalledTimes(1); + }); + }); + + describe('disabled on mount', () => { + it('should publish that the dimension is disabled', () => { + const expected = { + ...droppable, + isEnabled: false, + }; + + wrapper = mount( + , + ); + wrapper.setProps({ + shouldPublish: true, + }); + + expect(publish).toBeCalledWith(expected); + expect(publish).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('should publish changes to the enabled state of the droppable during a drag', () => { + wrapper = mount( + , + ); + + // initial publish + wrapper.setProps({ + shouldPublish: true, + }); + expect(publish.mock.calls[0][0].isEnabled).toBe(true); + + // disable + wrapper.setProps({ + isDropDisabled: true, + }); + expect(updateIsEnabled).toHaveBeenCalledTimes(1); + expect(updateIsEnabled.mock.calls[0]).toEqual([droppable.id, false]); + + // enable + wrapper.setProps({ + isDropDisabled: false, + }); + expect(updateIsEnabled).toHaveBeenCalledTimes(2); + expect(updateIsEnabled.mock.calls[1]).toEqual([droppable.id, true]); + }); + + it('should not publish changes to the enabled state of the droppable when a drag is not occuring', () => { + wrapper = mount( + , + ); + const change = () => { + // disabling + wrapper.setProps({ + isDropDisabled: true, + }); + // enabling + wrapper.setProps({ + isDropDisabled: false, + }); + }; + // not publishing yet + change(); + expect(updateIsEnabled).not.toHaveBeenCalled(); + + // now publishing + wrapper.setProps({ + shouldPublish: true, + }); + + // this change will now trigger an update x 2 + change(); + expect(updateIsEnabled).toHaveBeenCalledTimes(2); + // disabling + expect(updateIsEnabled.mock.calls[0]).toEqual([droppable.id, false]); + // enabling + expect(updateIsEnabled.mock.calls[1]).toEqual([droppable.id, true]); + + // no longer publishing + wrapper.setProps({ + shouldPublish: false, + }); + + // should not do anything + change(); + expect(updateIsEnabled).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/test/unit/view/unconnected-droppable.spec.js b/test/unit/view/unconnected-droppable.spec.js index e2ba96fc7b..4cef5fc464 100644 --- a/test/unit/view/unconnected-droppable.spec.js +++ b/test/unit/view/unconnected-droppable.spec.js @@ -4,6 +4,7 @@ import { mount } from 'enzyme'; // eslint-disable-next-line no-duplicate-imports import type { ReactWrapper } from 'enzyme'; import Droppable from '../../../src/view/droppable/droppable'; +import Placeholder from '../../../src/view/placeholder/'; import { withStore } from '../../utils/get-context-options'; import type { DroppableId } from '../../../src/types'; import type { MapProps, OwnProps, Provided, StateSnapshot } from '../../../src/view/droppable/droppable-types'; @@ -27,9 +28,19 @@ const getStubber = (mock: Function) => const defaultDroppableId: DroppableId = 'droppable-1'; const notDraggingOverMapProps: MapProps = { isDraggingOver: false, + placeholder: null, }; -const isDraggingOverMapProps: MapProps = { +const isDraggingOverHomeMapProps: MapProps = { isDraggingOver: true, + placeholder: null, +}; + +const isDraggingOverForeignMapProps: MapProps = { + isDraggingOver: true, + placeholder: { + width: 100, + height: 50, + }, }; // $ExpectError - not providing children @@ -60,23 +71,55 @@ const mountDroppable = ({ , withStore()); describe('Droppable - unconnected', () => { - it('should provide the props to its children', () => { - const props: MapProps[] = [ - notDraggingOverMapProps, isDraggingOverMapProps, - ]; - - props.forEach((mapProps: MapProps) => { + describe('dragging over home droppable', () => { + it('should provide the props to its children', () => { const myMock = jest.fn(); + mountDroppable({ + mapProps: isDraggingOverHomeMapProps, + WrappedComponent: getStubber(myMock), + }); + const provided: Provided = myMock.mock.calls[0][0].provided; + const snapshot: StateSnapshot = myMock.mock.calls[0][0].snapshot; + expect(provided.innerRef).toBeInstanceOf(Function); + expect(snapshot.isDraggingOver).toBe(true); + expect(provided.placeholder).toBe(null); + }); + }); + + describe('dragging over foreign droppable', () => { + it('should provide the props to its children', () => { + const myMock = jest.fn(); mountDroppable({ - mapProps, + mapProps: isDraggingOverForeignMapProps, WrappedComponent: getStubber(myMock), }); const provided: Provided = myMock.mock.calls[0][0].provided; const snapshot: StateSnapshot = myMock.mock.calls[0][0].snapshot; expect(provided.innerRef).toBeInstanceOf(Function); - expect(snapshot.isDraggingOver).toBe(mapProps.isDraggingOver); + expect(snapshot.isDraggingOver).toBe(true); + // $ExpectError - type property of placeholder + expect(provided.placeholder.type).toBe(Placeholder); + // $ExpectError - props property of placeholder + expect(provided.placeholder.props).toEqual(isDraggingOverForeignMapProps.placeholder); + }); + + describe('not dragging over droppable', () => { + it('should provide the props to its children', () => { + const myMock = jest.fn(); + mountDroppable({ + mapProps: notDraggingOverMapProps, + WrappedComponent: getStubber(myMock), + }); + + const provided: Provided = myMock.mock.calls[0][0].provided; + const snapshot: StateSnapshot = myMock.mock.calls[0][0].snapshot; + expect(provided.innerRef).toBeInstanceOf(Function); + expect(snapshot.isDraggingOver).toBe(false); + expect(provided.placeholder).toBe(null); + }); }); }); }); + diff --git a/test/utils/get-client-rect.js b/test/utils/get-client-rect.js deleted file mode 100644 index ccc4f3254b..0000000000 --- a/test/utils/get-client-rect.js +++ /dev/null @@ -1,17 +0,0 @@ -import type { ClientRect } from '../../src/state/dimension'; - -type GetClientRect = {| - top: number, - right: number, - bottom: number, - left: number, -|} - -export default ({ top, right, bottom, left }: GetClientRect): ClientRect => ({ - top, - right, - bottom, - left, - width: (right - left), - height: (bottom - top), -}); diff --git a/test/utils/get-droppable-with-draggables.js b/test/utils/get-droppable-with-draggables.js new file mode 100644 index 0000000000..d0c809b856 --- /dev/null +++ b/test/utils/get-droppable-with-draggables.js @@ -0,0 +1,65 @@ +// @flow +import { getDraggableDimension, getDroppableDimension } from '../../src/state/dimension'; +import type { + DroppableId, + DraggableDimension, + DraggableDimensionMap, + DroppableDimension, + Direction, + Spacing, +} from '../../src/types'; +import getClientRect from '../../src/state/get-client-rect'; + +type Args = {| + direction?: Direction, + droppableId?: DroppableId, + droppableRect: Spacing, + draggableRects: Spacing[], +|}; + +export type Result = { + droppableId: string, + droppable: DroppableDimension, + draggables: DraggableDimensionMap, + draggableIds: string[], + draggableDimensions: DraggableDimension[], +}; + +let idCount = 0; + +export default ({ + direction = 'vertical', + droppableId = `droppable-generated-id-${idCount++}`, + droppableRect, + draggableRects, +}: Args): Result => { + const droppable: DroppableDimension = getDroppableDimension({ + id: droppableId, + direction, + clientRect: getClientRect(droppableRect), + }); + + const draggableDimensions: DraggableDimension[] = draggableRects.map( + (draggableRect, index) => getDraggableDimension({ + id: `${droppableId}::drag-${index}`, + droppableId, + clientRect: getClientRect(draggableRect), + }) + ); + + const draggables: DraggableDimensionMap = draggableDimensions.reduce( + (currentDraggables, draggable) => ({ + ...currentDraggables, + [draggable.id]: draggable, + }), {}); + + const draggableIds = Object.keys(draggables); + + return { + droppableId, + droppable, + draggables, + draggableIds, + draggableDimensions, + }; +}; diff --git a/test/utils/get-fragment.js b/test/utils/get-fragment.js new file mode 100644 index 0000000000..eaa7054035 --- /dev/null +++ b/test/utils/get-fragment.js @@ -0,0 +1,16 @@ +// @flow +import type { DimensionFragment, ClientRect } from '../../src/types'; + +export default (clientRect: ClientRect): DimensionFragment => { + const { top, left, bottom, right, width, height } = clientRect; + const center = { + x: (right + left) / 2, + y: (top + bottom) / 2, + }; + + const fragment: DimensionFragment = { + top, left, bottom, right, width, height, center, + }; + + return fragment; +};