diff --git a/README.md b/README.md index 80dc68ac97..97dff0b45d 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,21 @@ See how beautiful it is for yourself! > We provide different links for touch devices as currently [storybook](https://github.com/storybooks/storybook) does not have a good mobile menu experience [more information](https://github.com/storybooks/storybook/issues/124) -## Upgrading from `3.x` to `4.x` +## Basic usage examples -You can find an upgrade guide in our [release notes](https://github.com/atlassian/react-beautiful-dnd/releases/tag/v4.0.0). +We have created some basic examples on `codesandbox` for you to play with directly: + +- [simple vertical list](https://codesandbox.io/s/k260nyxq9v) +- [simple horizontal list](https://codesandbox.io/s/mmrp44okvj) + +> Coming soon: a getting starting guide! + +## Upgrading + +We have created upgrade instructions in our release notes to help you upgrade to the latest version! + +- [Upgrading from `4.x` to `5.x`](https://github.com/atlassian/react-beautiful-dnd/releases/tag/v5.0.0); +- [Upgrading from `3.x` to `4.x`](https://github.com/atlassian/react-beautiful-dnd/releases/tag/v4.0.0); ## Core characteristics @@ -32,6 +44,7 @@ You can find an upgrade guide in our [release notes](https://github.com/atlassia - Plays extremely well with standard browser interactions - Unopinionated styling - No creation of additional wrapper dom nodes - flexbox and focus management friendly! +- Accessible ## Currently supported feature set @@ -39,12 +52,14 @@ You can find an upgrade guide in our [release notes](https://github.com/atlassia - Horizontal lists ↔ - Movement between lists (▤ ↔ ▤) - Mouse 🐭, keyboard 🎹 and touch 👉📱 (mobile, tablet and so on) support +- Auto scrolling - automatically scroll containers and the window as required during a drag (even with keyboard 🔥) +- Incredible screen reader support - we provide an amazing experience for english screen readers out of the box 📦. We also provide complete customisation control and internationalisation support for those who need it 💖 - Conditional [dragging](https://github.com/atlassian/react-beautiful-dnd#props-1) and [dropping](https://github.com/atlassian/react-beautiful-dnd#conditionally-dropping) - Multiple independent lists on the one page +- Flexible item sizes - the draggable items can have different heights (vertical lists) or widths (horizontal lists) +- Custom drag handles - you can drag a whole item by just a part of it +- A `Droppable` list can be a scroll container (without a scrollable parent) or be the child of a scroll container (that also does not have a scrollable parent) - Independent nested lists - a list can be a child of another list, but you cannot drag items from the parent list into a child list -- Flexible item sizes - the draggable items can have different heights (vertical) or widths (horizontal)) -- Custom drag handle - you can drag a whole item by just a part of it -- A droppable list can be a scroll container (without a scrollable parent) or be the child of a scroll container (that also does not have a scrollable parent) - Server side rendering compatible - Plays well with [nested interactive elements](https://github.com/atlassian/react-beautiful-dnd#interactive-child-elements-within-a-draggable) by default @@ -58,13 +73,6 @@ There are a lot of libraries out there that allow for drag and drop interactions `react-beautiful-dnd` [uses `position: fixed` to position the dragging element](#positioning-ownership). In some layouts, this might break how the element is rendered. One example is a ``-based layout which will lose column widths for dragged ``s. Follow [#103](https://github.com/atlassian/react-beautiful-dnd/issues/103) for updates on support for this use case. -## Basic usage examples on `codesandbox` - -We have created some basic examples for you to play with directly: - -- [vertical list](https://codesandbox.io/s/k260nyxq9v) -- [horizontal list](https://codesandbox.io/s/mmrp44okvj) - ## Driving philosophy: physicality The core design idea of `react-beautiful-dnd` is physicality: we want users to feel like they are moving physical objects around @@ -132,11 +140,39 @@ How it is composed: `react-beautiful-dnd` does not create any wrapper elements. This means that it will not impact the usual tab flow of a document. For example, if you are wrapping an *anchor* tag then the user will tab to the anchor directly and not an element surrounding the *anchor*. Whatever element you wrap will be given a `tab-index` to ensure that users can tab to the element to perform keyboard dragging. +### Auto scrolling + +When a user drags a `Draggable` near the edge of a *container* we automatically scroll the container as we are able to in order make room for the `Draggable`. + +> A *container* is either a `Droppable` that is scrollable or has a scroll parent - or the `window`. + +#### For mouse and touch inputs 🐭📱 + +When the center of a `Draggable` gets within a small distance from the edge of a container we start auto scrolling. As the user gets closer to the edge of the container we increase the speed of the auto scroll. This acceleration uses an easing function to exponentially increase the rate of acceleration the closer we move towards the edge. We reach a maximum rate of acceleration a small distance from the true edge of a container so that the user does not need to be extremely precise to obtain the maximum scroll speed. This logic applies for any edge that is scrollable. + +The distances required for auto scrolling are based on a percentage of the height or width of the container for vertical and horizontal scrolling respectively. By using percentages rather than raw pixel values we are able to have a great experience regardless of the size and shape of your containers. + +##### A note about big `Draggable`s + +If the `Draggable` is bigger than a container on the axis you are trying to scroll - we will not permit scrolling on that axis. For example, if you have a `Draggable` that is longer than the height of the window we will not auto scroll vertically. However, we will still permit scrolling to occur horizontally. + +##### iOS auto scroll shake 📱🤕 + +When auto scrolling on an iOS browser (webkit) the `Draggable` noticeably shakes. This is due to a [bug with webkit](https://bugs.webkit.org/show_bug.cgi?id=181954) that has no known work around. We tried for a long time to work around the issue! If you are interesting in seeing this improved please engage with the [webkit issue](https://bugs.webkit.org/show_bug.cgi?id=181954). + +#### For keyboard dragging 🎹 + +We also correctly update the scroll position as required when keyboard dragging. In order to move a `Draggable` into the correct position we can do a combination of a `Droppable` scroll, `window` scroll and manual movements to ensure the `Draggable` ends up in the correct position in response to user movement instructions. This is boss 🔥. + +This is amazing for users with visual impairments as they can correctly move items around in big lists without needing to use mouse positioning. + ### Accessibility Traditionally drag and drop interactions have been exclusively a mouse or touch interaction. This library ships with support for drag and drop interactions **using only a keyboard**. This enables power users to drive their experience entirely from the keyboard. As well as opening up these experiences to users who would have been excluded previously. -In addition to supporting keyboard, we have also audited how the keyboard shortcuts interact with standard browser keyboard interactions. When the user is not dragging they can use their keyboard as they normally would. While dragging we override and disable certain browser shortcuts (such as `tab`) to ensure a fluid experience for the user. +We provide **fantastic support for screen readers** to assist users with visual (or other) impairments. We ship with english messaging out of the box 📦. However, you are welcome to override these messages by using the `announce` function that it provided to all of the `DragDropContext > hook` functions. + +See our [screen reader guide](TODO) for a guide on crafting useful screen reader messaging. ## Mouse dragging @@ -165,7 +201,7 @@ Other than these explicitly blocked keyboard events all standard keyboard events ## Keyboard dragging -`react-beautiful-dnd` supports dragging with only a keyboard +`react-beautiful-dnd` supports dragging with only a keyboard. We have audited how our keyboard shortcuts interact with standard browser keyboard interactions. When the user is not dragging they can use their keyboard as they normally would. While dragging we override and disable certain browser shortcuts (such as `tab`) to ensure a fluid experience for the user. ### Keyboard shortcuts: keyboard dragging @@ -197,10 +233,6 @@ During a drag the following standard keyboard events are blocked to prevent a ba - **tab** tab ↹ - blocking tabbing - **enter** - blocking submission -### Limitations of keyboard dragging - -There is current limitation of keyboard dragging: **the drag will cancel if the user scrolls the window**. This could be worked around but for now it is the simplest initial approach. - ## Touch dragging `react-beautiful-dnd` supports dragging on touch devices such as mobiles and tablets. @@ -282,6 +314,16 @@ Avoid the *pull to refresh action* and *delayed anchor focus* on Android Chrome touch-action: manipulation; ``` +#### Always: Droppable + +Styles applied to: **droppable element** using the `data-react-beautiful-dnd-droppable` attribute. + +Opting out of the browser feature which tries to maintain the scroll position when the DOM changes above the fold. We already correctly maintain the scroll position. The automatic `overflow-anchor` behavior leads to incorrect scroll positioning post drop. + +```css +overflow-anchor: none; +``` + ### Phase: resting #### (Phase: resting): drag handle @@ -423,13 +465,20 @@ In order to use drag and drop, you need to have the part of your `React` tree th ```js type Hooks = {| - onDragStart?: (initial: DragStart) => void, - onDragEnd: (result: DropResult) => void, + // optional + onDragStart?: OnDragStartHook, + onDragUpdate?: OnDragUpdateHook, + // always required + onDragEnd: OnDragEndHook, |} +type OnDragStartHook = (start: DragStart, provided: HookProvided) => void; +type OnDragUpdateHook = (update: DragUpdate, provided: HookProvided) => void; +type OnDragEndHook = (result: DropResult, provided: HookProvided) => void; + type Props = {| ...Hooks, - children?: ReactElement, + children: ?Node, |} ``` @@ -442,14 +491,18 @@ class App extends React.Component { onDragStart = () => { /*...*/ }; - onDragEnd = () => { + onDragUpdate = () => { /*...*/ + } + onDragEnd = () => { + // the only one that is required }; render() { return (
Hello world
@@ -461,24 +514,48 @@ class App extends React.Component { ### `Hook`s -These are top level application events that you can use to perform your own state updates. +These are top level application events that you can use to perform your own state updates as well as to make screen reader announcements. For more information about controlling the screen reader see our [screen reader guide](TODO) + +### `provided: HookProvided` + +```js +type HookProvided = {| + announce: Announce, +|} + +type Announce = (message: string) => void; +``` + +All hooks are provided with a second argument: `HookProvided`. This object has one property: `announce`. This function is used to synchronously announce a message to screen readers. If you do not use this function we will announce a default english message. We have created a [guide for screen reader usage](TODO) which we recommend using if you are interested in controlling the screen reader messages for yourself and to support internationalisation. If you are using `announce` it must be called synchronously. ### `onDragStart` (optional) -This function will get notified when a drag starts. You are provided with the following details: +```js +type OnDragStartHook = (start: DragStart, provided: HookProvided) => void; +``` + +`onDragStart` will get notified when a drag starts. This hook is *optional* and therefore does not need to be provided. It is **highly recommended** that you use this function to block updates to all `Draggable` and `Droppable` components during a drag. (See [*Best practices for `hooks` *](https://github.com/atlassian/react-beautiful-dnd#best-practices-for-hooks)) + +You are provided with the following details: -### `initial: DragStart` +#### `start: DragStart` -- `initial.draggableId`: the id of the `Draggable` that is now dragging -- `initial.type`: the `type` of the `Draggable` that is now dragging -- `initial.source`: the location (`droppableId` and `index`) of where the dragging item has started within a `Droppable`. +```js +type DragStart = {| + draggableId: DraggableId, + type: TypeId, + source: DraggableLocation, +|} +``` -This function is *optional* and therefore does not need to be provided. It is **highly recommended** that you use this function to block updates to all `Draggable` and `Droppable` components during a drag. (See [*Best practices for `hooks` *](https://github.com/atlassian/react-beautiful-dnd#best-practices-for-hooks)) +- `start.draggableId`: the id of the `Draggable` that is now dragging +- `start.type`: the `type` of the `Draggable` that is now dragging +- `start.source`: the location (`droppableId` and `index`) of where the dragging item has started within a `Droppable`. -### `onDragStart` type information +#### `onDragStart` type information ```js -onDragStart?: (initial: DragStart) => void +type OnDragStartHook = (start: DragStart, provided: HookProvided) => void; // supporting types type DragStart = {| @@ -498,24 +575,63 @@ type DroppableId = Id; type TypeId = Id; ``` +### `onDragUpdate` (optional) + +```js +type OnDragUpdateHook = (update: DragUpdate, provided: HookProvided) => void; +``` + +This hook is called whenever something changes during a drag. The possible changes are: + +- The position of the `Draggable` has changed +- The `Draggable` is now over a different `Droppable` +- The `Draggable` is now over no `Droppable` + +It is important that you not do too much work as a result of this function as it will slow down the drag. + +#### `update: DragUpdate` + +```js +type DragUpdate = {| + ...DragStart, + // may not have any destination (drag to nowhere) + destination: ?DraggableLocation, +|} +``` + +- `update.draggableId`: the id of the `Draggable` that is now dragging +- `update.type`: the `type` of the `Draggable` that is now dragging +- `update.source`: the location (`droppableId` and `index`) of where the dragging item has started within a `Droppable`. +- `update.destination`: the location (`droppableId` and `index`) of where the dragging item is now. This can be null if the user is currently not dragging over any `Droppable`. + ### `onDragEnd` (required) This function is *extremely* important and has an critical role to play in the application lifecycle. **This function must result in the *synchronous* reordering of a list of `Draggables`** It is provided with all the information about a drag: -### `result: DropResult` +#### `result: DropResult` + +```js +type DropResult = {| + ...DragUpdate, + reason: DropReason, +|} + +type DropReason = 'DROP' | 'CANCEL'; +``` - `result.draggableId`: the id of the `Draggable` that was dragging. - `result.type`: the `type` of the `Draggable` that was dragging. - `result.source`: the location where the `Draggable` started. -- `result.destination`: the location where the `Draggable` finished. The `destination` will be `null` if the user dropped into no position (such as outside any list) *or* if they dropped the `Draggable` back into the same position in which it started. +- `result.destination`: the location where the `Draggable` finished. The `destination` will be `null` if the user dropped while not over a `Droppable`. +- `result.reason`: the reason a drop occurred. This information can be helpful in crafting more useful messaging in the `HookProvided` > `announce` function. ### Synchronous reordering -Because this library does not control your state, it is up to you to *synchronously* reorder your lists based on the `result`. +Because this library does not control your state, it is up to you to *synchronously* reorder your lists based on the `result: DropResult`. -#### Here is what you need to do: +#### Here is what you need to do - if the `destination` is `null`: all done! - if `source.droppableId` equals `destination.droppableId` you need to remove the item from your list and insert it at the correct position. @@ -525,31 +641,6 @@ Because this library does not control your state, it is up to you to *synchronou If you need to persist a reorder to a remote data store - update the list synchronously on the client and fire off a request in the background to persist the change. If the remote save fails it is up to you how to communicate that to the user and update, or not update, the list. -### `onDragEnd` type information - -```js -onDragEnd: (result: DropResult) => void - -// supporting types -type DropResult = {| - draggableId: DraggableId, - type: TypeId, - source: DraggableLocation, - // may not have any destination (drag to nowhere) - destination: ?DraggableLocation -|} - -type Id = string; -type DroppableId = Id; -type DraggableId = Id; -type TypeId = Id; -type DraggableLocation = {| - droppableId: DroppableId, - // the position of the droppable within a droppable - index: number -|}; -``` - ### Best practices for `hooks` #### Block updates during a drag @@ -591,6 +682,7 @@ import { Droppable } from 'react-beautiful-dnd';

I am a droppable!

{provided.placeholder} @@ -626,18 +718,29 @@ The function is provided with two arguments: ```js type DroppableProvided = {| innerRef: (?HTMLElement) => void, + droppableProps: DroppableProps, placeholder: ?ReactElement, |} ``` -- `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.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. *This prop is planned to be removed when we move to React 16* - `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 for which you have provided the ref. We need to increase the side of the `Droppable` itself. This is different from `Draggable` where the `placeholder` needs to be a *sibling* to the draggable node. +- `provided.droppableProps (DroppableProps)`: This is an Object that contains properties that need to be applied to a Droppable element. It needs to be applied to the same element that you apply `provided.innerRef` to. It currently contains a `data` attribute that we use to control some non-visible css. + +#### Type information + +```js +// Props that can be spread onto the element directly +export type DroppableProps = {| + // used for shared global styles + 'data-react-beautiful-dnd-droppable': string, +|} +``` ```js {(provided, snapshot) => ( -
+
Good to go {provided.placeholder} @@ -650,7 +753,10 @@ type DroppableProvided = {| ```js type DroppableStateSnapshot = {| + // Is the Droppable being dragged over? isDraggingOver: boolean, + // What is the id of the draggable that is dragging over the Droppable? + draggingOverWith: ?DraggableId, |}; ``` @@ -662,6 +768,7 @@ The `children` function is also provided with a small amount of state relating t
I am a droppable! @@ -730,6 +837,7 @@ class Students extends Component {
{provided.placeholder} @@ -745,16 +853,6 @@ By using the approach you are able to make style changes to a `Droppable` when i Unfortunately we are [unable to apply this optimisation for you](https://medium.com/merrickchristensen/function-as-child-components-5f3920a9ace9). It is a byproduct of using the function-as-child pattern. -### Auto scrolling is not provided (yet!) - -Currently auto scrolling of scroll containers is not part of this library. Auto scrolling is where the container automatically scrolls to make room for the dragging item as you drag near the edge of a scroll container. You are welcome to build your own auto scrolling list, or if you would you really like it as part of this library we could provide a auto scrolling `Droppable`. - -Users will be able to scroll a scroll container while dragging by using their trackpad or mouse wheel. - -### Keyboard dragging limitation - -Getting keyboard dragging to work with scroll containers is quite difficult. Currently there is a limitation: you cannot drag with a keyboard beyond the visible edge of a scroll container. This limitation could be removed if we introduced auto scrolling. Scrolling a container with a mouse during a keyboard drag will cancel the drag. - ## `Draggable` `Draggable` components can be dragged around and dropped onto `Droppable`s. A `Draggable` must always be contained within a `Droppable`. It is **possible** to reorder a `Draggable` within its home `Droppable` or move to another `Droppable`. It is **possible** because a `Droppable` is free to control what it allows to be dropped on it. @@ -815,8 +913,10 @@ The function is provided with two arguments: ```js type DraggableProvided = {| innerRef: (HTMLElement) => void, - draggableProps: ?DraggableProps, + draggableProps: DraggableProps, + // will be null if the draggable is disabled dragHandleProps: ?DragHandleProps, + // null if not required placeholder: ?ReactElement, |} ``` @@ -831,15 +931,15 @@ Everything within the *provided* object must be applied for the `Draggable` to f ; ``` -### Type information +#### Type information ```js innerRef: (HTMLElement) => void ``` -- `provided.draggableProps (?DraggableProps)`: This is an Object that contains a `data` attribute and an inline style. This Object needs to be applied to the same node that you apply `provided.innerRef` to. This controls the movement of the draggable when it is dragging and not dragging. You are welcome to add your own styles to `DraggableProps > style` – but please do not remove or replace any of the properties. +- `provided.draggableProps (DraggableProps)`: This is an Object that contains a `data` attribute and an inline `style`. This Object needs to be applied to the same node that you apply `provided.innerRef` to. This controls the movement of the draggable when it is dragging and not dragging. You are welcome to add your own styles to `DraggableProps > style` – but please do not remove or replace any of the properties. -### Type information +#### Type information ```js // Props that can be spread onto the element directly @@ -1090,11 +1190,13 @@ const myOnClick = event => console.log('clicked on', event.target); ; ``` -#### 2. snapshot: (DraggableStateSnapshot)** +#### 2. Snapshot: (DraggableStateSnapshot)** ```js type DraggableStateSnapshot = {| isDragging: boolean, + // What Droppable (if any) the Draggable is currently over + draggingOver: ?DroppableId, |}; ``` @@ -1155,14 +1257,25 @@ type DroppableId = Id; type DraggableId = Id; // hooks -type DropResult = {| +type DragStart = {| draggableId: DraggableId, type: TypeId, source: DraggableLocation, +|} + +type DragUpdate = {| + ...DragStart, // may not have any destination (drag to nowhere) - destination: ?DraggableLocation + destination: ?DraggableLocation, +|} + +type DropResult = {| + ...DragUpdate, + reason: DropReason, |} +type DropReason = 'DROP' | 'CANCEL' + type DraggableLocation = {| droppableId: DroppableId, // the position of the droppable within a droppable @@ -1177,18 +1290,20 @@ type DroppableProvided = {| type DroppableStateSnapshot = {| isDraggingOver: boolean, + draggingOverWith: ?DraggableId, |} // Draggable type DraggableProvided = {| innerRef: (?HTMLElement) => void, - draggableProps: ?DraggableProps, + draggableProps: DraggableProps, dragHandleProps: ?DragHandleProps, placeholder: ?ReactElement, |} type DraggableStateSnapshot = {| isDragging: boolean, + draggingOver: ?DroppableId, |} export type DraggableProps = {| @@ -1221,7 +1336,7 @@ type DragHandleProvided = {| onTouchStart: (event: TouchEvent) => void, onTouchMove: (event: TouchEvent) => void, tabIndex: number, - 'aria-grabbed': boolean, + 'aria-roledescription': string, draggable: boolean, onDragStart: () => boolean, onDrop: () => boolean @@ -1240,7 +1355,7 @@ import type { DroppableProvided } from 'react-beautiful-dnd'; If you are using [TypeScript](https://www.typescriptlang.org/) you can use the community maintained [DefinitelyTyped type definitions](https://www.npmjs.com/package/@types/react-beautiful-dnd). [Installation instructions](http://definitelytyped.org/). -### Sample application +### Sample application with flow types We have created a [sample application](https://github.com/alexreardon/react-beautiful-dnd-flow-example) which exercises the flowtypes. It is a super simple `React` project based on [`react-create-app`](https://github.com/facebookincubator/create-react-app). You can use this as a reference to see how to set things up correctly. @@ -1258,19 +1373,14 @@ While code coverage is [not a guarantee of code health](https://stackoverflow.co ### Performance -This codebase is designed to be extremely performant - it is part of its DNA. It builds on prior investigations into `React` performance that you can read about [here](https://medium.com/@alexandereardon/performance-optimisations-for-react-applications-b453c597b191) and [here](https://medium.com/@alexandereardon/performance-optimisations-for-react-applications-round-2-2042e5c9af97). It is designed to perform the minimum number of renders required for each task. - -#### Highlights +This codebase is designed to be **extremely performant** - it is part of its DNA. It is designed to perform the smallest amount of updates possible. You can have a read about performance work done for `react-beautiful-dnd` here: -- using connected-components with memoization to ensure the only components that render are the ones that need to - thanks [`react-redux`](https://github.com/reactjs/react-redux), [`reselect`](https://github.com/reactjs/reselect) and [`memoize-one`](https://github.com/alexreardon/memoize-one) -- all movements are throttled with a [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) - thanks [`raf-schd`](https://github.com/alexreardon/raf-schd) -- memoization is used all over the place - thanks [`memoize-one`](https://github.com/alexreardon/memoize-one) -- conditionally disabling [`pointer-events`](https://developer.mozilla.org/en/docs/Web/CSS/pointer-events) on `Draggable`s while dragging to prevent the browser needing to do redundant work - you can read more about the technique [here](https://www.thecssninja.com/css/pointer-events-60fps) -- Non primary animations are done on the GPU +- [Rethinking drag and drop](https://medium.com/@alexandereardon/rethinking-drag-and-drop-d9f5770b4e6b) +- [Dragging React performance forward](https://medium.com/@alexandereardon/dragging-react-performance-forward-688b30d40a33) ## Size -Great care has been taken to keep the library as light as possible. It is currently **~34kb (gzip)** in size. There could be a smaller net cost if you where already using one of the underlying dependencies. +Great care has been taken to keep the library as light as possible. It is currently **~38kb (gzip)** in size. There could be a smaller net cost if you where already using one of the underlying dependencies. ## Supported browsers diff --git a/docs/guides/screen-reader.md b/docs/guides/screen-reader.md new file mode 100644 index 0000000000..b9dea6a989 --- /dev/null +++ b/docs/guides/screen-reader.md @@ -0,0 +1,163 @@ +# Screen reader guide + +`react-beautiful-dnd` ships with great screen reader support in english out of the box 📦! So if you are looking to just get started there is nothing you need to do. + +However, you have total control over all of the messages. This allows you to tailor the messaging for your particular usages as well as for internationalisation purposes. + +This guide has been written to assist you in creating your own messaging that is functional and delights users. It is possible for a user who is using a screen reader to use any input type. However, we have the screen reader experience to be focused on keyboard interactions. + +## Tone + +For the default messages we have gone for a friendly tone. We have also chosen to use personal language; preferring phases such as 'You have dropped the item' over 'Item dropped'. It is a little more wordy but is a friendlier experience. You are welcome to choose your own tone for your messaging. + +## How to control announcements + +The `announce` function that is provided to each of the `DragDropContext` > `Hook` functions can be used to provide your own screen reader message. This message will be immediately read out. In order to provide a fast and responsive experience to users **you must provide this message synchronously**. If you attempt to hold onto the `announce` function and call it later it will not work and will just print a warning to the console. Additionally, if you try to call `announce` twice for the same event then only the first will be read by the screen reader with subsequent calls to `announce` being ignored and a warning printed. + +## Step 1: lift instructions + +When a user `tabs` to a `Draggable` we need to instruct them on how they can start a drag. We do this by using the `aria-roledescription` property on a `drag handle`. + +**Default message**: "Draggable item. Press space bar to lift" + +Things to note: + +- We tell the user that the item is draggable +- We tell the user how they can start a drag + +We do not give all the drag movement instructions at this point as they are not needed until a user starts a drag. + +The **default** message is fairly robust, however, you may prefer to substitute the word "item" for a noun that more closely matches your problem domain, such as "task" or "issue". You may also want to drop the word "item" altogether. + +## Step 2: drag start + +When a user lifts a `Draggable` by using the `spacebar` we want to tell them a few things: + +- they have lifted the item +- what position the item is in +- how to move the item around + +**Default message**: "You have lifted an item in position ${start.source.index + 1}. Use the arrow keys to move, space bar to drop, and escape to cancel." + +By default we do not say they are in position `1 of x`. This is because we do not have access to the size of the list in the current api. We have kept it like this for now to keep the api light and future proof as we move towards virtual lists. You are welcome to add the `1 of x` language yourself if you like! + +You may also want to say what list the item is in and potentially the index of the list. + +Here is an message that has a little more information: + +"You have lifted an item in position ${startPosition} of ${listLength} in the ${listName} list. Use the arrow keys to move, space bar to drop, and escape to cancel." + +You can control the message printed to the user by using the `DragDropContext` > `onDragStart` hook + +```js +onDragStart = (start: DragStart, provided: HookProvided) => { + provided.announce('My super cool message'); +} +``` + +## Step 3: drag movement + +When something changes in response to a user interaction we want to announce the current state of the drag to the user. There are a lot of different things that can happen so we will need a different message for these different stages. + +We can control the announcement by using the `DragDropContext` > `onDragUpdate` hook. + +```js +onDragUpdate = (update: DragUpdate, provided: HookProvided) => { + provided.announce('Update message'); +} +``` + +### Moved in the same list + +In this scenario the user has moved backwards or forwards within the same list. We want to instruct the user what position they are now in. + +**Default message**: "You have moved the item to position ${update.destination.index + 1}". + +You may also want to include `of ${listLength}` in your messaging. + +### Moved into a different list + +In this case we want to tell the user + +- they have moved to a new list +- some information about the new list +- what position they have moved from +- what position they are now in + +**Default message**: "You have moved the item from list ${update.source.droppableId} in position ${update.source.index + 1} to list ${update.destination.droppableId} in position ${update.destination.index + 1}". + +You will probably want to change this messaging to use some friendly text for the name of the droppable. It would also be good to say the size of the lists in the message + +Suggestion: + +"You have moved the item from list ${sourceName} in position ${lastIndex} of ${sourceLength} to list ${destinationName} in position ${newIndex} of ${destinationLength}". + +### Moved over no list + +While this is not possible to do with a keyboard, it is worth having a message for this in case a screen reader user is using a pointer for dragging. + +You will want to simply explain that they are not over a droppable area. + +**Default message**: "You are currently not dragging over a droppable area". + +## Step 3: on drop + +In this phase we give a small summary of what the user has achieved. + +There are two ways a drop can occur. Either the drag was cancelled or the user released the dragging item. You are able to control the messaging for these events using the `DragDropContext` > `onDragEnd` hook. + +### Cancel + +A `DropResult` object has a `reason` property which can either be `DROP` or `CANCEL`. You can use this to announce your cancel message. + +```js +onDragEnd = (result: DropResult, provided: HookProvided) => { + if(result.reason === 'CANCEL') { + provided.announce('Your cancel message'); + return; + } +} +``` + +This announcement should: + +- Inform the user that the drag have been cancelled +- Let the user know where the item has returned to + +**Default message**: "Movement cancelled. The item has returned to its starting position of ${result.source.index + 1}" + +You are also welcome to add information about the size of the list, and the name of the list you have dropped into. + +**Suggestion** "Movement cancelled. The item has returned to list ${listName} to its starting position of ${startPosition} of ${listLength}". + +### Drop in the home list + +This announcement should: + +- Inform the user that they have completed the drag +- Let them know what position the item is in now + +**Default message**: "You have dropped the item. It has moved from position ${result.source.index + 1} to ${result.destination.index + 1}" + +You may also want to provide a different message if they drop in the same position that they started in. + +**Default message**: "You have dropped the item. It has been dropped on its starting position of ${result.source.index + 1}" + +### Drop in a foreign list + +The messaging for this scenario should be similar to that of dropping in the home list, with the additional information of what list the item started in and where it finished. + +**Default message**: "You have dropped the item. It has moved from position ${result.source.index + 1} in list ${result.source.droppableId} to position ${result.destination.index + 1} in list ${result.destination.droppableId}" + +You may want to extend this to include the name of the `Droppable` rather than the id. Also, if your `Droppable`s are ordered you may also want to include some positioning information. + +### Drop on no destination + +It is possible for a user to drop on no Droppable. This is not possible to do with a keyboard. However, if a user is using a pointer input such as a mouse. Our messaging is geared towards keyboard usage. However, it is a good idea to provide messaging for this scenario also. + +In this message you should: + +- Let the user know that they dropped while not over a droppable location +- Let them know where the item has returned to + +**Default message**: "The item has been dropped while not over a droppable location. The item has returned to its starting position of ${result.source.index + 1}" diff --git a/src/index.js b/src/index.js index 049b645063..a5fa1f98e6 100644 --- a/src/index.js +++ b/src/index.js @@ -16,14 +16,21 @@ export type { // Hooks DragStart, + DragUpdate, DropResult, + HookProvided, + Announce, DraggableLocation, + OnDragStartHook, + OnDragUpdateHook, + OnDragEndHook, } from './types'; // Droppable export type { Provided as DroppableProvided, StateSnapshot as DroppableStateSnapshot, + DroppableProps, } from './view/droppable/droppable-types'; // Draggable diff --git a/src/state/action-creators.js b/src/state/action-creators.js index e2430907a8..f38c92139c 100644 --- a/src/state/action-creators.js +++ b/src/state/action-creators.js @@ -11,12 +11,15 @@ import type { Position, Dispatch, State, - DropTrigger, CurrentDrag, InitialDrag, DraggableDescriptor, + LiftRequest, + AutoScrollMode, + ScrollOptions, } from '../types'; import noImpact from './no-impact'; +import withDroppableDisplacement from './with-droppable-displacement'; import getNewHomeClientCenter from './get-new-home-client-center'; import { add, subtract, isEqual } from './position'; @@ -38,21 +41,21 @@ const getScrollDiff = ({ current.windowScroll ); - const droppableScrollDiff: Position = droppable ? - droppable.viewport.frameScroll.diff.displacement : - origin; + if (!droppable) { + return windowScrollDiff; + } - return add(windowScrollDiff, droppableScrollDiff); + return withDroppableDisplacement(droppable, windowScrollDiff); }; export type RequestDimensionsAction = {| type: 'REQUEST_DIMENSIONS', - payload: DraggableId, + payload: LiftRequest, |} -export const requestDimensions = (id: DraggableId): RequestDimensionsAction => ({ +export const requestDimensions = (request: LiftRequest): RequestDimensionsAction => ({ type: 'REQUEST_DIMENSIONS', - payload: id, + payload: request, }); export type CompleteLiftAction = {| @@ -61,7 +64,7 @@ export type CompleteLiftAction = {| id: DraggableId, client: InitialDragPositions, windowScroll: Position, - isScrollAllowed: boolean, + autoScrollMode: AutoScrollMode, |} |} @@ -69,39 +72,58 @@ export const completeLift = ( id: DraggableId, client: InitialDragPositions, windowScroll: Position, - isScrollAllowed: boolean, + autoScrollMode: AutoScrollMode, ): CompleteLiftAction => ({ type: 'COMPLETE_LIFT', payload: { id, client, windowScroll, - isScrollAllowed, + autoScrollMode, }, }); -export type PublishDraggableDimensionsAction = {| - type: 'PUBLISH_DRAGGABLE_DIMENSIONS', - payload: DraggableDimension[] +export type PublishDraggableDimensionAction = {| + type: 'PUBLISH_DRAGGABLE_DIMENSION', + payload: DraggableDimension, |} -export const publishDraggableDimensions = - (dimensions: DraggableDimension[]): PublishDraggableDimensionsAction => ({ - type: 'PUBLISH_DRAGGABLE_DIMENSIONS', - payload: dimensions, +export const publishDraggableDimension = + (dimension: DraggableDimension): PublishDraggableDimensionAction => ({ + type: 'PUBLISH_DRAGGABLE_DIMENSION', + payload: dimension, }); -export type PublishDroppableDimensionsAction = {| - type: 'PUBLISH_DROPPABLE_DIMENSIONS', - payload: DroppableDimension[] +export type PublishDroppableDimensionAction = {| + type: 'PUBLISH_DROPPABLE_DIMENSION', + payload: DroppableDimension, |} -export const publishDroppableDimensions = - (dimensions: DroppableDimension[]): PublishDroppableDimensionsAction => ({ - type: 'PUBLISH_DROPPABLE_DIMENSIONS', - payload: dimensions, +export const publishDroppableDimension = + (dimension: DroppableDimension): PublishDroppableDimensionAction => ({ + type: 'PUBLISH_DROPPABLE_DIMENSION', + payload: dimension, }); +export type BulkPublishDimensionsAction = {| + type: 'BULK_DIMENSION_PUBLISH', + payload: {| + droppables: DroppableDimension[], + draggables: DraggableDimension[], + |} +|} + +export const bulkPublishDimensions = ( + droppables: DroppableDimension[], + draggables: DraggableDimension[], +): BulkPublishDimensionsAction => ({ + type: 'BULK_DIMENSION_PUBLISH', + payload: { + droppables, + draggables, + }, +}); + export type UpdateDroppableDimensionScrollAction = {| type: 'UPDATE_DROPPABLE_DIMENSION_SCROLL', payload: { @@ -142,17 +164,22 @@ export type MoveAction = {| id: DraggableId, client: Position, windowScroll: Position, + shouldAnimate: boolean, |} |} -export const move = (id: DraggableId, +export const move = ( + id: DraggableId, client: Position, - windowScroll: Position): MoveAction => ({ + windowScroll: Position, + shouldAnimate?: boolean = false, +): MoveAction => ({ type: 'MOVE', payload: { id, client, windowScroll, + shouldAnimate, }, }); @@ -236,7 +263,6 @@ export const prepare = (): PrepareAction => ({ export type DropAnimateAction = { type: 'DROP_ANIMATE', payload: {| - trigger: DropTrigger, newHomeOffset: Position, impact: DragImpact, result: DropResult, @@ -244,21 +270,18 @@ export type DropAnimateAction = { } type AnimateDropArgs = {| - trigger: DropTrigger, newHomeOffset: Position, impact: DragImpact, result: DropResult |} const animateDrop = ({ - trigger, newHomeOffset, impact, result, }: AnimateDropArgs): DropAnimateAction => ({ type: 'DROP_ANIMATE', payload: { - trigger, newHomeOffset, impact, result, @@ -316,6 +339,7 @@ export const drop = () => type: home.descriptor.type, source, destination: impact.destination, + reason: 'DROP', }; const newCenter: Position = getNewHomeClientCenter({ @@ -347,7 +371,6 @@ export const drop = () => } dispatch(animateDrop({ - trigger: 'DROP', newHomeOffset, impact, result, @@ -385,6 +408,7 @@ export const cancel = () => source, // no destination when cancelling destination: null, + reason: 'CANCEL', }; const isAnimationRequired = !isEqual(current.client.offset, origin); @@ -397,7 +421,6 @@ export const cancel = () => const scrollDiff: Position = getScrollDiff({ initial, current, droppable: home }); dispatch(animateDrop({ - trigger: 'CANCEL', newHomeOffset: scrollDiff, impact: noImpact, result, @@ -429,7 +452,7 @@ export type LiftAction = {| id: DraggableId, client: InitialDragPositions, windowScroll: Position, - isScrollAllowed: boolean, + autoScrollMode: AutoScrollMode, |} |} @@ -438,7 +461,7 @@ export type LiftAction = {| export const lift = (id: DraggableId, client: InitialDragPositions, windowScroll: Position, - isScrollAllowed: boolean, + autoScrollMode: AutoScrollMode, ) => (dispatch: Dispatch, getState: Function) => { // Phase 1: Quickly finish any current drop animations const initial: State = getState(); @@ -469,7 +492,14 @@ export const lift = (id: DraggableId, } // will communicate with the marshal to start requesting dimensions - dispatch(requestDimensions(id)); + const scrollOptions: ScrollOptions = { + shouldPublishImmediately: autoScrollMode === 'JUMP', + }; + const request: LiftRequest = { + draggableId: id, + scrollOptions, + }; + dispatch(requestDimensions(request)); // Need to allow an opportunity for the dimensions to be requested. setTimeout(() => { @@ -481,7 +511,7 @@ export const lift = (id: DraggableId, return; } - dispatch(completeLift(id, client, windowScroll, isScrollAllowed)); + dispatch(completeLift(id, client, windowScroll, autoScrollMode)); }); }); }; @@ -489,8 +519,9 @@ export const lift = (id: DraggableId, export type Action = CompleteLiftAction | RequestDimensionsAction | - PublishDraggableDimensionsAction | - PublishDroppableDimensionsAction | + PublishDraggableDimensionAction | + PublishDroppableDimensionAction | + BulkPublishDimensionsAction | UpdateDroppableDimensionScrollAction | UpdateDroppableDimensionIsEnabledAction | MoveByWindowScrollAction | diff --git a/src/state/auto-scroller/auto-scroller-types.js b/src/state/auto-scroller/auto-scroller-types.js new file mode 100644 index 0000000000..b638b9ad3d --- /dev/null +++ b/src/state/auto-scroller/auto-scroller-types.js @@ -0,0 +1,6 @@ +// @flow +import type { State } from '../../types'; + +export type AutoScroller = {| + onStateChange: (previous: State, current: State) => void, +|} diff --git a/src/state/auto-scroller/can-scroll.js b/src/state/auto-scroller/can-scroll.js new file mode 100644 index 0000000000..ab0eaeb53d --- /dev/null +++ b/src/state/auto-scroller/can-scroll.js @@ -0,0 +1,178 @@ +// @flow +import { add, apply, isEqual } from '../position'; +import getWindowScroll from '../../window/get-window-scroll'; +import getViewport from '../../window/get-viewport'; +import getMaxScroll from '../get-max-scroll'; +import type { + ClosestScrollable, + DroppableDimension, + Position, + Area, +} from '../../types'; + +type CanScrollArgs = {| + max: Position, + current: Position, + change: Position, +|} + +const origin: Position = { x: 0, y: 0 }; + +const smallestSigned = apply((value: number) => { + if (value === 0) { + return 0; + } + return value > 0 ? 1 : -1; +}); + +type GetRemainderArgs = {| + current: Position, + max: Position, + change: Position, +|} + +// We need to figure out how much of the movement +// cannot be done with a scroll +export const getOverlap = (() => { + const getRemainder = (target: number, max: number): number => { + if (target < 0) { + return target; + } + if (target > max) { + return target - max; + } + return 0; + }; + + return ({ + current, + max, + change, + }: GetRemainderArgs): ?Position => { + const targetScroll: Position = add(current, change); + + const overlap: Position = { + x: getRemainder(targetScroll.x, max.x), + y: getRemainder(targetScroll.y, max.y), + }; + + if (isEqual(overlap, origin)) { + return null; + } + + return overlap; + }; +})(); + +export const canPartiallyScroll = ({ + max, + current, + change, +}: CanScrollArgs): boolean => { + // Only need to be able to move the smallest amount in the desired direction + const smallestChange: Position = smallestSigned(change); + + const overlap: ?Position = getOverlap({ + max, current, change: smallestChange, + }); + + // no overlap at all - we can move there! + if (!overlap) { + return true; + } + + // if there was an x value, but there is no x overlap - then we can scroll on the x! + if (smallestChange.x !== 0 && overlap.x === 0) { + return true; + } + + // if there was an y value, but there is no y overlap - then we can scroll on the y! + if (smallestChange.y !== 0 && overlap.y === 0) { + return true; + } + + return false; +}; + +const getMaxWindowScroll = (): Position => { + const el: ?HTMLElement = document.documentElement; + + if (!el) { + return origin; + } + + const viewport: Area = getViewport(); + + // window.innerWidth / innerHeight includes scrollbar + // however the scrollHeight / scrollWidth do not :( + + const maxScroll: Position = getMaxScroll({ + scrollHeight: el.scrollHeight, + scrollWidth: el.scrollWidth, + width: viewport.width, + height: viewport.height, + }); + + return maxScroll; +}; + +export const canScrollWindow = (change: Position): boolean => { + const maxScroll: Position = getMaxWindowScroll(); + const currentScroll: Position = getWindowScroll(); + + return canPartiallyScroll({ + current: currentScroll, + max: maxScroll, + change, + }); +}; + +export const canScrollDroppable = ( + droppable: DroppableDimension, + change: Position, +): boolean => { + const closestScrollable: ?ClosestScrollable = droppable.viewport.closestScrollable; + // Cannot scroll when there is no scroll container! + if (!closestScrollable) { + return false; + } + + return canPartiallyScroll({ + current: closestScrollable.scroll.current, + max: closestScrollable.scroll.max, + change, + }); +}; + +export const getWindowOverlap = (change: Position): ?Position => { + if (!canScrollWindow(change)) { + return null; + } + + const max: Position = getMaxWindowScroll(); + const current: Position = getWindowScroll(); + + return getOverlap({ + current, + max, + change, + }); +}; + +export const getDroppableOverlap = (droppable: DroppableDimension, change: Position): ?Position => { + if (!canScrollDroppable(droppable, change)) { + return null; + } + + const closestScrollable: ?ClosestScrollable = droppable.viewport.closestScrollable; + // Cannot scroll when there is no scroll container! + if (!closestScrollable) { + return null; + } + + return getOverlap({ + current: closestScrollable.scroll.current, + max: closestScrollable.scroll.max, + change, + }); +}; diff --git a/src/state/auto-scroller/fluid-scroller.js b/src/state/auto-scroller/fluid-scroller.js new file mode 100644 index 0000000000..6e1bbe5a84 --- /dev/null +++ b/src/state/auto-scroller/fluid-scroller.js @@ -0,0 +1,334 @@ +// @flow +import rafSchd from 'raf-schd'; +import getViewport from '../../window/get-viewport'; +import { add, apply, isEqual, patch } from '../position'; +import getBestScrollableDroppable from './get-best-scrollable-droppable'; +import { horizontal, vertical } from '../axis'; +import { + canScrollWindow, + canPartiallyScroll, +} from './can-scroll'; +import type { + Area, + Axis, + Spacing, + DroppableId, + DragState, + DroppableDimension, + Position, + State, + DraggableDimension, + ClosestScrollable, +} from '../../types'; + +// Values used to control how the fluid auto scroll feels +export const config = { + // percentage distance from edge of container: + startFrom: 0.25, + maxSpeedAt: 0.05, + // pixels per frame + maxScrollSpeed: 28, + // A function used to ease the distance been the startFrom and maxSpeedAt values + // A simple linear function would be: (percentage) => percentage; + // percentage is between 0 and 1 + // result must be between 0 and 1 + ease: (percentage: number) => Math.pow(percentage, 2), +}; + +const origin: Position = { x: 0, y: 0 }; + +// will replace -0 and replace with +0 +const clean = apply((value: number) => (value === 0 ? 0 : value)); + +export type PixelThresholds = {| + startFrom: number, + maxSpeedAt: number, + accelerationPlane: number, +|} + +// converts the percentages in the config into actual pixel values +export const getPixelThresholds = (container: Area, axis: Axis): PixelThresholds => { + const startFrom: number = container[axis.size] * config.startFrom; + const maxSpeedAt: number = container[axis.size] * config.maxSpeedAt; + const accelerationPlane: number = startFrom - maxSpeedAt; + + const thresholds: PixelThresholds = { + startFrom, + maxSpeedAt, + accelerationPlane, + }; + + return thresholds; +}; + +const getSpeed = (distance: number, thresholds: PixelThresholds): number => { + // Not close enough to the edge + if (distance >= thresholds.startFrom) { + return 0; + } + + // Already past the maxSpeedAt point + + if (distance <= thresholds.maxSpeedAt) { + return config.maxScrollSpeed; + } + + // We need to perform a scroll as a percentage of the max scroll speed + + const distancePastStart: number = thresholds.startFrom - distance; + const percentage: number = distancePastStart / thresholds.accelerationPlane; + const transformed: number = config.ease(percentage); + + const speed: number = config.maxScrollSpeed * transformed; + + return speed; +}; + +type AdjustForSizeLimitsArgs = {| + container: Area, + subject: Area, + proposedScroll: Position, +|} + +const adjustForSizeLimits = ({ + container, + subject, + proposedScroll, +}: AdjustForSizeLimitsArgs): ?Position => { + const isTooBigVertically: boolean = subject.height > container.height; + const isTooBigHorizontally: boolean = subject.width > container.width; + + // not too big on any axis + if (!isTooBigHorizontally && !isTooBigVertically) { + return proposedScroll; + } + + // too big on both axis + if (isTooBigHorizontally && isTooBigVertically) { + return null; + } + + // Only too big on one axis + // Exclude the axis that we cannot scroll on + return { + x: isTooBigHorizontally ? 0 : proposedScroll.x, + y: isTooBigVertically ? 0 : proposedScroll.y, + }; +}; + +type GetRequiredScrollArgs = {| + container: Area, + subject: Area, + center: Position, +|} + +// returns null if no scroll is required +const getRequiredScroll = ({ container, subject, center }: GetRequiredScrollArgs): ?Position => { + // get distance to each edge + const distance: Spacing = { + top: center.y - container.top, + right: container.right - center.x, + bottom: container.bottom - center.y, + left: center.x - container.left, + }; + + // 1. Figure out which x,y values are the best target + // 2. Can the container scroll in that direction at all? + // If no for both directions, then return null + // 3. Is the center close enough to a edge to start a drag? + // 4. Based on the distance, calculate the speed at which a scroll should occur + // The lower distance value the faster the scroll should be. + // Maximum speed value should be hit before the distance is 0 + // Negative values to not continue to increase the speed + + const y: number = (() => { + const thresholds: PixelThresholds = getPixelThresholds(container, vertical); + const isCloserToBottom: boolean = distance.bottom < distance.top; + + if (isCloserToBottom) { + return getSpeed(distance.bottom, thresholds); + } + + // closer to top + return -1 * getSpeed(distance.top, thresholds); + })(); + + const x: number = (() => { + const thresholds: PixelThresholds = getPixelThresholds(container, horizontal); + const isCloserToRight: boolean = distance.right < distance.left; + + if (isCloserToRight) { + return getSpeed(distance.right, thresholds); + } + + // closer to left + return -1 * getSpeed(distance.left, thresholds); + })(); + + const required: Position = clean({ x, y }); + + // nothing required + if (isEqual(required, origin)) { + return null; + } + + // need to not scroll in a direction that we are too big to scroll in + const limited: ?Position = adjustForSizeLimits({ + container, + subject, + proposedScroll: required, + }); + + if (!limited) { + return null; + } + + return isEqual(limited, origin) ? null : limited; +}; + +type WithPlaceholderResult = {| + current: Position, + max: Position, +|} + +const withPlaceholder = ( + droppable: DroppableDimension, + draggable: DraggableDimension, +): ?WithPlaceholderResult => { + const closest: ?ClosestScrollable = droppable.viewport.closestScrollable; + + if (!closest) { + return null; + } + + const isOverHome: boolean = droppable.descriptor.id === draggable.descriptor.droppableId; + const max: Position = closest.scroll.max; + const current: Position = closest.scroll.current; + + // only need to add the buffer for foreign lists + if (isOverHome) { + return { max, current }; + } + + const spaceForPlaceholder: Position = patch( + droppable.axis.line, + draggable.placeholder.withoutMargin[droppable.axis.size] + ); + + const newMax: Position = add(max, spaceForPlaceholder); + // because we are pulling the max forward, on subsequent updates + // it is possible for the current position to be greater than the max + // as such we need to ensure that the current position is never bigger + // than the max position + const newCurrent: Position = { + x: Math.min(current.x, newMax.x), + y: Math.min(current.y, newMax.y), + }; + + return { + max: newMax, + current: newCurrent, + }; +}; + +type Api = {| + scrollWindow: (offset: Position) => void, + scrollDroppable: (id: DroppableId, offset: Position) => void, +|} + +type ResultFn = (state: State) => void; +type ResultCancel = { cancel: () => void }; + +export type FluidScroller = ResultFn & ResultCancel; + +export default ({ + scrollWindow, + scrollDroppable, +}: Api): FluidScroller => { + const scheduleWindowScroll = rafSchd(scrollWindow); + const scheduleDroppableScroll = rafSchd(scrollDroppable); + + const scroller = (state: State): void => { + const drag: ?DragState = state.drag; + if (!drag) { + console.error('Invalid drag state'); + return; + } + + const center: Position = drag.current.page.center; + + // 1. Can we scroll the viewport? + + const draggable: DraggableDimension = state.dimension.draggable[drag.initial.descriptor.id]; + const subject: Area = draggable.page.withMargin; + const viewport: Area = getViewport(); + const requiredWindowScroll: ?Position = getRequiredScroll({ + container: viewport, + subject, + center, + }); + + if (requiredWindowScroll && canScrollWindow(requiredWindowScroll)) { + scheduleWindowScroll(requiredWindowScroll); + return; + } + + // 2. We are not scrolling the window. Can we scroll a Droppable? + + const droppable: ?DroppableDimension = getBestScrollableDroppable({ + center, + destination: drag.impact.destination, + droppables: state.dimension.droppable, + }); + + // No scrollable targets + if (!droppable) { + return; + } + + // We know this has a closestScrollable + const closestScrollable: ?ClosestScrollable = droppable.viewport.closestScrollable; + + // this should never happen - just being safe + if (!closestScrollable) { + return; + } + + const requiredFrameScroll: ?Position = getRequiredScroll({ + container: closestScrollable.frame, + subject, + center, + }); + + if (!requiredFrameScroll) { + return; + } + + // need to adjust the current and max scroll positions to account for placeholders + const result: ?WithPlaceholderResult = withPlaceholder(droppable, draggable); + + if (!result) { + return; + } + + // using the can partially scroll function directly as we want to control + // the current and max values without modifying the droppable + const canScrollDroppable: boolean = canPartiallyScroll({ + max: result.max, + current: result.current, + change: requiredFrameScroll, + }); + + if (canScrollDroppable) { + scheduleDroppableScroll(droppable.descriptor.id, requiredFrameScroll); + } + }; + + scroller.cancel = () => { + scheduleWindowScroll.cancel(); + scheduleDroppableScroll.cancel(); + }; + + return scroller; +}; + diff --git a/src/state/auto-scroller/get-best-scrollable-droppable.js b/src/state/auto-scroller/get-best-scrollable-droppable.js new file mode 100644 index 0000000000..344d53068a --- /dev/null +++ b/src/state/auto-scroller/get-best-scrollable-droppable.js @@ -0,0 +1,77 @@ +// @flow +import memoizeOne from 'memoize-one'; +import isPositionInFrame from '../visibility/is-position-in-frame'; +import type { + Position, + DroppableId, + DroppableDimension, + DroppableDimensionMap, + DraggableLocation, +} from '../../types'; + +const getScrollableDroppables = memoizeOne( + (droppables: DroppableDimensionMap): DroppableDimension[] => ( + Object.keys(droppables) + .map((id: DroppableId): DroppableDimension => droppables[id]) + .filter((droppable: DroppableDimension): boolean => { + // exclude disabled droppables + if (!droppable.isEnabled) { + return false; + } + + // only want droppables that are scrollable + if (!droppable.viewport.closestScrollable) { + return false; + } + + return true; + }) + ) +); + +const getScrollableDroppableOver = ( + target: Position, + droppables: DroppableDimensionMap +): ?DroppableDimension => { + const maybe: ?DroppableDimension = + getScrollableDroppables(droppables) + .find((droppable: DroppableDimension): boolean => { + if (!droppable.viewport.closestScrollable) { + throw new Error('Invalid result'); + } + return isPositionInFrame(droppable.viewport.closestScrollable.frame)(target); + }); + + return maybe; +}; + +type Api = {| + center: Position, + destination: ?DraggableLocation, + droppables: DroppableDimensionMap, +|} + +export default ({ + center, + destination, + droppables, +}: Api): ?DroppableDimension => { + // We need to scroll the best droppable frame we can so that the + // placeholder buffer logic works correctly + + if (destination) { + const dimension: DroppableDimension = droppables[destination.droppableId]; + if (!dimension.viewport.closestScrollable) { + return null; + } + return dimension; + } + + // 2. If we are not over a droppable - are we over a droppable frame? + const dimension: ?DroppableDimension = getScrollableDroppableOver( + center, + droppables, + ); + + return dimension; +}; diff --git a/src/state/auto-scroller/index.js b/src/state/auto-scroller/index.js new file mode 100644 index 0000000000..2754c28e50 --- /dev/null +++ b/src/state/auto-scroller/index.js @@ -0,0 +1,74 @@ +// @flow +import createFluidScroller, { type FluidScroller } from './fluid-scroller'; +import createJumpScroller, { type JumpScroller } from './jump-scroller'; +import type { AutoScroller } from './auto-scroller-types'; +import type { + DraggableId, + DroppableId, + Position, + State, +} from '../../types'; + +type Args = {| + scrollDroppable: (id: DroppableId, change: Position) => void, + scrollWindow: (change: Position) => void, + move: ( + id: DraggableId, + client: Position, + windowScroll: Position, + shouldAnimate?: boolean + ) => void, +|} + +export default ({ + scrollDroppable, + scrollWindow, + move, +}: Args): AutoScroller => { + const fluidScroll: FluidScroller = createFluidScroller({ + scrollWindow, + scrollDroppable, + }); + + const jumpScroll: JumpScroller = createJumpScroller({ + move, + scrollWindow, + scrollDroppable, + }); + + const onStateChange = (previous: State, current: State): void => { + // now dragging + if (current.phase === 'DRAGGING') { + if (!current.drag) { + console.error('invalid drag state'); + return; + } + + if (current.drag.initial.autoScrollMode === 'FLUID') { + fluidScroll(current); + return; + } + + // autoScrollMode == 'JUMP' + + if (!current.drag.scrollJumpRequest) { + return; + } + + jumpScroll(current); + return; + } + + // cancel any pending scrolls if no longer dragging + if (previous.phase === 'DRAGGING' && current.phase !== 'DRAGGING') { + fluidScroll.cancel(); + } + }; + + const marshal: AutoScroller = { + onStateChange, + }; + + return marshal; +}; + diff --git a/src/state/auto-scroller/jump-scroller.js b/src/state/auto-scroller/jump-scroller.js new file mode 100644 index 0000000000..33d0054bbd --- /dev/null +++ b/src/state/auto-scroller/jump-scroller.js @@ -0,0 +1,144 @@ +// @flow +import { add, subtract } from '../position'; +import getWindowScroll from '../../window/get-window-scroll'; +import { + canScrollDroppable, + canScrollWindow, + getWindowOverlap, + getDroppableOverlap, +} from './can-scroll'; +import type { + DraggableId, + DroppableId, + DragState, + DroppableDimension, + Position, + State, + DraggableLocation, +} from '../../types'; + +type Args = {| + scrollDroppable: (id: DroppableId, offset: Position) => void, + scrollWindow: (offset: Position) => void, + move: ( + id: DraggableId, + client: Position, + windowScroll: Position, + shouldAnimate?: boolean + ) => void, +|} + +export type JumpScroller = (state: State) => void; + +type Remainder = Position; + +export default ({ + move, + scrollDroppable, + scrollWindow, +}: Args): JumpScroller => { + const moveByOffset = (state: State, offset: Position) => { + const drag: ?DragState = state.drag; + if (!drag) { + console.error('Cannot move by offset when not dragging'); + return; + } + + const client: Position = add(drag.current.client.selection, offset); + move(drag.initial.descriptor.id, client, getWindowScroll(), true); + }; + + const scrollDroppableAsMuchAsItCan = ( + droppable: DroppableDimension, + change: Position + ): ?Remainder => { + // Droppable cannot absorb any of the scroll + if (!canScrollDroppable(droppable, change)) { + return change; + } + + const overlap: ?Position = getDroppableOverlap(droppable, change); + + // Droppable can absorb the entire change + if (!overlap) { + scrollDroppable(droppable.descriptor.id, change); + return null; + } + + // Droppable can only absorb a part of the change + const whatTheDroppableCanScroll: Position = subtract(change, overlap); + scrollDroppable(droppable.descriptor.id, whatTheDroppableCanScroll); + + const remainder: Position = subtract(change, whatTheDroppableCanScroll); + return remainder; + }; + + const scrollWindowAsMuchAsItCan = (change: Position): ?Position => { + // window cannot absorb any of the scroll + if (!canScrollWindow(change)) { + return change; + } + + const overlap: ?Position = getWindowOverlap(change); + + // window can absorb entire scroll + if (!overlap) { + scrollWindow(change); + return null; + } + + // window can only absorb a part of the scroll + const whatTheWindowCanScroll: Position = subtract(change, overlap); + scrollWindow(whatTheWindowCanScroll); + + const remainder: Position = subtract(change, whatTheWindowCanScroll); + return remainder; + }; + + const jumpScroller: JumpScroller = (state: State) => { + const drag: ?DragState = state.drag; + + if (!drag) { + return; + } + + const request: ?Position = drag.scrollJumpRequest; + + if (!request) { + return; + } + + const destination: ?DraggableLocation = drag.impact.destination; + + if (!destination) { + console.error('Cannot perform a jump scroll when there is no destination'); + return; + } + + // 1. We scroll the droppable first if we can to avoid the draggable + // leaving the list + + const droppableRemainder: ?Position = scrollDroppableAsMuchAsItCan( + state.dimension.droppable[destination.droppableId], + request, + ); + + // droppable absorbed the entire scroll + if (!droppableRemainder) { + return; + } + + const windowRemainder: ?Position = scrollWindowAsMuchAsItCan(droppableRemainder); + + // window could absorb all the droppable remainder + if (!windowRemainder) { + return; + } + + // The entire scroll could not be absorbed by the droppable and window + // so we manually move whatever is left + moveByOffset(state, windowRemainder); + }; + + return jumpScroller; +}; diff --git a/src/state/axis.js b/src/state/axis.js index e3bdec7dbb..79e605efe6 100644 --- a/src/state/axis.js +++ b/src/state/axis.js @@ -4,7 +4,7 @@ import type { HorizontalAxis, VerticalAxis } from '../types'; export const vertical: VerticalAxis = { direction: 'vertical', line: 'y', - crossLine: 'x', + crossAxisLine: 'x', start: 'top', end: 'bottom', size: 'height', @@ -16,7 +16,7 @@ export const vertical: VerticalAxis = { export const horizontal: HorizontalAxis = { direction: 'horizontal', line: 'x', - crossLine: 'y', + crossAxisLine: 'y', start: 'left', end: 'right', size: 'width', diff --git a/src/state/can-start-drag.js b/src/state/can-start-drag.js index f761d82d6d..d67aafd27f 100644 --- a/src/state/can-start-drag.js +++ b/src/state/can-start-drag.js @@ -38,7 +38,7 @@ export default (state: State, id: DraggableId): boolean => { // if dropping - allow lifting // if cancelling - disallow lifting - return state.drop.pending.trigger === 'DROP'; + return state.drop.pending.result.reason === 'DROP'; } // this should not happen diff --git a/src/state/create-store.js b/src/state/create-store.js index c7aca34eda..0fb84d4399 100644 --- a/src/state/create-store.js +++ b/src/state/create-store.js @@ -18,7 +18,9 @@ export default (): Store => createStore( applyMiddleware( thunk, // debugging logger - // require('./log-middleware').default, + // require('./debug-middleware/log-middleware').default, + // debugging timer + // require('./debug-middleware/timing-middleware').default, ), ), ); diff --git a/src/state/log-middleware.js b/src/state/debug-middleware/log-middleware.js similarity index 87% rename from src/state/log-middleware.js rename to src/state/debug-middleware/log-middleware.js index 22db0159be..fc9184625c 100644 --- a/src/state/log-middleware.js +++ b/src/state/debug-middleware/log-middleware.js @@ -1,6 +1,6 @@ // @flow /* eslint-disable no-console */ -import type { Store, Action, State } from '../types'; +import type { Store, Action, State } from '../../types'; export default (store: Store) => (next: (Action) => mixed) => (action: Action): mixed => { console.group(`action: ${action.type}`); diff --git a/src/state/debug-middleware/timing-middleware.js b/src/state/debug-middleware/timing-middleware.js new file mode 100644 index 0000000000..b074235cf9 --- /dev/null +++ b/src/state/debug-middleware/timing-middleware.js @@ -0,0 +1,14 @@ +// @flow +/* eslint-disable no-console */ +import type { Action } from '../../types'; + +export default () => (next: (Action) => mixed) => (action: Action): mixed => { + const key = `action: ${action.type}`; + console.time(key); + + const result: mixed = next(action); + + console.timeEnd(key); + + return result; +}; diff --git a/src/state/dimension-marshal/dimension-marshal-types.js b/src/state/dimension-marshal/dimension-marshal-types.js index cc6ff72e22..c144bed584 100644 --- a/src/state/dimension-marshal/dimension-marshal-types.js +++ b/src/state/dimension-marshal/dimension-marshal-types.js @@ -8,6 +8,7 @@ import type { DroppableId, State, Position, + ScrollOptions, } from '../../types'; export type GetDraggableDimensionFn = () => DraggableDimension; @@ -15,10 +16,12 @@ export type GetDroppableDimensionFn = () => DroppableDimension; export type DroppableCallbacks = {| getDimension: GetDroppableDimensionFn, + // scroll a droppable + scroll: (change: Position) => void, // Droppable must listen to scroll events and publish them using the // onChange callback. If the Droppable is not in a scroll container then // it does not need to do anything - watchScroll: () => void, + watchScroll: (options: ScrollOptions) => void, // If the Droppable is listening for scrol events - it needs to stop! // This may be called even if watchScroll was not previously called unwatchScroll: () => void, @@ -56,6 +59,7 @@ export type DimensionMarshal = {| // it is possible for a droppable to change whether it is enabled during a drag updateDroppableIsEnabled: (id: DroppableId, isEnabled: boolean) => void, updateDroppableScroll: (id: DroppableId, newScroll: Position) => void, + scrollDroppable: (id: DroppableId, change: Position) => void, unregisterDroppable: (descriptor: DroppableDescriptor) => void, // Entry onPhaseChange: (current: State) => void, @@ -63,8 +67,12 @@ export type DimensionMarshal = {| export type Callbacks = {| cancel: () => void, - publishDraggables: (DraggableDimension[]) => void, - publishDroppables: (DroppableDimension[]) => void, + publishDraggable: (DraggableDimension) => void, + publishDroppable: (DroppableDimension) => void, + bulkPublish: ( + droppables: DroppableDimension[], + draggables: DraggableDimension[], + ) => void, updateDroppableScroll: (id: DroppableId, newScroll: Position) => void, updateDroppableIsEnabled: (id: DroppableId, isEnabled: boolean) => void, |} diff --git a/src/state/dimension-marshal/dimension-marshal.js b/src/state/dimension-marshal/dimension-marshal.js index d65c793679..0065ede53a 100644 --- a/src/state/dimension-marshal/dimension-marshal.js +++ b/src/state/dimension-marshal/dimension-marshal.js @@ -9,6 +9,8 @@ import type { State as AppState, Phase, Position, + LiftRequest, + ScrollOptions, } from '../../types'; import type { DimensionMarshal, @@ -27,14 +29,16 @@ type State = {| // long lived droppables: DroppableEntryMap, draggables: DraggableEntryMap, + // short lived isCollecting: boolean, - request: ?DraggableId, + scrollOptions: ?ScrollOptions, + request: ?LiftRequest, frameId: ?number, |} type ToBePublished = {| - draggables: DraggableDimension[], droppables: DroppableDimension[], + draggables: DraggableDimension[], |} export default (callbacks: Callbacks) => { @@ -42,6 +46,7 @@ export default (callbacks: Callbacks) => { droppables: {}, draggables: {}, isCollecting: false, + scrollOptions: null, request: null, frameId: null, }; @@ -156,6 +161,19 @@ export default (callbacks: Callbacks) => { callbacks.updateDroppableScroll(id, newScroll); }; + const scrollDroppable = (id: DroppableId, change: Position) => { + const entry: ?DroppableEntry = state.droppables[id]; + if (!entry) { + return; + } + + if (!state.isCollecting) { + return; + } + + entry.callbacks.scroll(change); + }; + const unregisterDraggable = (descriptor: DraggableDescriptor) => { const entry: ?DraggableEntry = state.draggables[descriptor.id]; @@ -224,14 +242,14 @@ export default (callbacks: Callbacks) => { const getToBeCollected = (): UnknownDescriptorType[] => { const draggables: DraggableEntryMap = state.draggables; const droppables: DroppableEntryMap = state.droppables; - const request: ?DraggableId = state.request; + const request: ?LiftRequest = state.request; if (!request) { console.error('cannot find request in state'); return []; } - - const descriptor: DraggableDescriptor = draggables[request].descriptor; + const draggableId: DraggableId = request.draggableId; + const descriptor: DraggableDescriptor = draggables[draggableId].descriptor; const home: DroppableDescriptor = droppables[descriptor.droppableId].descriptor; const draggablesToBeCollected: DraggableDescriptor[] = @@ -272,7 +290,7 @@ export default (callbacks: Callbacks) => { return toBeCollected; }; - const processPrimaryDimensions = (request: ?DraggableId) => { + const processPrimaryDimensions = (request: ?LiftRequest) => { if (state.isCollecting) { cancel('Cannot start capturing dimensions for a drag it is already dragging'); return; @@ -283,6 +301,8 @@ export default (callbacks: Callbacks) => { return; } + const draggableId: DraggableId = request.draggableId; + setState({ isCollecting: true, request, @@ -290,17 +310,20 @@ export default (callbacks: Callbacks) => { const draggables: DraggableEntryMap = state.draggables; const droppables: DroppableEntryMap = state.droppables; - const draggableEntry: ?DraggableEntry = draggables[request]; + const draggableEntry: ?DraggableEntry = draggables[draggableId]; if (!draggableEntry) { - cancel(`Cannot find Draggable with id ${request} to start collecting dimensions`); + cancel(`Cannot find Draggable with id ${draggableId} to start collecting dimensions`); return; } const homeEntry: ?DroppableEntry = droppables[draggableEntry.descriptor.droppableId]; if (!homeEntry) { - cancel(`Cannot find home Droppable [id:${draggableEntry.descriptor.droppableId}] for Draggable [id:${request}]`); + cancel(` + Cannot find home Droppable [id:${draggableEntry.descriptor.droppableId}] + for Draggable [id:${request.draggableId}] + `); return; } @@ -308,10 +331,10 @@ export default (callbacks: Callbacks) => { const home: DroppableDimension = homeEntry.callbacks.getDimension(); const draggable: DraggableDimension = draggableEntry.getDimension(); // Publishing dimensions - callbacks.publishDroppables([home]); - callbacks.publishDraggables([draggable]); + callbacks.publishDroppable(home); + callbacks.publishDraggable(draggable); // Watching the scroll of the home droppable - homeEntry.callbacks.watchScroll(); + homeEntry.callbacks.watchScroll(request.scrollOptions); }; const setFrameId = (frameId: ?number) => { @@ -320,12 +343,29 @@ export default (callbacks: Callbacks) => { }); }; - const processSecondaryDimensions = (): void => { + const processSecondaryDimensions = (requestInAppState: ?LiftRequest): void => { if (!state.isCollecting) { cancel('Cannot collect secondary dimensions when collection is not occurring'); return; } + const request: ?LiftRequest = state.request; + + if (!request) { + cancel('Cannot process secondary dimensions without a request'); + return; + } + + if (!requestInAppState) { + cancel('Cannot process secondary dimensions without a request on the state'); + return; + } + + if (requestInAppState.draggableId !== request.draggableId) { + cancel('Cannot process secondary dimensions as local request does not match app state'); + return; + } + const toBeCollected: UnknownDescriptorType[] = getToBeCollected(); // Phase 1: collect dimensions in a single frame @@ -355,17 +395,15 @@ export default (callbacks: Callbacks) => { }, { draggables: [], droppables: [] } ); - if (toBePublished.droppables.length) { - callbacks.publishDroppables(toBePublished.droppables); - } - if (toBePublished.draggables.length) { - callbacks.publishDraggables(toBePublished.draggables); - } + callbacks.bulkPublish( + toBePublished.droppables, + toBePublished.draggables, + ); // need to watch the scroll on each droppable toBePublished.droppables.forEach((dimension: DroppableDimension) => { const entry: DroppableEntry = state.droppables[dimension.descriptor.id]; - entry.callbacks.watchScroll(); + entry.callbacks.watchScroll(request.scrollOptions); }); setFrameId(null); @@ -404,12 +442,7 @@ export default (callbacks: Callbacks) => { } if (phase === 'DRAGGING') { - if (current.dimension.request !== state.request) { - cancel('Request in local state does not match that of the store'); - return; - } - - processSecondaryDimensions(); + processSecondaryDimensions(current.dimension.request); return; } @@ -435,6 +468,7 @@ export default (callbacks: Callbacks) => { registerDroppable, unregisterDroppable, updateDroppableIsEnabled, + scrollDroppable, updateDroppableScroll, onPhaseChange, }; diff --git a/src/state/dimension.js b/src/state/dimension.js index ce70dc1854..38573f2543 100644 --- a/src/state/dimension.js +++ b/src/state/dimension.js @@ -1,8 +1,9 @@ // @flow import { vertical, horizontal } from './axis'; import getArea from './get-area'; -import { add, offset } from './spacing'; +import { offsetByPosition, expandBySpacing } from './spacing'; import { subtract, negate } from './position'; +import getMaxScroll from './get-max-scroll'; import type { DraggableDescriptor, DroppableDescriptor, @@ -13,6 +14,7 @@ import type { Spacing, Area, DroppableDimensionViewport, + ClosestScrollable, } from '../types'; const origin: Position = { x: 0, y: 0 }; @@ -24,28 +26,6 @@ export const noSpacing: Spacing = { left: 0, }; -const addPosition = (area: Area, point: Position): Area => { - const { top, right, bottom, left } = area; - return getArea({ - top: top + point.y, - left: left + point.x, - bottom: bottom + point.y, - right: right + point.x, - }); -}; - -const addSpacing = (area: Area, spacing: Spacing): Area => { - const { top, right, bottom, left } = area; - return getArea({ - // pulling back to increase size - top: top - spacing.top, - left: left - spacing.left, - // pushing forward to increase size - bottom: bottom + spacing.bottom, - right: right + spacing.right, - }); -}; - type GetDraggableArgs = {| descriptor: DraggableDescriptor, client: Area, @@ -59,7 +39,7 @@ export const getDraggableDimension = ({ margin = noSpacing, windowScroll = origin, }: GetDraggableArgs): DraggableDimension => { - const withScroll = addPosition(client, windowScroll); + const withScroll = offsetByPosition(client, windowScroll); const dimension: DraggableDimension = { descriptor, @@ -73,33 +53,18 @@ export const getDraggableDimension = ({ // on the viewport client: { withoutMargin: getArea(client), - withMargin: getArea(addSpacing(client, margin)), + withMargin: getArea(expandBySpacing(client, margin)), }, // with scroll page: { withoutMargin: getArea(withScroll), - withMargin: getArea(addSpacing(withScroll, margin)), + withMargin: getArea(expandBySpacing(withScroll, margin)), }, }; return dimension; }; -type GetDroppableArgs = {| - descriptor: DroppableDescriptor, - client: Area, - // optionally provided - and can also be null - frameClient?: ?Area, - frameScroll?: Position, - direction?: Direction, - margin?: Spacing, - padding?: Spacing, - windowScroll?: Position, - // Whether or not the droppable is currently enabled (can change at during a drag) - // defaults to true - isEnabled?: boolean, -|} - // will return null if the subject is completely not visible within frame export const clip = (frame: Area, subject: Spacing): ?Area => { const result: Area = getArea({ @@ -120,28 +85,49 @@ export const scrollDroppable = ( droppable: DroppableDimension, newScroll: Position ): DroppableDimension => { - const existing: DroppableDimensionViewport = droppable.viewport; + if (!droppable.viewport.closestScrollable) { + console.error('Cannot scroll droppble that does not have a closest scrollable'); + return droppable; + } + + const existingScrollable: ClosestScrollable = droppable.viewport.closestScrollable; + + const frame: Area = existingScrollable.frame; - const scrollDiff: Position = subtract(newScroll, existing.frameScroll.initial); + const scrollDiff: Position = subtract(newScroll, existingScrollable.scroll.initial); // a positive scroll difference leads to a negative displacement // (scrolling down pulls an item upwards) const scrollDisplacement: Position = negate(scrollDiff); - const displacedSubject: Spacing = offset(existing.subject, scrollDisplacement); - const viewport: DroppableDimensionViewport = { - // does not change - frame: existing.frame, - subject: existing.subject, - // below here changes - frameScroll: { - initial: existing.frameScroll.initial, + // Sometimes it is possible to scroll beyond the max point. + // This can occur when scrolling a foreign list that now has a placeholder. + + const closestScrollable: ClosestScrollable = { + frame: existingScrollable.frame, + shouldClipSubject: existingScrollable.shouldClipSubject, + scroll: { + initial: existingScrollable.scroll.initial, current: newScroll, diff: { value: scrollDiff, displacement: scrollDisplacement, }, + // TODO: rename 'softMax?' + max: existingScrollable.scroll.max, }, - clipped: clip(existing.frame, displacedSubject), + }; + + const displacedSubject: Spacing = + offsetByPosition(droppable.viewport.subject, scrollDisplacement); + + const clipped: ?Area = closestScrollable.shouldClipSubject ? + clip(frame, displacedSubject) : + getArea(displacedSubject); + + const viewport: DroppableDimensionViewport = { + closestScrollable, + subject: droppable.viewport.subject, + clipped, }; // $ExpectError - using spread @@ -151,47 +137,80 @@ export const scrollDroppable = ( }; }; +type GetDroppableArgs = {| + descriptor: DroppableDescriptor, + client: Area, + // optionally provided - and can also be null + closest?: ?{| + frameClient: Area, + scrollWidth: number, + scrollHeight: number, + scroll: Position, + shouldClipSubject: boolean, + |}, + direction?: Direction, + margin?: Spacing, + padding?: Spacing, + windowScroll?: Position, + // Whether or not the droppable is currently enabled (can change at during a drag) + // defaults to true + isEnabled?: boolean, +|} + export const getDroppableDimension = ({ descriptor, client, - frameClient, - frameScroll = origin, + closest, direction = 'vertical', margin = noSpacing, padding = noSpacing, windowScroll = origin, isEnabled = true, }: GetDroppableArgs): DroppableDimension => { - const withMargin = addSpacing(client, margin); - const withWindowScroll = addPosition(client, windowScroll); - // If no frameClient is provided, or if the area matches the frameClient, this - // droppable is its own container. In this case we include its margin in the container bounds. - // Otherwise, the container is a scrollable parent. In this case we don't care about margins - // in the container bounds. - - const subject: Area = addSpacing(withWindowScroll, margin); - - // use client + margin if frameClient is not provided - const frame: Area = (() => { - if (!frameClient) { - return subject; + const withMargin: Spacing = expandBySpacing(client, margin); + const withWindowScroll: Spacing = offsetByPosition(client, windowScroll); + const subject: Area = getArea(expandBySpacing(withWindowScroll, margin)); + + const closestScrollable: ?ClosestScrollable = (() => { + if (!closest) { + return null; } - return addPosition(frameClient, windowScroll); + + const frame: Area = getArea(offsetByPosition(closest.frameClient, windowScroll)); + + const maxScroll: Position = getMaxScroll({ + scrollHeight: closest.scrollHeight, + scrollWidth: closest.scrollWidth, + height: frame.height, + width: frame.width, + }); + + const result: ClosestScrollable = { + frame, + shouldClipSubject: closest.shouldClipSubject, + scroll: { + initial: closest.scroll, + // no scrolling yet, so current = initial + current: closest.scroll, + max: maxScroll, + diff: { + value: origin, + displacement: origin, + }, + }, + }; + + return result; })(); + const clipped: ?Area = (closestScrollable && closestScrollable.shouldClipSubject) ? + clip(closestScrollable.frame, subject) : + subject; + const viewport: DroppableDimensionViewport = { - frame, - frameScroll: { - initial: frameScroll, - // no scrolling yet, so current = initial - current: frameScroll, - diff: { - value: origin, - displacement: origin, - }, - }, + closestScrollable, subject, - clipped: clip(frame, subject), + clipped, }; const dimension: DroppableDimension = { @@ -201,12 +220,13 @@ export const getDroppableDimension = ({ client: { withoutMargin: getArea(client), withMargin: getArea(withMargin), - withMarginAndPadding: getArea(addSpacing(withMargin, padding)), + withMarginAndPadding: getArea(expandBySpacing(withMargin, padding)), }, page: { withoutMargin: getArea(withWindowScroll), withMargin: subject, - withMarginAndPadding: getArea(addSpacing(withWindowScroll, add(margin, padding))), + withMarginAndPadding: + getArea(expandBySpacing(expandBySpacing(withWindowScroll, margin), padding)), }, viewport, }; diff --git a/src/state/fire-hooks.js b/src/state/fire-hooks.js deleted file mode 100644 index fca52f5637..0000000000 --- a/src/state/fire-hooks.js +++ /dev/null @@ -1,143 +0,0 @@ -// @flow -import type { - State, - Hooks, - DragStart, - DropResult, - DraggableLocation, - DraggableDescriptor, - DroppableDimension, -} from '../types'; - -export default (hooks: Hooks, previous: State, current: State): void => { - const { onDragStart, onDragEnd } = hooks; - const currentPhase = current.phase; - const previousPhase = previous.phase; - - // Exit early if phase in unchanged - if (currentPhase === previousPhase) { - return; - } - - // Drag start - if (currentPhase === 'DRAGGING' && previousPhase !== 'DRAGGING') { - // onDragStart is optional - if (!onDragStart) { - return; - } - - if (!current.drag) { - console.error('cannot fire onDragStart hook without drag state', { current, previous }); - return; - } - - const descriptor: DraggableDescriptor = current.drag.initial.descriptor; - const home: ?DroppableDimension = current.dimension.droppable[descriptor.droppableId]; - - if (!home) { - console.error('cannot find dimension for home droppable'); - return; - } - - const source: DraggableLocation = { - index: descriptor.index, - droppableId: descriptor.droppableId, - }; - - const start: DragStart = { - draggableId: descriptor.id, - type: home.descriptor.type, - source, - }; - - onDragStart(start); - return; - } - - // Drag end - if (currentPhase === 'DROP_COMPLETE' && previousPhase !== 'DROP_COMPLETE') { - if (!current.drop || !current.drop.result) { - console.error('cannot fire onDragEnd hook without drag state', { current, previous }); - return; - } - - const { - source, - destination, - draggableId, - type, - } = current.drop.result; - - // Could be a cancel or a drop nowhere - if (!destination) { - onDragEnd(current.drop.result); - return; - } - - // Do not publish a result.destination where nothing moved - const didMove: boolean = source.droppableId !== destination.droppableId || - source.index !== destination.index; - - if (didMove) { - onDragEnd(current.drop.result); - return; - } - - const muted: DropResult = { - draggableId, - type, - source, - destination: null, - }; - - onDragEnd(muted); - return; - } - - // Drag ended while dragging - if (currentPhase === 'IDLE' && previousPhase === 'DRAGGING') { - if (!previous.drag) { - console.error('cannot fire onDragEnd for cancel because cannot find previous drag'); - return; - } - - const descriptor: DraggableDescriptor = previous.drag.initial.descriptor; - const home: ?DroppableDimension = previous.dimension.droppable[descriptor.droppableId]; - - if (!home) { - console.error('cannot find dimension for home droppable'); - return; - } - - const source: DraggableLocation = { - index: descriptor.index, - droppableId: descriptor.droppableId, - }; - - const result: DropResult = { - draggableId: descriptor.id, - type: home.descriptor.type, - source, - destination: null, - }; - onDragEnd(result); - return; - } - - // Drag ended during a drop animation. Not super sure how this can even happen. - // This is being really safe - if (currentPhase === 'IDLE' && previousPhase === 'DROP_ANIMATING') { - if (!previous.drop || !previous.drop.pending) { - console.error('cannot fire onDragEnd for cancel because cannot find previous pending drop'); - return; - } - - const result: DropResult = { - draggableId: previous.drop.pending.result.draggableId, - type: previous.drop.pending.result.type, - source: previous.drop.pending.result.source, - destination: null, - }; - onDragEnd(result); - } -}; diff --git a/src/state/get-displacement.js b/src/state/get-displacement.js index ae17fc9702..04d49cb1a9 100644 --- a/src/state/get-displacement.js +++ b/src/state/get-displacement.js @@ -1,6 +1,6 @@ // @flow import getDisplacementMap, { type DisplacementMap } from './get-displacement-map'; -import isPartiallyVisible from './visibility/is-partially-visible'; +import { isPartiallyVisible } from './visibility/is-visible'; import type { DraggableId, Displacement, @@ -17,6 +17,10 @@ type Args = {| viewport: Area, |} +// Note: it is also an optimisation to undo the displacement on +// items when they are no longer visible. +// This prevents a lot of .render() calls when leaving a list + export default ({ draggable, destination, diff --git a/src/state/get-drag-impact/in-foreign-list.js b/src/state/get-drag-impact/in-foreign-list.js index cc8d537d4a..30b58cd592 100644 --- a/src/state/get-drag-impact/in-foreign-list.js +++ b/src/state/get-drag-impact/in-foreign-list.js @@ -9,9 +9,10 @@ import type { Displacement, Area, } from '../../types'; -import { add, patch } from '../position'; +import { patch } from '../position'; import getDisplacement from '../get-displacement'; -import getViewport from '../visibility/get-viewport'; +import getViewport from '../../window/get-viewport'; +import withDroppableScroll from '../with-droppable-scroll'; type Args = {| pageCenter: Position, @@ -36,9 +37,7 @@ export default ({ // To do this we need to consider any displacement caused by // a change in scroll in the droppable we are currently over. - const destinationScrollDiff: Position = - destination.viewport.frameScroll.diff.value; - const currentCenter: Position = add(pageCenter, destinationScrollDiff); + const currentCenter: Position = withDroppableScroll(destination, pageCenter); const displaced: Displacement[] = insideDestination .filter((child: DraggableDimension): boolean => { diff --git a/src/state/get-drag-impact/in-home-list.js b/src/state/get-drag-impact/in-home-list.js index 4c18977c78..fafaebb643 100644 --- a/src/state/get-drag-impact/in-home-list.js +++ b/src/state/get-drag-impact/in-home-list.js @@ -9,9 +9,10 @@ import type { Displacement, Area, } from '../../types'; -import { add, patch } from '../position'; +import { patch } from '../position'; import getDisplacement from '../get-displacement'; -import getViewport from '../visibility/get-viewport'; +import withDroppableScroll from '../with-droppable-scroll'; +import getViewport from '../../window/get-viewport'; // It is the responsibility of this function // to return the impact of a drag @@ -36,13 +37,9 @@ export default ({ // The starting center position const originalCenter: Position = draggable.page.withoutMargin.center; - // Where is the element now? - + // Where the element actually is now. // Need to take into account the change of scroll in the droppable - const homeScrollDiff: Position = home.viewport.frameScroll.diff.value; - - // Where the element actually is now - const currentCenter: Position = add(pageCenter, homeScrollDiff); + const currentCenter: Position = withDroppableScroll(home, pageCenter); // not considering margin so that items move based on visible edges const isBeyondStartPosition: boolean = currentCenter[axis.line] - originalCenter[axis.line] > 0; diff --git a/src/state/get-droppable-over.js b/src/state/get-droppable-over.js index 87f3539dd0..dd585da53c 100644 --- a/src/state/get-droppable-over.js +++ b/src/state/get-droppable-over.js @@ -4,9 +4,10 @@ import getArea from './get-area'; import getDraggablesInsideDroppable from './get-draggables-inside-droppable'; import isPositionInFrame from './visibility/is-position-in-frame'; import { patch } from './position'; -import { addPosition } from './spacing'; +import { expandByPosition } from './spacing'; import { clip } from './dimension'; import type { + ClosestScrollable, DraggableDimension, DraggableDimensionMap, DroppableDimension, @@ -65,7 +66,7 @@ type GetBufferedDroppableArgs = { }; const getWithGrowth = memoizeOne( - (area: Area, growth: Position): Area => getArea(addPosition(area, growth)) + (area: Area, growth: Position): Area => getArea(expandByPosition(area, growth)) ); const getClippedAreaWithPlaceholder = ({ @@ -79,8 +80,6 @@ const getClippedAreaWithPlaceholder = ({ previousDroppableOverId && previousDroppableOverId === droppable.descriptor.id ); - const subject: Area = droppable.viewport.subject; - const frame: Area = droppable.viewport.frame; const clipped: ?Area = droppable.viewport.clipped; // clipped area is totally hidden behind frame @@ -100,17 +99,22 @@ const getClippedAreaWithPlaceholder = ({ return clipped; } - const isClippedByFrame: boolean = subject[droppable.axis.size] !== frame[droppable.axis.size]; + const subjectWithGrowth: Area = getWithGrowth(clipped, requiredGrowth); + const closestScrollable: ?ClosestScrollable = droppable.viewport.closestScrollable; - const subjectWithGrowth = getWithGrowth(clipped, requiredGrowth); + // The droppable has no scroll container + if (!closestScrollable) { + return subjectWithGrowth; + } - if (!isClippedByFrame) { + // We are not clipping the subject + if (!closestScrollable.shouldClipSubject) { return subjectWithGrowth; } // We need to clip the new subject by the frame which does not change // This will allow the user to continue to scroll into the placeholder - return clip(frame, subjectWithGrowth); + return clip(closestScrollable.frame, subjectWithGrowth); }; type Args = {| diff --git a/src/state/get-max-scroll.js b/src/state/get-max-scroll.js new file mode 100644 index 0000000000..4036909bb4 --- /dev/null +++ b/src/state/get-max-scroll.js @@ -0,0 +1,31 @@ +// @flow +import { subtract } from './position'; +import type { Position } from '../types'; + +type Args = {| + scrollHeight: number, + scrollWidth: number, + height: number, + width: number, +|} +export default ({ + scrollHeight, + scrollWidth, + height, + width, +}: Args): Position => { + const maxScroll: Position = subtract( + // full size + { x: scrollWidth, y: scrollHeight }, + // viewport size + { x: width, y: height } + ); + + const adjustedMaxScroll: Position = { + x: Math.max(0, maxScroll.x), + y: Math.max(0, maxScroll.y), + }; + + return adjustedMaxScroll; +}; + diff --git a/src/state/hooks/hook-caller.js b/src/state/hooks/hook-caller.js new file mode 100644 index 0000000000..5ff211cca5 --- /dev/null +++ b/src/state/hooks/hook-caller.js @@ -0,0 +1,311 @@ +// @flow +import messagePreset from './message-preset'; +import type { HookCaller } from './hooks-types'; +import type { + Announce, + Hooks, + HookProvided, + State as AppState, + DragState, + DragStart, + DragUpdate, + DropResult, + DraggableLocation, + DraggableDescriptor, + DroppableDimension, + OnDragStartHook, + OnDragUpdateHook, + OnDragEndHook, +} from '../../types'; + +type State = { + isDragging: boolean, + start: ?DraggableLocation, + lastDestination: ?DraggableLocation, + hasMovedFromStartLocation: boolean, +} + +type AnyHookFn = OnDragStartHook | OnDragUpdateHook | OnDragEndHook; +type AnyHookData = DragStart | DragUpdate | DropResult; + +const notDragging: State = { + isDragging: false, + start: null, + lastDestination: null, + hasMovedFromStartLocation: false, +}; + +const areLocationsEqual = (current: ?DraggableLocation, next: ?DraggableLocation) => { + // if both are null - we are equal + if (current == null && next == null) { + return true; + } + + // if one is null - then they are not equal + if (current == null || next == null) { + return false; + } + + // compare their actual values + return current.droppableId === next.droppableId && + current.index === next.index; +}; + +const getAnnouncerForConsumer = (announce: Announce) => { + let wasCalled: boolean = false; + let isExpired: boolean = false; + + // not allowing async announcements + setTimeout(() => { + isExpired = true; + }); + + const result = (message: string): void => { + if (wasCalled) { + console.warn('Announcement already made. Not making a second announcement'); + return; + } + + if (isExpired) { + console.warn(` + Announcements cannot be made asynchronously. + Default message has already been announced. + `); + return; + } + + wasCalled = true; + announce(message); + }; + + // getter for isExpired + // using this technique so that a consumer cannot + // set the isExpired or wasCalled flags + result.wasCalled = (): boolean => wasCalled; + + return result; +}; + +export default (announce: Announce): HookCaller => { + let state: State = notDragging; + + const setState = (partial: Object): void => { + const newState: State = { + ...state, + ...partial, + }; + state = newState; + }; + + const getDragStart = (appState: AppState): ?DragStart => { + if (!appState.drag) { + return null; + } + + const descriptor: DraggableDescriptor = appState.drag.initial.descriptor; + const home: ?DroppableDimension = appState.dimension.droppable[descriptor.droppableId]; + + if (!home) { + return null; + } + + const source: DraggableLocation = { + index: descriptor.index, + droppableId: descriptor.droppableId, + }; + + const start: DragStart = { + draggableId: descriptor.id, + type: home.descriptor.type, + source, + }; + + return start; + }; + + const execute = ( + hook: ?AnyHookFn, + data: AnyHookData, + getDefaultMessage: (data: any) => string, + ) => { + // if no hook: announce the default message + if (!hook) { + announce(getDefaultMessage(data)); + return; + } + + const managed: Announce = getAnnouncerForConsumer(announce); + const provided: HookProvided = { + announce: managed, + }; + + hook((data: any), provided); + + if (!managed.wasCalled()) { + announce(getDefaultMessage(data)); + } + }; + + const onDrag = (current: AppState, onDragUpdate: ?OnDragUpdateHook) => { + if (!state.isDragging) { + console.error('Cannot process dragging update if drag has not started'); + return; + } + + const drag: ?DragState = current.drag; + const start: ?DragStart = getDragStart(current); + if (!start || !drag) { + console.error('Cannot update drag when there is invalid state'); + return; + } + + const destination: ?DraggableLocation = drag.impact.destination; + const update: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination, + }; + + if (!state.hasMovedFromStartLocation) { + // has not moved past the home yet + if (areLocationsEqual(start.source, destination)) { + return; + } + + // We have now moved past the home location + setState({ + lastDestination: destination, + hasMovedFromStartLocation: true, + }); + + execute(onDragUpdate, update, messagePreset.onDragUpdate); + + // announceMessage(update, onDragUpdate); + return; + } + + // has not moved from the previous location + if (areLocationsEqual(state.lastDestination, destination)) { + return; + } + + setState({ + lastDestination: destination, + }); + + execute(onDragUpdate, update, messagePreset.onDragUpdate); + }; + + const onStateChange = (hooks: Hooks, previous: AppState, current: AppState): void => { + const { onDragStart, onDragUpdate, onDragEnd } = hooks; + const currentPhase = current.phase; + const previousPhase = previous.phase; + + // Dragging in progress + if (currentPhase === 'DRAGGING' && previousPhase === 'DRAGGING') { + onDrag(current, onDragUpdate); + return; + } + + // We are not in the dragging phase so we can clear this state + if (state.isDragging) { + setState(notDragging); + } + + // From this point we only care about phase changes + + if (currentPhase === previousPhase) { + return; + } + + // Drag start + if (currentPhase === 'DRAGGING' && previousPhase !== 'DRAGGING') { + const start: ?DragStart = getDragStart(current); + + if (!start) { + console.error('Unable to publish onDragStart'); + return; + } + + setState({ + isDragging: true, + hasMovedFromStartLocation: false, + start, + }); + + // onDragStart is optional + execute(onDragStart, start, messagePreset.onDragStart); + return; + } + + // Drag end + if (currentPhase === 'DROP_COMPLETE' && previousPhase !== 'DROP_COMPLETE') { + if (!current.drop || !current.drop.result) { + console.error('cannot fire onDragEnd hook without drag state', { current, previous }); + return; + } + const result: DropResult = current.drop.result; + + execute(onDragEnd, result, messagePreset.onDragEnd); + return; + } + + // Drag ended while dragging + if (currentPhase === 'IDLE' && previousPhase === 'DRAGGING') { + if (!previous.drag) { + console.error('cannot fire onDragEnd for cancel because cannot find previous drag'); + return; + } + + const descriptor: DraggableDescriptor = previous.drag.initial.descriptor; + const home: ?DroppableDimension = previous.dimension.droppable[descriptor.droppableId]; + + if (!home) { + console.error('cannot find dimension for home droppable'); + return; + } + + const source: DraggableLocation = { + index: descriptor.index, + droppableId: descriptor.droppableId, + }; + const result: DropResult = { + draggableId: descriptor.id, + type: home.descriptor.type, + source, + destination: null, + reason: 'CANCEL', + }; + + execute(onDragEnd, result, messagePreset.onDragEnd); + return; + } + + // Drag ended during a drop animation. Not super sure how this can even happen. + // This is being really safe + if (currentPhase === 'IDLE' && previousPhase === 'DROP_ANIMATING') { + if (!previous.drop || !previous.drop.pending) { + console.error('cannot fire onDragEnd for cancel because cannot find previous pending drop'); + return; + } + + const result: DropResult = { + draggableId: previous.drop.pending.result.draggableId, + type: previous.drop.pending.result.type, + source: previous.drop.pending.result.source, + destination: null, + reason: 'CANCEL', + }; + + execute(onDragEnd, result, messagePreset.onDragEnd); + } + }; + + const caller: HookCaller = { + onStateChange, + }; + + return caller; +}; + diff --git a/src/state/hooks/hooks-types.js b/src/state/hooks/hooks-types.js new file mode 100644 index 0000000000..6854aeacdc --- /dev/null +++ b/src/state/hooks/hooks-types.js @@ -0,0 +1,9 @@ +// @flow +import type { + State, + Hooks, +} from '../../types'; + +export type HookCaller = {| + onStateChange: (hooks: Hooks, previous: State, current: State) => void, +|} diff --git a/src/state/hooks/message-preset.js b/src/state/hooks/message-preset.js new file mode 100644 index 0000000000..3942dbc74d --- /dev/null +++ b/src/state/hooks/message-preset.js @@ -0,0 +1,84 @@ +// @flow +import type { + DragStart, + DragUpdate, + DropResult, +} from '../../types'; + +export type MessagePreset = {| + onDragStart: (start: DragStart) => string, + onDragUpdate: (update: DragUpdate) => string, + onDragEnd: (result: DropResult) => string, +|} + +// We cannot list what index the Droppable is in automatically as we are not sure how +// the Droppable's have been configured +const onDragStart = (start: DragStart): string => ` + You have lifted an item in position ${start.source.index + 1}. + Use the arrow keys to move, space bar to drop, and escape to cancel. +`; + +const onDragUpdate = (update: DragUpdate): string => { + if (!update.destination) { + return 'You are currently not dragging over a droppable area'; + } + + // Moving in the same list + if (update.source.droppableId === update.destination.droppableId) { + return `You have moved the item to position ${update.destination.index + 1}`; + } + + // Moving into a new list + + return ` + You have moved the item from list ${update.source.droppableId} in position ${update.source.index + 1} + to list ${update.destination.droppableId} in position ${update.destination.index + 1} + `; +}; + +const onDragEnd = (result: DropResult): string => { + if (result.reason === 'CANCEL') { + return ` + Movement cancelled. + The item has returned to its starting position of ${result.source.index + 1} + `; + } + + // Not moved anywhere (such as when dropped over no list) + if (!result.destination) { + return ` + The item has been dropped while not over a droppable location. + The item has returned to its starting position of ${result.source.index + 1} + `; + } + + // Dropped in home list + if (result.source.droppableId === result.destination.droppableId) { + // It is in the position that it started in + if (result.source.index === result.destination.index) { + return ` + You have dropped the item. + It has been dropped on its starting position of ${result.source.index + 1} + `; + } + + // It is in a new position + return ` + You have dropped the item. + It has moved from position ${result.source.index + 1} to ${result.destination.index + 1} + `; + } + + // Dropped in a new list + return ` + You have dropped the item. + It has moved from position ${result.source.index + 1} in list ${result.source.droppableId} + to position ${result.destination.index + 1} in list ${result.destination.droppableId} + `; +}; + +const preset: MessagePreset = { + onDragStart, onDragUpdate, onDragEnd, +}; + +export default preset; 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 index 8293821a80..3e5b9e1952 100644 --- a/src/state/move-cross-axis/get-best-cross-axis-droppable.js +++ b/src/state/move-cross-axis/get-best-cross-axis-droppable.js @@ -2,8 +2,8 @@ import { closest } from '../position'; import isWithin from '../is-within'; import { getCorners } from '../spacing'; -import getViewport from '../visibility/get-viewport'; -import isVisibleThroughFrame from '../visibility/is-visible-through-frame'; +import getViewport from '../../window/get-viewport'; +import isPartiallyVisibleThroughFrame from '../visibility/is-partially-visible-through-frame'; import type { Axis, DroppableDimension, @@ -58,13 +58,16 @@ export default ({ .filter((droppable: DroppableDimension): boolean => droppable !== source) // Remove any options that are not enabled .filter((droppable: DroppableDimension): boolean => droppable.isEnabled) - // Remove any droppables that have invisible subjects - .filter((droppable: DroppableDimension): boolean => Boolean(droppable.viewport.clipped)) // Remove any droppables that are not partially visible - .filter((droppable: DroppableDimension): boolean => ( - isVisibleThroughFrame(viewport)(droppable.viewport.frame) - )) - + .filter((droppable: DroppableDimension): boolean => { + const clipped: ?Area = droppable.viewport.clipped; + // subject is not visible at all in frame + if (!clipped) { + return false; + } + // TODO: only need to be totally visible on the cross axis + return isPartiallyVisibleThroughFrame(viewport)(clipped); + }) .filter((droppable: DroppableDimension): boolean => { const targetClipped: Area = getSafeClipped(droppable); diff --git a/src/state/move-cross-axis/get-closest-draggable.js b/src/state/move-cross-axis/get-closest-draggable.js index 1514c3b753..8485ad91d9 100644 --- a/src/state/move-cross-axis/get-closest-draggable.js +++ b/src/state/move-cross-axis/get-closest-draggable.js @@ -1,7 +1,8 @@ // @flow import { distance } from '../position'; -import getViewport from '../visibility/get-viewport'; -import isPartiallyVisible from '../visibility/is-partially-visible'; +import getViewport from '../../window/get-viewport'; +import { isTotallyVisible } from '../visibility/is-visible'; +import withDroppableDisplacement from '../with-droppable-displacement'; import type { Area, Axis, @@ -34,16 +35,23 @@ export default ({ const result: DraggableDimension[] = insideDestination // Remove any options that are hidden by overflow - // Draggable must be partially visible to move to it + // Draggable must be totally visible to move to it .filter((draggable: DraggableDimension): boolean => - isPartiallyVisible({ + isTotallyVisible({ target: draggable.page.withMargin, destination, viewport, })) .sort((a: DraggableDimension, b: DraggableDimension): number => { - const distanceToA = distance(pageCenter, a.page.withMargin.center); - const distanceToB = distance(pageCenter, b.page.withMargin.center); + // Need to consider the change in scroll in the destination + const distanceToA = distance( + pageCenter, + withDroppableDisplacement(destination, a.page.withMargin.center) + ); + const distanceToB = distance( + pageCenter, + withDroppableDisplacement(destination, b.page.withMargin.center) + ); // if a is closer - return a if (distanceToA < distanceToB) { 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 index bb0fcd027f..b1552e39a5 100644 --- 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 @@ -2,7 +2,8 @@ import moveToEdge from '../../move-to-edge'; import type { Result } from '../move-cross-axis-types'; import getDisplacement from '../../get-displacement'; -import getViewport from '../../visibility/get-viewport'; +import getViewport from '../../../window/get-viewport'; +import withDroppableDisplacement from '../../with-droppable-displacement'; import type { Axis, Position, @@ -64,7 +65,7 @@ export default ({ }; return { - pageCenter: newCenter, + pageCenter: withDroppableDisplacement(droppable, newCenter), impact: newImpact, }; } @@ -116,7 +117,7 @@ export default ({ }; return { - pageCenter: newCenter, + pageCenter: withDroppableDisplacement(droppable, 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 index 590bbe7b89..483c38975f 100644 --- 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 @@ -1,7 +1,8 @@ // @flow import moveToEdge from '../../move-to-edge'; -import getViewport from '../../visibility/get-viewport'; +import getViewport from '../../../window/get-viewport'; import getDisplacement from '../../get-displacement'; +import withDroppableDisplacement from '../../with-droppable-displacement'; import type { Edge } from '../../move-to-edge'; import type { Result } from '../move-cross-axis-types'; import type { @@ -64,7 +65,7 @@ export default ({ }; return { - pageCenter: newCenter, + pageCenter: withDroppableDisplacement(droppable, newCenter), impact: newImpact, }; } @@ -128,7 +129,7 @@ export default ({ }; return { - pageCenter: newCenter, + pageCenter: withDroppableDisplacement(droppable, newCenter), impact: newImpact, }; }; diff --git a/src/state/move-to-edge.js b/src/state/move-to-edge.js index fbdd3a7e44..335bc5224b 100644 --- a/src/state/move-to-edge.js +++ b/src/state/move-to-edge.js @@ -55,7 +55,7 @@ export default ({ 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], + centerDiff[destinationAxis.crossAxisLine], ); return add(corner, signed); diff --git a/src/state/move-to-next-index/get-forced-displacement.js b/src/state/move-to-next-index/get-forced-displacement.js new file mode 100644 index 0000000000..604fa70995 --- /dev/null +++ b/src/state/move-to-next-index/get-forced-displacement.js @@ -0,0 +1,143 @@ +// @flow +import getDisplacement from '../get-displacement'; +import type { + Area, + Axis, + DraggableId, + DragImpact, + DraggableDimensionMap, + DroppableDimension, + DraggableDimension, + Displacement, +} from '../../types'; + +type WithAdded = {| + add: DraggableId, + previousImpact: DragImpact, + droppable: DroppableDimension, + draggables: DraggableDimensionMap, + viewport: Area, +|} + +export const withFirstAdded = ({ + add, + previousImpact, + droppable, + draggables, + viewport, +}: WithAdded): Displacement[] => { + const newDisplacement: Displacement = { + draggableId: add, + isVisible: true, + shouldAnimate: true, + }; + + const added: Displacement[] = [ + newDisplacement, + ...previousImpact.movement.displaced, + ]; + + const withUpdatedVisibility: Displacement[] = + added.map((current: Displacement): Displacement => { + // we have already calculated the displacement for this item + if (current === newDisplacement) { + return current; + } + + const updated: Displacement = getDisplacement({ + draggable: draggables[current.draggableId], + destination: droppable, + previousImpact, + viewport, + }); + + return updated; + }); + + return withUpdatedVisibility; +}; + +type WithLastRemoved = {| + dragging: DraggableId, + isVisibleInNewLocation: boolean, + previousImpact: DragImpact, + droppable: DroppableDimension, + draggables: DraggableDimensionMap, +|} + +const forceVisibleDisplacement = (current: Displacement): Displacement => { + // if already visible - can use the existing displacement + if (current.isVisible) { + return current; + } + + // if not visible - immediately force visibility + return { + draggableId: current.draggableId, + isVisible: true, + shouldAnimate: false, + }; +}; + +export const withFirstRemoved = ({ + dragging, + isVisibleInNewLocation, + previousImpact, + droppable, + draggables, +}: WithLastRemoved): Displacement[] => { + const last: Displacement[] = previousImpact.movement.displaced; + if (!last.length) { + console.error('cannot remove displacement from empty list'); + return []; + } + + const withFirstRestored: Displacement[] = last.slice(1, last.length); + + // list is now empty + if (!withFirstRestored.length) { + return withFirstRestored; + } + + // Simple case: no forced movement required + // no displacement visibility will be updated by this move + // so we can simply return the previous values + if (isVisibleInNewLocation) { + return withFirstRestored; + } + + const axis: Axis = droppable.axis; + + // When we are forcing this displacement, we need to adjust the visibility of draggables + // within a particular range. This range is the size of the dragging item and the item + // that is being restored to its original + const sizeOfRestored: number = draggables[last[0].draggableId].page.withMargin[axis.size]; + const sizeOfDragging: number = draggables[dragging].page.withMargin[axis.size]; + let buffer: number = sizeOfRestored + sizeOfDragging; + + const withUpdatedVisibility: Displacement[] = + withFirstRestored.map((displacement: Displacement, index: number): Displacement => { + // we are ripping this one away and forcing it to move + if (index === 0) { + return forceVisibleDisplacement(displacement); + } + + if (buffer > 0) { + const current: DraggableDimension = draggables[displacement.draggableId]; + const size: number = current.page.withMargin[axis.size]; + buffer -= size; + + return forceVisibleDisplacement(displacement); + } + + // We know that these items cannot be visible after the move + return { + draggableId: displacement.draggableId, + isVisible: false, + shouldAnimate: false, + }; + }); + + return withUpdatedVisibility; +}; + diff --git a/src/state/move-to-next-index/in-foreign-list.js b/src/state/move-to-next-index/in-foreign-list.js index 83f38e16f5..69d5fd2206 100644 --- a/src/state/move-to-next-index/in-foreign-list.js +++ b/src/state/move-to-next-index/in-foreign-list.js @@ -1,10 +1,11 @@ // @flow import getDraggablesInsideDroppable from '../get-draggables-inside-droppable'; -import { patch } from '../position'; +import { patch, subtract } from '../position'; +import withDroppableDisplacement from '../with-droppable-displacement'; import moveToEdge from '../move-to-edge'; -import getDisplacement from '../get-displacement'; -import getViewport from '../visibility/get-viewport'; -import isVisibleInNewLocation from './is-visible-in-new-location'; +import getViewport from '../../window/get-viewport'; +import isTotallyVisibleInNewLocation from './is-totally-visible-in-new-location'; +import { withFirstAdded, withFirstRemoved } from './get-forced-displacement'; import type { Edge } from '../move-to-edge'; import type { Args, Result } from './move-to-next-index-types'; import type { @@ -21,6 +22,7 @@ export default ({ isMovingForward, draggableId, previousImpact, + previousPageCenter, droppable, draggables, }: Args): ?Result => { @@ -73,7 +75,7 @@ export default ({ })(); const viewport: Area = getViewport(); - const newCenter: Position = moveToEdge({ + const newPageCenter: Position = moveToEdge({ source: draggable.page.withoutMargin, sourceEdge, destination: movingRelativeTo.page.withMargin, @@ -81,67 +83,35 @@ export default ({ destinationAxis: droppable.axis, }); - const isVisible: boolean = (() => { - // Moving into placeholder position - // Usually this would be outside of the visible bounds - if (isMovingPastLastIndex) { - return true; - } - - // checking the shifted draggable rather than just the new center - // as the new center might not be visible but the whole draggable - // might be partially visible - return isVisibleInNewLocation({ - draggable, - destination: droppable, - newCenter, - viewport, - }); - })(); - - if (!isVisible) { - return null; - } - - // at this point we know that the destination is droppable - const movingRelativeToDisplacement: Displacement = { - draggableId: movingRelativeTo.descriptor.id, - isVisible: true, - shouldAnimate: true, - }; + const isVisibleInNewLocation: boolean = isTotallyVisibleInNewLocation({ + draggable, + destination: droppable, + newPageCenter, + viewport, + }); - // When we are in foreign list we are only displacing items forward - // This list is always sorted by the closest impacted draggable - const modified: Displacement[] = (isMovingForward ? - // Stop displacing the closest draggable forward - previousImpact.movement.displaced.slice(1, previousImpact.movement.displaced.length) : - // Add the draggable that we are moving into the place of - [movingRelativeToDisplacement, ...previousImpact.movement.displaced]); - - // update displacement to consider viewport and droppable visibility - const displaced: Displacement[] = modified - .map((displacement: Displacement): Displacement => { - // already processed - if (displacement === movingRelativeToDisplacement) { - return displacement; - } - - const target: DraggableDimension = draggables[displacement.draggableId]; - - const updated: Displacement = getDisplacement({ - draggable: target, - destination: droppable, - viewport, + const displaced: Displacement[] = (() => { + if (isMovingForward) { + return withFirstRemoved({ + dragging: draggableId, + isVisibleInNewLocation, previousImpact, + droppable, + draggables, }); - - return updated; + } + return withFirstAdded({ + add: movingRelativeTo.descriptor.id, + previousImpact, + droppable, + draggables, + viewport, }); + })(); const newImpact: DragImpact = { movement: { displaced, - // 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, @@ -153,8 +123,21 @@ export default ({ direction: droppable.axis.direction, }; + if (isVisibleInNewLocation) { + return { + pageCenter: withDroppableDisplacement(droppable, newPageCenter), + impact: newImpact, + scrollJumpRequest: null, + }; + } + + // The full distance required to get from the previous page center to the new page center + const distanceMoving: Position = subtract(newPageCenter, previousPageCenter); + const distanceWithScroll: Position = withDroppableDisplacement(droppable, distanceMoving); + return { - pageCenter: newCenter, + pageCenter: previousPageCenter, impact: newImpact, + scrollJumpRequest: distanceWithScroll, }; }; diff --git a/src/state/move-to-next-index/in-home-list.js b/src/state/move-to-next-index/in-home-list.js index 413cd5f5d2..44139d59b0 100644 --- a/src/state/move-to-next-index/in-home-list.js +++ b/src/state/move-to-next-index/in-home-list.js @@ -1,13 +1,14 @@ // @flow -import memoizeOne from 'memoize-one'; import getDraggablesInsideDroppable from '../get-draggables-inside-droppable'; -import { patch } from '../position'; -import isVisibleInNewLocation from './is-visible-in-new-location'; -import getViewport from '../visibility/get-viewport'; +import { patch, subtract } from '../position'; +import withDroppableDisplacement from '../with-droppable-displacement'; +import isTotallyVisibleInNewLocation from './is-totally-visible-in-new-location'; +import getViewport from '../../window/get-viewport'; +// import getScrollJumpResult from './get-scroll-jump-result'; import moveToEdge from '../move-to-edge'; +import { withFirstAdded, withFirstRemoved } from './get-forced-displacement'; import type { Edge } from '../move-to-edge'; import type { Args, Result } from './move-to-next-index-types'; -import getDisplacement from '../get-displacement'; import type { DraggableLocation, DraggableDimension, @@ -18,15 +19,10 @@ import type { Area, } from '../../types'; -const getIndex = memoizeOne( - (draggables: DraggableDimension[], - target: DraggableDimension - ): number => draggables.indexOf(target) -); - export default ({ isMovingForward, draggableId, + previousPageCenter, previousImpact, droppable, draggables, @@ -46,7 +42,7 @@ export default ({ draggables, ); - const startIndex: number = getIndex(insideDroppable, draggable); + const startIndex: number = draggable.descriptor.index; const currentIndex: number = location.index; const proposedIndex = isMovingForward ? currentIndex + 1 : currentIndex - 1; @@ -65,6 +61,7 @@ export default ({ return null; } + const viewport: Area = getViewport(); const destination: DraggableDimension = insideDroppable[proposedIndex]; const isMovingTowardStart = (isMovingForward && proposedIndex <= startIndex) || (!isMovingForward && proposedIndex >= startIndex); @@ -78,7 +75,7 @@ export default ({ return isMovingForward ? 'start' : 'end'; })(); - const newCenter: Position = moveToEdge({ + const newPageCenter: Position = moveToEdge({ source: draggable.page.withoutMargin, sourceEdge: edge, destination: destination.page.withoutMargin, @@ -86,52 +83,35 @@ export default ({ destinationAxis: droppable.axis, }); - const viewport: Area = getViewport(); - - const isVisible: boolean = isVisibleInNewLocation({ + const isVisibleInNewLocation: boolean = isTotallyVisibleInNewLocation({ draggable, destination: droppable, - newCenter, + newPageCenter, viewport, }); - if (!isVisible) { - return null; - } - - // Calculate DragImpact - // at this point we know that the destination is droppable - const destinationDisplacement: Displacement = { - draggableId: destination.descriptor.id, - isVisible: true, - shouldAnimate: true, - }; - - const modified: Displacement[] = (isMovingTowardStart ? - // remove the most recently impacted - previousImpact.movement.displaced.slice(1, previousImpact.movement.displaced.length) : - // add the destination as the most recently impacted - [destinationDisplacement, ...previousImpact.movement.displaced]); - - // update impact with visibility - stops redundant work! - const displaced: Displacement[] = modified - .map((displacement: Displacement): Displacement => { - const target: DraggableDimension = draggables[displacement.draggableId]; - - const updated: Displacement = getDisplacement({ - draggable: target, - destination: droppable, + const displaced: Displacement[] = (() => { + if (isMovingTowardStart) { + return withFirstRemoved({ + dragging: draggableId, + isVisibleInNewLocation, previousImpact, - viewport, + droppable, + draggables, }); - - return updated; + } + return withFirstAdded({ + add: destination.descriptor.id, + previousImpact, + droppable, + draggables, + viewport, }); + })(); const newImpact: DragImpact = { movement: { displaced, - // 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, }, @@ -142,10 +122,21 @@ export default ({ direction: droppable.axis.direction, }; - const result: Result = { - pageCenter: newCenter, + if (isVisibleInNewLocation) { + return { + pageCenter: withDroppableDisplacement(droppable, newPageCenter), + impact: newImpact, + scrollJumpRequest: null, + }; + } + + // The full distance required to get from the previous page center to the new page center + const distance: Position = subtract(newPageCenter, previousPageCenter); + const distanceWithScroll: Position = withDroppableDisplacement(droppable, distance); + + return { + pageCenter: previousPageCenter, impact: newImpact, + scrollJumpRequest: distanceWithScroll, }; - - return result; }; diff --git a/src/state/move-to-next-index/is-totally-visible-in-new-location.js b/src/state/move-to-next-index/is-totally-visible-in-new-location.js new file mode 100644 index 0000000000..0f3a3130ac --- /dev/null +++ b/src/state/move-to-next-index/is-totally-visible-in-new-location.js @@ -0,0 +1,40 @@ +// @flow +import { subtract } from '../position'; +import { offsetByPosition } from '../spacing'; +import { isTotallyVisible } from '../visibility/is-visible'; +import type { + Area, + DraggableDimension, + DroppableDimension, + Position, + Spacing, +} from '../../types'; + +type Args = {| + draggable: DraggableDimension, + destination: DroppableDimension, + newPageCenter: Position, + viewport: Area, +|} + +export default ({ + draggable, + destination, + newPageCenter, + viewport, +}: Args): boolean => { + // What would the location of the Draggable be once the move is completed? + // We are not considering margins for this calculation. + // This is because a move might move a Draggable slightly outside of the bounds + // of a Droppable (which is okay) + const diff: Position = subtract(newPageCenter, draggable.page.withoutMargin.center); + const shifted: Spacing = offsetByPosition(draggable.page.withoutMargin, diff); + + // Must be totally visible, not just partially visible. + + return isTotallyVisible({ + target: shifted, + destination, + viewport, + }); +}; diff --git a/src/state/move-to-next-index/is-visible-in-new-location.js b/src/state/move-to-next-index/is-visible-in-new-location.js deleted file mode 100644 index c07a71f47d..0000000000 --- a/src/state/move-to-next-index/is-visible-in-new-location.js +++ /dev/null @@ -1,35 +0,0 @@ -// @flow -import { subtract } from '../position'; -import { offset } from '../spacing'; -import isPartiallyVisible from '../visibility/is-partially-visible'; -import type { - Area, - DraggableDimension, - DroppableDimension, - Position, - Spacing, -} from '../../types'; - -type Args = {| - draggable: DraggableDimension, - destination: DroppableDimension, - newCenter: Position, - viewport: Area, -|} - -export default ({ - draggable, - destination, - newCenter, - viewport, -}: Args): boolean => { - // what the new draggable boundary be if it had the new center - const diff: Position = subtract(newCenter, draggable.page.withMargin.center); - const shifted: Spacing = offset(draggable.page.withMargin, diff); - - return isPartiallyVisible({ - target: shifted, - destination, - viewport, - }); -}; 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 index 9f3f9dcd89..3c60dccae1 100644 --- 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 @@ -10,6 +10,7 @@ import type { export type Args = {| isMovingForward: boolean, draggableId: DraggableId, + previousPageCenter: Position, previousImpact: DragImpact, droppable: DroppableDimension, draggables: DraggableDimensionMap, @@ -20,4 +21,8 @@ export type Result = {| pageCenter: Position, // the impact of the movement impact: DragImpact, + // Any scroll that is required for the movement. + // If this is present then the pageCenter and impact + // will just be the same as the previous drag + scrollJumpRequest: ?Position, |} diff --git a/src/state/position.js b/src/state/position.js index 0b28eaf86e..2d548a28a3 100644 --- a/src/state/position.js +++ b/src/state/position.js @@ -51,3 +51,10 @@ export const distance = (point1: Position, point2: Position): number => // 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))); + +// used to apply any function to both values of a point +// eg: const floor = apply(Math.floor)(point); +export const apply = (fn: (value: number) => number) => (point: Position): Position => ({ + x: fn(point.x), + y: fn(point.y), +}); diff --git a/src/state/reducer.js b/src/state/reducer.js index d18f6d1296..69a3574ba2 100644 --- a/src/state/reducer.js +++ b/src/state/reducer.js @@ -22,8 +22,9 @@ import type { CurrentDragPositions, Position, InitialDragPositions, + LiftRequest, } from '../types'; -import { add, subtract } from './position'; +import { add, subtract, isEqual } from './position'; import { noMovement } from './no-impact'; import getDragImpact from './get-drag-impact/'; import moveToNextIndex from './move-to-next-index/'; @@ -52,8 +53,10 @@ type MoveArgs = {| clientSelection: Position, shouldAnimate: boolean, windowScroll ?: Position, - // force a custom drag impact - impact?: DragImpact, + // force a custom drag impact (optionally provided) + impact?: ?DragImpact, + // provide a scroll jump request (optionally provided - and can be null) + scrollJumpRequest?: ?Position, |} const canPublishDimension = (phase: Phase): boolean => @@ -66,6 +69,7 @@ const move = ({ shouldAnimate, windowScroll, impact, + scrollJumpRequest, }: MoveArgs): State => { if (state.phase !== 'DRAGGING') { console.error('cannot move while not dragging'); @@ -105,6 +109,7 @@ const move = ({ page, shouldAnimate, windowScroll: currentWindowScroll, + hasCompletedFirstBulkPublish: previous.hasCompletedFirstBulkPublish, }; const newImpact: DragImpact = (impact || getDragImpact({ @@ -119,6 +124,7 @@ const move = ({ initial, impact: newImpact, current, + scrollJumpRequest, }; return { @@ -127,7 +133,7 @@ const move = ({ }; }; -const updateStateAfterDimensionChange = (newState: State): State => { +const updateStateAfterDimensionChange = (newState: State, impact?: ?DragImpact): State => { // not dragging yet if (newState.phase === 'COLLECTING_INITIAL_DIMENSIONS') { return newState; @@ -149,6 +155,7 @@ const updateStateAfterDimensionChange = (newState: State): State => { // use the existing values clientSelection: newState.drag.current.client.selection, shouldAnimate: newState.drag.current.shouldAnimate, + impact, }); }; @@ -167,33 +174,28 @@ export default (state: State = clean('IDLE'), action: Action): State => { return clean(); } - const id: DraggableId = action.payload; + const request: LiftRequest = action.payload; return { phase: 'COLLECTING_INITIAL_DIMENSIONS', drag: null, drop: null, dimension: { - request: id, + request, draggable: {}, droppable: {}, }, }; } - if (action.type === 'PUBLISH_DRAGGABLE_DIMENSIONS') { - const dimensions: DraggableDimension[] = action.payload; + if (action.type === 'PUBLISH_DRAGGABLE_DIMENSION') { + const dimension: DraggableDimension = action.payload; if (!canPublishDimension(state.phase)) { console.warn('dimensions rejected as no longer allowing dimension capture in phase', state.phase); return state; } - const additions: DraggableDimensionMap = dimensions.reduce((previous, current) => { - previous[current.descriptor.id] = current; - return previous; - }, {}); - const newState: State = { ...state, dimension: { @@ -201,7 +203,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { droppable: state.dimension.droppable, draggable: { ...state.dimension.draggable, - ...additions, + [dimension.descriptor.id]: dimension, }, }, }; @@ -209,27 +211,82 @@ export default (state: State = clean('IDLE'), action: Action): State => { return updateStateAfterDimensionChange(newState); } - if (action.type === 'PUBLISH_DROPPABLE_DIMENSIONS') { - const dimensions: DroppableDimension[] = action.payload; + if (action.type === 'PUBLISH_DROPPABLE_DIMENSION') { + const dimension: DroppableDimension = action.payload; if (!canPublishDimension(state.phase)) { console.warn('dimensions rejected as no longer allowing dimension capture in phase', state.phase); return state; } - const additions: DroppableDimensionMap = dimensions.reduce((previous, current) => { + const newState: State = { + ...state, + dimension: { + request: state.dimension.request, + draggable: state.dimension.draggable, + droppable: { + ...state.dimension.droppable, + [dimension.descriptor.id]: dimension, + }, + }, + }; + + return updateStateAfterDimensionChange(newState); + } + + if (action.type === 'BULK_DIMENSION_PUBLISH') { + const draggables: DraggableDimension[] = action.payload.draggables; + const droppables: DroppableDimension[] = action.payload.droppables; + + if (!canPublishDimension(state.phase)) { + console.warn('dimensions rejected as no longer allowing dimension capture in phase', state.phase); + return state; + } + + const newDraggables: DraggableDimensionMap = draggables.reduce((previous, current) => { previous[current.descriptor.id] = current; return previous; }, {}); + const newDroppables: DroppableDimensionMap = droppables.reduce((previous, current) => { + previous[current.descriptor.id] = current; + return previous; + }, {}); + + const drag: ?DragState = (() => { + const existing: ?DragState = state.drag; + if (!existing) { + return null; + } + + if (existing.current.hasCompletedFirstBulkPublish) { + return existing; + } + + // $ExpectError - using spread + const newDrag: DragState = { + ...existing, + current: { + ...existing.current, + hasCompletedFirstBulkPublish: true, + }, + }; + + return newDrag; + })(); + const newState: State = { ...state, + drag, dimension: { request: state.dimension.request, - draggable: state.dimension.draggable, + draggable: { + ...state.dimension.draggable, + ...newDraggables, + }, droppable: { ...state.dimension.droppable, - ...additions, + ...newDroppables, }, }, }; @@ -243,7 +300,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { return state; } - const { id, client, windowScroll, isScrollAllowed } = action.payload; + const { id, client, windowScroll, autoScrollMode } = action.payload; const page: InitialDragPositions = { selection: add(client.selection, windowScroll), center: add(client.center, windowScroll), @@ -260,7 +317,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { const initial: InitialDrag = { descriptor, - isScrollAllowed, + autoScrollMode, client, page, windowScroll, @@ -278,6 +335,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { offset: origin, }, windowScroll, + hasCompletedFirstBulkPublish: false, shouldAnimate: false, }; @@ -308,6 +366,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { initial, current, impact, + scrollJumpRequest: null, }, }; } @@ -318,16 +377,10 @@ export default (state: State = clean('IDLE'), action: Action): State => { return clean(); } - if (state.drag == null) { - console.error('invalid store state'); - return clean(); - } + const drag: ?DragState = state.drag; - // 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.initial.isScrollAllowed) { + if (drag == null) { + console.error('invalid store state'); return clean(); } @@ -342,6 +395,10 @@ export default (state: State = clean('IDLE'), action: Action): State => { const dimension: DroppableDimension = scrollDroppable(target, offset); + // If we are jump scrolling - dimension changes should not update the impact + const impact: ?DragImpact = drag.initial.autoScrollMode === 'JUMP' ? + drag.impact : null; + const newState: State = { ...state, dimension: { @@ -354,7 +411,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { }, }; - return updateStateAfterDimensionChange(newState); + return updateStateAfterDimensionChange(newState, impact); } if (action.type === 'UPDATE_DROPPABLE_DIMENSION_IS_ENABLED') { @@ -395,28 +452,63 @@ export default (state: State = clean('IDLE'), action: Action): State => { } if (action.type === 'MOVE') { - const { client, windowScroll } = action.payload; + // Otherwise get an incorrect index calculated before the other dimensions are published + const { client, windowScroll, shouldAnimate } = action.payload; + const drag: ?DragState = state.drag; + + if (!drag) { + console.error('Cannot move while there is no drag state'); + return state; + } + + const impact: ?DragImpact = (() => { + // we do not want to recalculate the initial impact until the first bulk publish is finished + if (!drag.current.hasCompletedFirstBulkPublish) { + return drag.impact; + } + + // If we are jump scrolling - manual movements should not update the impact + if (drag.initial.autoScrollMode === 'JUMP') { + return drag.impact; + } + + return null; + })(); + return move({ state, clientSelection: client, windowScroll, - shouldAnimate: false, + shouldAnimate, + impact, }); } if (action.type === 'MOVE_BY_WINDOW_SCROLL') { const { windowScroll } = action.payload; + const drag: ?DragState = state.drag; - if (!state.drag) { + if (!drag) { console.error('cannot move with window scrolling if no current drag'); return clean(); } + if (isEqual(windowScroll, drag.current.windowScroll)) { + return state; + } + + // return state; + const isJumpScrolling: boolean = drag.initial.autoScrollMode === 'JUMP'; + + // If we are jump scrolling - any window scrolls should not update the impact + const impact: ?DragImpact = isJumpScrolling ? drag.impact : null; + return move({ state, - clientSelection: state.drag.current.client.selection, + clientSelection: drag.current.client.selection, windowScroll, shouldAnimate: false, + impact, }); } @@ -448,6 +540,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { draggableId: existing.initial.descriptor.id, droppable, draggables: state.dimension.draggable, + previousPageCenter: existing.current.page.center, previousImpact: existing.impact, }); @@ -465,6 +558,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { impact, clientSelection: client, shouldAnimate: true, + scrollJumpRequest: result.scrollJumpRequest, }); } @@ -521,7 +615,7 @@ export default (state: State = clean('IDLE'), action: Action): State => { } if (action.type === 'DROP_ANIMATE') { - const { trigger, newHomeOffset, impact, result } = action.payload; + const { newHomeOffset, impact, result } = action.payload; if (state.phase !== 'DRAGGING') { console.error('cannot animate drop while not dragging', action); @@ -534,7 +628,6 @@ export default (state: State = clean('IDLE'), action: Action): State => { } const pending: PendingDrop = { - trigger, newHomeOffset, result, impact, diff --git a/src/state/spacing.js b/src/state/spacing.js index e7056da3bb..62963f3150 100644 --- a/src/state/spacing.js +++ b/src/state/spacing.js @@ -4,20 +4,31 @@ import type { Spacing, } from '../types'; -// expands a spacing -export 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 offsetByPosition = (spacing: Spacing, point: Position): Spacing => ({ + top: spacing.top + point.y, + left: spacing.left + point.x, + bottom: spacing.bottom + point.y, + right: spacing.right + point.x, }); -export const addPosition = (spacing: Spacing, position: Position): Spacing => ({ - ...spacing, +export const expandByPosition = (spacing: Spacing, position: Position): Spacing => ({ + // pulling back to increase size + top: spacing.top - position.y, + left: spacing.left - position.x, + // pushing forward to increase size right: spacing.right + position.x, bottom: spacing.bottom + position.y, }); +export const expandBySpacing = (spacing1: Spacing, spacing2: Spacing): Spacing => ({ + // pulling back to increase size + top: spacing1.top - spacing2.top, + left: spacing1.left - spacing2.left, + // pushing forward to increase size + bottom: spacing1.bottom + spacing2.bottom, + right: spacing1.right + spacing2.right, +}); + export const isEqual = (spacing1: Spacing, spacing2: Spacing): boolean => ( spacing1.top === spacing2.top && spacing1.right === spacing2.right && @@ -25,13 +36,6 @@ export const isEqual = (spacing1: Spacing, spacing2: Spacing): boolean => ( spacing1.left === spacing2.left ); -export const offset = (spacing: Spacing, point: Position): Spacing => ({ - top: spacing.top + point.y, - right: spacing.right + point.x, - bottom: spacing.bottom + point.y, - left: spacing.left + point.x, -}); - export const getCorners = (spacing: Spacing): Position[] => [ { x: spacing.left, y: spacing.top }, { x: spacing.right, y: spacing.top }, diff --git a/src/state/visibility/get-viewport.js b/src/state/visibility/get-viewport.js deleted file mode 100644 index 3639c6d260..0000000000 --- a/src/state/visibility/get-viewport.js +++ /dev/null @@ -1,19 +0,0 @@ -// @flow -import type { Area } from '../../types'; -import getArea from '../get-area'; - -export default (): Area => { - // would use window.scrollY and window.scrollX but it is not supported in ie11 - const top: number = window.pageYOffset; - const left: number = window.pageXOffset; - const width: number = window.innerWidth; - const height: number = window.innerHeight; - - // computed - const right: number = left + width; - const bottom: number = top + height; - - return getArea({ - top, left, right, bottom, - }); -}; diff --git a/src/state/visibility/is-visible-through-frame.js b/src/state/visibility/is-partially-visible-through-frame.js similarity index 100% rename from src/state/visibility/is-visible-through-frame.js rename to src/state/visibility/is-partially-visible-through-frame.js diff --git a/src/state/visibility/is-partially-visible.js b/src/state/visibility/is-partially-visible.js deleted file mode 100644 index af40960462..0000000000 --- a/src/state/visibility/is-partially-visible.js +++ /dev/null @@ -1,47 +0,0 @@ -// @flow -import isVisibleThroughFrame from './is-visible-through-frame'; -import { offset } from '../spacing'; -import type { - Spacing, - Position, - Area, - DroppableDimension, -} from '../../types'; - -type Args = {| - target: Spacing, - destination: DroppableDimension, - viewport: Area, -|} - -// will return true if the position is visible: -// 1. within the viewport AND -// 2. within the destination Droppable -export default ({ - target, - destination, - viewport, -}: Args): boolean => { - const displacement: Position = destination.viewport.frameScroll.diff.displacement; - const withScroll: Spacing = offset(target, displacement); - - // destination subject is totally hidden by frame - // this should never happen - but just guarding against it - if (!destination.viewport.clipped) { - return false; - } - - // When considering if the target is visible in the droppable we need - // to consider the change in scroll of the droppable. We need to - // adjust for the scroll as the clipped viewport takes into account - // the scroll of the droppable. - const isVisibleInDroppable: boolean = - isVisibleThroughFrame(destination.viewport.clipped)(withScroll); - - // We also need to consider whether the destination scroll when detecting - // if we are visible in the viewport. - const isVisibleInViewport: boolean = - isVisibleThroughFrame(viewport)(withScroll); - - return isVisibleInDroppable && isVisibleInViewport; -}; diff --git a/src/state/visibility/is-totally-visible-through-frame.js b/src/state/visibility/is-totally-visible-through-frame.js new file mode 100644 index 0000000000..a37d680966 --- /dev/null +++ b/src/state/visibility/is-totally-visible-through-frame.js @@ -0,0 +1,20 @@ +// @flow +import isWithin from '../is-within'; +import type { + Spacing, +} from '../../types'; + +export default (frame: Spacing) => { + const isWithinVertical = isWithin(frame.top, frame.bottom); + const isWithinHorizontal = isWithin(frame.left, frame.right); + + return (subject: Spacing) => { + const isContained: boolean = + isWithinVertical(subject.top) && + isWithinVertical(subject.bottom) && + isWithinHorizontal(subject.left) && + isWithinHorizontal(subject.right); + + return isContained; + }; +}; diff --git a/src/state/visibility/is-visible.js b/src/state/visibility/is-visible.js new file mode 100644 index 0000000000..0e20843a47 --- /dev/null +++ b/src/state/visibility/is-visible.js @@ -0,0 +1,77 @@ +// @flow +import isPartiallyVisibleThroughFrame from './is-partially-visible-through-frame'; +import isTotallyVisibleThroughFrame from './is-totally-visible-through-frame'; +import { offsetByPosition } from '../spacing'; +import type { + Spacing, + Position, + Area, + DroppableDimension, +} from '../../types'; + +type Args = {| + target: Spacing, + destination: DroppableDimension, + viewport: Area, +|} + +type HelperArgs = {| + ...Args, + isVisibleThroughFrameFn: (frame: Spacing) => (subject: Spacing) => boolean +|} + +const origin: Position = { x: 0, y: 0 }; + +const isVisible = ({ + target, + destination, + viewport, + isVisibleThroughFrameFn, +}: HelperArgs): boolean => { + const displacement: Position = destination.viewport.closestScrollable ? + destination.viewport.closestScrollable.scroll.diff.displacement : + origin; + const withDisplacement: Spacing = offsetByPosition(target, displacement); + + // destination subject is totally hidden by frame + // this should never happen - but just guarding against it + if (!destination.viewport.clipped) { + return false; + } + + // When considering if the target is visible in the droppable we need + // to consider the change in scroll of the droppable. We need to + // adjust for the scroll as the clipped viewport takes into account + // the scroll of the droppable. + const isVisibleInDroppable: boolean = + isVisibleThroughFrameFn(destination.viewport.clipped)(withDisplacement); + + // We also need to consider whether the destination scroll when detecting + // if we are visible in the viewport. + const isVisibleInViewport: boolean = + isVisibleThroughFrameFn(viewport)(withDisplacement); + + return isVisibleInDroppable && isVisibleInViewport; +}; + +export const isPartiallyVisible = ({ + target, + destination, + viewport, +}: Args): boolean => isVisible({ + target, + destination, + viewport, + isVisibleThroughFrameFn: isPartiallyVisibleThroughFrame, +}); + +export const isTotallyVisible = ({ + target, + destination, + viewport, +}: Args): boolean => isVisible({ + target, + destination, + viewport, + isVisibleThroughFrameFn: isTotallyVisibleThroughFrame, +}); diff --git a/src/state/with-droppable-displacement.js b/src/state/with-droppable-displacement.js new file mode 100644 index 0000000000..384f354864 --- /dev/null +++ b/src/state/with-droppable-displacement.js @@ -0,0 +1,16 @@ +// @flow +import { add } from './position'; +import type { + Position, + ClosestScrollable, + DroppableDimension, +} from '../types'; + +export default (droppable: DroppableDimension, point: Position): Position => { + const closestScrollable: ?ClosestScrollable = droppable.viewport.closestScrollable; + if (!closestScrollable) { + return point; + } + + return add(point, closestScrollable.scroll.diff.displacement); +}; diff --git a/src/state/with-droppable-scroll.js b/src/state/with-droppable-scroll.js new file mode 100644 index 0000000000..5f3292f31a --- /dev/null +++ b/src/state/with-droppable-scroll.js @@ -0,0 +1,16 @@ +// @flow +import { add } from './position'; +import type { + Position, + ClosestScrollable, + DroppableDimension, +} from '../types'; + +export default (droppable: DroppableDimension, point: Position): Position => { + const closestScrollable: ?ClosestScrollable = droppable.viewport.closestScrollable; + if (!closestScrollable) { + return point; + } + + return add(point, closestScrollable.scroll.diff.value); +}; diff --git a/src/types.js b/src/types.js index 144160475f..1f4e4ad3f8 100644 --- a/src/types.js +++ b/src/types.js @@ -49,10 +49,10 @@ export type Direction = 'horizontal' | 'vertical'; export type VerticalAxis = {| direction: 'vertical', line: 'y', - crossLine: 'x', start: 'top', end: 'bottom', size: 'height', + crossAxisLine: 'x', crossAxisStart: 'left', crossAxisEnd: 'right', crossAxisSize: 'width', @@ -61,10 +61,10 @@ export type VerticalAxis = {| export type HorizontalAxis = {| direction: 'horizontal', line: 'x', - crossLine: 'y', start: 'left', end: 'right', size: 'width', + crossAxisLine: 'y', crossAxisStart: 'top', crossAxisEnd: 'bottom', crossAxisSize: 'height', @@ -98,14 +98,18 @@ export type DraggableDimension = {| |}, |} -export type DroppableDimensionViewport = {| +export type ClosestScrollable = {| // This is the window through which the droppable is observed // It does not change during a drag frame: Area, - // keeping track of the scroll - frameScroll: {| + // Whether or not we should clip the subject by the frame + // Is controlled by the ignoreContainerClipping prop + shouldClipSubject: boolean, + scroll: {| initial: Position, current: Position, + // the maximum allowable scroll for the frame + max: Position, diff: {| value: Position, // The actual displacement as a result of a scroll is in the opposite @@ -113,13 +117,16 @@ export type DroppableDimensionViewport = {| // upwards. This value is the negated version of the 'value' displacement: Position, |} - |}, - // The area to be clipped by the frame - // This is the initial capture of the subject and is not updated + |} +|} + +export type DroppableDimensionViewport = {| + // will be null if there is no closest scrollable + closestScrollable: ?ClosestScrollable, subject: Area, - // this is the subject through the viewport of the frame + // this is the subject through the viewport of the frame (if applicable) // it also takes into account any changes to the viewport scroll - // clipped area will be null if it is completely outside of the frame + // clipped area will be null if it is completely outside of the frame and frame clipping is on clipped: ?Area, |} @@ -182,10 +189,14 @@ export type InitialDragPositions = {| center: Position, |} +// When dragging with a pointer such as a mouse or touch input we want to automatically +// scroll user the under input when we get near the bottom of a Droppable or the window. +// When Dragging with a keyboard we want to jump as required +export type AutoScrollMode = 'FLUID' | 'JUMP'; + export type InitialDrag = {| descriptor: DraggableDescriptor, - // whether scrolling is allowed - otherwise a scroll will cancel the drag - isScrollAllowed: boolean, + autoScrollMode: AutoScrollMode, // relative to the viewport when the drag started client: InitialDragPositions, // viewport + window scroll (position relative to 0, 0) @@ -211,12 +222,13 @@ export type CurrentDrag = {| windowScroll: Position, // whether or not draggable movements should be animated shouldAnimate: boolean, + // We do not want to calculate drag impacts until we have completed + // the first bulk publish. Otherwise the onDragUpdate hook will + // be called with incorrect indexes. + // Before the first bulk publish the calculations will return incorrect indexes. + hasCompletedFirstBulkPublish: boolean, |} -// type PreviousDrag = { -// droppableOverId: ?DroppableId, -// }; - // published when a drag starts export type DragStart = {| draggableId: DraggableId, @@ -224,25 +236,29 @@ export type DragStart = {| source: DraggableLocation, |} -// published when a drag finishes -export type DropResult = {| - draggableId: DraggableId, - type: TypeId, - source: DraggableLocation, +export type DragUpdate = {| + ...DragStart, // may not have any destination (drag to nowhere) destination: ?DraggableLocation, |} +export type DropReason = 'DROP' | 'CANCEL'; + +// published when a drag finishes +export type DropResult = {| + ...DragUpdate, + reason: DropReason, +|} + export type DragState = {| initial: InitialDrag, current: CurrentDrag, impact: DragImpact, + // if we need to jump the scroll (keyboard dragging) + scrollJumpRequest: ?Position, |} -export type DropTrigger = 'DROP' | 'CANCEL'; - export type PendingDrop = {| - trigger: DropTrigger, newHomeOffset: Position, impact: DragImpact, result: DropResult, @@ -273,13 +289,22 @@ export type Phase = // This will result in the onDragEnd hook being fired 'DROP_COMPLETE'; +export type ScrollOptions = {| + shouldPublishImmediately: boolean, +|} + +export type LiftRequest = {| + draggableId: DraggableId, + scrollOptions: ScrollOptions, +|} + export type DimensionState = {| // using the draggable id rather than the descriptor as the descriptor // may change as a result of the initial flush. This means that the lift // descriptor may not be the same as the actual descriptor. To avoid // confusion the request is just an id which is looked up // in the dimension-marshal post-flush - request: ?DraggableId, + request: ?LiftRequest, draggable: DraggableDimensionMap, droppable: DroppableDimensionMap, |}; @@ -303,7 +328,20 @@ export type Action = ActionCreators; export type Dispatch = ReduxDispatch; export type Store = ReduxStore; +export type Announce = (message: string) => void; + +export type HookProvided = {| + announce: Announce, +|} + +export type OnDragStartHook = (start: DragStart, provided: HookProvided) => void; +export type OnDragUpdateHook = (update: DragUpdate, provided: HookProvided) => void; +export type OnDragEndHook = (result: DropResult, provided: HookProvided) => void; + export type Hooks = {| - onDragStart?: (start: DragStart) => void, - onDragEnd: (result: DropResult) => void, + onDragStart?: OnDragStartHook, + onDragUpdate?: OnDragUpdateHook, + // always required + onDragEnd: OnDragEndHook, |} + diff --git a/src/view/announcer/announcer-types.js b/src/view/announcer/announcer-types.js new file mode 100644 index 0000000000..2235b1005a --- /dev/null +++ b/src/view/announcer/announcer-types.js @@ -0,0 +1,9 @@ +// @flow +import type { Announce } from '../../types'; + +export type Announcer = {| + announce: Announce, + id: string, + mount: () => void, + unmount: () => void, +|} diff --git a/src/view/announcer/announcer.js b/src/view/announcer/announcer.js new file mode 100644 index 0000000000..846238accf --- /dev/null +++ b/src/view/announcer/announcer.js @@ -0,0 +1,107 @@ +// @flow +import type { Announce } from '../../types'; +import type { Announcer } from './announcer-types'; + +type State = {| + el: ?HTMLElement, +|} + +let count: number = 0; + +// https://allyjs.io/tutorials/hiding-elements.html +// Element is visually hidden but is readable by screen readers +const visuallyHidden: Object = { + position: 'absolute', + width: '1px', + height: '1px', + margin: '-1px', + border: '0', + padding: '0', + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + // for if 'clip' is ever removed + 'clip-path': 'inset(100%)', +}; + +export default (): Announcer => { + const id: string = `react-beautiful-dnd-announcement-${count++}`; + + let state: State = { + el: null, + }; + + const setState = (newState: State) => { + state = newState; + }; + + const announce: Announce = (message: string): void => { + const el: ?HTMLElement = state.el; + if (!el) { + console.error('Cannot announce to unmounted node'); + return; + } + + el.textContent = message; + }; + + const mount = () => { + if (state.el) { + console.error('Announcer already mounted'); + return; + } + + const el: HTMLElement = document.createElement('div'); + // identifier + el.id = id; + + // Aria live region + + // will force itself to be read + el.setAttribute('aria-live', 'assertive'); + el.setAttribute('role', 'log'); + // must read the whole thing every time + el.setAttribute('aria-atomic', 'true'); + + // hide the element visually + Object.assign(el.style, visuallyHidden); + + if (!document.body) { + throw new Error('Cannot find the head to append a style to'); + } + + // add el tag to body + document.body.appendChild(el); + setState({ + el, + }); + }; + + const unmount = () => { + if (!state.el) { + console.error('Will not unmount annoucer as it is already unmounted'); + return; + } + const node: HTMLElement = state.el; + + setState({ + el: null, + }); + + if (!node.parentNode) { + console.error('Cannot unmount style marshal as cannot find parent'); + return; + } + + node.parentNode.removeChild(node); + }; + + const announcer: Announcer = { + announce, + id, + mount, + unmount, + }; + + return announcer; +}; + diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index 546ef94716..e0cd6fa431 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -2,10 +2,15 @@ import React, { type Node } from 'react'; import PropTypes from 'prop-types'; import createStore from '../../state/create-store'; -import fireHooks from '../../state/fire-hooks'; +import createHookCaller from '../../state/hooks/hook-caller'; import createDimensionMarshal from '../../state/dimension-marshal/dimension-marshal'; import createStyleMarshal from '../style-marshal/style-marshal'; import canStartDrag from '../../state/can-start-drag'; +import scrollWindow from '../../window/scroll-window'; +import createAnnouncer from '../announcer/announcer'; +import type { Announcer } from '../announcer/announcer-types'; +import createAutoScroller from '../../state/auto-scroller'; +import type { AutoScroller } from '../../state/auto-scroller/auto-scroller-types'; import type { StyleMarshal } from '../style-marshal/style-marshal-types'; import type { DimensionMarshal, @@ -15,12 +20,15 @@ import type { DraggableId, Store, State, - Hooks, DraggableDimension, DroppableDimension, DroppableId, Position, + Hooks, } from '../../types'; +import type { + HookCaller, +} from '../../state/hooks/hooks-types'; import { storeKey, dimensionMarshalKey, @@ -29,10 +37,12 @@ import { } from '../context-keys'; import { clean, - publishDraggableDimensions, - publishDroppableDimensions, + move, + publishDraggableDimension, + publishDroppableDimension, updateDroppableDimensionScroll, updateDroppableDimensionIsEnabled, + bulkPublishDimensions, } from '../../state/action-creators'; type Props = {| @@ -49,6 +59,9 @@ export default class DragDropContext extends React.Component { store: Store dimensionMarshal: DimensionMarshal styleMarshal: StyleMarshal + autoScroller: AutoScroller + hookCaller: HookCaller + announcer: Announcer unsubscribe: Function // Need to declare childContextTypes without flow @@ -86,6 +99,11 @@ export default class DragDropContext extends React.Component { componentWillMount() { this.store = createStore(); + this.announcer = createAnnouncer(); + + // create the hook caller + this.hookCaller = createHookCaller(this.announcer.announce); + // create the style marshal this.styleMarshal = createStyleMarshal(); @@ -94,11 +112,14 @@ export default class DragDropContext extends React.Component { cancel: () => { this.store.dispatch(clean()); }, - publishDraggables: (dimensions: DraggableDimension[]) => { - this.store.dispatch(publishDraggableDimensions(dimensions)); + publishDraggable: (dimension: DraggableDimension) => { + this.store.dispatch(publishDraggableDimension(dimension)); + }, + publishDroppable: (dimension: DroppableDimension) => { + this.store.dispatch(publishDroppableDimension(dimension)); }, - publishDroppables: (dimensions: DroppableDimension[]) => { - this.store.dispatch(publishDroppableDimensions(dimensions)); + bulkPublish: (droppables: DroppableDimension[], draggables: DraggableDimension[]) => { + this.store.dispatch(bulkPublishDimensions(droppables, draggables)); }, updateDroppableScroll: (id: DroppableId, newScroll: Position) => { this.store.dispatch(updateDroppableDimensionScroll(id, newScroll)); @@ -108,6 +129,18 @@ export default class DragDropContext extends React.Component { }, }; this.dimensionMarshal = createDimensionMarshal(callbacks); + this.autoScroller = createAutoScroller({ + scrollWindow, + scrollDroppable: this.dimensionMarshal.scrollDroppable, + move: ( + id: DraggableId, + client: Position, + windowScroll: Position, + shouldAnimate?: boolean + ): void => { + this.store.dispatch(move(id, client, windowScroll, shouldAnimate)); + }, + }); let previous: State = this.store.getState(); @@ -118,24 +151,25 @@ export default class DragDropContext extends React.Component { // functions synchronously trigger more updates previous = current; - // no lifecycle changes have occurred if phase has not changed - if (current.phase === previousValue.phase) { - return; - } - - // Allowing dynamic hooks by re-capturing the hook functions + // TODO: this probs needs to be done first const hooks: Hooks = { onDragStart: this.props.onDragStart, onDragEnd: this.props.onDragEnd, + onDragUpdate: this.props.onDragUpdate, }; - fireHooks(hooks, previousValue, current); + this.hookCaller.onStateChange(hooks, previousValue, current); + + if (current.phase !== previousValue.phase) { + // executing phase change handlers first + // Update the global styles + this.styleMarshal.onPhaseChange(current); - // Update the global styles - this.styleMarshal.onPhaseChange(current); + // inform the dimension marshal about updates + // this can trigger more actions synchronously so we are placing it last + this.dimensionMarshal.onPhaseChange(current); + } - // inform the dimension marshal about updates - // this can trigger more actions synchronously so we are placing it last - this.dimensionMarshal.onPhaseChange(current); + this.autoScroller.onStateChange(previousValue, current); }); } @@ -144,11 +178,13 @@ export default class DragDropContext extends React.Component { // this cannot be done before otherwise it would break // server side rendering this.styleMarshal.mount(); + this.announcer.mount(); } componentWillUnmount() { this.unsubscribe(); this.styleMarshal.unmount(); + this.announcer.unmount(); } render() { diff --git a/src/view/drag-handle/drag-handle-types.js b/src/view/drag-handle/drag-handle-types.js index dd42b884ff..d310caab4b 100644 --- a/src/view/drag-handle/drag-handle-types.js +++ b/src/view/drag-handle/drag-handle-types.js @@ -1,11 +1,16 @@ // @flow import type { Node } from 'react'; -import type { Position, Direction, DraggableId } from '../../types'; +import type { + AutoScrollMode, + Position, + Direction, + DraggableId, +} from '../../types'; export type Callbacks = {| - onLift: ({ client: Position, isScrollAllowed: boolean }) => void, + onLift: ({ client: Position, autoScrollMode: AutoScrollMode }) => void, onMove: (point: Position) => void, - onWindowScroll: (diff: Position) => void, + onWindowScroll: () => void, onMoveForward: () => void, onMoveBackward: () => void, onCrossAxisMoveForward: () => void, @@ -26,12 +31,12 @@ export type DragHandleProps = {| // Control styling from style marshal 'data-react-beautiful-dnd-drag-handle': string, + // Aria role (nicer screen reader text) + 'aria-roledescription': string, + // Allow tabbing to this element tabIndex: number, - // Aria - 'aria-grabbed': boolean, - // Stop html5 drag and drop draggable: boolean, onDragStart: () => boolean, diff --git a/src/view/drag-handle/drag-handle.jsx b/src/view/drag-handle/drag-handle.jsx index 91872ea9a2..8e6ddb6542 100644 --- a/src/view/drag-handle/drag-handle.jsx +++ b/src/view/drag-handle/drag-handle.jsx @@ -176,13 +176,10 @@ export default class DragHandle extends Component { return shouldAllowDraggingFromTarget(event, this.props); } - isAnySensorDragging = (): boolean => - this.sensors.some((sensor: Sensor) => sensor.isDragging()) - isAnySensorCapturing = (): boolean => this.sensors.some((sensor: Sensor) => sensor.isCapturing()) - getProvided = memoizeOne((isEnabled: boolean, isDragging: boolean): ?DragHandleProps => { + getProvided = memoizeOne((isEnabled: boolean): ?DragHandleProps => { if (!isEnabled) { return null; } @@ -194,8 +191,9 @@ export default class DragHandle extends Component { onTouchMove: this.onTouchMove, onClick: this.onClick, tabIndex: 0, - 'aria-grabbed': isDragging, 'data-react-beautiful-dnd-drag-handle': this.styleContext, + // English default. Consumers are welcome to add their own start instruction + 'aria-roledescription': 'Draggable item. Press space bar to lift', draggable: false, onDragStart: getFalse, onDrop: getFalse, @@ -207,6 +205,6 @@ export default class DragHandle extends Component { render() { const { children, isEnabled } = this.props; - return children(this.getProvided(isEnabled, this.isAnySensorDragging())); + return children(this.getProvided(isEnabled)); } } diff --git a/src/view/drag-handle/sensor/create-keyboard-sensor.js b/src/view/drag-handle/sensor/create-keyboard-sensor.js index c60c5c245a..36c038799c 100644 --- a/src/view/drag-handle/sensor/create-keyboard-sensor.js +++ b/src/view/drag-handle/sensor/create-keyboard-sensor.js @@ -21,6 +21,17 @@ type ExecuteBasedOnDirection = {| horizontal: () => void, |} +type KeyMap = { + [key: number]: true +} + +const scrollJumpKeys: KeyMap = { + [keyCodes.pageDown]: true, + [keyCodes.pageUp]: true, + [keyCodes.home]: true, + [keyCodes.end]: true, +}; + const noop = () => { }; export default ({ @@ -82,8 +93,10 @@ export default ({ // using center position as selection const center: Position = getCenterPosition(ref); - // not allowing scrolling with a mouse when lifting with a keyboard - startDragging(() => callbacks.onLift({ client: center, isScrollAllowed: false })); + startDragging(() => callbacks.onLift({ + client: center, + autoScrollMode: 'JUMP', + })); return; } @@ -156,14 +169,27 @@ export default ({ } blockStandardKeyEvents(event); + + // blocking scroll jumping at this time + if (scrollJumpKeys[event.keyCode]) { + stopEvent(event); + } }; const windowBindings = { - // any mouse down kills a drag + // any mouse actions kills a drag mousedown: cancel, + mouseup: cancel, + click: cancel, + touchstart: cancel, + // resizing the browser kills a drag resize: cancel, - // currently not supporting window scrolling with a keyboard - scroll: cancel, + // kill if the user is using the mouse wheel + // We are not supporting wheel / trackpad scrolling with keyboard dragging + wheel: cancel, + // Need to respond instantly to a jump scroll request + // Not using the scheduler + scroll: callbacks.onWindowScroll, }; const eventKeys: string[] = Object.keys(windowBindings); diff --git a/src/view/drag-handle/sensor/create-mouse-sensor.js b/src/view/drag-handle/sensor/create-mouse-sensor.js index 1d62820e13..7e5cdb724f 100644 --- a/src/view/drag-handle/sensor/create-mouse-sensor.js +++ b/src/view/drag-handle/sensor/create-mouse-sensor.js @@ -116,7 +116,10 @@ export default ({ return; } - startDragging(() => callbacks.onLift({ client: point, isScrollAllowed: true })); + startDragging(() => callbacks.onLift({ + client: point, + autoScrollMode: 'FLUID', + })); }, mouseup: () => { if (state.pending) { @@ -175,6 +178,7 @@ export default ({ eventKeys.forEach((eventKey: string) => { if (eventKey === 'scroll') { + // eventual consistency is fine because we use position: fixed on the item win.addEventListener(eventKey, windowBindings.scroll, { passive: true }); return; } diff --git a/src/view/drag-handle/sensor/create-touch-sensor.js b/src/view/drag-handle/sensor/create-touch-sensor.js index d89687470b..1707bee57f 100644 --- a/src/view/drag-handle/sensor/create-touch-sensor.js +++ b/src/view/drag-handle/sensor/create-touch-sensor.js @@ -71,8 +71,7 @@ export default ({ callbacks.onLift({ client: pending, - // not allowing container scrolling for touch movements at this stage - isScrollAllowed: false, + autoScrollMode: 'FLUID', }); }; const stopDragging = (fn?: Function = noop) => { @@ -178,10 +177,14 @@ export default ({ orientationchange: cancel, // some devices fire resize if the orientation changes resize: cancel, - // A window scroll will cancel a pending or current drag. - // This should not happen as we are calling preventDefault in touchmove, - // but just being extra safe - scroll: cancel, + scroll: () => { + // stop a pending drag + if (state.pending) { + stopPendingDrag(); + return; + } + schedule.windowScrollMove(); + }, // Long press can bring up a context menu // need to opt out of this behavior contextmenu: stopEvent, @@ -220,6 +223,14 @@ export default ({ return; } + // For scroll events we are okay with eventual consistency. + // Passive scroll listeners is the default behavior for mobile + // but we are being really clear here + if (eventKey === 'scroll') { + win.addEventListener(eventKey, fn, { passive: true }); + return; + } + win.addEventListener(eventKey, fn); }); }; diff --git a/src/view/drag-handle/util/block-standard-key-events.js b/src/view/drag-handle/util/block-standard-key-events.js index 3a253b6813..e43b220c82 100644 --- a/src/view/drag-handle/util/block-standard-key-events.js +++ b/src/view/drag-handle/util/block-standard-key-events.js @@ -2,15 +2,19 @@ import * as keyCodes from '../../key-codes'; import stopEvent from './stop-event'; -const blocked: number[] = [ +type KeyMap = { + [key: number]: true +} + +const blocked: KeyMap = { // submission - keyCodes.enter, + [keyCodes.enter]: true, // tabbing - keyCodes.tab, -]; + [keyCodes.tab]: true, +}; export default (event: KeyboardEvent) => { - if (blocked.indexOf(event.keyCode) >= 0) { + if (blocked[event.keyCode]) { stopEvent(event); } }; diff --git a/src/view/drag-handle/util/should-allow-dragging-from-target.js b/src/view/drag-handle/util/should-allow-dragging-from-target.js index 1affdd9de1..58a40c0077 100644 --- a/src/view/drag-handle/util/should-allow-dragging-from-target.js +++ b/src/view/drag-handle/util/should-allow-dragging-from-target.js @@ -1,16 +1,20 @@ // @flow import type { Props } from '../drag-handle-types'; -export const interactiveTagNames: string[] = [ - 'input', - 'button', - 'textarea', - 'select', - 'option', - 'optgroup', - 'video', - 'audio', -]; +export type TagNameMap = { + [tagName: string]: true +} + +export const interactiveTagNames: TagNameMap = { + input: true, + button: true, + textarea: true, + select: true, + option: true, + optgroup: true, + video: true, + audio: true, +}; const isContentEditable = (parent: Element, current: ?Element): boolean => { if (current == null) { @@ -48,8 +52,7 @@ export default (event: Event, props: Props): boolean => { return true; } - const isTargetInteractive: boolean = - interactiveTagNames.indexOf(target.tagName.toLowerCase()) !== -1; + const isTargetInteractive: boolean = Boolean(interactiveTagNames[target.tagName.toLowerCase()]); if (isTargetInteractive) { return false; diff --git a/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx b/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx index 481afca400..2f97b60151 100644 --- a/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx +++ b/src/view/draggable-dimension-publisher/draggable-dimension-publisher.jsx @@ -3,7 +3,7 @@ import { Component } from 'react'; import type { Node } from 'react'; import PropTypes from 'prop-types'; import memoizeOne from 'memoize-one'; -import getWindowScrollPosition from '../get-window-scroll-position'; +import getWindowScroll from '../../window/get-window-scroll'; import { getDraggableDimension } from '../../state/dimension'; import { dimensionMarshalKey } from '../context-keys'; import getArea from '../../state/get-area'; @@ -119,7 +119,7 @@ export default class DraggableDimensionPublisher extends Component { descriptor, client, margin, - windowScroll: getWindowScrollPosition(), + windowScroll: getWindowScroll(), }); return dimension; diff --git a/src/view/draggable/connected-draggable.js b/src/view/draggable/connected-draggable.js index 041573a23f..7830bb94dc 100644 --- a/src/view/draggable/connected-draggable.js +++ b/src/view/draggable/connected-draggable.js @@ -22,10 +22,12 @@ import type { State, Position, DraggableId, + DroppableId, DragMovement, DraggableDimension, Direction, Displacement, + PendingDrop, } from '../../types'; import type { MapProps, @@ -47,6 +49,7 @@ const defaultMapProps: MapProps = { // these properties are only populated when the item is dragging dimension: null, direction: null, + draggingOver: null, }; export const makeSelector = (): Selector => { @@ -66,6 +69,7 @@ export const makeSelector = (): Selector => { shouldAnimateDragMovement: false, dimension: null, direction: null, + draggingOver: null, }), ); @@ -75,6 +79,8 @@ export const makeSelector = (): Selector => { dimension: DraggableDimension, // direction of the droppable you are over direction: ?Direction, + // the id of the droppable you are over + draggingOver: ?DroppableId, ): MapProps => ({ isDragging: true, isDropAnimating: false, @@ -83,6 +89,7 @@ export const makeSelector = (): Selector => { shouldAnimateDragMovement, dimension, direction, + draggingOver, })); const draggingSelector = (state: State, ownProps: OwnProps): ?MapProps => { @@ -105,36 +112,47 @@ export const makeSelector = (): Selector => { const dimension: DraggableDimension = state.dimension.draggable[ownProps.draggableId]; const direction: ?Direction = state.drag.impact.direction; const shouldAnimateDragMovement: boolean = state.drag.current.shouldAnimate; + const draggingOver: ?DroppableId = state.drag.impact.destination ? + state.drag.impact.destination.droppableId : + null; return getDraggingProps( memoizedOffset(offset.x, offset.y), shouldAnimateDragMovement, dimension, direction, + draggingOver, ); } // dropping - if (!state.drop || !state.drop.pending) { + const pending: ?PendingDrop = state.drop && state.drop.pending; + + if (!pending) { console.error('cannot provide props for dropping item when there is invalid state'); return null; } // this was not the dragging item - if (state.drop.pending.result.draggableId !== ownProps.draggableId) { + if (pending.result.draggableId !== ownProps.draggableId) { return null; } + const draggingOver: ?DroppableId = pending.result.destination ? + pending.result.destination.droppableId : null; + const direction: ?Direction = pending.impact.direction ? + pending.impact.direction : null; + // not memoized as it is the only execution return { isDragging: false, isDropAnimating: true, - offset: state.drop.pending.newHomeOffset, + offset: pending.newHomeOffset, // still need to provide the dimension for the placeholder dimension: state.dimension.draggable[ownProps.draggableId], - // direction no longer needed as drag handle is unbound - direction: null, + draggingOver, + direction, // animation will be controlled by the isDropAnimating flag shouldAnimateDragMovement: false, // not relevant, diff --git a/src/view/draggable/draggable-types.js b/src/view/draggable/draggable-types.js index 95b59a7f10..36d765191d 100644 --- a/src/view/draggable/draggable-types.js +++ b/src/view/draggable/draggable-types.js @@ -2,6 +2,7 @@ import type { Node } from 'react'; import type { DraggableId, + DroppableId, DraggableDimension, Position, Direction, @@ -106,6 +107,7 @@ export type Provided = {| export type StateSnapshot = {| isDragging: boolean, + draggingOver: ?DroppableId, |} export type DispatchProps = {| @@ -129,12 +131,13 @@ export type MapProps = {| // when an item is being displaced by a dragging item, // we need to know if that movement should be animated shouldAnimateDisplacement: boolean, + isDropAnimating: boolean, + offset: Position, // only provided when dragging // can be null if not over a droppable direction: ?Direction, - isDropAnimating: boolean, - offset: Position, dimension: ?DraggableDimension, + draggingOver: ?DroppableId, |} export type OwnProps = {| diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 5035e9e190..2e1597156a 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -8,11 +8,12 @@ import type { DraggableDimension, InitialDragPositions, DroppableId, + AutoScrollMode, } from '../../types'; import DraggableDimensionPublisher from '../draggable-dimension-publisher/'; import Moveable from '../moveable/'; import DragHandle from '../drag-handle'; -import getWindowScrollPosition from '../get-window-scroll-position'; +import getWindowScroll from '../../window/get-window-scroll'; // eslint-disable-next-line no-duplicate-imports import type { DragHandleProps, @@ -102,9 +103,9 @@ export default class Draggable extends Component { this.props.dropAnimationFinished(); } - onLift = (options: {client: Position, isScrollAllowed: boolean}) => { + onLift = (options: {client: Position, autoScrollMode: AutoScrollMode}) => { this.throwIfCannotDrag(); - const { client, isScrollAllowed } = options; + const { client, autoScrollMode } = options; const { lift, draggableId } = this.props; const { ref } = this.state; @@ -117,9 +118,9 @@ export default class Draggable extends Component { center: getCenterPosition(ref), }; - const windowScroll: Position = getWindowScrollPosition(); + const windowScroll: Position = getWindowScroll(); - lift(draggableId, initial, windowScroll, isScrollAllowed); + lift(draggableId, initial, windowScroll, autoScrollMode); } onMove = (client: Position) => { @@ -132,7 +133,7 @@ export default class Draggable extends Component { return; } - const windowScroll: Position = getWindowScrollPosition(); + const windowScroll: Position = getWindowScroll(); move(draggableId, client, windowScroll); } @@ -159,7 +160,7 @@ export default class Draggable extends Component { onWindowScroll = () => { this.throwIfCannotDrag(); - const windowScroll = getWindowScrollPosition(); + const windowScroll = getWindowScroll(); this.props.moveByWindowScroll(this.props.draggableId, windowScroll); } @@ -274,8 +275,13 @@ export default class Draggable extends Component { } ) - getSnapshot = memoizeOne((isDragging: boolean, isDropAnimating: boolean): StateSnapshot => ({ + getSnapshot = memoizeOne(( + isDragging: boolean, + isDropAnimating: boolean, + draggingOver: ?DroppableId, + ): StateSnapshot => ({ isDragging: (isDragging || isDropAnimating), + draggingOver, })) getSpeed = memoizeOne( @@ -302,11 +308,12 @@ export default class Draggable extends Component { isDropAnimating, isDragDisabled, dimension, - children, + draggingOver, direction, shouldAnimateDragMovement, shouldAnimateDisplacement, disableInteractiveElementBlocking, + children, } = this.props; const droppableId: DroppableId = this.context[droppableIdKey]; @@ -349,7 +356,11 @@ export default class Draggable extends Component { dragHandleProps, movementStyle, ), - this.getSnapshot(isDragging, isDropAnimating) + this.getSnapshot( + isDragging, + isDropAnimating, + draggingOver, + ) ) } diff --git a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx b/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx index beb5927563..64705b45d8 100644 --- a/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx +++ b/src/view/droppable-dimension-publisher/droppable-dimension-publisher.jsx @@ -4,7 +4,7 @@ import type { Node } from 'react'; import PropTypes from 'prop-types'; import memoizeOne from 'memoize-one'; import rafSchedule from 'raf-schd'; -import getWindowScrollPosition from '../get-window-scroll-position'; +import getWindowScroll from '../../window/get-window-scroll'; import getArea from '../../state/get-area'; import { getDroppableDimension } from '../../state/dimension'; import getClosestScrollable from '../get-closest-scrollable'; @@ -22,6 +22,7 @@ import type { Area, Spacing, Direction, + ScrollOptions, } from '../../types'; type Props = {| @@ -41,6 +42,7 @@ export default class DroppableDimensionPublisher extends Component { /* eslint-disable react/sort-comp */ closestScrollable: ?Element = null; isWatchingScroll: boolean = false; + scrollOptions: ?ScrollOptions = null; callbacks: DroppableCallbacks; publishedDescriptor: ?DroppableDescriptor = null; @@ -50,6 +52,7 @@ export default class DroppableDimensionPublisher extends Component { getDimension: this.getDimension, watchScroll: this.watchScroll, unwatchScroll: this.unwatchScroll, + scroll: this.scroll, }; this.callbacks = callbacks; } @@ -58,7 +61,7 @@ export default class DroppableDimensionPublisher extends Component { [dimensionMarshalKey]: PropTypes.object.isRequired, }; - getScrollOffset = (): Position => { + getClosestScroll = (): Position => { if (!this.closestScrollable) { return origin; } @@ -82,18 +85,41 @@ export default class DroppableDimensionPublisher extends Component { marshal.updateDroppableScroll(this.publishedDescriptor.id, newScroll); }); - scheduleScrollUpdate = rafSchedule((offset: Position) => { - // might no longer be listening for scroll changes by the time a frame comes back - if (this.isWatchingScroll) { - this.memoizedUpdateScroll(offset.x, offset.y); - } - }); + updateScroll = () => { + const offset: Position = this.getClosestScroll(); + this.memoizedUpdateScroll(offset.x, offset.y); + } + + scheduleScrollUpdate = rafSchedule(this.updateScroll); onClosestScroll = () => { - this.scheduleScrollUpdate(this.getScrollOffset()); + if (!this.scrollOptions) { + console.error('Cannot find scroll options while scrolling'); + return; + } + if (this.scrollOptions.shouldPublishImmediately) { + this.updateScroll(); + return; + } + this.scheduleScrollUpdate(); } - watchScroll = () => { + scroll = (change: Position) => { + if (this.closestScrollable == null) { + console.error('Cannot scroll a droppable with no closest scrollable'); + return; + } + + if (!this.isWatchingScroll) { + console.error('Updating Droppable scroll while not watching for updates'); + return; + } + + this.closestScrollable.scrollTop += change.y; + this.closestScrollable.scrollLeft += change.x; + } + + watchScroll = (options: ScrollOptions) => { if (!this.props.targetRef) { console.error('cannot watch droppable scroll if not in the dom'); return; @@ -109,6 +135,7 @@ export default class DroppableDimensionPublisher extends Component { } this.isWatchingScroll = true; + this.scrollOptions = options; this.closestScrollable.addEventListener('scroll', this.onClosestScroll, { passive: true }); }; @@ -120,6 +147,8 @@ export default class DroppableDimensionPublisher extends Component { } this.isWatchingScroll = false; + this.scrollOptions = null; + this.scheduleScrollUpdate.cancel(); if (!this.closestScrollable) { console.error('cannot unbind event listener if element is null'); @@ -223,7 +252,6 @@ export default class DroppableDimensionPublisher extends Component { // side effect - grabbing it for scroll listening so we know it is the same node this.closestScrollable = getClosestScrollable(targetRef); - const frameScroll: Position = this.getScrollOffset(); const style: Object = window.getComputedStyle(targetRef); // keeping it simple and always using the margin of the droppable @@ -249,28 +277,35 @@ export default class DroppableDimensionPublisher extends Component { // 2. There is no scroll container // 3. The droppable has internal scrolling - const frameClient: ?Area = (() => { - if (ignoreContainerClipping) { - return null; - } - if (!this.closestScrollable) { - return null; - } - if (this.closestScrollable === targetRef) { + const closest = (() => { + const closestScrollable: ?Element = this.closestScrollable; + + if (!closestScrollable) { return null; } - return getArea(this.closestScrollable.getBoundingClientRect()); + + const frameClient: Area = getArea(closestScrollable.getBoundingClientRect()); + const scroll: Position = this.getClosestScroll(); + const scrollWidth: number = closestScrollable.scrollWidth; + const scrollHeight: number = closestScrollable.scrollHeight; + + return { + frameClient, + scrollWidth, + scrollHeight, + scroll, + shouldClipSubject: !ignoreContainerClipping, + }; })(); const dimension: DroppableDimension = getDroppableDimension({ descriptor, direction, client, - frameClient, - frameScroll, + closest, margin, padding, - windowScroll: getWindowScrollPosition(), + windowScroll: getWindowScroll(), isEnabled: !isDropDisabled, }); diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index ef7bd582d3..6c873b6aa7 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -16,6 +16,7 @@ import type { DragState, State, DroppableId, + DraggableId, DraggableLocation, DraggableDimension, Placeholder, @@ -71,8 +72,12 @@ export const makeSelector = (): Selector => { ); const getMapProps = memoizeOne( - (isDraggingOver: boolean, placeholder: ?Placeholder): MapProps => ({ + (isDraggingOver: boolean, + draggingOverWith: ?DraggableId, + placeholder: ?Placeholder, + ): MapProps => ({ isDraggingOver, + draggingOverWith, placeholder, }) ); @@ -93,41 +98,47 @@ export const makeSelector = (): Selector => { isDropDisabled: boolean, ): MapProps => { if (isDropDisabled) { - return getMapProps(false, null); + return getMapProps(false, null, null); } if (phase === 'DRAGGING') { if (!drag) { console.error('cannot determine dragging over as there is not drag'); - return getMapProps(false, null); + return getMapProps(false, null, null); } const isDraggingOver = getIsDraggingOver(id, drag.impact.destination); + const draggingOverWith: ?DraggableId = isDraggingOver ? + drag.initial.descriptor.id : null; const placeholder: ?Placeholder = getPlaceholder( id, drag.impact.destination, draggable, ); - return getMapProps(isDraggingOver, placeholder); + + return getMapProps(isDraggingOver, draggingOverWith, placeholder); } if (phase === 'DROP_ANIMATING') { if (!pending) { console.error('cannot determine dragging over as there is no pending result'); - return getMapProps(false, null); + return getMapProps(false, null, null); } const isDraggingOver = getIsDraggingOver(id, pending.impact.destination); + const draggingOverWith: ?DraggableId = isDraggingOver ? + pending.result.draggableId : null; + const placeholder: ?Placeholder = getPlaceholder( id, pending.result.destination, draggable, ); - return getMapProps(isDraggingOver, placeholder); + return getMapProps(isDraggingOver, draggingOverWith, placeholder); } - return getMapProps(false, null); + return getMapProps(false, null, null); }, ); }; diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index f815f15fcb..0bb3412e3f 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -1,23 +1,33 @@ // @flow import type { Node } from 'react'; import type { + DraggableId, DroppableId, TypeId, Direction, Placeholder, } from '../../types'; +export type DroppableProps = {| + // used for shared global styles + 'data-react-beautiful-dnd-droppable': string, +|} + export type Provided = {| innerRef: (?HTMLElement) => void, placeholder: ?Node, + droppableProps: DroppableProps, |} export type StateSnapshot = {| isDraggingOver: boolean, + draggingOverWith: ?DraggableId, |} export type MapProps = {| isDraggingOver: boolean, + // The id of the draggable that is dragging over + draggingOverWith: ?DraggableId, // placeholder is used to hold space when // not the user is dragging over a list that // is not the source list diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index 1c9320ff37..9f4f5ffe94 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -5,7 +5,10 @@ import type { Props, Provided, StateSnapshot, DefaultProps } from './droppable-t import type { DroppableId } from '../../types'; import DroppableDimensionPublisher from '../droppable-dimension-publisher/'; import Placeholder from '../placeholder/'; -import { droppableIdKey } from '../context-keys'; +import { + droppableIdKey, + styleContextKey, +} from '../context-keys'; type State = {| ref: ?HTMLElement, @@ -17,6 +20,8 @@ type Context = {| export default class Droppable extends Component { /* eslint-disable react/sort-comp */ + styleContext: string + state: State = { ref: null, } @@ -28,6 +33,17 @@ export default class Droppable extends Component { ignoreContainerClipping: false, } + // Need to declare childContextTypes without flow + static contextTypes = { + [styleContextKey]: PropTypes.string.isRequired, + } + + constructor(props: Props, context: Object) { + super(props, context); + + this.styleContext = context[styleContextKey]; + } + // Need to declare childContextTypes without flow // https://github.com/brigand/babel-plugin-flow-react-proptypes/issues/22 static childContextTypes = { @@ -40,6 +56,7 @@ export default class Droppable extends Component { }; return value; } + /* eslint-enable */ // React calls ref callback twice for every render @@ -78,14 +95,19 @@ export default class Droppable extends Component { ignoreContainerClipping, isDraggingOver, isDropDisabled, + draggingOverWith, type, } = this.props; const provided: Provided = { innerRef: this.setRef, placeholder: this.getPlaceholder(), + droppableProps: { + 'data-react-beautiful-dnd-droppable': this.styleContext, + }, }; const snapshot: StateSnapshot = { isDraggingOver, + draggingOverWith, }; return ( diff --git a/src/view/get-window-scroll-position.js b/src/view/get-window-scroll-position.js deleted file mode 100644 index 15d0b6d050..0000000000 --- a/src/view/get-window-scroll-position.js +++ /dev/null @@ -1,8 +0,0 @@ -// @flow -import type { Position } from '../types'; - -export default (): Position => ({ - x: window.pageXOffset, - y: window.pageYOffset, -}); - diff --git a/src/view/key-codes.js b/src/view/key-codes.js index 0c8939afba..f8d433091e 100644 --- a/src/view/key-codes.js +++ b/src/view/key-codes.js @@ -3,6 +3,10 @@ export const tab: number = 9; export const enter: number = 13; export const escape: number = 27; export const space: number = 32; +export const pageUp: number = 33; +export const pageDown: number = 34; +export const end: number = 35; +export const home: number = 36; export const arrowLeft: number = 37; export const arrowUp: number = 38; export const arrowRight: number = 39; diff --git a/src/view/style-marshal/get-styles.js b/src/view/style-marshal/get-styles.js index 4cea955a02..48895ac94d 100644 --- a/src/view/style-marshal/get-styles.js +++ b/src/view/style-marshal/get-styles.js @@ -13,6 +13,7 @@ const prefix: string = 'data-react-beautiful-dnd'; export default (styleContext: string): Styles => { const dragHandleSelector: string = `[${prefix}-drag-handle="${styleContext}"]`; const draggableSelector: string = `[${prefix}-draggable="${styleContext}"]`; + const droppableSelector: string = `[${prefix}-droppable="${styleContext}"]`; // ## Drag handle styles @@ -87,6 +88,25 @@ export default (styleContext: string): Styles => { `, }; + // ## Droppable styles + + // ### Base + // > Applied at all times + + // overflow-anchor: none; + // Opting out of the browser feature which tries to maintain + // the scroll position when the DOM changes above the fold. + // This does not work well with reordering DOM nodes. + // When we drop a Draggable it already has the correct scroll applied. + + const droppableStyles = { + base: ` + ${droppableSelector} { + overflow-anchor: none; + } + `, + }; + // ## Body styles // ### While active dragging @@ -112,20 +132,25 @@ export default (styleContext: string): Styles => { `, }; - const resting: string = [ + const base: string[] = [ dragHandleStyles.base, + droppableStyles.base, + ]; + + const resting: string = [ + ...base, dragHandleStyles.grabCursor, ].join(''); const dragging: string = [ - dragHandleStyles.base, + ...base, dragHandleStyles.blockPointerEvents, draggableStyles.animateMovement, bodyStyles.whileActiveDragging, ].join(''); const dropAnimating: string = [ - dragHandleStyles.base, + ...base, dragHandleStyles.grabCursor, draggableStyles.animateMovement, ].join(''); @@ -133,7 +158,7 @@ export default (styleContext: string): Styles => { // Not applying grab cursor during a cancel as it is not possible for users to reorder // items during a cancel const userCancel: string = [ - dragHandleStyles.base, + ...base, draggableStyles.animateMovement, ].join(''); diff --git a/src/view/style-marshal/style-marshal.js b/src/view/style-marshal/style-marshal.js index f048e67222..06096350c8 100644 --- a/src/view/style-marshal/style-marshal.js +++ b/src/view/style-marshal/style-marshal.js @@ -5,7 +5,7 @@ import type { StyleMarshal } from './style-marshal-types'; import type { State as AppState, Phase, - DropTrigger, + DropReason, } from '../../types'; let count: number = 0; @@ -89,9 +89,9 @@ export default () => { return; } - const trigger: DropTrigger = current.drop.pending.trigger; + const reason: DropReason = current.drop.pending.result.reason; - if (trigger === 'DROP') { + if (reason === 'DROP') { setStyle(styles.dropAnimating); return; } diff --git a/src/window/get-viewport.js b/src/window/get-viewport.js new file mode 100644 index 0000000000..b55f8d4d1f --- /dev/null +++ b/src/window/get-viewport.js @@ -0,0 +1,25 @@ +// @flow +import type { Position, Area } from '../types'; +import getArea from '../state/get-area'; +import getWindowScroll from './get-window-scroll'; + +export default (): Area => { + const windowScroll: Position = getWindowScroll(); + + const top: number = windowScroll.y; + const left: number = windowScroll.x; + + const doc: HTMLElement = (document.documentElement : any); + + // Using these values as they do not consider scrollbars + const width: number = doc.clientWidth; + const height: number = doc.clientHeight; + + // Computed + const right: number = left + width; + const bottom: number = top + height; + + return getArea({ + top, left, right, bottom, + }); +}; diff --git a/src/window/get-window-from-ref.js b/src/window/get-window-from-ref.js new file mode 100644 index 0000000000..4fa01fc348 --- /dev/null +++ b/src/window/get-window-from-ref.js @@ -0,0 +1,3 @@ +// @flow +export default (ref: ?HTMLElement): HTMLElement => + (ref ? ref.ownerDocument.defaultView : window); diff --git a/src/window/get-window-scroll.js b/src/window/get-window-scroll.js new file mode 100644 index 0000000000..64e39a1433 --- /dev/null +++ b/src/window/get-window-scroll.js @@ -0,0 +1,31 @@ +// @flow +import type { Position } from '../types'; + +// The browsers update document.documentElement.scrollTop and window.pageYOffset +// differently as the window scrolls. + +// Webkit +// documentElement.scrollTop: no update. Stays at 0 +// window.pageYOffset: updates to whole number + +// Chrome +// documentElement.scrollTop: update with fractional value +// window.pageYOffset: update with fractional value + +// FireFox +// documentElement.scrollTop: updates to whole number +// window.pageYOffset: updates to whole number + +// IE11 (same as firefox) +// documentElement.scrollTop: updates to whole number +// window.pageYOffset: updates to whole number + +// Edge (same as webkit) +// documentElement.scrollTop: no update. Stays at 0 +// window.pageYOffset: updates to whole number + +export default (): Position => ({ + x: window.pageXOffset, + y: window.pageYOffset, +}); + diff --git a/src/window/scroll-window.js b/src/window/scroll-window.js new file mode 100644 index 0000000000..8d33773e58 --- /dev/null +++ b/src/window/scroll-window.js @@ -0,0 +1,10 @@ +// @flow +import type { + Position, +} from '../types'; + +// Not guarenteed to scroll by the entire amount +export default (change: Position): void => { + window.scrollBy(change.x, change.y); +}; + diff --git a/stories/1-single-vertical-list-story.js b/stories/1-single-vertical-list-story.js index 2039a37742..a1d85300fc 100644 --- a/stories/1-single-vertical-list-story.js +++ b/stories/1-single-vertical-list-story.js @@ -8,6 +8,7 @@ import { grid } from './src/constants'; const data = { small: quotes, + // small: getQuotes(3), medium: getQuotes(40), large: getQuotes(500), }; diff --git a/stories/3-board-story.js b/stories/3-board-story.js index f8fbd7aaa4..663135c70d 100644 --- a/stories/3-board-story.js +++ b/stories/3-board-story.js @@ -5,7 +5,7 @@ import Board from './src/board/board'; import { authorQuoteMap, generateQuoteMap } from './src/data'; const data = { - medium: generateQuoteMap(50), + medium: generateQuoteMap(100), large: generateQuoteMap(500), }; @@ -13,9 +13,12 @@ storiesOf('board', module) .add('simple', () => ( )) + .add('medium data set', () => ( + + )) .add('large data set', () => ( )) .add('long lists in a short container', () => ( - + )); diff --git a/stories/8-accessibility-story.js b/stories/8-accessibility-story.js new file mode 100644 index 0000000000..cfda0cc888 --- /dev/null +++ b/stories/8-accessibility-story.js @@ -0,0 +1,9 @@ +// @flow +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import TaskApp from './src/accessible/task-app'; + +storiesOf('Accessibility', module) + .add('single list', () => ( + + )); diff --git a/stories/src/accessible/data.js b/stories/src/accessible/data.js new file mode 100644 index 0000000000..e7f6056f0b --- /dev/null +++ b/stories/src/accessible/data.js @@ -0,0 +1,19 @@ +// @flow +import type { Task } from './types'; + +const tasks: Task[] = [ + { + id: '1', + content: 'Eat lunch', + }, + { + id: '2', + content: 'Finish that book I have been reading', + }, + { + id: '3', + content: 'Go to the store', + }, +]; + +export default tasks; diff --git a/stories/src/accessible/task-app.jsx b/stories/src/accessible/task-app.jsx new file mode 100644 index 0000000000..abcef4a02f --- /dev/null +++ b/stories/src/accessible/task-app.jsx @@ -0,0 +1,146 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; +import TaskList from './task-list'; +import initial from './data'; +import reorder from '../reorder'; +import { grid } from '../constants'; +import { DragDropContext } from '../../../src/'; +import type { + Announce, + DragStart, + DragUpdate, + DropResult, + DraggableLocation, + HookProvided, +} from '../../../src/'; +import type { Task } from './types'; + +type State = {| + tasks: Task[], + blur: number, +|} + +const Container = styled.div` + padding-top: 20vh; + display: flex; + flex-direction: column; + align-items: center; +`; +const Blur = styled.div` + filter: blur(${props => props.amount}px); +`; + +const BlurControls = styled.div` + display: flex; + align-items: center; + font-size: 20px; + margin-top: 20vh; +`; + +const BlurTitle = styled.h4` + margin: 0; +`; + +const Button = styled.button` + height: ${grid * 5}px; + width: ${grid * 5}px; + font-size: 20px; + justify-content: center; + margin: 0 ${grid * 2}px; + cursor: pointer; +`; + +export default class TaskApp extends Component<*, State> { + state: State = { + tasks: initial, + blur: 0, + } + + // in? + onDragStart = (start: DragStart, provided: HookProvided): void => provided.announce(` + You have lifted a task. + It is in position ${start.source.index + 1} of ${this.state.tasks.length} in the list. + Use the arrow keys to move, space bar to drop, and escape to cancel. + `) + + onDragUpdate = (update: DragUpdate, provided: HookProvided): void => { + const announce: Announce = provided.announce; + if (!update.destination) { + announce('You are currently not dragging over any droppable area'); + return; + } + announce(`You have moved the task to position ${update.destination.index + 1}`); + } + + onDragEnd = (result: DropResult, provided: HookProvided): void => { + const announce: Announce = provided.announce; + // TODO: not being called on cancel!!! + if (result.reason === 'CANCEL') { + announce(` + Movement cancelled. + The task has returned to its starting position of ${result.source.index + 1} + `); + return; + } + + const destination: ?DraggableLocation = result.destination; + + if (!destination) { + announce(` + The task has been dropped while not over a location. + The task has returned to its starting position of ${result.source.index + 1} + `); + return; + } + + const tasks: Task[] = reorder( + this.state.tasks, + result.source.index, + destination.index, + ); + + this.setState({ + tasks, + }); + + announce(` + You have dropped the task. + It has moved from position ${result.source.index + 1} to ${destination.index + 1} + `); + } + + render() { + return ( + + + + + + + + Blur + + + + + ); + } +} diff --git a/stories/src/accessible/task-list.jsx b/stories/src/accessible/task-list.jsx new file mode 100644 index 0000000000..01572c03f3 --- /dev/null +++ b/stories/src/accessible/task-list.jsx @@ -0,0 +1,58 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; +import { Droppable } from '../../../src/'; +import Task from './task'; +import type { DroppableProvided, DroppableStateSnapshot } from '../../../src/'; +import type { Task as TaskType } from './types'; +import { colors, grid, borderRadius } from '../constants'; + +type Props = {| + tasks: TaskType[], + title: string, +|} + +const Container = styled.div` + width: 300px; + background-color: ${colors.grey}; + border-radius: ${borderRadius}px; +`; + +const Title = styled.h3` + font-weight: bold; + padding: ${grid}px; +`; + +const List = styled.div` + padding: ${grid}px; + padding-bottom: 0px; + display: flex; + flex-direction: column; +`; + +export default class TaskList extends Component { + render() { + return ( + + {(provided: DroppableProvided) => ( + + {this.props.title} + + {this.props.tasks.map((task: TaskType, index: number) => ( + + ))} + + {provided.placeholder} + + )} + + ); + } +} diff --git a/stories/src/accessible/task.jsx b/stories/src/accessible/task.jsx new file mode 100644 index 0000000000..c358eef323 --- /dev/null +++ b/stories/src/accessible/task.jsx @@ -0,0 +1,51 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; +import { Draggable } from '../../../src/'; +import type { DraggableProvided, DraggableStateSnapshot } from '../../../src/'; +import type { Task as TaskType } from './types'; +import { colors, grid, borderRadius } from '../constants'; + +type Props = {| + task: TaskType, + index: number, +|} + +const Container = styled.div` + border-bottom: 1px solid #ccc; + background: ${colors.white}; + padding: ${grid}px; + margin-bottom: ${grid}px; + border-radius: ${borderRadius}px; + font-size: 18px; + + ${({ isDragging }) => (isDragging ? 'box-shadow: 1px 1px 1px grey; background: lightblue' : '')} +`; + +const Wrapper = styled.div``; + +export default class Task extends Component { + render() { + const task: TaskType = this.props.task; + const index: number = this.props.index; + + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( + + + {this.props.task.content} + + {provided.placeholder} + + )} + + ); + } +} diff --git a/stories/src/accessible/types.js b/stories/src/accessible/types.js new file mode 100644 index 0000000000..754a20f2df --- /dev/null +++ b/stories/src/accessible/types.js @@ -0,0 +1,6 @@ +// @flow + +export type Task = {| + id: string, + content: string, +|} diff --git a/stories/src/board/board.jsx b/stories/src/board/board.jsx index fa23e13159..e71a55e0db 100644 --- a/stories/src/board/board.jsx +++ b/stories/src/board/board.jsx @@ -120,7 +120,7 @@ export default class Board extends Component { ignoreContainerClipping={Boolean(containerHeight)} > {(provided: DroppableProvided) => ( - + {ordered.map((key: string, index: number) => ( { } /* eslint-enable react/sort-comp */ - componentDidMount() { - // eslint-disable-next-line no-unused-expressions - injectGlobal` - body.${isDraggingClassName} { - cursor: grabbing; - user-select: none; - } - `; - } - onDragStart = (initial: DragStart) => { publishOnDragStart(initial); - // $ExpectError - body wont be null - document.body.classList.add(isDraggingClassName); } onDragEnd = (result: DropResult) => { publishOnDragEnd(result); - // $ExpectError - body wont be null - document.body.classList.remove(isDraggingClassName); // dropped outside the list if (!result.destination) { diff --git a/stories/src/interactive-elements/interactive-elements-app.jsx b/stories/src/interactive-elements/interactive-elements-app.jsx index 00888b9e48..77ec3cf2e7 100644 --- a/stories/src/interactive-elements/interactive-elements-app.jsx +++ b/stories/src/interactive-elements/interactive-elements-app.jsx @@ -46,6 +46,12 @@ const initial: ItemType[] = [
), }, + { + id: 'range', + component: ( + + ), + }, { id: 'content editable', component: ( @@ -133,6 +139,7 @@ export default class InteractiveElementsApp extends React.Component<*, State> { {(droppableProvided: DroppableProvided) => ( {this.state.items.map((item: ItemType, index: number) => ( { 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) { @@ -63,16 +57,6 @@ export default class QuoteApp extends Component { })); } - componentDidMount() { - // eslint-disable-next-line no-unused-expressions - injectGlobal` - body.${isDraggingClassName} { - cursor: grabbing; - user-select: none; - } - `; - } - render() { const { quoteMap, autoFocusQuoteId } = this.state; diff --git a/stories/src/multiple-vertical/quote-app.jsx b/stories/src/multiple-vertical/quote-app.jsx index 71bd69460b..c1e8bd6cc8 100644 --- a/stories/src/multiple-vertical/quote-app.jsx +++ b/stories/src/multiple-vertical/quote-app.jsx @@ -1,6 +1,6 @@ // @flow import React, { Component } from 'react'; -import styled, { injectGlobal } from 'styled-components'; +import styled from 'styled-components'; import { action } from '@storybook/addon-actions'; import { DragDropContext } from '../../../src/'; import QuoteList from '../primatives/quote-list'; @@ -54,8 +54,6 @@ const PushDown = styled.div` height: 200px; `; -const isDraggingClassName = 'is-dragging'; - type Props = {| initial: QuoteMap, |} @@ -75,14 +73,10 @@ export default class QuoteApp extends Component { // 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) { @@ -99,16 +93,6 @@ export default class QuoteApp extends Component { })); } - componentDidMount() { - // eslint-disable-next-line no-unused-expressions - injectGlobal` - body.${isDraggingClassName} { - cursor: grabbing; - user-select: none; - } - `; - } - // TODO getDisabledDroppable = (sourceDroppable: ?string) => { if (!sourceDroppable) { diff --git a/stories/src/primatives/author-item.jsx b/stories/src/primatives/author-item.jsx index 0ab27ad275..cfba27c41b 100644 --- a/stories/src/primatives/author-item.jsx +++ b/stories/src/primatives/author-item.jsx @@ -12,7 +12,6 @@ const Avatar = styled.img` width: 60px; height: 60px; border-radius: 50%; - cursor: grab; margin-right: ${grid}px; border-color: ${({ isDragging }) => (isDragging ? colors.green : colors.white)}; border-style: solid; diff --git a/stories/src/primatives/author-list.jsx b/stories/src/primatives/author-list.jsx index 303d53ea1c..6b62483e7d 100644 --- a/stories/src/primatives/author-list.jsx +++ b/stories/src/primatives/author-list.jsx @@ -95,7 +95,7 @@ export default class AuthorList extends Component { return ( {(dropProvided: DroppableProvided, dropSnapshot: DroppableStateSnapshot) => ( - + {internalScroll ? ( {this.renderBoard(dropProvided)} diff --git a/stories/src/primatives/quote-item.jsx b/stories/src/primatives/quote-item.jsx index 71a268f6c9..1efdce758d 100644 --- a/stories/src/primatives/quote-item.jsx +++ b/stories/src/primatives/quote-item.jsx @@ -13,8 +13,6 @@ type Props = { autoFocus?: boolean, } -type HTMLElement = any; - const Container = styled.a` border-radius: ${borderRadius}px; border: 1px solid grey; @@ -107,7 +105,7 @@ export default class QuoteItem extends React.PureComponent { } // eslint-disable-next-line react/no-find-dom-node - const node: HTMLElement = ReactDOM.findDOMNode(this); + const node: HTMLElement = (ReactDOM.findDOMNode(this) : any); node.focus(); } diff --git a/stories/src/primatives/quote-list.jsx b/stories/src/primatives/quote-list.jsx index 438390f575..ca8581141a 100644 --- a/stories/src/primatives/quote-list.jsx +++ b/stories/src/primatives/quote-list.jsx @@ -148,6 +148,7 @@ export default class QuoteList extends Component { style={style} isDraggingOver={dropSnapshot.isDraggingOver} isDropDisabled={isDropDisabled} + {...dropProvided.droppableProps} > {internalScroll ? ( diff --git a/stories/src/primatives/title.jsx b/stories/src/primatives/title.jsx index ac683992f4..aeaf20b92a 100644 --- a/stories/src/primatives/title.jsx +++ b/stories/src/primatives/title.jsx @@ -4,7 +4,6 @@ import { colors, 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/vertical-grouped/quote-app.jsx b/stories/src/vertical-grouped/quote-app.jsx index e3f611ff8d..6c46e0ad39 100644 --- a/stories/src/vertical-grouped/quote-app.jsx +++ b/stories/src/vertical-grouped/quote-app.jsx @@ -1,6 +1,6 @@ // @flow import React, { Component } from 'react'; -import styled, { injectGlobal } from 'styled-components'; +import styled from 'styled-components'; import { action } from '@storybook/addon-actions'; import { DragDropContext } from '../../../src/'; import QuoteList from '../primatives/quote-list'; @@ -37,8 +37,6 @@ const Title = styled.h4` margin: ${grid}px; `; -const isDraggingClassName = 'is-dragging'; - type Props = {| initial: QuoteMap, |} @@ -56,14 +54,10 @@ export default class QuoteApp extends Component { 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); if (!result.destination) { return; @@ -78,16 +72,6 @@ export default class QuoteApp extends Component { this.setState({ quoteMap }); } - componentDidMount() { - // eslint-disable-next-line no-unused-expressions - injectGlobal` - body.${isDraggingClassName} { - cursor: grabbing; - user-select: none; - } - `; - } - render() { const { quoteMap } = this.state; diff --git a/stories/src/vertical-nested/quote-app.jsx b/stories/src/vertical-nested/quote-app.jsx index 8994afb12c..bfed014d27 100644 --- a/stories/src/vertical-nested/quote-app.jsx +++ b/stories/src/vertical-nested/quote-app.jsx @@ -1,6 +1,6 @@ // @flow import React, { Component } from 'react'; -import styled, { injectGlobal } from 'styled-components'; +import styled from 'styled-components'; import { action } from '@storybook/addon-actions'; import { DragDropContext } from '../../../src/'; import { colors, grid } from '../constants'; @@ -42,8 +42,6 @@ const Root = styled.div` align-items: flex-start; `; -const isDraggingClassName = 'is-dragging'; - type State = {| list: NestedQuoteList, |} @@ -55,26 +53,12 @@ export default class QuoteApp extends Component<*, State> { }; /* eslint-enable */ - componentDidMount() { - // eslint-disable-next-line no-unused-expressions - injectGlobal` - body.${isDraggingClassName} { - cursor: grabbing; - user-select: none; - } - `; - } - 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) { diff --git a/stories/src/vertical-nested/quote-list.jsx b/stories/src/vertical-nested/quote-list.jsx index c3e41716ba..78a2cea234 100644 --- a/stories/src/vertical-nested/quote-list.jsx +++ b/stories/src/vertical-nested/quote-list.jsx @@ -35,9 +35,6 @@ const Container = styled.div` const NestedContainer = Container.extend` padding: 0; margin-bottom: ${grid}px; - &:hover { - cursor: grab; - } `; export default class QuoteList extends Component<{ list: NestedQuoteList }> { @@ -71,6 +68,7 @@ export default class QuoteList extends Component<{ list: NestedQuoteList }> { {list.title} {list.children.map((item: Quote | NestedQuoteList, index: number) => ( diff --git a/test/setup.js b/test/setup.js index 0b55eafc88..14389de18e 100644 --- a/test/setup.js +++ b/test/setup.js @@ -6,6 +6,22 @@ // run with browser globals enabled if (typeof window !== 'undefined') { require('raf-stub').replaceRaf([global, window]); + + // overriding these properties in jsdom to allow them to be controlled + + Object.defineProperties(document.documentElement, { + clientWidth: { writable: true, value: document.documentElement.clientWidth }, + clientHeight: { writable: true, value: document.documentElement.clientHeight }, + scrollWidth: { writable: true, value: document.documentElement.scrollWidth }, + scrollHeight: { writable: true, value: document.documentElement.scrollHeight }, + }); +} + +// Setting initial viewport +// Need to set clientWidth and clientHeight as jsdom does not set these properties +if (typeof document !== 'undefined' && typeof window !== 'undefined') { + document.documentElement.clientWidth = window.innerWidth; + document.documentElement.clientHeight = window.innerHeight; } // setting up global enzyme @@ -14,3 +30,4 @@ const Enzyme = require('enzyme'); const Adapter = require('enzyme-adapter-react-15'); Enzyme.configure({ adapter: new Adapter() }); + diff --git a/test/unit/integration/__snapshots__/server-side-rendering.spec.js.snap b/test/unit/integration/__snapshots__/server-side-rendering.spec.js.snap index bbe2573268..c34fa743f0 100644 --- a/test/unit/integration/__snapshots__/server-side-rendering.spec.js.snap +++ b/test/unit/integration/__snapshots__/server-side-rendering.spec.js.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`server side rendering should support rendering to a string 1`] = `"
"`; +exports[`server side rendering should support rendering to a string 1`] = `"
"`; -exports[`server side rendering should support rendering to static markup 1`] = `"
"`; +exports[`server side rendering should support rendering to static markup 1`] = `"
"`; diff --git a/test/unit/integration/hooks-integration.spec.js b/test/unit/integration/hooks-integration.spec.js index ab893d545d..6b4b253866 100644 --- a/test/unit/integration/hooks-integration.spec.js +++ b/test/unit/integration/hooks-integration.spec.js @@ -57,11 +57,12 @@ describe('hooks integration', () => { return mount( {(droppableProvided: DroppableProvided) => ( -
+

Droppable

{(draggableProvided: DraggableProvided) => ( @@ -90,6 +91,7 @@ describe('hooks integration', () => { jest.useFakeTimers(); hooks = { onDragStart: jest.fn(), + onDragUpdate: jest.fn(), onDragEnd: jest.fn(), }; wrapper = getMountedApp(); @@ -195,7 +197,9 @@ describe('hooks integration', () => { draggableId, type: 'DEFAULT', source, - destination: null, + // did not move anywhere + destination: source, + reason: 'DROP', }; const cancelled: DropResult = { @@ -203,6 +207,7 @@ describe('hooks integration', () => { type: 'DEFAULT', source, destination: null, + reason: 'CANCEL', }; return { start, completed, cancelled }; diff --git a/test/unit/integration/lift-action-and-dimension-marshal.spec.js b/test/unit/integration/lift-action-and-dimension-marshal.spec.js index 82e3507138..02dee53b11 100644 --- a/test/unit/integration/lift-action-and-dimension-marshal.spec.js +++ b/test/unit/integration/lift-action-and-dimension-marshal.spec.js @@ -1,14 +1,16 @@ // @flow import createStore from '../../../src/state/create-store'; import createDimensionMarshal from '../../../src/state/dimension-marshal/dimension-marshal'; +import createHookCaller from '../../../src/state/hooks/hook-caller'; +import type { HookCaller } from '../../../src/state/hooks/hooks-types'; import { getPreset } from '../../utils/dimension'; import { add } from '../../../src/state/position'; -import fireHooks from '../../../src/state/fire-hooks'; import { lift, clean, - publishDraggableDimensions, - publishDroppableDimensions, + publishDraggableDimension, + publishDroppableDimension, + bulkPublishDimensions, updateDroppableDimensionScroll, moveForward, drop, @@ -35,11 +37,14 @@ const getDimensionMarshal = (store: Store): DimensionMarshal => { cancel: () => { store.dispatch(clean()); }, - publishDraggables: (dimensions: DraggableDimension[]) => { - store.dispatch(publishDraggableDimensions(dimensions)); + publishDraggable: (dimension: DraggableDimension) => { + store.dispatch(publishDraggableDimension(dimension)); }, - publishDroppables: (dimensions: DroppableDimension[]) => { - store.dispatch(publishDroppableDimensions(dimensions)); + publishDroppable: (dimension: DroppableDimension) => { + store.dispatch(publishDroppableDimension(dimension)); + }, + bulkPublish: (droppables: DroppableDimension[], draggables: DraggableDimension[]) => { + store.dispatch(bulkPublishDimensions(droppables, draggables)); }, updateDroppableScroll: (id: DroppableId, offset: Position) => { store.dispatch(updateDroppableDimensionScroll(id, offset)); @@ -66,12 +71,14 @@ describe('lifting and the dimension marshal', () => { it('should have the correct indexes in the descriptor post lift', () => { const store: Store = createStore(); const dimensionMarshal: DimensionMarshal = getDimensionMarshal(store); + const caller: HookCaller = createHookCaller(() => { }); // register home dimensions dimensionMarshal.registerDroppable(preset.home.descriptor, { getDimension: () => preset.home, watchScroll: () => { }, unwatchScroll: () => { }, + scroll: () => { }, }); preset.inHomeList.forEach((dimension: DraggableDimension) => { dimensionMarshal.registerDraggable(dimension.descriptor, () => dimension); @@ -124,7 +131,7 @@ describe('lifting and the dimension marshal', () => { return; } - fireHooks(hooks, previousValue, current); + caller.onStateChange(hooks, previousValue, current); dimensionMarshal.onPhaseChange(current); }); @@ -137,7 +144,7 @@ describe('lifting and the dimension marshal', () => { preset.inHome1.descriptor.id, initial, preset.windowScroll, - true + 'JUMP', )(store.dispatch, store.getState); // drag should be started after flushing all timers @@ -188,7 +195,7 @@ describe('lifting and the dimension marshal', () => { preset.inHome3.descriptor.id, forSecondDrag, origin, - true, + 'JUMP', )(store.dispatch, store.getState); // drag should be started after flushing all timers and all state will be published diff --git a/test/unit/integration/server-side-rendering.spec.js b/test/unit/integration/server-side-rendering.spec.js index b3a3eb55b8..1407c80761 100644 --- a/test/unit/integration/server-side-rendering.spec.js +++ b/test/unit/integration/server-side-rendering.spec.js @@ -25,7 +25,7 @@ class App extends Component<*, *> { > {(provided: DroppableProvided) => ( -
+
{(dragProvided: DraggableProvided) => (
diff --git a/test/unit/state/action-creators.spec.js b/test/unit/state/action-creators.spec.js index 24966a9efd..c6c19586f4 100644 --- a/test/unit/state/action-creators.spec.js +++ b/test/unit/state/action-creators.spec.js @@ -7,18 +7,19 @@ import { prepare, completeLift, requestDimensions, - publishDraggableDimensions, - publishDroppableDimensions, + publishDraggableDimension, + publishDroppableDimension, } from '../../../src/state/action-creators'; import createStore from '../../../src/state/create-store'; import { getPreset } from '../../utils/dimension'; -import * as state from '../../utils/simple-state-preset'; +import getStatePreset from '../../utils/get-simple-state-preset'; import type { State, Position, DraggableId, Store, InitialDragPositions, + LiftRequest, } from '../../../src/types'; const preset = getPreset(); @@ -32,19 +33,21 @@ type LiftFnArgs = {| id: DraggableId, client: InitialDragPositions, windowScroll: Position, - isScrollAllowed: boolean, + autoScrollMode: 'FLUID' | 'JUMP', |} const liftDefaults: LiftFnArgs = { id: preset.inHome1.descriptor.id, windowScroll: origin, client: noWhere, - isScrollAllowed: true, + autoScrollMode: 'FLUID', }; +const state = getStatePreset(); + const liftWithDefaults = (args?: LiftFnArgs = liftDefaults) => { - const { id, client, windowScroll, isScrollAllowed } = args; - return lift(id, client, windowScroll, isScrollAllowed); + const { id, client, windowScroll, autoScrollMode } = args; + return lift(id, client, windowScroll, autoScrollMode); }; describe('action creators', () => { @@ -74,12 +77,18 @@ describe('action creators', () => { // Phase 2: request dimensions after flushing animations jest.runOnlyPendingTimers(); - expect(store.dispatch).toHaveBeenCalledWith(requestDimensions(preset.inHome1.descriptor.id)); + const request: LiftRequest = { + draggableId: preset.inHome1.descriptor.id, + scrollOptions: { + shouldPublishImmediately: false, + }, + }; + expect(store.dispatch).toHaveBeenCalledWith(requestDimensions(request)); expect(store.dispatch).toHaveBeenCalledTimes(2); // publishing some fake dimensions - store.dispatch(publishDroppableDimensions([preset.home])); - store.dispatch(publishDraggableDimensions([preset.inHome1])); + store.dispatch(publishDroppableDimension(preset.home)); + store.dispatch(publishDraggableDimension(preset.inHome1)); // now called four times expect(store.dispatch).toHaveBeenCalledTimes(4); @@ -90,7 +99,7 @@ describe('action creators', () => { liftDefaults.id, liftDefaults.client, liftDefaults.windowScroll, - liftDefaults.isScrollAllowed + liftDefaults.autoScrollMode, )); expect(store.dispatch).toHaveBeenCalledTimes(5); }); @@ -152,7 +161,7 @@ describe('action creators', () => { liftDefaults.id, liftDefaults.client, liftDefaults.windowScroll, - liftDefaults.isScrollAllowed + liftDefaults.autoScrollMode, ) ); @@ -176,7 +185,12 @@ describe('action creators', () => { jest.runOnlyPendingTimers(); expect(store.dispatch).toHaveBeenCalledWith( - requestDimensions(preset.inHome1.descriptor.id) + requestDimensions({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: { + shouldPublishImmediately: false, + }, + }) ); expect(store.dispatch).toHaveBeenCalledTimes(2); @@ -193,7 +207,7 @@ describe('action creators', () => { liftDefaults.id, liftDefaults.client, liftDefaults.windowScroll, - liftDefaults.isScrollAllowed + liftDefaults.autoScrollMode, )); // being super careful diff --git a/test/unit/state/auto-scroll/can-scroll.spec.js b/test/unit/state/auto-scroll/can-scroll.spec.js new file mode 100644 index 0000000000..291a041420 --- /dev/null +++ b/test/unit/state/auto-scroll/can-scroll.spec.js @@ -0,0 +1,591 @@ +// @flow +import type { + Area, + Position, + DroppableDimension, +} from '../../../../src/types'; +import { + canPartiallyScroll, + getOverlap, + getWindowOverlap, + getDroppableOverlap, + canScrollDroppable, + canScrollWindow, +} from '../../../../src/state/auto-scroller/can-scroll'; +import { add, subtract } from '../../../../src/state/position'; +import getArea from '../../../../src/state/get-area'; +import { getPreset } from '../../../utils/dimension'; +import { getDroppableDimension, scrollDroppable } from '../../../../src/state/dimension'; +import setViewport, { resetViewport } from '../../../utils/set-viewport'; +import setWindowScroll, { resetWindowScroll } from '../../../utils/set-window-scroll'; +import setWindowScrollSize, { resetWindowScrollSize } from '../../../utils/set-window-scroll-size'; +import getMaxScroll from '../../../../src/state/get-max-scroll'; + +const origin: Position = { x: 0, y: 0 }; +const preset = getPreset(); + +const scrollableScrollSize = { + scrollWidth: 200, + scrollHeight: 200, +}; + +const scrollable: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'drop-1', + type: 'TYPE', + }, + client: getArea({ + top: 0, + left: 0, + right: 100, + // bigger than the frame + bottom: 200, + }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + scrollWidth: scrollableScrollSize.scrollWidth, + scrollHeight: scrollableScrollSize.scrollHeight, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, +}); + +describe('can scroll', () => { + afterEach(() => { + resetWindowScroll(); + resetWindowScrollSize(); + resetViewport(); + }); + + describe('can partially scroll', () => { + it('should return true if not scrolling anywhere', () => { + const result: boolean = canPartiallyScroll({ + max: { x: 100, y: 100 }, + current: { x: 0, y: 0 }, + // not + change: origin, + }); + + expect(result).toBe(true); + }); + + it('should return true if scrolling to a boundary', () => { + const current: Position = origin; + const max: Position = { x: 100, y: 200 }; + + const corners: Position[] = [ + // top left + { x: 0, y: 0 }, + // top right + { x: max.x, y: 0 }, + // bottom right + { x: max.x, y: max.y }, + // bottom left + { x: 0, y: max.y }, + ]; + + corners.forEach((corner: Position) => { + const result: boolean = canPartiallyScroll({ + max, + current, + change: corner, + }); + + expect(result).toBe(true); + }); + }); + + it('should return true if moving in any direction within the allowable scroll region', () => { + const max: Position = { x: 100, y: 100 }; + const current: Position = { x: 50, y: 50 }; + + // all of these movements are totally possible + const changes: Position[] = [ + // top left + { x: -10, y: 10 }, + // top right + { x: 10, y: 10 }, + // bottom right + { x: 10, y: -10 }, + // bottom left + { x: -10, y: -10 }, + ]; + + changes.forEach((point: Position) => { + const result: boolean = canPartiallyScroll({ + max, + current, + change: point, + }); + + expect(result).toBe(true); + }); + }); + + it('should return true if able to partially move in both directions', () => { + const max: Position = { x: 100, y: 100 }; + const current: Position = { x: 50, y: 50 }; + + // all of these movements are partially possible + const changes: Position[] = [ + // top left + { x: -200, y: 200 }, + // top right + { x: 200, y: 200 }, + // bottom right + { x: 200, y: -200 }, + // bottom left + { x: -200, y: -200 }, + ]; + + changes.forEach((point: Position) => { + const result: boolean = canPartiallyScroll({ + max, + current, + change: point, + }); + + expect(result).toBe(true); + }); + }); + + type Item = {| + current: Position, + change: Position, + |} + + it('should return true if can only partially move in one direction', () => { + const max: Position = { x: 100, y: 200 }; + + const changes: Item[] = [ + // Can move back in the y direction, but not back in the x direction + { + current: { x: 0, y: 1 }, + change: { x: -1, y: -1 }, + }, + // Can move back in the x direction, but not back in the y direction + { + current: { x: 1, y: 0 }, + change: { x: -1, y: -1 }, + }, + // Can move forward in the y direction, but not forward in the x direction + { + current: subtract(max, { x: 0, y: 1 }), + change: { x: 1, y: 1 }, + }, + // Can move forward in the x direction, but not forward in the y direction + { + current: subtract(max, { x: 1, y: 0 }), + change: { x: 1, y: 1 }, + }, + ]; + + changes.forEach((item: Item) => { + const result: boolean = canPartiallyScroll({ + max, + current: item.current, + change: item.change, + }); + + expect(result).toBe(true); + }); + }); + + it('should return false if on the min point and move backward in any direction', () => { + const current: Position = origin; + const max: Position = { x: 100, y: 200 }; + const tooFarBack: Position[] = [ + { x: 0, y: -1 }, + { x: -1, y: 0 }, + ]; + + tooFarBack.forEach((point: Position) => { + const result: boolean = canPartiallyScroll({ + max, + current, + change: point, + }); + + expect(result).toBe(false); + }); + }); + + it('should return false if on the max point and move forward in any direction', () => { + const max: Position = { x: 100, y: 200 }; + const current: Position = max; + const tooFarForward: Position[] = [ + add(max, { x: 0, y: 1 }), + add(max, { x: 1, y: 0 }), + ]; + + tooFarForward.forEach((point: Position) => { + const result: boolean = canPartiallyScroll({ + max, + current, + change: point, + }); + + expect(result).toBe(false); + }); + }); + }); + + describe('get overlap', () => { + describe('returning the remainder', () => { + const max: Position = { x: 100, y: 100 }; + const current: Position = { x: 50, y: 50 }; + + type Item = {| + change: Position, + expected: Position, + |} + + it('should return overlap on a single axis', () => { + const items: Item[] = [ + // too far back: top + { + change: { x: 0, y: -70 }, + expected: { x: 0, y: -20 }, + }, + // too far back: left + { + change: { x: -70, y: 0 }, + expected: { x: -20, y: 0 }, + }, + // too far forward: right + { + change: { x: 70, y: 0 }, + expected: { x: 20, y: 0 }, + }, + // too far forward: bottom + { + change: { x: 0, y: 70 }, + expected: { x: 0, y: 20 }, + }, + ]; + + items.forEach((item: Item) => { + const result: ?Position = getOverlap({ + current, + max, + change: item.change, + }); + + expect(result).toEqual(item.expected); + }); + }); + + it('should return overlap on two axis in the same direction', () => { + const items: Item[] = [ + // too far back: top + { + change: { x: -80, y: -70 }, + expected: { x: -30, y: -20 }, + }, + // too far back: left + { + change: { x: -70, y: -80 }, + expected: { x: -20, y: -30 }, + }, + // too far forward: right + { + change: { x: 70, y: 0 }, + expected: { x: 20, y: 0 }, + }, + // too far forward: bottom + { + change: { x: 80, y: 70 }, + expected: { x: 30, y: 20 }, + }, + ]; + + items.forEach((item: Item) => { + const result: ?Position = getOverlap({ + current, + max, + change: item.change, + }); + + expect(result).toEqual(item.expected); + }); + }); + + it('should return overlap on two axis in different directions', () => { + const items: Item[] = [ + // too far back: vertical + // too far forward: horizontal + { + change: { x: 80, y: -70 }, + expected: { x: 30, y: -20 }, + }, + // too far back: horizontal + // too far forward: vertical + { + change: { x: -70, y: 80 }, + expected: { x: -20, y: 30 }, + }, + ]; + + items.forEach((item: Item) => { + const result: ?Position = getOverlap({ + current, + max, + change: item.change, + }); + + expect(result).toEqual(item.expected); + }); + }); + + it('should trim values that can be scrolled', () => { + const items: Item[] = [ + // too far back: top + { + // x can be scrolled entirely + // y can be partially scrolled + change: { x: -20, y: -70 }, + expected: { x: 0, y: -20 }, + }, + // too far back: left + { + // x can be partially scrolled + // y can be scrolled entirely + change: { x: -70, y: -40 }, + expected: { x: -20, y: 0 }, + }, + // too far forward: right + { + // x can be partially scrolled + // y can be scrolled entirely + change: { x: 70, y: 40 }, + expected: { x: 20, y: 0 }, + }, + // too far forward: bottom + { + // x can be scrolled entirely + // y can be partially scrolled + change: { x: 20, y: 70 }, + expected: { x: 0, y: 20 }, + }, + + ]; + + items.forEach((item: Item) => { + const result: ?Position = getOverlap({ + current, + max, + change: item.change, + }); + + expect(result).toEqual(item.expected); + }); + }); + }); + }); + + describe('can scroll droppable', () => { + it('should return false if the droppable is not scrollable', () => { + const result: boolean = canScrollDroppable(preset.home, { x: 1, y: 1 }); + + expect(result).toBe(false); + }); + + it('should return true if the droppable is able to be scrolled', () => { + const result: boolean = canScrollDroppable(scrollable, { x: 0, y: 20 }); + + expect(result).toBe(true); + }); + + it('should return false if the droppable is not able to be scrolled', () => { + const result: boolean = canScrollDroppable(scrollable, { x: -1, y: 0 }); + + expect(result).toBe(false); + }); + }); + + describe('can scroll window', () => { + it('should return true if the window is able to be scrolled', () => { + setViewport(getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + })); + setWindowScrollSize({ + scrollHeight: 200, + scrollWidth: 100, + }); + setWindowScroll(origin); + + const result: boolean = canScrollWindow({ x: 0, y: 50 }); + + expect(result).toBe(true); + }); + + it('should return false if the window is not able to be scrolled', () => { + setViewport(getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + })); + setWindowScrollSize({ + scrollHeight: 200, + scrollWidth: 100, + }); + // already at the max scroll + setWindowScroll({ + x: 0, + y: 200, + }); + + const result: boolean = canScrollWindow({ x: 0, y: 1 }); + + expect(result).toBe(false); + }); + }); + + describe('get droppable overlap', () => { + it('should return null if there is no scroll container', () => { + const result: ?Position = getDroppableOverlap(preset.home, { x: 1, y: 1 }); + + expect(result).toBe(null); + }); + + it('should return null if the droppable cannot be scrolled', () => { + // end of the scrollable area + const scroll: Position = { + x: 0, + y: 200, + }; + const scrolled: DroppableDimension = scrollDroppable(scrollable, scroll); + const result: ?Position = getDroppableOverlap(scrolled, { x: 0, y: 1 }); + + expect(result).toBe(null); + }); + + // tested in get remainder + it('should return the overlap', () => { + // how far the droppable has already + const scroll: Position = { + x: 10, y: 20, + }; + const scrolled: DroppableDimension = scrollDroppable(scrollable, scroll); + // $ExpectError - not checking for null + const max: Position = scrolled.viewport.closestScrollable.scroll.max; + const totalSpace: Position = { + x: scrollableScrollSize.scrollWidth - max.x, + y: scrollableScrollSize.scrollHeight - max.y, + }; + const remainingSpace = subtract(totalSpace, scroll); + const change: Position = { x: 300, y: 300 }; + const expectedOverlap: Position = subtract(change, remainingSpace); + + const result: ?Position = getDroppableOverlap(scrolled, change); + + expect(result).toEqual(expectedOverlap); + }); + + it('should return null if there is no overlap', () => { + const change: Position = { x: 0, y: 1 }; + + const result: ?Position = getDroppableOverlap(scrollable, change); + + expect(result).toEqual(null); + + // verifying correctness of test + + expect(canScrollDroppable(scrollable, change)).toBe(true); + }); + }); + + describe('get window overlap', () => { + it('should return null if the window cannot be scrolled', () => { + setViewport(getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + })); + setWindowScrollSize({ + scrollHeight: 200, + scrollWidth: 100, + }); + // already at the max scroll + setWindowScroll({ + x: 0, + y: 200, + }); + + const result: ?Position = getWindowOverlap({ x: 0, y: 1 }); + + expect(result).toBe(null); + }); + + // tested in get remainder + it('should return the overlap', () => { + const viewport: Area = getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }); + setViewport(viewport); + const windowScrollSize = { + scrollHeight: 200, + scrollWidth: 200, + }; + setWindowScrollSize(windowScrollSize); + const windowScroll: Position = { + x: 50, + y: 50, + }; + setWindowScroll(windowScroll); + + // little validation + const maxScroll: Position = getMaxScroll({ + scrollHeight: windowScrollSize.scrollHeight, + scrollWidth: windowScrollSize.scrollWidth, + height: viewport.height, + width: viewport.width, + }); + expect(maxScroll).toEqual({ x: 100, y: 100 }); + + const availableScrollSpace: Position = { + x: 50, + y: 50, + }; + // cannot be absorbed in the current scroll plane + const bigChange: Position = { x: 300, y: 300 }; + const expectedOverlap: Position = subtract(bigChange, availableScrollSpace); + + const result: ?Position = getWindowOverlap(bigChange); + + expect(result).toEqual(expectedOverlap); + }); + + it('should return null if there is no overlap', () => { + setViewport(getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + })); + const scrollSize = { + scrollHeight: 100, + scrollWidth: 100, + }; + setWindowScrollSize(scrollSize); + setWindowScroll(origin); + + const result: ?Position = getWindowOverlap({ x: 10, y: 10 }); + + expect(result).toBe(null); + }); + }); +}); diff --git a/test/unit/state/auto-scroll/fluid-scroller.spec.js b/test/unit/state/auto-scroll/fluid-scroller.spec.js new file mode 100644 index 0000000000..116487135f --- /dev/null +++ b/test/unit/state/auto-scroll/fluid-scroller.spec.js @@ -0,0 +1,1486 @@ +// @flow +import type { + Area, + Axis, + Position, + State, + DraggableDimension, + DroppableDimension, + DragImpact, +} from '../../../../src/types'; +import type { AutoScroller } from '../../../../src/state/auto-scroller/auto-scroller-types'; +import type { PixelThresholds } from '../../../../src/state/auto-scroller/fluid-scroller'; +import { getPixelThresholds, config } from '../../../../src/state/auto-scroller/fluid-scroller'; +import { add, patch, subtract } from '../../../../src/state/position'; +import getArea from '../../../../src/state/get-area'; +import setViewport, { resetViewport } from '../../../utils/set-viewport'; +import setWindowScrollSize, { resetWindowScrollSize } from '../../../utils/set-window-scroll-size'; +import setWindowScroll, { resetWindowScroll } from '../../../utils/set-window-scroll'; +import noImpact, { noMovement } from '../../../../src/state/no-impact'; +import { vertical, horizontal } from '../../../../src/state/axis'; +import createAutoScroller from '../../../../src/state/auto-scroller/'; +import getStatePreset from '../../../utils/get-simple-state-preset'; +import { + getInitialImpact, + getClosestScrollable, + getPreset, + withImpact, + addDraggable, + addDroppable, +} from '../../../utils/dimension'; +import { expandByPosition } from '../../../../src/state/spacing'; +import { getDraggableDimension, getDroppableDimension, scrollDroppable } from '../../../../src/state/dimension'; + +const windowScrollSize = { + scrollHeight: 2000, + scrollWidth: 1600, +}; +const viewport: Area = getArea({ + top: 0, + left: 0, + right: 800, + bottom: 1000, +}); + +describe('fluid auto scrolling', () => { + let autoScroller: AutoScroller; + let mocks; + + beforeEach(() => { + mocks = { + scrollWindow: jest.fn(), + scrollDroppable: jest.fn(), + move: jest.fn(), + }; + autoScroller = createAutoScroller(mocks); + setViewport(viewport); + setWindowScrollSize(windowScrollSize); + }); + + afterEach(() => { + resetWindowScroll(); + resetWindowScrollSize(); + resetViewport(); + requestAnimationFrame.reset(); + }); + + [vertical, horizontal].forEach((axis: Axis) => { + describe(`on the ${axis.direction} axis`, () => { + const preset = getPreset(axis); + const state = getStatePreset(axis); + const scrollableScrollSize = { + scrollWidth: 800, + scrollHeight: 800, + }; + const frame: Area = getArea({ + top: 0, + left: 0, + right: 600, + bottom: 600, + }); + + const scrollable: DroppableDimension = getDroppableDimension({ + // stealing the home descriptor + descriptor: preset.home.descriptor, + direction: axis.direction, + client: getArea({ + top: 0, + left: 0, + // bigger than the frame + right: scrollableScrollSize.scrollWidth, + bottom: scrollableScrollSize.scrollHeight, + }), + closest: { + frameClient: frame, + scrollWidth: scrollableScrollSize.scrollWidth, + scrollHeight: scrollableScrollSize.scrollHeight, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + const dragTo = ( + selection: Position, + // seeding that we are over the home droppable + impact?: DragImpact = getInitialImpact(preset.inHome1, axis), + ): State => withImpact( + state.dragging(preset.inHome1.descriptor.id, selection), + impact, + ); + + describe('window scrolling', () => { + const thresholds: PixelThresholds = getPixelThresholds(viewport, axis); + const crossAxisThresholds: PixelThresholds = getPixelThresholds( + viewport, + axis === vertical ? horizontal : vertical, + ); + + describe('moving forward to end of window', () => { + const onStartBoundary: Position = patch( + axis.line, + // to the boundary is not enough to start + (viewport[axis.size] - thresholds.startFrom), + viewport.center[axis.crossAxisLine], + ); + const onMaxBoundary: Position = patch( + axis.line, + (viewport[axis.size] - thresholds.maxSpeedAt), + viewport.center[axis.crossAxisLine], + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange(state.idle, dragTo(onStartBoundary)); + + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + const target: Position = add(onStartBoundary, patch(axis.line, 1)); + + autoScroller.onStateChange(state.idle, dragTo(target)); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalled(); + // moving forwards + const request: Position = mocks.scrollWindow.mock.calls[0][0]; + expect(request[axis.line]).toBeGreaterThan(0); + }); + + it('should throttle multiple scrolls into a single animation frame', () => { + const target1: Position = add(onStartBoundary, patch(axis.line, 1)); + const target2: Position = add(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange(state.idle, dragTo(target1)); + autoScroller.onStateChange(dragTo(target1), dragTo(target2)); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(1); + + // verification + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(1); + + // not testing value called as we are not exposing getRequired scroll + }); + + it('should get faster the closer to the max speed point', () => { + const target1: Position = add(onStartBoundary, patch(axis.line, 1)); + const target2: Position = add(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange(state.idle, dragTo(target1)); + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(1); + const scroll1: Position = (mocks.scrollWindow.mock.calls[0][0] : any); + + autoScroller.onStateChange(dragTo(target1), dragTo(target2)); + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(2); + const scroll2: Position = (mocks.scrollWindow.mock.calls[1][0] : any); + + expect(scroll1[axis.line]).toBeLessThan(scroll2[axis.line]); + + // validation + expect(scroll1[axis.crossAxisLine]).toBe(0); + expect(scroll2[axis.crossAxisLine]).toBe(0); + }); + + it('should have the top speed at the max speed point', () => { + const expected: Position = patch(axis.line, config.maxScrollSpeed); + + autoScroller.onStateChange(state.idle, dragTo(onMaxBoundary)); + requestAnimationFrame.step(); + + expect(mocks.scrollWindow).toHaveBeenCalledWith(expected); + }); + + it('should have the top speed when moving beyond the max speed point', () => { + const target: Position = add(onMaxBoundary, patch(axis.line, 1)); + const expected: Position = patch(axis.line, config.maxScrollSpeed); + + autoScroller.onStateChange(state.idle, dragTo(target)); + requestAnimationFrame.step(); + + expect(mocks.scrollWindow).toHaveBeenCalledWith(expected); + }); + + it('should not scroll if the item is too big', () => { + const expanded: Area = getArea(expandByPosition(viewport, { x: 1, y: 1 })); + const tooBig: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + const selection: Position = onMaxBoundary; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBig.descriptor, + }, + }, + }; + + return addDraggable(updated, tooBig); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should not scroll if the window cannot scroll', () => { + setWindowScrollSize({ + scrollHeight: viewport.height, + scrollWidth: viewport.width, + }); + const target: Position = onMaxBoundary; + + autoScroller.onStateChange(state.idle, dragTo(target)); + + requestAnimationFrame.step(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + }); + + describe('moving backwards towards the start of window', () => { + const windowScroll: Position = patch(axis.line, 10); + + beforeEach(() => { + setWindowScroll(windowScroll); + }); + + const onStartBoundary: Position = patch( + axis.line, + // at the boundary is not enough to start + windowScroll[axis.line] + thresholds.startFrom, + viewport.center[axis.crossAxisLine], + ); + const onMaxBoundary: Position = patch( + axis.line, + (windowScroll[axis.line] + thresholds.maxSpeedAt), + viewport.center[axis.crossAxisLine], + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange(state.idle, dragTo(onStartBoundary)); + + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + const target: Position = subtract(onStartBoundary, patch(axis.line, 1)); + + autoScroller.onStateChange(state.idle, dragTo(target)); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalled(); + // moving backwards + const request: Position = mocks.scrollWindow.mock.calls[0][0]; + expect(request[axis.line]).toBeLessThan(0); + }); + + it('should throttle multiple scrolls into a single animation frame', () => { + const target1: Position = subtract(onStartBoundary, patch(axis.line, 1)); + const target2: Position = subtract(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange(state.idle, dragTo(target1)); + autoScroller.onStateChange(dragTo(target1), dragTo(target2)); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(1); + + // verification + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(1); + + // not testing value called as we are not exposing getRequired scroll + }); + + it('should get faster the closer to the max speed point', () => { + const target1: Position = subtract(onStartBoundary, patch(axis.line, 1)); + const target2: Position = subtract(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange(state.idle, dragTo(target1)); + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(1); + const scroll1: Position = (mocks.scrollWindow.mock.calls[0][0] : any); + + autoScroller.onStateChange(dragTo(target1), dragTo(target2)); + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledTimes(2); + const scroll2: Position = (mocks.scrollWindow.mock.calls[1][0] : any); + + // moving backwards so a smaller value is bigger + expect(scroll1[axis.line]).toBeGreaterThan(scroll2[axis.line]); + // or put another way: + expect(Math.abs(scroll1[axis.line])).toBeLessThan(Math.abs(scroll2[axis.line])); + + // validation + expect(scroll1[axis.crossAxisLine]).toBe(0); + expect(scroll2[axis.crossAxisLine]).toBe(0); + }); + + it('should have the top speed at the max speed point', () => { + const target: Position = onMaxBoundary; + const expected: Position = patch(axis.line, -config.maxScrollSpeed); + + autoScroller.onStateChange(state.idle, dragTo(target)); + requestAnimationFrame.step(); + + expect(mocks.scrollWindow).toHaveBeenCalledWith(expected); + }); + + it('should have the top speed when moving beyond the max speed point', () => { + const target: Position = subtract(onMaxBoundary, patch(axis.line, 1)); + const expected: Position = patch(axis.line, -config.maxScrollSpeed); + + autoScroller.onStateChange(state.idle, dragTo(target)); + requestAnimationFrame.step(); + + expect(mocks.scrollWindow).toHaveBeenCalledWith(expected); + }); + + it('should not scroll if the item is too big', () => { + const expanded: Area = getArea(expandByPosition(viewport, { x: 1, y: 1 })); + const tooBig: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + const selection: Position = onMaxBoundary; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBig.descriptor, + }, + }, + }; + + return addDraggable(updated, tooBig); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should not scroll if the window cannot scroll', () => { + setWindowScrollSize({ + scrollHeight: viewport.height, + scrollWidth: viewport.width, + }); + const target: Position = onMaxBoundary; + + autoScroller.onStateChange(state.idle, dragTo(target)); + + requestAnimationFrame.step(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + }); + + // just some light tests to ensure that cross axis moving also works + describe('moving forward on the cross axis', () => { + const onStartBoundary: Position = patch( + axis.line, + viewport.center[axis.line], + // to the boundary is not enough to start + (viewport[axis.crossAxisSize] - crossAxisThresholds.startFrom), + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange(state.idle, dragTo(onStartBoundary)); + + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + const target: Position = add(onStartBoundary, patch(axis.crossAxisLine, 1)); + + autoScroller.onStateChange(state.idle, dragTo(target)); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalled(); + // moving forwards + const request: Position = mocks.scrollWindow.mock.calls[0][0]; + expect(request[axis.crossAxisLine]).toBeGreaterThan(0); + }); + }); + + // just some light tests to ensure that cross axis moving also works + describe('moving backward on the cross axis', () => { + const windowScroll: Position = patch(axis.crossAxisLine, 10); + beforeEach(() => { + setWindowScroll(windowScroll); + }); + + const onStartBoundary: Position = patch( + axis.line, + viewport.center[axis.line], + // to the boundary is not enough to start + windowScroll[axis.crossAxisLine] + (crossAxisThresholds.startFrom) + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange(state.idle, dragTo(onStartBoundary)); + + requestAnimationFrame.flush(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + const target: Position = subtract(onStartBoundary, patch(axis.crossAxisLine, 1)); + + autoScroller.onStateChange(state.idle, dragTo(target)); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalled(); + // moving backwards + const request: Position = mocks.scrollWindow.mock.calls[0][0]; + expect(request[axis.crossAxisLine]).toBeLessThan(0); + }); + }); + + describe('big draggable', () => { + const onMaxBoundaryOfBoth: Position = patch( + axis.line, + (viewport[axis.size] - thresholds.maxSpeedAt), + (viewport[axis.crossAxisSize] - crossAxisThresholds.maxSpeedAt), + ); + + describe('bigger on the main axis', () => { + it('should not allow scrolling on the main axis, but allow scrolling on the cross axis', () => { + const expanded: Area = getArea(expandByPosition(viewport, patch(axis.line, 1))); + const tooBigOnMainAxis: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + + const selection: Position = onMaxBoundaryOfBoth; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBigOnMainAxis.descriptor, + }, + }, + }; + + return addDraggable(updated, tooBigOnMainAxis); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledWith( + // scroll ocurred on the cross axis, but not on the main axis + patch(axis.crossAxisLine, config.maxScrollSpeed) + ); + }); + }); + + describe('bigger on the cross axis', () => { + it('should not allow scrolling on the cross axis, but allow scrolling on the main axis', () => { + const expanded: Area = getArea( + expandByPosition(viewport, patch(axis.crossAxisLine, 1)) + ); + const tooBigOnCrossAxis: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + + const selection: Position = onMaxBoundaryOfBoth; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBigOnCrossAxis.descriptor, + }, + }, + }; + + return addDraggable(updated, tooBigOnCrossAxis); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.step(); + expect(mocks.scrollWindow).toHaveBeenCalledWith( + // scroll ocurred on the main axis, but not on the cross axis + patch(axis.line, config.maxScrollSpeed) + ); + }); + }); + + describe('bigger on both axis', () => { + it('should not allow scrolling on any axis', () => { + const expanded: Area = getArea( + expandByPosition(viewport, patch(axis.line, 1, 1)) + ); + const tooBig: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + + const selection: Position = onMaxBoundaryOfBoth; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBig.descriptor, + }, + }, + }; + + return addDraggable(updated, tooBig); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.step(); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('droppable scrolling', () => { + const thresholds: PixelThresholds = getPixelThresholds(frame, axis); + const crossAxisThresholds: PixelThresholds = getPixelThresholds( + frame, + axis === vertical ? horizontal : vertical + ); + const maxScrollSpeed: Position = patch(axis.line, config.maxScrollSpeed); + + beforeEach(() => { + // avoiding any window scrolling + setWindowScrollSize({ + scrollHeight: viewport.height, + scrollWidth: viewport.width, + }); + }); + + describe('moving forward to end of droppable', () => { + const onStartBoundary: Position = patch( + axis.line, + // to the boundary is not enough to start + (frame[axis.size] - thresholds.startFrom), + frame.center[axis.crossAxisLine], + ); + const onMaxBoundary: Position = patch( + axis.line, + (frame[axis.size] - thresholds.maxSpeedAt), + frame.center[axis.crossAxisLine], + ); + const onEndOfFrame: Position = patch( + axis.line, + frame[axis.size], + frame.center[axis.crossAxisLine], + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(onStartBoundary), scrollable) + ); + + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + const target: Position = add(onStartBoundary, patch(axis.line, 1)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrollable), + ); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalled(); + // moving forwards + const [id, scroll] = mocks.scrollDroppable.mock.calls[0]; + + expect(id).toBe(scrollable.descriptor.id); + expect(scroll[axis.line]).toBeGreaterThan(0); + expect(scroll[axis.crossAxisLine]).toBe(0); + }); + + it('should throttle multiple scrolls into a single animation frame', () => { + const target1: Position = add(onStartBoundary, patch(axis.line, 1)); + const target2: Position = add(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target1), scrollable), + ); + autoScroller.onStateChange( + addDroppable(dragTo(target1), scrollable), + addDroppable(dragTo(target2), scrollable), + ); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(1); + + // verification + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(1); + + // not testing value called as we are not exposing getRequired scroll + }); + + it('should get faster the closer to the max speed point', () => { + const target1: Position = add(onStartBoundary, patch(axis.line, 1)); + const target2: Position = add(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target1), scrollable), + ); + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(1); + const scroll1: Position = (mocks.scrollDroppable.mock.calls[0][1] : any); + + autoScroller.onStateChange( + addDroppable(dragTo(target1), scrollable), + addDroppable(dragTo(target2), scrollable), + ); + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(2); + const scroll2: Position = (mocks.scrollDroppable.mock.calls[1][1] : any); + + expect(scroll1[axis.line]).toBeLessThan(scroll2[axis.line]); + + // validation + expect(scroll1[axis.crossAxisLine]).toBe(0); + expect(scroll2[axis.crossAxisLine]).toBe(0); + }); + + it('should have the top speed at the max speed point', () => { + const expected: Position = patch(axis.line, config.maxScrollSpeed); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(onMaxBoundary), scrollable), + ); + requestAnimationFrame.step(); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrollable.descriptor.id, + expected + ); + }); + + it('should have the top speed when moving beyond the max speed point', () => { + const target: Position = add(onMaxBoundary, patch(axis.line, 1)); + const expected: Position = patch(axis.line, config.maxScrollSpeed); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrollable), + ); + requestAnimationFrame.step(); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrollable.descriptor.id, + expected + ); + }); + + it('should allow scrolling to the end of the droppable', () => { + const target: Position = onEndOfFrame; + // scrolling to max scroll point + const maxChange: Position = getClosestScrollable(scrollable).scroll.max; + const scrolled: DroppableDimension = scrollDroppable(scrollable, maxChange); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrolled), + ); + requestAnimationFrame.flush(); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + + describe('big draggable', () => { + const onMaxBoundaryOfBoth: Position = patch( + axis.line, + (frame[axis.size] - thresholds.maxSpeedAt), + (frame[axis.crossAxisSize] - crossAxisThresholds.maxSpeedAt), + ); + + describe('bigger on the main axis', () => { + it('should not allow scrolling on the main axis, but allow scrolling on the cross axis', () => { + const expanded: Area = getArea(expandByPosition(frame, patch(axis.line, 1))); + const tooBigOnMainAxis: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + + const selection: Position = onMaxBoundaryOfBoth; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBigOnMainAxis.descriptor, + }, + }, + }; + + return addDroppable(addDraggable(updated, tooBigOnMainAxis), scrollable); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrollable.descriptor.id, + // scroll ocurred on the cross axis, but not on the main axis + patch(axis.crossAxisLine, config.maxScrollSpeed) + ); + }); + }); + + describe('bigger on the cross axis', () => { + it('should not allow scrolling on the cross axis, but allow scrolling on the main axis', () => { + const expanded: Area = getArea( + expandByPosition(frame, patch(axis.crossAxisLine, 1)) + ); + const tooBigOnCrossAxis: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + + const selection: Position = onMaxBoundaryOfBoth; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBigOnCrossAxis.descriptor, + }, + }, + }; + + return addDroppable(addDraggable(updated, tooBigOnCrossAxis), scrollable); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrollable.descriptor.id, + // scroll ocurred on the main axis, but not on the cross axis + patch(axis.line, config.maxScrollSpeed) + ); + }); + }); + + describe('bigger on both axis', () => { + it('should not allow scrolling on the cross axis, but allow scrolling on the main axis', () => { + const expanded: Area = getArea( + expandByPosition(frame, patch(axis.line, 1, 1)) + ); + const tooBig: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + + const selection: Position = onMaxBoundaryOfBoth; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBig.descriptor, + }, + }, + }; + + return addDroppable(addDraggable(updated, tooBig), scrollable); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + }); + }); + + describe('over home list', () => { + it('should not scroll if the droppable if moving past the end of the frame', () => { + const target: Position = add(onEndOfFrame, patch(axis.line, 1)); + // scrolling to max scroll point + const maxChange: Position = getClosestScrollable(scrollable).scroll.max; + const scrolled: DroppableDimension = scrollDroppable(scrollable, maxChange); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrolled), + ); + requestAnimationFrame.flush(); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + }); + + describe('over foreign list', () => { + // $ExpectError - using spread + const foreign: DroppableDimension = { + ...scrollable, + descriptor: preset.foreign.descriptor, + }; + const placeholder: Position = patch( + axis.line, + preset.inHome1.placeholder.withoutMargin[axis.size], + ); + const overForeign: DragImpact = { + movement: noMovement, + direction: foreign.axis.direction, + destination: { + index: 0, + droppableId: foreign.descriptor.id, + }, + }; + + it('should allow scrolling up to the end of the frame + the size of the placeholder', () => { + // scrolling to just before the end of the placeholder + // this goes beyond the usual max scroll. + const scroll: Position = add( + // usual max scroll + getClosestScrollable(foreign).scroll.max, + // with a small bit of room towards the end of the placeholder space + subtract(placeholder, patch(axis.line, 1)) + ); + const scrolledForeign: DroppableDimension = scrollDroppable(foreign, scroll); + const target: Position = add(onEndOfFrame, placeholder); + const expected: Position = patch(axis.line, config.maxScrollSpeed); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target, overForeign), scrolledForeign), + ); + requestAnimationFrame.step(); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith(foreign.descriptor.id, expected); + }); + + it('should not allow scrolling past the placeholder buffer', () => { + // already on the placeholder + const scroll: Position = add( + // usual max scroll + getClosestScrollable(foreign).scroll.max, + // with the placeholder + placeholder, + ); + const scrolledForeign: DroppableDimension = scrollDroppable(foreign, scroll); + // targeting beyond the placeholder + const target: Position = add( + add(onEndOfFrame, placeholder), + patch(axis.line, 1), + ); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target, overForeign), scrolledForeign), + ); + requestAnimationFrame.flush(); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + }); + }); + + describe('moving backward to the start of droppable', () => { + const droppableScroll: Position = patch(axis.line, 10); + const scrolled: DroppableDimension = scrollDroppable(scrollable, droppableScroll); + + const onStartBoundary: Position = patch( + axis.line, + // to the boundary is not enough to start + (frame[axis.start] + thresholds.startFrom), + frame.center[axis.crossAxisLine], + ); + const onMaxBoundary: Position = patch( + axis.line, + (frame[axis.start] + thresholds.maxSpeedAt), + frame.center[axis.crossAxisLine], + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(onStartBoundary), scrolled) + ); + + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + // going backwards + const target: Position = subtract(onStartBoundary, patch(axis.line, 1)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrolled), + ); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalled(); + const [id, scroll] = mocks.scrollDroppable.mock.calls[0]; + + // validation + expect(id).toBe(scrollable.descriptor.id); + // moving backwards + expect(scroll[axis.line]).toBeLessThan(0); + expect(scroll[axis.crossAxisLine]).toBe(0); + }); + + it('should throttle multiple scrolls into a single animation frame', () => { + const target1: Position = subtract(onStartBoundary, patch(axis.line, 1)); + const target2: Position = subtract(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target1), scrolled), + ); + autoScroller.onStateChange( + addDroppable(dragTo(target1), scrolled), + addDroppable(dragTo(target2), scrolled), + ); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(1); + + // verification + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(1); + + // not testing value called as we are not exposing getRequired scroll + }); + + it('should get faster the closer to the max speed point', () => { + const target1: Position = subtract(onStartBoundary, patch(axis.line, 1)); + const target2: Position = subtract(onStartBoundary, patch(axis.line, 2)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target1), scrolled), + ); + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(1); + const scroll1: Position = (mocks.scrollDroppable.mock.calls[0][1] : any); + + autoScroller.onStateChange( + addDroppable(dragTo(target1), scrolled), + addDroppable(dragTo(target2), scrolled), + ); + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalledTimes(2); + const scroll2: Position = (mocks.scrollDroppable.mock.calls[1][1] : any); + + // moving backwards + expect(scroll1[axis.line]).toBeGreaterThan(scroll2[axis.line]); + + // validation + expect(scroll1[axis.crossAxisLine]).toBe(0); + expect(scroll2[axis.crossAxisLine]).toBe(0); + }); + + it('should have the top speed at the max speed point', () => { + const expected: Position = patch(axis.line, -config.maxScrollSpeed); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(onMaxBoundary), scrolled), + ); + requestAnimationFrame.step(); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrollable.descriptor.id, + expected + ); + }); + + it('should have the top speed when moving beyond the max speed point', () => { + const target: Position = subtract(onMaxBoundary, patch(axis.line, 1)); + const expected: Position = patch(axis.line, -config.maxScrollSpeed); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrolled), + ); + requestAnimationFrame.step(); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrollable.descriptor.id, + expected + ); + }); + + it('should not scroll if the item is too big', () => { + const expanded: Area = getArea(expandByPosition(frame, { x: 1, y: 1 })); + const tooBig: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'too big', + droppableId: preset.home.descriptor.id, + // after the last item + index: preset.inHomeList.length, + }, + client: expanded, + }); + const selection: Position = onMaxBoundary; + const custom: State = (() => { + const base: State = state.dragging( + preset.inHome1.descriptor.id, + selection, + ); + + const updated: State = { + ...base, + drag: { + ...base.drag, + initial: { + // $ExpectError + ...base.drag.initial, + descriptor: tooBig.descriptor, + }, + }, + }; + + return addDroppable(addDraggable(updated, tooBig), scrolled); + })(); + + autoScroller.onStateChange(state.idle, custom); + + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + + it('should not scroll if the droppable is unable to be scrolled', () => { + const target: Position = onMaxBoundary; + if (!scrollable.viewport.closestScrollable) { + throw new Error('Invalid test setup'); + } + // scrolling to max scroll point + + autoScroller.onStateChange( + state.idle, + // scrollable cannot be scrolled backwards + addDroppable(dragTo(target), scrollable) + ); + requestAnimationFrame.flush(); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + }); + + // just some light tests to ensure that cross axis moving also works + describe('moving forward on the cross axis', () => { + const droppableScroll: Position = patch(axis.crossAxisLine, 10); + const scrolled: DroppableDimension = scrollDroppable(scrollable, droppableScroll); + + const onStartBoundary: Position = patch( + axis.line, + frame.center[axis.line], + // to the boundary is not enough to start + (frame[axis.crossAxisSize] - crossAxisThresholds.startFrom), + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange(state.idle, dragTo(onStartBoundary)); + + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + const target: Position = add(onStartBoundary, patch(axis.crossAxisLine, 1)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrolled), + ); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalled(); + // moving forwards + const [id, scroll] = mocks.scrollDroppable.mock.calls[0]; + + expect(id).toBe(scrolled.descriptor.id); + expect(scroll[axis.crossAxisLine]).toBeGreaterThan(0); + }); + }); + + // just some light tests to ensure that cross axis moving also works + describe('moving backward on the cross axis', () => { + const droppableScroll: Position = patch(axis.crossAxisLine, 10); + const scrolled: DroppableDimension = scrollDroppable(scrollable, droppableScroll); + + const onStartBoundary: Position = patch( + axis.line, + frame.center[axis.line], + // to the boundary is not enough to start + (frame[axis.crossAxisStart] + crossAxisThresholds.startFrom) + ); + + it('should not scroll if not past the start threshold', () => { + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(onStartBoundary), scrolled), + ); + + requestAnimationFrame.flush(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + + it('should scroll if moving beyond the start threshold', () => { + const target: Position = subtract(onStartBoundary, patch(axis.crossAxisLine, 1)); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(target), scrolled), + ); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + + // only called after a frame + requestAnimationFrame.step(); + expect(mocks.scrollDroppable).toHaveBeenCalled(); + // moving backwards + const request: Position = mocks.scrollDroppable.mock.calls[0][1]; + expect(request[axis.crossAxisLine]).toBeLessThan(0); + }); + }); + + describe('over frame but not a subject', () => { + const withSmallSubject: DroppableDimension = getDroppableDimension({ + // stealing the home descriptor + descriptor: preset.home.descriptor, + direction: axis.direction, + client: getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + right: 5000, + bottom: 5000, + }), + scrollWidth: 10000, + scrollHeight: 10000, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + const endOfSubject: Position = patch(axis.line, 100); + const endOfFrame: Position = patch( + axis.line, + // on the end + 5000, + // half way + 2500, + ); + + it('should scroll a frame if it is being dragged over, even if not over the subject', () => { + const scrolled: DroppableDimension = scrollDroppable( + withSmallSubject, + // scrolling the whole client away + endOfSubject, + ); + // subject no longer visible + expect(scrolled.viewport.clipped).toBe(null); + // const target: Position = add(endOfFrame, patch(axis.line, 1)); + + autoScroller.onStateChange( + state.idle, + withImpact( + addDroppable(dragTo(endOfFrame), scrolled), + // being super clear that we are not currently over any droppable + noImpact, + ) + ); + requestAnimationFrame.step(); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrolled.descriptor.id, + maxScrollSpeed, + ); + }); + + it('should not scroll the frame if not over the frame', () => { + const scrolled: DroppableDimension = scrollDroppable( + withSmallSubject, + // scrolling the whole client away + endOfSubject, + ); + // subject no longer visible + expect(scrolled.viewport.clipped).toBe(null); + const target: Position = add(endOfFrame, patch(axis.line, 1)); + + autoScroller.onStateChange( + state.idle, + withImpact( + addDroppable(dragTo(target), scrolled), + // being super clear that we are not currently over any droppable + noImpact, + ) + ); + requestAnimationFrame.step(); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + }); + }); + + describe('window scrolling before droppable scrolling', () => { + const custom: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'scrollable that is similiar to the viewport', + type: 'TYPE', + }, + client: getArea({ + top: 0, + left: 0, + // bigger than the frame + right: windowScrollSize.scrollWidth, + bottom: windowScrollSize.scrollHeight, + }), + closest: { + frameClient: viewport, + scrollWidth: windowScrollSize.scrollWidth, + scrollHeight: windowScrollSize.scrollHeight, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + const thresholds: PixelThresholds = getPixelThresholds(viewport, axis); + + it('should scroll the window only if both the window and droppable can be scrolled', () => { + const onMaxBoundary: Position = patch( + axis.line, + (viewport[axis.size] - thresholds.maxSpeedAt), + viewport.center[axis.crossAxisLine], + ); + + autoScroller.onStateChange( + state.idle, + addDroppable(dragTo(onMaxBoundary), custom), + ); + requestAnimationFrame.step(); + + expect(mocks.scrollWindow).toHaveBeenCalled(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + }); + + describe('on drag end', () => { + const endDragStates = [ + state.idle, + state.dropAnimating(), + state.userCancel(), + state.dropComplete(), + ]; + + endDragStates.forEach((end: State) => { + it('should cancel any pending window scroll', () => { + const thresholds: PixelThresholds = getPixelThresholds(viewport, axis); + const onMaxBoundary: Position = patch( + axis.line, + (viewport[axis.size] - thresholds.maxSpeedAt), + viewport.center[axis.crossAxisLine], + ); + + autoScroller.onStateChange(state.idle, dragTo(onMaxBoundary)); + + // frame not cleared + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + + // should cancel the next frame + autoScroller.onStateChange(dragTo(onMaxBoundary), end); + requestAnimationFrame.flush(); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should cancel any pending droppable scroll', () => { + const thresholds: PixelThresholds = getPixelThresholds(frame, axis); + const onMaxBoundary: Position = patch( + axis.line, + (frame[axis.size] - thresholds.maxSpeedAt), + frame.center[axis.crossAxisLine], + ); + const drag: State = addDroppable(dragTo(onMaxBoundary), scrollable); + + autoScroller.onStateChange( + state.idle, + drag + ); + + // frame not cleared + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + + // should cancel the next frame + autoScroller.onStateChange(drag, end); + requestAnimationFrame.flush(); + + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/state/auto-scroll/jump-scroller.spec.js b/test/unit/state/auto-scroll/jump-scroller.spec.js new file mode 100644 index 0000000000..6a540e6382 --- /dev/null +++ b/test/unit/state/auto-scroll/jump-scroller.spec.js @@ -0,0 +1,550 @@ +// @flow +import type { + Area, + Axis, + Position, + State, + DroppableDimension, +} from '../../../../src/types'; +import type { AutoScroller } from '../../../../src/state/auto-scroller/auto-scroller-types'; +import { add, patch, subtract, negate } from '../../../../src/state/position'; +import getArea from '../../../../src/state/get-area'; +import setViewport, { resetViewport } from '../../../utils/set-viewport'; +import setWindowScrollSize, { resetWindowScrollSize } from '../../../utils/set-window-scroll-size'; +import setWindowScroll, { resetWindowScroll } from '../../../utils/set-window-scroll'; +import { vertical, horizontal } from '../../../../src/state/axis'; +import createAutoScroller from '../../../../src/state/auto-scroller'; +import getStatePreset from '../../../utils/get-simple-state-preset'; +import { getPreset, addDroppable } from '../../../utils/dimension'; +import { getDroppableDimension, scrollDroppable } from '../../../../src/state/dimension'; +import getMaxScroll from '../../../../src/state/get-max-scroll'; + +const origin: Position = { x: 0, y: 0 }; + +const windowScrollSize = { + scrollHeight: 2000, + scrollWidth: 1600, +}; +const viewport: Area = getArea({ + top: 0, + left: 0, + right: 800, + bottom: 1000, +}); + +const disableWindowScroll = () => { + setWindowScrollSize({ + scrollHeight: viewport.height, + scrollWidth: viewport.width, + }); +}; + +describe('jump auto scrolling', () => { + let autoScroller: AutoScroller; + let mocks; + + beforeEach(() => { + mocks = { + scrollWindow: jest.fn(), + scrollDroppable: jest.fn(), + move: jest.fn(), + }; + autoScroller = createAutoScroller(mocks); + setViewport(viewport); + setWindowScrollSize(windowScrollSize); + }); + + afterEach(() => { + resetWindowScroll(); + resetWindowScrollSize(); + resetViewport(); + requestAnimationFrame.reset(); + }); + + [vertical, horizontal].forEach((axis: Axis) => { + describe(`on the ${axis.direction} axis`, () => { + const preset = getPreset(axis); + const state = getStatePreset(axis); + + describe('window scrolling', () => { + describe('moving forwards', () => { + it('should manually move the item if the window is unable to scroll', () => { + disableWindowScroll(); + const request: Position = patch(axis.line, 1); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + const expected: Position = add(current.drag.current.client.selection, request); + + autoScroller.onStateChange(state.idle, current); + + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expected, + origin, + true, + ); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should scroll the window if can absorb all of the movement', () => { + const request: Position = patch(axis.line, 1); + + autoScroller.onStateChange(state.idle, state.scrollJumpRequest(request)); + + expect(mocks.scrollWindow).toHaveBeenCalledWith(request); + expect(mocks.move).not.toHaveBeenCalled(); + }); + + it('should manually move the item any distance that the window is unable to scroll', () => { + // only allowing scrolling by 1 px + setWindowScrollSize({ + scrollHeight: viewport.height + 1, + scrollWidth: viewport.width + 1, + }); + // more than the 1 pixel allowed + const request: Position = patch(axis.line, 3); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + const expected: Position = add( + current.drag.current.client.selection, + // the two pixels that could not be done by the window + patch(axis.line, 2) + ); + + autoScroller.onStateChange(state.idle, state.scrollJumpRequest(request)); + + // can scroll with what we have + expect(mocks.scrollWindow).toHaveBeenCalledWith(patch(axis.line, 1)); + // remainder to be done by movement + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expected, + origin, + true, + ); + }); + }); + + describe('moving backwards', () => { + it('should manually move the item if the window is unable to scroll', () => { + // unable to scroll backwards to start with + const request: Position = patch(axis.line, -1); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + const expected: Position = add(current.drag.current.client.selection, request); + + autoScroller.onStateChange(state.idle, current); + + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expected, + origin, + true, + ); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + }); + + it('should scroll the window if can absorb all of the movement', () => { + setWindowScroll(patch(axis.line, 1)); + const request: Position = patch(axis.line, -1); + + autoScroller.onStateChange(state.idle, state.scrollJumpRequest(request)); + + expect(mocks.scrollWindow).toHaveBeenCalledWith(request); + expect(mocks.move).not.toHaveBeenCalled(); + }); + + it('should manually move the item any distance that the window is unable to scroll', () => { + // only allowing scrolling by 1 px + const windowScroll: Position = patch(axis.line, 1); + setWindowScroll(windowScroll); + // more than the 1 pixel allowed + const request: Position = patch(axis.line, -3); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + const expected: Position = add( + current.drag.current.client.selection, + // the two pixels that could not be done by the window + patch(axis.line, -2) + ); + + autoScroller.onStateChange(state.idle, current); + + // can scroll with what we have + expect(mocks.scrollWindow).toHaveBeenCalledWith(patch(axis.line, -1)); + // remainder to be done by movement + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expected, + windowScroll, + true, + ); + }); + }); + }); + + describe('droppable scrolling (which can involve some window scrolling)', () => { + const scrollableScrollSize = { + scrollWidth: 800, + scrollHeight: 800, + }; + const frame: Area = getArea({ + top: 0, + left: 0, + right: 600, + bottom: 600, + }); + const scrollable: DroppableDimension = getDroppableDimension({ + // stealing the home descriptor so that the dragging item will + // be within in + descriptor: preset.home.descriptor, + client: getArea({ + top: 0, + left: 0, + // bigger than the frame + right: scrollableScrollSize.scrollWidth, + bottom: scrollableScrollSize.scrollHeight, + }), + closest: { + frameClient: frame, + scrollWidth: scrollableScrollSize.scrollWidth, + scrollHeight: scrollableScrollSize.scrollHeight, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + if (!scrollable.viewport.closestScrollable) { + throw new Error('Invalid droppable'); + } + + const maxDroppableScroll: Position = + scrollable.viewport.closestScrollable.scroll.max; + + describe('moving forwards', () => { + describe('droppable is able to complete entire scroll', () => { + it('should only scroll the droppable', () => { + const request: Position = patch(axis.line, 1); + + autoScroller.onStateChange( + state.idle, + addDroppable(state.scrollJumpRequest(request), scrollable), + ); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrollable.descriptor.id, + request, + ); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + expect(mocks.move).not.toHaveBeenCalled(); + }); + }); + + describe('droppable is unable to complete the entire scroll', () => { + it('should manually move the entire request if it is unable to be partially completed by the window or the droppable', () => { + // droppable can no longer be scrolled + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + maxDroppableScroll, + ); + disableWindowScroll(); + const request: Position = patch(axis.line, 1); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + const expected: Position = add(current.drag.current.client.selection, request); + + autoScroller.onStateChange( + state.idle, + addDroppable(current, scrolled), + ); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expected, + origin, + true, + ); + }); + + describe('window is unable to absorb some of the scroll', () => { + beforeEach(() => { + disableWindowScroll(); + }); + + it('should scroll the droppable what it can and move the rest', () => { + // able to scroll 1 pixel forward + const availableScroll: Position = patch(axis.line, 1); + const scroll: Position = subtract(maxDroppableScroll, availableScroll); + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + scroll, + ); + // want to move 3 pixels + const request: Position = patch(axis.line, 3); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + const expectedManualMove: Position = + add(current.drag.current.client.selection, patch(axis.line, 2)); + + autoScroller.onStateChange( + state.idle, + addDroppable(current, scrolled), + ); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + preset.home.descriptor.id, + availableScroll, + ); + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expectedManualMove, + origin, + true, + ); + }); + }); + + describe('window can absorb some of the scroll', () => { + it('should scroll the entire overlap if it can', () => { + const availableScroll: Position = patch(axis.line, 1); + const scroll: Position = subtract(maxDroppableScroll, availableScroll); + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + scroll, + ); + // want to move 3 pixels + const request: Position = patch(axis.line, 3); + + autoScroller.onStateChange( + state.idle, + addDroppable(state.scrollJumpRequest(request), scrolled), + ); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrolled.descriptor.id, + availableScroll, + ); + expect(mocks.scrollWindow).toHaveBeenCalledWith(patch(axis.line, 2)); + expect(mocks.move).not.toHaveBeenCalled(); + }); + + it('should scroll the droppable and window by what it can, and manually move the rest', () => { + // Setting the window scroll so it has a small amount of available space + const availableWindowScroll: Position = patch(axis.line, 2); + const maxWindowScroll: Position = getMaxScroll({ + scrollHeight: windowScrollSize.scrollHeight, + scrollWidth: windowScrollSize.scrollWidth, + height: viewport.height, + width: viewport.width, + }); + const windowScroll: Position = subtract(maxWindowScroll, availableWindowScroll); + setWindowScroll(windowScroll); + // Setting the droppable scroll so it has a small amount of available space + const availableDroppableScroll: Position = patch(axis.line, 1); + const droppableScroll: Position = subtract( + maxDroppableScroll, + availableDroppableScroll + ); + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + droppableScroll, + ); + // How much we want to scroll + const request: Position = patch(axis.line, 5); + // How much we will not be able to absorb with droppable and window scroll + const remainder: Position = + subtract(subtract(request, availableDroppableScroll), availableWindowScroll); + const current = addDroppable(state.scrollJumpRequest(request), scrolled); + if (!current.drag) { + throw new Error('invalid state'); + } + const expectedManualMove: Position = + add(current.drag.current.client.selection, remainder); + + autoScroller.onStateChange(state.idle, current); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrolled.descriptor.id, + availableDroppableScroll, + ); + expect(mocks.scrollWindow).toHaveBeenCalledWith(availableWindowScroll); + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expectedManualMove, + windowScroll, + true, + ); + }); + }); + }); + }); + + describe('moving backwards', () => { + describe('droppable is able to complete entire scroll', () => { + it('should only scroll the droppable', () => { + // move forward slightly to allow us to move forwards + const scrolled: DroppableDimension = scrollDroppable(scrollable, patch(axis.line, 1)); + const request: Position = patch(axis.line, -1); + + autoScroller.onStateChange( + state.idle, + addDroppable(state.scrollJumpRequest(request), scrolled), + ); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrolled.descriptor.id, + request, + ); + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + expect(mocks.move).not.toHaveBeenCalled(); + }); + }); + + describe('droppable is unable to complete the entire scroll', () => { + it('should manually move the entire request if it is unable to be partially completed by the window or the droppable', () => { + // scrollable cannot scroll backwards by default + disableWindowScroll(); + const request: Position = patch(axis.line, -1); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + const expected: Position = add(current.drag.current.client.selection, request); + + autoScroller.onStateChange( + state.idle, + addDroppable(current, scrollable), + ); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + expect(mocks.scrollDroppable).not.toHaveBeenCalled(); + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expected, + origin, + true, + ); + }); + + describe('window is unable to absorb some of the scroll', () => { + beforeEach(() => { + disableWindowScroll(); + }); + + it('should scroll the droppable what it can and move the rest', () => { + // able to scroll 1 pixel forward + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + patch(axis.line, 1), + ); + // want to move backwards 3 pixels + const request: Position = patch(axis.line, -3); + const current: State = state.scrollJumpRequest(request); + if (!current.drag) { + throw new Error('invalid state'); + } + // manual move will take what the droppable cannot + const expectedManualMove: Position = + add(current.drag.current.client.selection, patch(axis.line, -2)); + + autoScroller.onStateChange( + state.idle, + addDroppable(current, scrolled), + ); + + expect(mocks.scrollWindow).not.toHaveBeenCalled(); + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + preset.home.descriptor.id, + // can only scroll backwards what it has! + patch(axis.line, -1), + ); + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expectedManualMove, + origin, + true, + ); + }); + }); + + describe('window can absorb some of the scroll', () => { + it('should scroll the entire overlap if it can', () => { + // let the window scroll be enough to move back into + setWindowScroll(patch(axis.line, 100)); + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + patch(axis.line, 1), + ); + // want to move 3 pixels backwards + const request: Position = patch(axis.line, -3); + + autoScroller.onStateChange( + state.idle, + addDroppable(state.scrollJumpRequest(request), scrolled), + ); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrolled.descriptor.id, + patch(axis.line, -1), + ); + expect(mocks.scrollWindow).toHaveBeenCalledWith(patch(axis.line, -2)); + expect(mocks.move).not.toHaveBeenCalled(); + }); + + it('should scroll the droppable and window by what it can, and manually move the rest', () => { + // Setting the window scroll so it has a small amount of available space + const windowScroll: Position = patch(axis.line, 2); + setWindowScroll(windowScroll); + // Setting the droppable scroll so it has a small amount of available space + const droppableScroll: Position = patch(axis.line, 1); + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + droppableScroll, + ); + // How much we want to scroll + const request: Position = patch(axis.line, -5); + // How much we will not be able to absorb with droppable and window scroll + const remainder: Position = patch(axis.line, -2); + const current = addDroppable(state.scrollJumpRequest(request), scrolled); + if (!current.drag) { + throw new Error('invalid state'); + } + const expectedManualMove: Position = + add(current.drag.current.client.selection, remainder); + + autoScroller.onStateChange(state.idle, current); + + expect(mocks.scrollDroppable).toHaveBeenCalledWith( + scrolled.descriptor.id, + negate(droppableScroll), + ); + expect(mocks.scrollWindow).toHaveBeenCalledWith(negate(windowScroll)); + expect(mocks.move).toHaveBeenCalledWith( + preset.inHome1.descriptor.id, + expectedManualMove, + windowScroll, + true, + ); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/state/can-start-drag.spec.js b/test/unit/state/can-start-drag.spec.js index a4de7a0157..8718e110ca 100644 --- a/test/unit/state/can-start-drag.spec.js +++ b/test/unit/state/can-start-drag.spec.js @@ -1,10 +1,11 @@ // @flow import canStartDrag from '../../../src/state/can-start-drag'; -import * as state from '../../utils/simple-state-preset'; +import getStatePreset from '../../utils/get-simple-state-preset'; import { getPreset } from '../../utils/dimension'; import type { State } from '../../../src/types'; const preset = getPreset(); +const state = getStatePreset(); describe('can start drag', () => { describe('at rest', () => { diff --git a/test/unit/state/dimension.spec.js b/test/unit/state/dimension.spec.js index 3fbbdd2c0e..b7ce15f5df 100644 --- a/test/unit/state/dimension.spec.js +++ b/test/unit/state/dimension.spec.js @@ -6,9 +6,11 @@ import { clip, } from '../../../src/state/dimension'; import { vertical, horizontal } from '../../../src/state/axis'; -import { offset } from '../../../src/state/spacing'; +import { offsetByPosition } from '../../../src/state/spacing'; import getArea from '../../../src/state/get-area'; import { negate } from '../../../src/state/position'; +import getMaxScroll from '../../../src/state/get-max-scroll'; +import { getClosestScrollable } from '../../utils/dimension'; import type { Area, Spacing, @@ -17,6 +19,7 @@ import type { Position, DraggableDimension, DroppableDimension, + ClosestScrollable, } from '../../../src/types'; const droppableDescriptor: DroppableDescriptor = { @@ -47,28 +50,6 @@ const windowScroll: Position = { }; const origin: Position = { x: 0, y: 0 }; -const addPosition = (area: Area, point: Position): Area => { - const { top, right, bottom, left } = area; - return getArea({ - top: top + point.y, - left: left + point.x, - bottom: bottom + point.y, - right: right + point.x, - }); -}; - -const addSpacing = (area: Area, spacing: Spacing): Area => { - const { top, right, bottom, left } = area; - return getArea({ - // pulling back to increase size - top: top - spacing.top, - left: left - spacing.left, - // pushing forward to increase size - bottom: bottom + spacing.bottom, - right: right + spacing.right, - }); -}; - describe('dimension', () => { describe('draggable dimension', () => { const dimension: DraggableDimension = getDraggableDimension({ @@ -128,29 +109,12 @@ describe('dimension', () => { }); describe('droppable dimension', () => { - const frameScroll: Position = { - x: 10, - y: 20, - }; - const dimension: DroppableDimension = getDroppableDimension({ descriptor: droppableDescriptor, client, margin, padding, windowScroll, - frameScroll, - }); - - it('should return the initial scroll as the initial and current scroll', () => { - expect(dimension.viewport.frameScroll).toEqual({ - initial: frameScroll, - current: frameScroll, - diff: { - value: origin, - displacement: origin, - }, - }); }); it('should apply the correct axis', () => { @@ -159,14 +123,12 @@ describe('dimension', () => { client, margin, windowScroll, - frameScroll, }); const withVertical: DroppableDimension = getDroppableDimension({ descriptor: droppableDescriptor, client, margin, windowScroll, - frameScroll, direction: 'vertical', }); const withHorizontal: DroppableDimension = getDroppableDimension({ @@ -174,7 +136,6 @@ describe('dimension', () => { client, margin, windowScroll, - frameScroll, direction: 'horizontal', }); @@ -186,7 +147,7 @@ describe('dimension', () => { expect(withHorizontal.axis).toBe(horizontal); }); - describe('without scroll (client)', () => { + describe('without window scroll (client)', () => { it('should return a portion that does not consider margins', () => { const area: Area = getArea({ top: client.top, @@ -221,7 +182,7 @@ describe('dimension', () => { }); }); - describe('with scroll (page)', () => { + describe('with window scroll (page)', () => { it('should return a portion that does not consider margins', () => { const area: Area = getArea({ top: client.top + windowScroll.y, @@ -248,79 +209,115 @@ describe('dimension', () => { const area: Area = getArea({ top: (client.top + windowScroll.y) - margin.top - padding.top, left: (client.left + windowScroll.x) - margin.left - padding.left, - bottom: client.bottom + windowScroll.y + margin.bottom + padding.bottom, - right: client.right + windowScroll.x + margin.right + padding.right, + bottom: (client.bottom + windowScroll.y) + margin.bottom + padding.bottom, + right: (client.right + windowScroll.x) + margin.right + padding.right, }); expect(dimension.page.withMarginAndPadding).toEqual(area); }); }); - describe('viewport', () => { - it('should use the area as the frame if no frame is provided', () => { - const droppable: DroppableDimension = getDroppableDimension({ - descriptor: droppableDescriptor, - client, - margin, - windowScroll: origin, - frameScroll, + describe('closest scrollable', () => { + describe('basic info about the scrollable', () => { + // eslint-disable-next-line no-shadow + const client: Area = getArea({ + top: 0, + right: 300, + bottom: 300, + left: 0, + }); + const frameClient: Area = getArea({ + top: 0, + right: 100, + bottom: 100, + left: 0, }); - expect(droppable.viewport.frame).toEqual(addSpacing(client, margin)); - }); - - it('should include the window scroll', () => { - const droppable: DroppableDimension = getDroppableDimension({ + const withScrollable: DroppableDimension = getDroppableDimension({ descriptor: droppableDescriptor, client, - margin, windowScroll, - frameScroll, + closest: { + frameClient, + scrollWidth: 500, + scrollHeight: 500, + scroll: { x: 10, y: 10 }, + shouldClipSubject: true, + }, }); - expect(droppable.viewport.frame).toEqual( - addPosition(addSpacing(client, margin), windowScroll), - ); - }); + it('should not have a closest scrollable if there is no closest scrollable', () => { + const noClosestScrollable: DroppableDimension = getDroppableDimension({ + descriptor: droppableDescriptor, + client, + }); - it('should use the frameClient as the frame if provided', () => { - const frameClient: Area = getArea({ - top: 20, - left: 30, - right: 40, - bottom: 50, + expect(noClosestScrollable.viewport.closestScrollable).toBe(null); + expect(noClosestScrollable.viewport.subject) + .toEqual(noClosestScrollable.viewport.clipped); }); - const droppable: DroppableDimension = getDroppableDimension({ - descriptor: droppableDescriptor, - client, - frameClient, + it('should offset the frame client by the window scroll', () => { + expect(getClosestScrollable(withScrollable).frame).toEqual( + getArea(offsetByPosition(frameClient, windowScroll)) + ); }); - expect(droppable.viewport.frame).toEqual(frameClient); + it('should set the max scroll point for the closest scrollable', () => { + expect(getClosestScrollable(withScrollable).scroll.max).toEqual({ x: 400, y: 400 }); + }); }); describe('frame clipping', () => { + const frameClient = getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }); + const getWithClient = (subject: Area): DroppableDimension => getDroppableDimension({ + descriptor: droppableDescriptor, + client: subject, + closest: { + frameClient, + scrollWidth: 300, + scrollHeight: 300, + scroll: origin, + shouldClipSubject: true, + }, + }); + + it('should not clip the frame if requested not to', () => { + const withoutClipping: DroppableDimension = getDroppableDimension({ + descriptor: droppableDescriptor, + client, + windowScroll, + closest: { + frameClient, + scrollWidth: 300, + scrollHeight: 300, + scroll: origin, + // disabling clipping + shouldClipSubject: false, + }, + }); + + expect(withoutClipping.viewport.subject).toEqual(withoutClipping.viewport.clipped); + + expect(getClosestScrollable(withoutClipping).shouldClipSubject).toBe(false); + }); + describe('frame is smaller than subject', () => { it('should clip the subject to the size of the frame', () => { const subject = getArea({ top: 0, - right: 100, - bottom: 100, left: 0, - }); - const frameClient = getArea({ - top: 10, - right: 90, - bottom: 90, - left: 10, + // 100px bigger than the frame on the bottom and right + bottom: 200, + right: 200, }); - const droppable: DroppableDimension = getDroppableDimension({ - descriptor: droppableDescriptor, - client: subject, - frameClient, - }); + const droppable: DroppableDimension = getWithClient(subject); expect(droppable.viewport.clipped).toEqual(frameClient); }); @@ -328,12 +325,7 @@ describe('dimension', () => { describe('frame is larger than subject', () => { it('should return a clipped size that is equal to that of the subject', () => { - const frameClient = getArea({ - top: 0, - right: 100, - bottom: 100, - left: 0, - }); + // 10px smaller on every side const subject = getArea({ top: 10, right: 90, @@ -341,24 +333,13 @@ describe('dimension', () => { left: 10, }); - const droppable: DroppableDimension = getDroppableDimension({ - descriptor: droppableDescriptor, - client: subject, - frameClient, - }); + const droppable: DroppableDimension = getWithClient(subject); expect(droppable.viewport.clipped).toEqual(subject); }); }); describe('subject clipped on one side by frame', () => { - const frameClient = getArea({ - top: 0, - right: 100, - bottom: 100, - left: 0, - }); - it('should clip on all sides', () => { // each of these subjects bleeds out past the frame in one direction const subjects: Area[] = [ @@ -381,11 +362,7 @@ describe('dimension', () => { ]; subjects.forEach((subject: Area) => { - const droppable: DroppableDimension = getDroppableDimension({ - descriptor: droppableDescriptor, - client: subject, - frameClient, - }); + const droppable: DroppableDimension = getWithClient(subject); expect(droppable.viewport.clipped).toEqual(frameClient); }); @@ -397,49 +374,67 @@ describe('dimension', () => { describe('scrolling a droppable', () => { it('should update the frame scroll and the clipping', () => { + const scrollSize = { + scrollHeight: 500, + scrollWidth: 100, + }; const subject = getArea({ // 500 px high top: 0, - bottom: 500, - right: 100, + bottom: scrollSize.scrollHeight, + right: scrollSize.scrollWidth, left: 0, }); const frameClient = getArea({ // only viewing top 100px - top: 0, bottom: 100, // unchanged - right: 100, + top: 0, + right: scrollSize.scrollWidth, left: 0, }); const frameScroll: Position = { x: 0, y: 0 }; const droppable: DroppableDimension = getDroppableDimension({ descriptor: droppableDescriptor, client: subject, - frameClient, - frameScroll, + closest: { + frameClient, + scroll: frameScroll, + scrollWidth: scrollSize.scrollWidth, + scrollHeight: scrollSize.scrollHeight, + shouldClipSubject: true, + }, }); + const closestScrollable: ClosestScrollable = getClosestScrollable(droppable); + // original frame - expect(droppable.viewport.frame).toEqual(frameClient); + expect(closestScrollable.frame).toEqual(frameClient); // subject is currently clipped by the frame expect(droppable.viewport.clipped).toEqual(frameClient); // scrolling down const newScroll: Position = { x: 0, y: 100 }; const updated: DroppableDimension = scrollDroppable(droppable, newScroll); + const updatedClosest: ClosestScrollable = getClosestScrollable(updated); // unchanged frame client - expect(updated.viewport.frame).toEqual(frameClient); + expect(updatedClosest.frame).toEqual(frameClient); // updated scroll info - expect(updated.viewport.frameScroll).toEqual({ + expect(updatedClosest.scroll).toEqual({ initial: frameScroll, current: newScroll, diff: { value: newScroll, displacement: negate(newScroll), }, + max: getMaxScroll({ + scrollWidth: scrollSize.scrollWidth, + scrollHeight: scrollSize.scrollHeight, + width: frameClient.width, + height: frameClient.height, + }), }); // updated clipped @@ -452,6 +447,40 @@ describe('dimension', () => { left: 0, })); }); + + it('should allow scrolling beyond the max position', () => { + // this is to allow for scrolling into a foreign placeholder + const scrollable: DroppableDimension = getDroppableDimension({ + descriptor: droppableDescriptor, + client: getArea({ + top: 0, + left: 0, + right: 200, + bottom: 200, + }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + scroll: { x: 0, y: 0 }, + scrollWidth: 200, + scrollHeight: 200, + shouldClipSubject: true, + }, + }); + + const scrolled: DroppableDimension = scrollDroppable(scrollable, { x: 300, y: 300 }); + + // current is larger than max + expect(getClosestScrollable(scrolled).scroll.current).toEqual({ x: 300, y: 300 }); + // current max is unchanged + expect(getClosestScrollable(scrolled).scroll.max).toEqual({ x: 100, y: 100 }); + // original max + expect(getClosestScrollable(scrollable).scroll.max).toEqual({ x: 100, y: 100 }); + }); }); describe('subject clipping', () => { @@ -481,13 +510,13 @@ describe('dimension', () => { }); const outside: Spacing[] = [ // top - offset(frame, { x: 0, y: -200 }), + offsetByPosition(frame, { x: 0, y: -200 }), // right - offset(frame, { x: 200, y: 0 }), + offsetByPosition(frame, { x: 200, y: 0 }), // bottom - offset(frame, { x: 0, y: 200 }), + offsetByPosition(frame, { x: 0, y: 200 }), // left - offset(frame, { x: -200, y: 0 }), + offsetByPosition(frame, { x: -200, y: 0 }), ]; outside.forEach((subject: Spacing) => { diff --git a/test/unit/state/fire-hooks.spec.js b/test/unit/state/fire-hooks.spec.js deleted file mode 100644 index e61a9963a8..0000000000 --- a/test/unit/state/fire-hooks.spec.js +++ /dev/null @@ -1,273 +0,0 @@ -// @flow -import fireHooks from '../../../src/state/fire-hooks'; -import * as state from '../../utils/simple-state-preset'; -import { getPreset } from '../../utils/dimension'; -import type { - DropResult, - Hooks, - State, - DimensionState, - DraggableLocation, - DragStart, -} from '../../../src/types'; - -const preset = getPreset(); - -const noDimensions: DimensionState = { - request: null, - draggable: {}, - droppable: {}, -}; - -describe('fire hooks', () => { - let hooks: Hooks; - - beforeEach(() => { - hooks = { - onDragStart: jest.fn(), - onDragEnd: jest.fn(), - }; - jest.spyOn(console, 'error').mockImplementation(() => { }); - }); - - afterEach(() => { - console.error.mockRestore(); - }); - - describe('drag start', () => { - it('should call the onDragStart hook when a drag starts', () => { - fireHooks(hooks, state.requesting(), state.dragging()); - const expected: DragStart = { - draggableId: preset.inHome1.descriptor.id, - type: preset.home.descriptor.type, - source: { - droppableId: preset.inHome1.descriptor.droppableId, - index: preset.inHome1.descriptor.index, - }, - }; - - expect(hooks.onDragStart).toHaveBeenCalledWith(expected); - }); - - it('should do nothing if no onDragStart is not provided', () => { - const customHooks: Hooks = { - onDragEnd: jest.fn(), - }; - - fireHooks(customHooks, state.requesting(), state.dragging()); - - expect(console.error).not.toHaveBeenCalled(); - }); - - it('should log an error and not call the callback if there is no current drag', () => { - const invalid: State = { - ...state.dragging(), - drag: null, - }; - - fireHooks(hooks, state.requesting(), invalid); - - expect(console.error).toHaveBeenCalled(); - }); - - it('should not call if only collecting dimensions (not dragging yet)', () => { - fireHooks(hooks, state.idle, state.preparing); - fireHooks(hooks, state.preparing, state.requesting()); - - expect(hooks.onDragStart).not.toHaveBeenCalled(); - }); - }); - - describe('drag end', () => { - // it is possible to complete a drag from a DRAGGING or DROP_ANIMATING (drop or cancel) - const preEndStates: State[] = [ - state.dragging(), - state.dropAnimating(), - state.userCancel(), - ]; - - preEndStates.forEach((previous: State): void => { - it('should call onDragEnd with the drop result', () => { - const result: DropResult = { - draggableId: preset.inHome1.descriptor.id, - type: preset.home.descriptor.type, - source: { - droppableId: preset.inHome1.descriptor.droppableId, - index: preset.inHome1.descriptor.index, - }, - destination: { - droppableId: preset.inHome1.descriptor.droppableId, - index: preset.inHome1.descriptor.index + 1, - }, - }; - const current: State = { - phase: 'DROP_COMPLETE', - drop: { - pending: null, - result, - }, - drag: null, - dimension: noDimensions, - }; - - fireHooks(hooks, previous, current); - - if (!current.drop || !current.drop.result) { - throw new Error('invalid state'); - } - - const provided: DropResult = current.drop.result; - expect(hooks.onDragEnd).toHaveBeenCalledWith(provided); - }); - - it('should log an error and not call the callback if there is no drop result', () => { - const invalid: State = { - ...state.dropComplete(), - drop: null, - }; - - fireHooks(hooks, previous, invalid); - - expect(hooks.onDragEnd).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalled(); - }); - - it('should call onDragEnd with null as the destination if there is no destination', () => { - const result: DropResult = { - draggableId: preset.inHome1.descriptor.id, - type: preset.home.descriptor.type, - source: { - droppableId: preset.inHome1.descriptor.droppableId, - index: preset.inHome1.descriptor.index, - }, - destination: null, - }; - const current: State = { - phase: 'DROP_COMPLETE', - drop: { - pending: null, - result, - }, - drag: null, - dimension: noDimensions, - }; - - fireHooks(hooks, previous, current); - - expect(hooks.onDragEnd).toHaveBeenCalledWith(result); - }); - - it('should call onDragEnd with null if the item did not move', () => { - const source: DraggableLocation = { - droppableId: preset.inHome1.descriptor.droppableId, - index: preset.inHome1.descriptor.index, - }; - const result: DropResult = { - draggableId: preset.inHome1.descriptor.id, - type: preset.home.descriptor.type, - source, - destination: source, - }; - const current: State = { - phase: 'DROP_COMPLETE', - drop: { - pending: null, - result, - }, - drag: null, - dimension: noDimensions, - }; - const expected : DropResult = { - draggableId: result.draggableId, - type: result.type, - source: result.source, - // destination has been cleared - destination: null, - }; - - fireHooks(hooks, previous, current); - - expect(hooks.onDragEnd).toHaveBeenCalledWith(expected); - }); - }); - }); - - describe('drag cleared', () => { - describe('cleared while dragging', () => { - it('should return a result with a null destination', () => { - const expected: DropResult = { - draggableId: preset.inHome1.descriptor.id, - type: preset.home.descriptor.type, - // $ExpectError - not checking for null - source: { - index: preset.inHome1.descriptor.index, - droppableId: preset.inHome1.descriptor.droppableId, - }, - destination: null, - }; - - fireHooks(hooks, state.dragging(), state.idle); - - expect(hooks.onDragEnd).toHaveBeenCalledWith(expected); - }); - - it('should log an error and do nothing if it cannot find a previous drag to publish', () => { - const invalid: State = { - phase: 'DRAGGING', - drag: null, - drop: null, - dimension: noDimensions, - }; - - fireHooks(hooks, state.idle, invalid); - - expect(hooks.onDragEnd).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalled(); - }); - }); - - // this should never really happen - but just being safe - describe('cleared while drop animating', () => { - it('should return a result with a null destination', () => { - const expected: DropResult = { - draggableId: preset.inHome1.descriptor.id, - type: preset.home.descriptor.type, - source: { - index: preset.inHome1.descriptor.index, - droppableId: preset.inHome1.descriptor.droppableId, - }, - destination: null, - }; - - fireHooks(hooks, state.dropAnimating(), state.idle); - - expect(hooks.onDragEnd).toHaveBeenCalledWith(expected); - }); - - it('should log an error and do nothing if it cannot find a previous drag to publish', () => { - const invalid: State = { - ...state.dropAnimating(), - drop: null, - }; - - fireHooks(hooks, invalid, state.idle); - - expect(hooks.onDragEnd).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalled(); - }); - }); - }); - - describe('phase unchanged', () => { - it('should not do anything if the previous and next phase are the same', () => { - Object.keys(state).forEach((key: string) => { - const current: State = state[key]; - - fireHooks(hooks, current, current); - - expect(hooks.onDragStart).not.toHaveBeenCalled(); - expect(hooks.onDragEnd).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/test/unit/state/get-drag-impact.spec.js b/test/unit/state/get-drag-impact.spec.js index 24a9b7beb4..bd704ff3cf 100644 --- a/test/unit/state/get-drag-impact.spec.js +++ b/test/unit/state/get-drag-impact.spec.js @@ -13,8 +13,9 @@ import { import { getPreset, disableDroppable, + makeScrollable, } from '../../utils/dimension'; -import getViewport from '../../../src/state/visibility/get-viewport'; +import getViewport from '../../../src/window/get-viewport'; import type { Axis, DraggableDimension, @@ -120,7 +121,7 @@ describe('get drag impact', () => { // up to the line but not over it inHome2.page.withoutMargin[axis.start], // no movement on cross axis - inHome1.page.withoutMargin.center[axis.crossLine], + inHome1.page.withoutMargin.center[axis.crossAxisLine], ); const expected: DragImpact = { movement: { @@ -153,7 +154,7 @@ describe('get drag impact', () => { axis.line, inHome4.page.withoutMargin[axis.start] + 1, // no change - inHome2.page.withoutMargin.center[axis.crossLine], + inHome2.page.withoutMargin.center[axis.crossAxisLine], ); const expected: DragImpact = { movement: { @@ -199,7 +200,7 @@ describe('get drag impact', () => { axis.line, inHome1.page.withoutMargin[axis.end] - 1, // no change - inHome3.page.withoutMargin.center[axis.crossLine], + inHome3.page.withoutMargin.center[axis.crossAxisLine], ); const expected: DragImpact = { @@ -241,6 +242,12 @@ describe('get drag impact', () => { }); describe('home droppable scroll has changed during a drag', () => { + const scrollableHome: DroppableDimension = makeScrollable(home); + const withScrollableHome = { + ...droppables, + [home.descriptor.id]: scrollableHome, + }; + // moving inHome1 past inHome2 by scrolling the dimension describe('moving beyond start position with own scroll', () => { it('should move past other draggables', () => { @@ -248,19 +255,19 @@ describe('get drag impact', () => { const startOfInHome2: Position = patch( axis.line, inHome2.page.withoutMargin[axis.start], - inHome2.page.withoutMargin.center[axis.crossLine], + inHome2.page.withoutMargin.center[axis.crossAxisLine], ); const distanceNeeded: Position = add( subtract(startOfInHome2, inHome1.page.withoutMargin.center), // need to move over the edge patch(axis.line, 1), ); - const homeWithScroll: DroppableDimension = scrollDroppable( - home, distanceNeeded + const scrolledHome: DroppableDimension = scrollDroppable( + scrollableHome, distanceNeeded ); const updatedDroppables: DroppableDimensionMap = { - ...droppables, - [home.descriptor.id]: homeWithScroll, + ...withScrollableHome, + [home.descriptor.id]: scrolledHome, }; // no changes in current page center from original const pageCenter: Position = inHome1.page.withoutMargin.center; @@ -302,19 +309,19 @@ describe('get drag impact', () => { const endOfInHome2: Position = patch( axis.line, inHome2.page.withoutMargin[axis.end], - inHome2.page.withoutMargin.center[axis.crossLine], + inHome2.page.withoutMargin.center[axis.crossAxisLine], ); const distanceNeeded: Position = add( subtract(endOfInHome2, inHome4.page.withoutMargin.center), // need to move over the edge patch(axis.line, -1), ); - const homeWithScroll: DroppableDimension = scrollDroppable( - home, distanceNeeded + const scrolledHome: DroppableDimension = scrollDroppable( + scrollableHome, distanceNeeded ); const updatedDroppables: DroppableDimensionMap = { - ...droppables, - [home.descriptor.id]: homeWithScroll, + ...withScrollableHome, + [home.descriptor.id]: scrolledHome, }; // no changes in current page center from original const pageCenter: Position = inHome4.page.withoutMargin.center; @@ -372,13 +379,19 @@ describe('get drag impact', () => { // will be cut by the frame [axis.end]: 200, }), - frameClient: getArea({ - [axis.crossAxisStart]: 0, - [axis.crossAxisEnd]: 100, - [axis.start]: 0, - // will cut the subject, - [axis.end]: 100, - }), + closest: { + frameClient: getArea({ + [axis.crossAxisStart]: 0, + [axis.crossAxisEnd]: 100, + [axis.start]: 0, + // will cut the subject, + [axis.end]: 100, + }), + scrollWidth: 100, + scrollHeight: 100, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const visible: DraggableDimension = getDraggableDimension({ descriptor: { @@ -603,7 +616,7 @@ describe('get drag impact', () => { axis.line, // just before the end of the dimension which is the cut off inForeign1.page.withoutMargin[axis.end] - 1, - inForeign1.page.withoutMargin.center[axis.crossLine], + inForeign1.page.withoutMargin.center[axis.crossAxisLine], ); const expected: DragImpact = { movement: { @@ -660,7 +673,7 @@ describe('get drag impact', () => { const pageCenter: Position = patch( axis.line, inForeign2.page.withoutMargin[axis.end] - 1, - inForeign2.page.withoutMargin.center[axis.crossLine], + inForeign2.page.withoutMargin.center[axis.crossAxisLine], ); const expected: DragImpact = { movement: { @@ -712,7 +725,7 @@ describe('get drag impact', () => { const pageCenter: Position = patch( axis.line, inForeign4.page.withoutMargin[axis.end], - inForeign4.page.withoutMargin.center[axis.crossLine], + inForeign4.page.withoutMargin.center[axis.crossAxisLine], ); const expected: DragImpact = { movement: { @@ -777,15 +790,16 @@ describe('get drag impact', () => { const pageCenter: Position = patch( axis.line, inForeign2.page.withoutMargin[axis.end] - 1, - inForeign2.page.withoutMargin.center[axis.crossLine], + inForeign2.page.withoutMargin.center[axis.crossAxisLine], ); it('should have no impact impact the destination (actual)', () => { // will go over the threshold of inForeign2 so that it will not be displaced forward const scroll: Position = patch(axis.line, 1000); + const scrollableHome: DroppableDimension = makeScrollable(home, 1000); const map: DroppableDimensionMap = { ...droppables, - [home.descriptor.id]: scrollDroppable(home, scroll), + [home.descriptor.id]: scrollDroppable(scrollableHome, scroll), }; const expected: DragImpact = { @@ -870,10 +884,16 @@ describe('get drag impact', () => { }); describe('destination droppable scroll is updated during a drag', () => { + const scrollableForeign: DroppableDimension = makeScrollable(foreign); + const withScrollableForeign = { + ...droppables, + [foreign.descriptor.id]: scrollableForeign, + }; + const pageCenter: Position = patch( axis.line, inForeign2.page.withoutMargin[axis.end] - 1, - inForeign2.page.withoutMargin.center[axis.crossLine], + inForeign2.page.withoutMargin.center[axis.crossAxisLine], ); it('should impact the destination (actual)', () => { @@ -881,8 +901,8 @@ describe('get drag impact', () => { // be displaced forward const scroll: Position = patch(axis.line, 1); const map: DroppableDimensionMap = { - ...droppables, - [foreign.descriptor.id]: scrollDroppable(foreign, scroll), + ...withScrollableForeign, + [foreign.descriptor.id]: scrollDroppable(scrollableForeign, scroll), }; const expected: DragImpact = { @@ -994,6 +1014,7 @@ describe('get drag impact', () => { const foreignCrossAxisStart: number = 120; const foreignCrossAxisEnd: number = 200; + const destination: DroppableDimension = getDroppableDimension({ descriptor: { id: 'destination', @@ -1007,13 +1028,19 @@ describe('get drag impact', () => { // will be cut off by the frame [axis.end]: 200, }), - frameClient: getArea({ - [axis.crossAxisStart]: foreignCrossAxisStart, - [axis.crossAxisEnd]: foreignCrossAxisEnd, - [axis.start]: 0, - // will cut off the subject - [axis.end]: 100, - }), + closest: { + frameClient: getArea({ + [axis.crossAxisStart]: foreignCrossAxisStart, + [axis.crossAxisEnd]: foreignCrossAxisEnd, + [axis.start]: 0, + // will cut off the subject + [axis.end]: 100, + }), + scrollWidth: 100, + scrollHeight: 100, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const visible: DraggableDimension = getDraggableDimension({ descriptor: { diff --git a/test/unit/state/get-droppable-over.spec.js b/test/unit/state/get-droppable-over.spec.js index 40d496e94a..b8748306b1 100644 --- a/test/unit/state/get-droppable-over.spec.js +++ b/test/unit/state/get-droppable-over.spec.js @@ -87,10 +87,16 @@ describe('get droppable over', () => { client: getArea({ top: 0, left: 0, right: 100, bottom: 100, }), - // will partially hide the subject - frameClient: getArea({ - top: 0, left: 0, right: 50, bottom: 100, - }), + closest: { + // will partially hide the subject + frameClient: getArea({ + top: 0, left: 0, right: 50, bottom: 100, + }), + scrollHeight: 100, + scrollWidth: 100, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const draggable: DraggableDimension = getDraggableDimension({ descriptor: { @@ -124,10 +130,17 @@ describe('get droppable over', () => { client: getArea({ top: 0, left: 0, right: 100, bottom: 100, }), - // will totally hide the subject - frameClient: getArea({ - top: 0, left: 101, right: 200, bottom: 100, - }), + closest: { + // will partially hide the subject + // will totally hide the subject + frameClient: getArea({ + top: 0, left: 101, right: 200, bottom: 100, + }), + scrollHeight: 100, + scrollWidth: 200, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const draggable: DraggableDimension = getDraggableDimension({ descriptor: { @@ -458,12 +471,18 @@ describe('get droppable over', () => { // cut off by the frame bottom: 120, }), - frameClient: getArea({ - top: 0, - left: 0, - right: 100, - bottom: 100, - }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + scrollHeight: 120, + scrollWidth: 100, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); // scrolling custom down so that it the bottom is visible const scrolled: DroppableDimension = scrollDroppable(custom, { x: 0, y: 20 }); @@ -503,13 +522,19 @@ describe('get droppable over', () => { // this will ensure that there is required growth in the droppable bottom: inHome1.page.withMargin.height - 1, }), - frameClient: getArea({ - top: 0, - left: 0, - right: 100, - // currently much bigger than client - bottom: 500, - }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + right: 100, + // currently much bigger than client + bottom: 500, + }), + scrollWidth: 100, + scrollHeight: 500, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const scrolled: DroppableDimension = scrollDroppable(foreign, { x: 0, y: 50 }); const clipped: ?Area = scrolled.viewport.clipped; diff --git a/test/unit/state/hook-caller.spec.js b/test/unit/state/hook-caller.spec.js new file mode 100644 index 0000000000..ac83239f50 --- /dev/null +++ b/test/unit/state/hook-caller.spec.js @@ -0,0 +1,1259 @@ +// @flow +import createHookCaller from '../../../src/state/hooks/hook-caller'; +import messagePreset from '../../../src/state/hooks/message-preset'; +import type { HookCaller } from '../../../src/state/hooks/hooks-types'; +import getStatePreset from '../../utils/get-simple-state-preset'; +import { getPreset } from '../../utils/dimension'; +import noImpact, { noMovement } from '../../../src/state/no-impact'; +import type { + Announce, + Hooks, + HookProvided, + DropResult, + State, + DimensionState, + DraggableLocation, + DraggableDescriptor, + DroppableDimension, + DragStart, + DragUpdate, + DragImpact, +} from '../../../src/types'; + +const preset = getPreset(); +const state = getStatePreset(); + +const noDimensions: DimensionState = { + request: null, + draggable: {}, + droppable: {}, +}; + +describe('fire hooks', () => { + let hooks: Hooks; + let caller: HookCaller; + let announceMock: Announce; + + beforeEach(() => { + jest.useFakeTimers(); + announceMock = jest.fn(); + caller = createHookCaller(announceMock); + hooks = { + onDragStart: jest.fn(), + onDragUpdate: jest.fn(), + onDragEnd: jest.fn(), + }; + jest.spyOn(console, 'error').mockImplementation(() => { }); + jest.spyOn(console, 'warn').mockImplementation(() => { }); + }); + + afterEach(() => { + console.error.mockRestore(); + console.warn.mockRestore(); + jest.useRealTimers(); + }); + + describe('drag start', () => { + it('should call the onDragStart hook when a drag starts', () => { + const expected: DragStart = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + source: { + droppableId: preset.inHome1.descriptor.droppableId, + index: preset.inHome1.descriptor.index, + }, + }; + + caller.onStateChange(hooks, state.requesting(), state.dragging()); + + expect(hooks.onDragStart).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + it('should log an error and not call the callback if there is no current drag', () => { + const invalid: State = { + ...state.dragging(), + drag: null, + }; + + caller.onStateChange(hooks, state.requesting(), invalid); + + expect(console.error).toHaveBeenCalled(); + }); + + it('should not call if only collecting dimensions (not dragging yet)', () => { + caller.onStateChange(hooks, state.idle, state.preparing); + caller.onStateChange(hooks, state.preparing, state.requesting()); + + expect(hooks.onDragStart).not.toHaveBeenCalled(); + }); + + describe('announcements', () => { + const getDragStart = (appState: State): ?DragStart => { + if (!appState.drag) { + return null; + } + + const descriptor: DraggableDescriptor = appState.drag.initial.descriptor; + const home: ?DroppableDimension = appState.dimension.droppable[descriptor.droppableId]; + + if (!home) { + return null; + } + + const source: DraggableLocation = { + index: descriptor.index, + droppableId: descriptor.droppableId, + }; + + const start: DragStart = { + draggableId: descriptor.id, + type: home.descriptor.type, + source, + }; + + return start; + }; + + const dragStart: ?DragStart = getDragStart(state.dragging()); + if (!dragStart) { + throw new Error('Invalid test setup'); + } + + it('should announce with the default lift message if no message is provided', () => { + caller.onStateChange(hooks, state.requesting(), state.dragging()); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragStart(dragStart)); + }); + + it('should announce with the default lift message if no onDragStart hook is provided', () => { + const customHooks: Hooks = { + onDragEnd: jest.fn(), + }; + + caller.onStateChange(customHooks, state.requesting(), state.dragging()); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragStart(dragStart)); + }); + + it('should announce with a provided message', () => { + const customHooks: Hooks = { + onDragStart: (start: DragStart, provided: HookProvided) => provided.announce('test'), + onDragEnd: jest.fn(), + }; + + caller.onStateChange(customHooks, state.requesting(), state.dragging()); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(announceMock).toHaveBeenCalledWith('test'); + }); + + it('should prevent double announcing', () => { + let myAnnounce: ?Announce; + const customHooks: Hooks = { + onDragStart: (start: DragStart, provided: HookProvided) => { + myAnnounce = provided.announce; + myAnnounce('test'); + }, + onDragEnd: jest.fn(), + }; + + caller.onStateChange(customHooks, state.requesting(), state.dragging()); + expect(announceMock).toHaveBeenCalledWith('test'); + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + + if (!myAnnounce) { + throw new Error('Invalid test setup'); + } + + myAnnounce('second'); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should prevent async announcing', () => { + const customHooks: Hooks = { + onDragStart: (start: DragStart, provided: HookProvided) => { + setTimeout(() => { + // boom + provided.announce('too late'); + }); + }, + onDragEnd: jest.fn(), + }; + + caller.onStateChange(customHooks, state.requesting(), state.dragging()); + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragStart(dragStart)); + expect(console.warn).not.toHaveBeenCalled(); + + // not releasing the async message + jest.runOnlyPendingTimers(); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + }); + }); + + describe('drag update', () => { + const withImpact = (current: State, impact: DragImpact) => { + if (!current.drag) { + throw new Error('invalid state'); + } + return { + ...current, + drag: { + ...current.drag, + impact, + }, + }; + }; + + const start: DragStart = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + source: { + index: preset.inHome1.descriptor.index, + droppableId: preset.inHome1.descriptor.droppableId, + }, + }; + + const inHomeImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination: start.source, + }; + + describe('has not moved from home location', () => { + beforeEach(() => { + // start a drag + caller.onStateChange( + hooks, + state.requesting(), + withImpact(state.dragging(), inHomeImpact), + ); + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + }); + + it('should not provide an update if the location has not changed since the last drag', () => { + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), inHomeImpact), + ); + + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + }); + + it('should provide an update if the index changes', () => { + const destination: DraggableLocation = { + index: preset.inHome1.descriptor.index + 1, + droppableId: preset.inHome1.descriptor.droppableId, + }; + const impact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination, + }; + const expected: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination, + }; + + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), impact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + it('should provide an update if the droppable changes', () => { + const destination: DraggableLocation = { + // same index + index: preset.inHome1.descriptor.index, + // different droppable + droppableId: preset.foreign.descriptor.id, + }; + const impact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination, + }; + const expected: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination, + }; + + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), impact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + it('should provide an update if moving from a droppable to nothing', () => { + const expected: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: null, + }; + + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), noImpact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + describe('announcements', () => { + const destination: DraggableLocation = { + // new index + index: preset.inHome1.descriptor.index + 1, + // different droppable + droppableId: preset.inHome1.descriptor.droppableId, + }; + const updateImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination, + }; + const dragUpdate: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination, + }; + const inHome = withImpact(state.dragging(), inHomeImpact); + const withUpdate = withImpact(state.dragging(), updateImpact); + + const perform = (myHooks: Hooks) => { + caller.onStateChange(myHooks, inHome, withUpdate); + }; + + beforeEach(() => { + // from the lift + expect(announceMock).toHaveBeenCalledTimes(1); + // clear its state + announceMock.mockReset(); + }); + + it('should announce with the default update message if no message is provided', () => { + caller.onStateChange(hooks, inHome, withUpdate); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragUpdate(dragUpdate)); + }); + + it('should announce with the default update message if no onDragUpdate hook is provided', () => { + const customHooks: Hooks = { + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragUpdate(dragUpdate)); + }); + + it('should announce with a provided message', () => { + const customHooks: Hooks = { + onDragUpdate: (update: DragUpdate, provided: HookProvided) => provided.announce('test'), + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(announceMock).toHaveBeenCalledWith('test'); + }); + + it('should prevent double announcing', () => { + let myAnnounce: ?Announce; + const customHooks: Hooks = { + onDragUpdate: (update: DragUpdate, provided: HookProvided) => { + myAnnounce = provided.announce; + myAnnounce('test'); + }, + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith('test'); + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + + if (!myAnnounce) { + throw new Error('Invalid test setup'); + } + + myAnnounce('second'); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should prevent async announcing', () => { + const customHooks: Hooks = { + onDragUpdate: (update: DragUpdate, provided: HookProvided) => { + setTimeout(() => { + // boom + provided.announce('too late'); + }); + }, + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragUpdate(dragUpdate)); + expect(console.warn).not.toHaveBeenCalled(); + + // not releasing the async message + jest.runOnlyPendingTimers(); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + }); + }); + + describe('no longer in home location', () => { + const firstImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + // moved into the second index + destination: { + index: preset.inHome1.descriptor.index + 1, + droppableId: preset.inHome1.descriptor.droppableId, + }, + }; + + beforeEach(() => { + // initial lift + caller.onStateChange( + hooks, + state.requesting(), + withImpact(state.dragging(), inHomeImpact), + ); + // checking everything is well + expect(hooks.onDragStart).toHaveBeenCalled(); + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + + // first move into new location + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), firstImpact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalled(); + // cleaning the hook + // $ExpectError - no mock reset property + hooks.onDragUpdate.mockReset(); + }); + + it('should not provide an update if the location has not changed since the last drag', () => { + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), firstImpact), + withImpact(state.dragging(), firstImpact), + ); + + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + }); + + it('should provide an update if the index changes', () => { + const destination: DraggableLocation = { + index: preset.inHome1.descriptor.index + 2, + droppableId: preset.inHome1.descriptor.droppableId, + }; + const secondImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination, + }; + const expected: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination, + }; + + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), firstImpact), + withImpact(state.dragging(), secondImpact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + it('should provide an update if the droppable changes', () => { + const destination: DraggableLocation = { + index: preset.inHome1.descriptor.index + 1, + droppableId: preset.foreign.descriptor.id, + }; + const secondImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination, + }; + const expected: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination, + }; + + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), firstImpact), + withImpact(state.dragging(), secondImpact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + it('should provide an update if moving from a droppable to nothing', () => { + const secondImpact: DragImpact = { + movement: noMovement, + direction: null, + destination: null, + }; + const expected: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: null, + }; + + // drag to the same spot + caller.onStateChange( + hooks, + withImpact(state.dragging(), firstImpact), + withImpact(state.dragging(), secondImpact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + it('should provide an update if moving back to the home location', () => { + const impact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination: null, + }; + + // drag to nowhere + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), impact), + ); + const first: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: null, + }; + + expect(hooks.onDragUpdate).toHaveBeenCalledWith(first, { + announce: expect.any(Function), + }); + + // drag back to home + caller.onStateChange( + hooks, + withImpact(state.dragging(), impact), + withImpact(state.dragging(), inHomeImpact), + ); + const second: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: start.source, + }; + expect(hooks.onDragUpdate).toHaveBeenCalledWith(second, { + announce: expect.any(Function), + }); + }); + + describe('announcements', () => { + const destination: DraggableLocation = { + // new index + index: preset.inHome1.descriptor.index + 2, + // different droppable + droppableId: preset.inHome1.descriptor.droppableId, + }; + const secondImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + destination, + }; + const secondUpdate: DragUpdate = { + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination, + }; + const withFirstUpdate = withImpact(state.dragging(), firstImpact); + const withSecondUpdate = withImpact(state.dragging(), secondImpact); + + const perform = (myHooks: Hooks) => { + caller.onStateChange(myHooks, withFirstUpdate, withSecondUpdate); + }; + + beforeEach(() => { + // clear its state from previous updates + announceMock.mockReset(); + }); + + it('should announce with the default update message if no message is provided', () => { + caller.onStateChange(hooks, withFirstUpdate, withSecondUpdate); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragUpdate(secondUpdate)); + }); + + it('should announce with the default update message if no onDragUpdate hook is provided', () => { + const customHooks: Hooks = { + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragUpdate(secondUpdate)); + }); + + it('should announce with a provided message', () => { + const customHooks: Hooks = { + onDragUpdate: (update: DragUpdate, provided: HookProvided) => provided.announce('test'), + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(announceMock).toHaveBeenCalledWith('test'); + }); + + it('should prevent double announcing', () => { + let myAnnounce: ?Announce; + const customHooks: Hooks = { + onDragUpdate: (update: DragUpdate, provided: HookProvided) => { + myAnnounce = provided.announce; + myAnnounce('test'); + }, + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith('test'); + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + + if (!myAnnounce) { + throw new Error('Invalid test setup'); + } + + myAnnounce('second'); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should prevent async announcing', () => { + const customHooks: Hooks = { + onDragUpdate: (update: DragUpdate, provided: HookProvided) => { + setTimeout(() => { + // boom + provided.announce('too late'); + }); + }, + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragUpdate(secondUpdate)); + expect(console.warn).not.toHaveBeenCalled(); + + // not releasing the async message + jest.runOnlyPendingTimers(); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + }); + }); + + describe('multiple updates', () => { + it('should correctly update across multiple updates', () => { + // initial lift + caller.onStateChange( + hooks, + state.requesting(), + withImpact(state.dragging(), inHomeImpact), + ); + // checking everything is well + expect(hooks.onDragStart).toHaveBeenCalled(); + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + + // first move into new location + const firstImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + // moved into the second index + destination: { + index: preset.inHome1.descriptor.index + 1, + droppableId: preset.inHome1.descriptor.droppableId, + }, + }; + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), firstImpact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledTimes(1); + expect(hooks.onDragUpdate).toHaveBeenCalledWith({ + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: firstImpact.destination, + }, { announce: expect.any(Function) }); + + // second move into new location + const secondImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + // moved into the second index + destination: { + index: preset.inHome1.descriptor.index + 2, + droppableId: preset.inHome1.descriptor.droppableId, + }, + }; + caller.onStateChange( + hooks, + withImpact(state.dragging(), firstImpact), + withImpact(state.dragging(), secondImpact), + ); + + expect(hooks.onDragUpdate).toHaveBeenCalledTimes(2); + expect(hooks.onDragUpdate).toHaveBeenCalledWith({ + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: secondImpact.destination, + }, { announce: expect.any(Function) }); + }); + + it('should update correctly across multiple drags', () => { + // initial lift + caller.onStateChange( + hooks, + state.requesting(), + withImpact(state.dragging(), inHomeImpact), + ); + // checking everything is well + expect(hooks.onDragStart).toHaveBeenCalled(); + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + + // first move into new location + const firstImpact: DragImpact = { + movement: noMovement, + direction: preset.home.axis.direction, + // moved into the second index + destination: { + index: preset.inHome1.descriptor.index + 1, + droppableId: preset.inHome1.descriptor.droppableId, + }, + }; + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), firstImpact), + ); + expect(hooks.onDragUpdate).toHaveBeenCalledTimes(1); + expect(hooks.onDragUpdate).toHaveBeenCalledWith({ + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: firstImpact.destination, + }, { announce: expect.any(Function) }); + // resetting the mock + // $ExpectError - resetting mock + hooks.onDragUpdate.mockReset(); + + // drop + caller.onStateChange( + hooks, + withImpact(state.dragging(), firstImpact), + state.idle, + ); + + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + + // a new lift! + caller.onStateChange( + hooks, + state.requesting(), + withImpact(state.dragging(), inHomeImpact), + ); + // checking everything is well + expect(hooks.onDragStart).toHaveBeenCalled(); + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + + // first move into new location + caller.onStateChange( + hooks, + withImpact(state.dragging(), inHomeImpact), + withImpact(state.dragging(), firstImpact), + ); + expect(hooks.onDragUpdate).toHaveBeenCalledTimes(1); + expect(hooks.onDragUpdate).toHaveBeenCalledWith({ + draggableId: start.draggableId, + type: start.type, + source: start.source, + destination: firstImpact.destination, + }, { announce: expect.any(Function) }); + }); + }); + }); + + describe('drag end', () => { + // it is possible to complete a drag from a DRAGGING or DROP_ANIMATING (drop or cancel) + const preEndStates: State[] = [ + state.dragging(), + state.dropAnimating(), + state.userCancel(), + ]; + + preEndStates.forEach((previous: State, index: number): void => { + describe(`end state ${index}`, () => { + it('should call onDragEnd with the drop result', () => { + const result: DropResult = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + source: { + droppableId: preset.inHome1.descriptor.droppableId, + index: preset.inHome1.descriptor.index, + }, + destination: { + droppableId: preset.inHome1.descriptor.droppableId, + index: preset.inHome1.descriptor.index + 1, + }, + reason: 'DROP', + }; + const current: State = { + phase: 'DROP_COMPLETE', + drop: { + pending: null, + result, + }, + drag: null, + dimension: noDimensions, + }; + + caller.onStateChange(hooks, previous, current); + + if (!current.drop || !current.drop.result) { + throw new Error('invalid state'); + } + + const provided: DropResult = current.drop.result; + expect(hooks.onDragEnd).toHaveBeenCalledWith(provided, { + announce: expect.any(Function), + }); + }); + + it('should log an error and not call the callback if there is no drop result', () => { + const invalid: State = { + ...state.dropComplete(), + drop: null, + }; + + caller.onStateChange(hooks, previous, invalid); + + expect(hooks.onDragEnd).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + }); + + it('should call onDragEnd with null as the destination if there is no destination', () => { + const result: DropResult = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + source: { + droppableId: preset.inHome1.descriptor.droppableId, + index: preset.inHome1.descriptor.index, + }, + destination: null, + reason: 'DROP', + }; + const current: State = { + phase: 'DROP_COMPLETE', + drop: { + pending: null, + result, + }, + drag: null, + dimension: noDimensions, + }; + + caller.onStateChange(hooks, previous, current); + + expect(hooks.onDragEnd).toHaveBeenCalledWith(result, { + announce: expect.any(Function), + }); + }); + + it('should call onDragEnd with original source if the item did not move', () => { + const source: DraggableLocation = { + droppableId: preset.inHome1.descriptor.droppableId, + index: preset.inHome1.descriptor.index, + }; + const result: DropResult = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + source, + destination: source, + reason: 'DROP', + }; + const current: State = { + phase: 'DROP_COMPLETE', + drop: { + pending: null, + result, + }, + drag: null, + dimension: noDimensions, + }; + const expected: DropResult = { + draggableId: result.draggableId, + type: result.type, + source: result.source, + // destination has been cleared + destination: source, + reason: 'DROP', + }; + + caller.onStateChange(hooks, previous, current); + + expect(hooks.onDragEnd).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + describe('announcements', () => { + const result: DropResult = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + source: { + droppableId: preset.inHome1.descriptor.droppableId, + index: preset.inHome1.descriptor.index, + }, + destination: { + droppableId: preset.inHome1.descriptor.droppableId, + index: preset.inHome1.descriptor.index + 1, + }, + reason: 'DROP', + }; + const current: State = { + phase: 'DROP_COMPLETE', + drop: { + pending: null, + result, + }, + drag: null, + dimension: noDimensions, + }; + + const perform = (myHooks: Hooks) => { + caller.onStateChange(myHooks, previous, current); + }; + + beforeEach(() => { + // clear its state from previous updates + announceMock.mockReset(); + }); + + it('should announce with the default update message if no message is provided', () => { + perform(hooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragEnd(result)); + }); + + it('should announce with the default update message if no onDragEnd hook is provided', () => { + const customHooks: Hooks = { + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragEnd(result)); + }); + + it('should announce with a provided message', () => { + const customHooks: Hooks = { + onDragEnd: (drop: DropResult, provided: HookProvided) => provided.announce('the end'), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(announceMock).toHaveBeenCalledWith('the end'); + }); + + it('should prevent double announcing', () => { + let myAnnounce: ?Announce; + const customHooks: Hooks = { + onDragEnd: (drop: DropResult, provided: HookProvided) => { + myAnnounce = provided.announce; + myAnnounce('test'); + }, + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith('test'); + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + + if (!myAnnounce) { + throw new Error('Invalid test setup'); + } + + myAnnounce('second'); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should prevent async announcing', () => { + const customHooks: Hooks = { + onDragEnd: (drop: DropResult, provided: HookProvided) => { + setTimeout(() => { + // boom + provided.announce('too late'); + }); + }, + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragEnd(result)); + expect(console.warn).not.toHaveBeenCalled(); + + // not releasing the async message + jest.runOnlyPendingTimers(); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe('drag cleared', () => { + describe('cleared while dragging', () => { + const drop: DropResult = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + // $ExpectError - not checking for null + source: { + index: preset.inHome1.descriptor.index, + droppableId: preset.inHome1.descriptor.droppableId, + }, + destination: null, + reason: 'CANCEL', + }; + it('should return a result with a null destination', () => { + caller.onStateChange(hooks, state.dragging(), state.idle); + + expect(hooks.onDragEnd).toHaveBeenCalledWith(drop, { + announce: expect.any(Function), + }); + }); + + it('should log an error and do nothing if it cannot find a previous drag to publish', () => { + const invalid: State = { + phase: 'DRAGGING', + drag: null, + drop: null, + dimension: noDimensions, + }; + + caller.onStateChange(hooks, state.idle, invalid); + + expect(hooks.onDragEnd).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + }); + + describe('announcements', () => { + const perform = (myHooks: Hooks) => { + caller.onStateChange(myHooks, state.dragging(), state.idle); + }; + + beforeEach(() => { + // clear its state from previous updates + announceMock.mockReset(); + }); + + it('should announce with the default update message if no message is provided', () => { + perform(hooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragEnd(drop)); + }); + + it('should announce with the default update message if no onDragEnd hook is provided', () => { + const customHooks: Hooks = { + onDragEnd: jest.fn(), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragEnd(drop)); + }); + + it('should announce with a provided message', () => { + const customHooks: Hooks = { + onDragEnd: (dropResult: DropResult, provided: HookProvided) => provided.announce('the end'), + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(announceMock).toHaveBeenCalledWith('the end'); + }); + + it('should prevent double announcing', () => { + let myAnnounce: ?Announce; + const customHooks: Hooks = { + onDragEnd: (dropResult: DropResult, provided: HookProvided) => { + myAnnounce = provided.announce; + myAnnounce('test'); + }, + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith('test'); + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + + if (!myAnnounce) { + throw new Error('Invalid test setup'); + } + + myAnnounce('second'); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should prevent async announcing', () => { + const customHooks: Hooks = { + onDragEnd: (dropResult: DropResult, provided: HookProvided) => { + setTimeout(() => { + // boom + provided.announce('too late'); + }); + }, + }; + + perform(customHooks); + + expect(announceMock).toHaveBeenCalledWith(messagePreset.onDragEnd(drop)); + expect(console.warn).not.toHaveBeenCalled(); + + // not releasing the async message + jest.runOnlyPendingTimers(); + + expect(announceMock).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); + }); + }); + }); + + // this should never really happen - but just being safe + describe('cleared while drop animating', () => { + it('should return a result with a null destination', () => { + const expected: DropResult = { + draggableId: preset.inHome1.descriptor.id, + type: preset.home.descriptor.type, + source: { + index: preset.inHome1.descriptor.index, + droppableId: preset.inHome1.descriptor.droppableId, + }, + destination: null, + reason: 'CANCEL', + }; + + caller.onStateChange(hooks, state.dropAnimating(), state.idle); + + expect(hooks.onDragEnd).toHaveBeenCalledWith(expected, { + announce: expect.any(Function), + }); + }); + + it('should log an error and do nothing if it cannot find a previous drag to publish', () => { + const invalid: State = { + ...state.dropAnimating(), + drop: null, + }; + + caller.onStateChange(hooks, invalid, state.idle); + + expect(hooks.onDragEnd).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + }); + }); + }); + + describe('phase unchanged', () => { + it('should not do anything if the previous and next phase are the same', () => { + Object.keys(state).forEach((key: string) => { + const current: State = state[key]; + + caller.onStateChange(hooks, current, current); + + expect(hooks.onDragStart).not.toHaveBeenCalled(); + expect(hooks.onDragUpdate).not.toHaveBeenCalled(); + expect(hooks.onDragEnd).not.toHaveBeenCalled(); + }); + }); + }); +}); 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 index fb12a2803e..b3e6e52a84 100644 --- 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 @@ -4,7 +4,7 @@ import { getDroppableDimension } from '../../../../src/state/dimension'; import getArea from '../../../../src/state/get-area'; import { add } from '../../../../src/state/position'; import { horizontal, vertical } from '../../../../src/state/axis'; -import getViewport from '../../../../src/state/visibility/get-viewport'; +import getViewport from '../../../../src/window/get-viewport'; import type { Axis, Position, @@ -309,14 +309,20 @@ describe('get best cross axis droppable', () => { // long droppable inside a shorter container - this should be clipped bottom: 80, }), - frameClient: getArea({ - // not the same top value as source - top: 20, - // shares the left edge with the source - left: 20, - right: 40, - bottom: 40, - }), + closest: { + frameClient: getArea({ + // not the same top value as source + top: 20, + // shares the left edge with the source + left: 20, + right: 40, + bottom: 40, + }), + scrollWidth: 20, + scrollHeight: 80, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const sibling2 = getDroppableDimension({ descriptor: { @@ -635,13 +641,19 @@ describe('get best cross axis droppable', () => { [axis.crossAxisStart]: 200, [axis.crossAxisEnd]: 300, }), - frameClient: getArea({ - [axis.start]: 0, - [axis.end]: 100, - // frame hides subject - [axis.crossAxisStart]: 400, - [axis.crossAxisEnd]: 500, - }), + closest: { + frameClient: getArea({ + [axis.start]: 0, + [axis.end]: 100, + // frame hides subject + [axis.crossAxisStart]: 400, + [axis.crossAxisEnd]: 500, + }), + scroll: { x: 0, y: 0 }, + scrollWidth: 100, + scrollHeight: 100, + shouldClipSubject: true, + }, }); const droppables: DroppableDimensionMap = { [source.descriptor.id]: source, 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 index c2680f1f04..4b9b761a0c 100644 --- a/test/unit/state/move-cross-axis/get-closest-draggable.spec.js +++ b/test/unit/state/move-cross-axis/get-closest-draggable.spec.js @@ -1,11 +1,13 @@ // @flow import getClosestDraggable from '../../../../src/state/move-cross-axis/get-closest-draggable'; -import { getDroppableDimension, getDraggableDimension } from '../../../../src/state/dimension'; +import { getDroppableDimension, getDraggableDimension, scrollDroppable } from '../../../../src/state/dimension'; import { add, distance, patch } from '../../../../src/state/position'; +import { expandByPosition } from '../../../../src/state/spacing'; import { horizontal, vertical } from '../../../../src/state/axis'; import getArea from '../../../../src/state/get-area'; -import getViewport from '../../../../src/state/visibility/get-viewport'; +import getViewport from '../../../../src/window/get-viewport'; import type { + Area, Axis, Position, DraggableDimension, @@ -14,308 +16,359 @@ import type { 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({ - descriptor: { - id: 'droppable', - type: 'TYPE', - }, - client: getArea({ + describe(`on the ${axis.direction} axis`, () => { + const start: number = 0; + const end: number = 100; + const crossAxisStart: number = 0; + const crossAxisEnd: number = 20; + + const client: Area = getArea({ [axis.start]: start, [axis.end]: end, [axis.crossAxisStart]: crossAxisStart, [axis.crossAxisEnd]: crossAxisEnd, - }), - }); - - const hiddenBackwards: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'hiddenBackwards', - droppableId: droppable.descriptor.id, - index: 0, - }, - client: getArea({ - [axis.crossAxisStart]: crossAxisStart, - [axis.crossAxisEnd]: crossAxisEnd, - [axis.start]: -30, // -10 - [axis.end]: -10, - }), - }); - - // item bleeds backwards past the start of the droppable - const partiallyHiddenBackwards: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'partialHiddenBackwards', - droppableId: droppable.descriptor.id, - index: 1, - }, - client: getArea({ - [axis.crossAxisStart]: crossAxisStart, - [axis.crossAxisEnd]: crossAxisEnd, - [axis.start]: -10, // -10 - [axis.end]: 20, - }), - }); - - const visible1: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'visible1', - droppableId: droppable.descriptor.id, - index: 2, - }, - client: getArea({ - [axis.crossAxisStart]: crossAxisStart, - [axis.crossAxisEnd]: crossAxisEnd, - [axis.start]: 20, - [axis.end]: 40, - }), - }); + }); - const visible2: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'visible2', - droppableId: droppable.descriptor.id, - index: 3, - }, - client: getArea({ - [axis.crossAxisStart]: crossAxisStart, - [axis.crossAxisEnd]: crossAxisEnd, - [axis.start]: 40, - [axis.end]: 60, - }), - }); + const droppable: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'droppable', + type: 'TYPE', + }, + direction: axis.direction, + client, + }); - // bleeds over the end of the visible boundary - const partiallyHiddenForwards: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'partiallyHiddenForwards', - droppableId: droppable.descriptor.id, - index: 4, - }, - client: getArea({ - [axis.crossAxisStart]: crossAxisStart, - [axis.crossAxisEnd]: crossAxisEnd, - [axis.start]: 60, - [axis.end]: 120, - }), - }); + const hiddenBackwards: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'hiddenBackwards', + droppableId: droppable.descriptor.id, + index: 0, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: -30, // -10 + [axis.end]: -10, + }), + }); - // totally invisible - const hiddenForwards: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'hiddenForwards', - droppableId: droppable.descriptor.id, - index: 5, - }, - client: getArea({ - [axis.crossAxisStart]: crossAxisStart, - [axis.crossAxisEnd]: crossAxisEnd, - [axis.start]: 120, - [axis.end]: 140, - }), - }); + // item bleeds backwards past the start of the droppable + const partiallyHiddenBackwards: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'partialHiddenBackwards', + droppableId: droppable.descriptor.id, + index: 1, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: -10, // -10 + [axis.end]: 20, + }), + }); - const viewport = getViewport(); - const outOfViewport: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'hidden', - droppableId: droppable.descriptor.id, - index: 6, - }, - client: getArea({ - [axis.crossAxisStart]: crossAxisStart, - [axis.crossAxisEnd]: crossAxisEnd, - [axis.start]: viewport[axis.end] + 1, - [axis.end]: viewport[axis.end] + 10, - }), - }); + const visible1: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'visible1', + droppableId: droppable.descriptor.id, + index: 2, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 20, + [axis.end]: 40, + }), + }); - const insideDestination: DraggableDimension[] = [ - hiddenBackwards, - partiallyHiddenBackwards, - visible1, - visible2, - partiallyHiddenForwards, - hiddenForwards, - outOfViewport, - ]; - - 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, + const visible2: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'visible2', + droppableId: droppable.descriptor.id, + index: 3, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 40, + [axis.end]: 60, + }), }); - 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, + + // bleeds over the end of the visible boundary + const partiallyHiddenForwards: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'partiallyHiddenForwards', + droppableId: droppable.descriptor.id, + index: 4, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 60, + [axis.end]: 120, + }), }); - 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: [], + // totally invisible + const hiddenForwards: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'hiddenForwards', + droppableId: droppable.descriptor.id, + index: 5, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 120, + [axis.end]: 140, + }), }); - expect(result).toBe(null); - }); + const viewport = getViewport(); + const outOfViewport: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'hidden', + droppableId: droppable.descriptor.id, + index: 6, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: viewport[axis.end] + 1, + [axis.end]: viewport[axis.end] + 10, + }), + }); - describe('removal of draggables that are visible', () => { - it('should ignore draggables backward that have no visiblity', () => { - const center: Position = patch( - axis.line, hiddenBackwards.page.withoutMargin.center[axis.line], 100 + const insideDestination: DraggableDimension[] = [ + hiddenBackwards, + partiallyHiddenBackwards, + visible1, + visible2, + partiallyHiddenForwards, + hiddenForwards, + outOfViewport, + ]; + + it('should return the closest draggable', () => { + // closet to visible1 + const center1: Position = patch( + axis.line, visible1.page.withoutMargin.center[axis.line], 100 ); - - const result: ?DraggableDimension = getClosestDraggable({ + const result1: ?DraggableDimension = getClosestDraggable({ axis, - pageCenter: center, + pageCenter: center1, destination: droppable, insideDestination, }); + expect(result1).toBe(visible1); - expect(result).toBe(partiallyHiddenBackwards); - }); - - it('should not ignore draggables that have backwards partial visiblility', () => { - const center: Position = patch( - axis.line, partiallyHiddenBackwards.page.withoutMargin.center[axis.line], 100 + // closest to visible2 + const center2: Position = patch( + axis.line, visible2.page.withoutMargin.center[axis.line], 100 ); - - const result: ?DraggableDimension = getClosestDraggable({ + const result2: ?DraggableDimension = getClosestDraggable({ axis, - pageCenter: center, + pageCenter: center2, destination: droppable, insideDestination, }); - - expect(result).toBe(partiallyHiddenBackwards); + expect(result2).toBe(visible2); }); - it('should not ignore draggables that have forward partial visiblility', () => { - const center: Position = patch( - axis.line, partiallyHiddenForwards.page.withoutMargin.center[axis.line], 100 - ); + 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, + insideDestination: [], }); - expect(result).toBe(partiallyHiddenForwards); + expect(result).toBe(null); }); - it('should ignore draggables forward that have no visiblity', () => { + it('should take into account the change in droppable scroll', () => { + const scrollable: DroppableDimension = getDroppableDimension({ + descriptor: droppable.descriptor, + direction: axis.direction, + client, + closest: { + frameClient: getArea(expandByPosition(client, patch(axis.line, 100))), + scrollHeight: client.width + 100, + scrollWidth: client.height + 100, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + const scrolled: DroppableDimension = scrollDroppable( + scrollable, + patch(axis.line, 20) + ); const center: Position = patch( - axis.line, hiddenForwards.page.withoutMargin.center[axis.line], 100 + axis.line, + visible1.page.withoutMargin.center[axis.line], + 100 ); const result: ?DraggableDimension = getClosestDraggable({ axis, pageCenter: center, - destination: droppable, + destination: scrolled, insideDestination, }); - expect(result).toBe(partiallyHiddenForwards); - }); - - it('should ignore draggables that are outside of the viewport', () => { - const center: Position = patch( - axis.line, outOfViewport.page.withoutMargin.center[axis.line], 100 - ); + expect(result).toBe(visible2); - const result: ?DraggableDimension = getClosestDraggable({ + // validation - with no scroll applied we are normally closer to visible1 + const result1: ?DraggableDimension = getClosestDraggable({ axis, pageCenter: center, destination: droppable, insideDestination, }); + expect(result1).toBe(visible1); + }); + + describe('removal of draggables that are visible', () => { + it('should ignore draggables backward that have no total visiblity', () => { + const center: Position = patch( + axis.line, + hiddenBackwards.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 backwards partial visiblility', () => { + const center: Position = patch( + axis.line, + partiallyHiddenBackwards.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 forward that have no visiblity', () => { + const center: Position = patch( + axis.line, hiddenForwards.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 are outside of the viewport', () => { + const center: Position = patch( + axis.line, outOfViewport.page.withoutMargin.center[axis.line], 100 + ); + + const result: ?DraggableDimension = getClosestDraggable({ + axis, + pageCenter: center, + destination: droppable, + insideDestination, + }); + + expect(result).toBe(visible2); + }); - expect(result).toBe(partiallyHiddenForwards); + it('should return null if there are no visible targets', () => { + const notVisible: DraggableDimension[] = [ + hiddenBackwards, + hiddenForwards, + outOfViewport, + ]; + 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 null if there are no visible targets', () => { - const notVisible: DraggableDimension[] = [ - hiddenBackwards, - hiddenForwards, - outOfViewport, - ]; - const center: Position = { - x: 0, - y: 0, - }; + 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: notVisible, + insideDestination, }); - 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); - expect(result).toBe(visible1); + // validating test assumptions - // validating test assumptions + // 1. that they have equal distances + expect(distance(center, visible1.page.withoutMargin.center)) + .toEqual(distance(center, visible2.page.withoutMargin.center)); - // 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, + // 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); }); - 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 index 2aecfa77d9..72856c2722 100644 --- a/test/unit/state/move-cross-axis/move-cross-axis.spec.js +++ b/test/unit/state/move-cross-axis/move-cross-axis.spec.js @@ -1,7 +1,7 @@ // @flow import moveCrossAxis from '../../../../src/state/move-cross-axis/'; import noImpact from '../../../../src/state/no-impact'; -import getViewport from '../../../../src/state/visibility/get-viewport'; +import getViewport from '../../../../src/window/get-viewport'; import getArea from '../../../../src/state/get-area'; import { getDroppableDimension, getDraggableDimension } from '../../../../src/state/dimension'; import { getPreset } from '../../../utils/dimension'; 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 index 6995d0fa07..36af179307 100644 --- 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 @@ -1,14 +1,14 @@ // @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 { getDraggableDimension, getDroppableDimension, scrollDroppable } from '../../../../src/state/dimension'; import getArea from '../../../../src/state/get-area'; import moveToEdge from '../../../../src/state/move-to-edge'; -import { patch } from '../../../../src/state/position'; +import { add, negate, patch } from '../../../../src/state/position'; import { horizontal, vertical } from '../../../../src/state/axis'; -import { getPreset } from '../../../utils/dimension'; +import { getPreset, makeScrollable } from '../../../utils/dimension'; import noImpact from '../../../../src/state/no-impact'; -import getViewport from '../../../../src/state/visibility/get-viewport'; +import getViewport from '../../../../src/window/get-viewport'; import type { Axis, DragImpact, @@ -86,129 +86,217 @@ describe('move to new droppable', () => { }); 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.descriptor.id, - }, - previousImpact: noImpact, - }); + describe('without droppable scroll', () => { + // 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.descriptor.id, + }, + previousImpact: noImpact, + }); - if (!result) { - throw new Error('invalid test setup'); - } + 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 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: { + displaced: [], + amount: patch(axis.line, inHome2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: home.descriptor.id, + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); }); - it('should return an empty impact with the original location', () => { - const expected: DragImpact = { - movement: { - displaced: [], - amount: patch(axis.line, inHome2.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - direction: axis.direction, - destination: { - droppableId: home.descriptor.id, + describe('with droppable scroll', () => { + const scrollable: DroppableDimension = makeScrollable(home, 10); + const scroll: Position = patch(axis.line, 10); + const displacement: Position = negate(scroll); + const scrolled: DroppableDimension = scrollDroppable(scrollable, patch(axis.line, 10)); + + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: inHome2, + target: inHome2, + destination: scrolled, + insideDestination: draggables, + home: { index: 1, + droppableId: home.descriptor.id, }, - }; + previousImpact: noImpact, + }); - expect(result.impact).toEqual(expected); + if (!result) { + throw new Error('Invalid result'); + } + + it('should account for changes in droppable scroll', () => { + const expected: Position = add(inHome2.page.withoutMargin.center, displacement); + + expect(result.pageCenter).toEqual(expected); + }); + + it('should return an empty impact with the original location', () => { + const expected: DragImpact = { + movement: { + displaced: [], + amount: patch(axis.line, inHome2.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: home.descriptor.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.descriptor.id, - }, - previousImpact: noImpact, - }); + describe('without droppable scroll', () => { + // 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.descriptor.id, + }, + previousImpact: noImpact, + }); - if (!result) { - throw new Error('invalid test setup'); - } + 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, + }); - 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); }); - 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 + displaced: [ + { + draggableId: inHome2.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inHome3.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + ], + amount: patch(axis.line, inHome4.page.withMargin[axis.size]), + isBeyondStartPosition: false, + }, + direction: axis.direction, + destination: { + droppableId: home.descriptor.id, + // original index of target + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); }); - it('should move the everything from the target index to the original index forward', () => { - const expected: DragImpact = { - movement: { - // ordered by closest impacted - displaced: [ - { - draggableId: inHome2.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - { - draggableId: inHome3.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - ], - amount: patch(axis.line, inHome4.page.withMargin[axis.size]), - isBeyondStartPosition: false, - }, - direction: axis.direction, - destination: { + describe('with droppable scroll', () => { + const scrollable: DroppableDimension = makeScrollable(home, 10); + const scroll: Position = patch(axis.line, 10); + const displacement: Position = negate(scroll); + const scrolled: DroppableDimension = scrollDroppable(scrollable, patch(axis.line, 10)); + + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: inHome4, + target: inHome2, + destination: scrolled, + insideDestination: draggables, + home: { + index: 3, droppableId: home.descriptor.id, - // original index of target - index: 1, }, - }; + previousImpact: noImpact, + }); - expect(result.impact).toEqual(expected); + if (!result) { + throw new Error('Invalid result'); + } + + it('should account for changes in droppable scroll', () => { + const withoutScroll: Position = moveToEdge({ + source: inHome4.page.withoutMargin, + sourceEdge: 'start', + destination: inHome2.page.withMargin, + destinationEdge: 'start', + destinationAxis: axis, + }); + const expected: Position = add(withoutScroll, displacement); + + expect(result.pageCenter).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.descriptor.id, - }, - previousImpact: noImpact, - }); + describe('without droppable scroll', () => { + // 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.descriptor.id, + }, + previousImpact: noImpact, + }); - if (!result) { - throw new Error('invalid test setup'); - } + 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, @@ -220,42 +308,79 @@ describe('move to new droppable', () => { 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 + displaced: [ + { + draggableId: inHome4.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inHome3.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inHome2.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + ], + amount: patch(axis.line, inHome1.page.withMargin[axis.size]), + // is moving beyond start position + isBeyondStartPosition: true, + }, + direction: axis.direction, + destination: { + droppableId: home.descriptor.id, + // original index of target + index: 3, + }, + }; + + expect(result.impact).toEqual(expected); + }); }); - it('should move the everything from the target index to the original index forward', () => { - const expected: DragImpact = { - movement: { - // ordered by closest impacted - displaced: [ - { - draggableId: inHome4.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - { - draggableId: inHome3.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - { - draggableId: inHome2.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - ], - amount: patch(axis.line, inHome1.page.withMargin[axis.size]), - // is moving beyond start position - isBeyondStartPosition: true, - }, - direction: axis.direction, - destination: { + describe('with droppable scroll', () => { + const scrollable: DroppableDimension = makeScrollable(home, 10); + const scroll: Position = patch(axis.line, 10); + const displacement: Position = negate(scroll); + const scrolled: DroppableDimension = scrollDroppable(scrollable, patch(axis.line, 10)); + + const result: ?Result = moveToNewDroppable({ + pageCenter: dontCare, + draggable: inHome1, + target: inHome4, + destination: scrolled, + insideDestination: draggables, + home: { + index: 0, droppableId: home.descriptor.id, - // original index of target - index: 3, }, - }; + previousImpact: noImpact, + }); - expect(result.impact).toEqual(expected); + if (!result) { + throw new Error('Invalid result'); + } + + it('should account for changes in droppable scroll', () => { + const withoutScroll: Position = moveToEdge({ + source: inHome1.page.withoutMargin, + sourceEdge: 'end', + destination: inHome4.page.withoutMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + const expected: Position = add(withoutScroll, displacement); + + expect(result.pageCenter).toEqual(expected); + }); }); }); @@ -274,13 +399,19 @@ describe('move to new droppable', () => { // will be cut by frame [axis.end]: 200, }), - frameClient: getArea({ - [axis.crossAxisStart]: 0, - [axis.crossAxisEnd]: 100, - [axis.start]: 0, - // will cut the subject - [axis.end]: 100, - }), + closest: { + frameClient: getArea({ + [axis.crossAxisStart]: 0, + [axis.crossAxisEnd]: 100, + [axis.start]: 0, + // will cut the subject + [axis.end]: 100, + }), + scrollWidth: 200, + scrollHeight: 200, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const inside: DraggableDimension = getDraggableDimension({ descriptor: { @@ -461,180 +592,298 @@ describe('move to new droppable', () => { }); 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.descriptor.id, - }, - previousImpact: noImpact, - }); + describe('without droppable scroll', () => { + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome1.page.withMargin.center, + draggable: inHome1, + target: null, + destination: foreign, + insideDestination: [], + home: { + index: 0, + droppableId: home.descriptor.id, + }, + previousImpact: noImpact, + }); - if (!result) { - throw new Error('invalid test setup'); - } + if (!result) { + throw new Error('invalid test setup'); + } + + it('should move to the start edge of the droppable (including its padding)', () => { + const expected: Position = moveToEdge({ + source: inHome1.page.withoutMargin, + sourceEdge: 'start', + destination: foreign.page.withMarginAndPadding, + destinationEdge: 'start', + destinationAxis: foreign.axis, + }); - it('should move to the start edge of the droppable (including its padding)', () => { - const expected: Position = moveToEdge({ - source: inHome1.page.withoutMargin, - sourceEdge: 'start', - destination: foreign.page.withMarginAndPadding, - destinationEdge: 'start', - destinationAxis: foreign.axis, + expect(result.pageCenter).toEqual(expected); }); - expect(result.pageCenter).toEqual(expected); + it('should return an empty impact', () => { + const expected: DragImpact = { + movement: { + displaced: [], + amount: patch(foreign.axis.line, inHome1.page.withMargin[foreign.axis.size]), + isBeyondStartPosition: false, + }, + direction: foreign.axis.direction, + destination: { + droppableId: foreign.descriptor.id, + index: 0, + }, + }; + + expect(result.impact).toEqual(expected); + }); }); - it('should return an empty impact', () => { - const expected: DragImpact = { - movement: { - displaced: [], - amount: patch(foreign.axis.line, inHome1.page.withMargin[foreign.axis.size]), - isBeyondStartPosition: false, - }, - direction: foreign.axis.direction, - destination: { - droppableId: foreign.descriptor.id, + describe('with droppable scroll', () => { + const scrollable: DroppableDimension = makeScrollable(foreign, 10); + const scroll: Position = patch(axis.line, 10); + const displacement: Position = negate(scroll); + const scrolled: DroppableDimension = scrollDroppable(scrollable, patch(axis.line, 10)); + + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome1.page.withMargin.center, + draggable: inHome1, + target: null, + destination: scrolled, + insideDestination: [], + home: { index: 0, + droppableId: home.descriptor.id, }, - }; + previousImpact: noImpact, + }); - expect(result.impact).toEqual(expected); + if (!result) { + throw new Error('Invalid result'); + } + + it('should account for changes in droppable scroll', () => { + const withoutScroll: Position = moveToEdge({ + source: inHome1.page.withoutMargin, + sourceEdge: 'start', + destination: foreign.page.withMarginAndPadding, + destinationEdge: 'start', + destinationAxis: foreign.axis, + }); + const expected: Position = add(withoutScroll, displacement); + + expect(result.pageCenter).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.descriptor.id, - }, - previousImpact: noImpact, - }); + describe('without droppable scroll', () => { + // 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.descriptor.id, + }, + previousImpact: noImpact, + }); - if (!result) { - throw new Error('invalid test setup'); - } + 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, + }); - 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); }); - expect(result.pageCenter).toEqual(expected); + it('should move the target and everything below it forward', () => { + const expected: DragImpact = { + movement: { + // ordered by closest impacted + displaced: [ + { + draggableId: inForeign2.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inForeign3.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inForeign4.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + ], + amount: patch(foreign.axis.line, inHome1.page.withMargin[foreign.axis.size]), + isBeyondStartPosition: false, + }, + direction: foreign.axis.direction, + destination: { + droppableId: foreign.descriptor.id, + // index of foreign2 + index: 1, + }, + }; + + expect(result.impact).toEqual(expected); + }); }); - it('should move the target and everything below it forward', () => { - const expected: DragImpact = { - movement: { - // ordered by closest impacted - displaced: [ - { - draggableId: inForeign2.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - { - draggableId: inForeign3.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - { - draggableId: inForeign4.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - ], - amount: patch(foreign.axis.line, inHome1.page.withMargin[foreign.axis.size]), - isBeyondStartPosition: false, - }, - direction: foreign.axis.direction, - destination: { - droppableId: foreign.descriptor.id, - // index of foreign2 - index: 1, + describe('with droppable scroll', () => { + const scrollable: DroppableDimension = makeScrollable(foreign, 10); + const scroll: Position = patch(axis.line, 10); + const displacement: Position = negate(scroll); + const scrolled: DroppableDimension = scrollDroppable(scrollable, patch(axis.line, 10)); + + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome1.page.withMargin.center, + draggable: inHome1, + target: inForeign2, + destination: scrolled, + insideDestination: draggables, + home: { + index: 0, + droppableId: home.descriptor.id, }, - }; + previousImpact: noImpact, + }); - expect(result.impact).toEqual(expected); + if (!result) { + throw new Error('Invalid result'); + } + + it('should account for changes in droppable scroll', () => { + const withoutScroll: Position = moveToEdge({ + source: inHome1.page.withoutMargin, + sourceEdge: 'start', + destination: inForeign2.page.withMargin, + destinationEdge: 'start', + destinationAxis: foreign.axis, + }); + const expected: Position = add(withoutScroll, displacement); + + expect(result.pageCenter).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.descriptor.id, - }, - previousImpact: noImpact, - }); + describe('without droppable scroll', () => { + // 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.descriptor.id, + }, + previousImpact: noImpact, + }); - if (!result) { - throw new Error('invalid test setup'); - } + 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, + 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); }); - expect(result.pageCenter).toEqual(expected); + it('should move everything after the proposed index forward', () => { + const expected: DragImpact = { + movement: { + // ordered by closest impacted + displaced: [ + { + draggableId: inForeign3.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inForeign4.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + ], + amount: patch(foreign.axis.line, inHome4.page.withMargin[foreign.axis.size]), + isBeyondStartPosition: false, + }, + direction: foreign.axis.direction, + destination: { + droppableId: foreign.descriptor.id, + // going after target, so index is target index + 1 + index: 2, + }, + }; + + expect(result.impact).toEqual(expected); + }); }); - it('should move everything after the proposed index forward', () => { - const expected: DragImpact = { - movement: { - // ordered by closest impacted - displaced: [ - { - draggableId: inForeign3.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - { - draggableId: inForeign4.descriptor.id, - isVisible: true, - shouldAnimate: true, - }, - ], - amount: patch(foreign.axis.line, inHome4.page.withMargin[foreign.axis.size]), - isBeyondStartPosition: false, - }, - direction: foreign.axis.direction, - destination: { - droppableId: foreign.descriptor.id, - // going after target, so index is target index + 1 - index: 2, + describe('with droppable scroll', () => { + const scrollable: DroppableDimension = makeScrollable(foreign, 10); + const scroll: Position = patch(axis.line, 10); + const displacement: Position = negate(scroll); + const scrolled: DroppableDimension = scrollDroppable(scrollable, patch(axis.line, 10)); + + const result: ?Result = moveToNewDroppable({ + pageCenter: inHome4.page.withMargin.center, + draggable: inHome4, + target: inForeign2, + destination: scrolled, + insideDestination: draggables, + home: { + index: 3, + droppableId: home.descriptor.id, }, - }; + previousImpact: noImpact, + }); - expect(result.impact).toEqual(expected); + if (!result) { + throw new Error('Invalid result'); + } + + it('should account for changes in droppable scroll', () => { + const withoutScroll: Position = moveToEdge({ + source: inHome4.page.withoutMargin, + sourceEdge: 'start', + destination: inForeign2.page.withMargin, + // going after + destinationEdge: 'end', + destinationAxis: foreign.axis, + }); + const expected: Position = add(withoutScroll, displacement); + + expect(result.pageCenter).toEqual(expected); + }); }); }); @@ -679,12 +928,18 @@ describe('move to new droppable', () => { // will be cut by frame bottom: 200, }), - frameClient: getArea({ - top: 0, - left: 0, - right: 100, - bottom: 100, - }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + scrollWidth: 200, + scrollHeight: 200, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const customInForeign: DraggableDimension = getDraggableDimension({ diff --git a/test/unit/state/move-to-edge.spec.js b/test/unit/state/move-to-edge.spec.js index 55d60232bd..45314e5b09 100644 --- a/test/unit/state/move-to-edge.spec.js +++ b/test/unit/state/move-to-edge.spec.js @@ -46,7 +46,7 @@ const destination: Area = getArea({ const pullBackwardsOnMainAxis = (axis: Axis) => (point: Position) => patch( axis.line, -point[axis.line], - point[axis.crossLine] + point[axis.crossAxisLine] ); // returns the absolute difference of the center position diff --git a/test/unit/state/move-to-next-index.spec.js b/test/unit/state/move-to-next-index.spec.js index 7758012a3a..eb4a62bd2d 100644 --- a/test/unit/state/move-to-next-index.spec.js +++ b/test/unit/state/move-to-next-index.spec.js @@ -1,15 +1,15 @@ // @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 { getPreset, disableDroppable } from '../../utils/dimension'; +import { getPreset, disableDroppable, getClosestScrollable } from '../../utils/dimension'; import moveToEdge from '../../../src/state/move-to-edge'; import noImpact, { noMovement } from '../../../src/state/no-impact'; -import { patch } from '../../../src/state/position'; +import { patch, subtract } from '../../../src/state/position'; import { vertical, horizontal } from '../../../src/state/axis'; -import getViewport from '../../../src/state/visibility/get-viewport'; +import { isPartiallyVisible } from '../../../src/state/visibility/is-visible'; +import getViewport from '../../../src/window/get-viewport'; import getArea from '../../../src/state/get-area'; -import setWindowScroll from '../../utils/set-window-scroll'; -import { getDroppableDimension, getDraggableDimension } from '../../../src/state/dimension'; +import { getDroppableDimension, getDraggableDimension, scrollDroppable } from '../../../src/state/dimension'; import type { Area, Axis, @@ -61,6 +61,7 @@ describe('move to next index', () => { const result: ?Result = moveToNextIndex({ isMovingForward: true, draggableId: preset.inHome1.descriptor.id, + previousPageCenter: preset.inHome1.page.withoutMargin.center, previousImpact: noImpact, droppable: disabled, draggables: preset.draggables, @@ -87,6 +88,7 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome3.descriptor.id, previousImpact, + previousPageCenter: preset.inHome3.page.withoutMargin.center, droppable: preset.home, draggables: preset.draggables, }); @@ -110,6 +112,7 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome1.descriptor.id, previousImpact, + previousPageCenter: preset.inHome1.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -167,6 +170,7 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome2.descriptor.id, previousImpact, + previousPageCenter: preset.inHome2.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -229,10 +233,14 @@ describe('move to next index', () => { index: 1, }, }; + const result: ?Result = moveToNextIndex({ isMovingForward: true, draggableId: preset.inHome1.descriptor.id, previousImpact, + // roughly correct previous page center + // not calculating the exact point as it is not required for this test + previousPageCenter: preset.inHome2.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -311,6 +319,8 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome2.descriptor.id, previousImpact, + // roughly correct: + previousPageCenter: preset.inHome1.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -384,6 +394,8 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome3.descriptor.id, previousImpact, + // this is roughly correct + previousPageCenter: preset.inHome1.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -427,6 +439,274 @@ describe('move to next index', () => { expect(result.impact).toEqual(expected); }); }); + + describe('forced visibility displacement', () => { + const crossAxisStart: number = 0; + const crossAxisEnd: number = 100; + + const droppableScrollSize = { + scrollHeight: axis === vertical ? 400 : crossAxisEnd, + scrollWidth: axis === horizontal ? 400 : crossAxisEnd, + }; + + const home: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'home', + type: 'TYPE', + }, + direction: axis.direction, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 0, + [axis.end]: 400, + }), + closest: { + frameClient: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 0, + // will cut off the subject + [axis.end]: 100, + }), + scrollHeight: droppableScrollSize.scrollHeight, + scrollWidth: droppableScrollSize.scrollWidth, + shouldClipSubject: true, + scroll: { x: 0, y: 0 }, + }, + }); + + const maxScroll: Position = getClosestScrollable(home).scroll.max; + + // half the size of the viewport + const inHome1: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inHome1', + droppableId: home.descriptor.id, + index: 0, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 0, + [axis.end]: 50, + }), + }); + + const inHome2: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inHome2', + droppableId: home.descriptor.id, + index: 1, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 50, + [axis.end]: 100, + }), + }); + + const inHome3: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inHome3', + droppableId: home.descriptor.id, + index: 2, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 100, + [axis.end]: 150, + }), + }); + + const inHome4: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inHome4', + droppableId: home.descriptor.id, + index: 3, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 200, + [axis.end]: 250, + }), + }); + + const inHome5: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inHome5', + droppableId: home.descriptor.id, + index: 4, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 300, + [axis.end]: 350, + }), + }); + + const inHome6: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inHome5', + droppableId: home.descriptor.id, + index: 5, + }, + client: getArea({ + [axis.crossAxisStart]: crossAxisStart, + [axis.crossAxisEnd]: crossAxisEnd, + [axis.start]: 350, + [axis.end]: 400, + }), + }); + + const draggables: DraggableDimensionMap = { + [inHome1.descriptor.id]: inHome1, + [inHome2.descriptor.id]: inHome2, + [inHome3.descriptor.id]: inHome3, + [inHome4.descriptor.id]: inHome4, + [inHome5.descriptor.id]: inHome5, + [inHome6.descriptor.id]: inHome6, + }; + + it('should force the displacement of the items up to the size of the item dragging and the item no longer being displaced', () => { + // We have moved inHome1 to the end of the list + const previousImpact: DragImpact = { + movement: { + // ordered by most recently impacted + displaced: [ + // the last impact would have been before the last addition. + // At this point the last two items would have been visible + { + draggableId: inHome6.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inHome5.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + { + draggableId: inHome4.descriptor.id, + isVisible: false, + shouldAnimate: false, + }, + { + draggableId: inHome3.descriptor.id, + isVisible: false, + shouldAnimate: false, + }, + { + draggableId: inHome2.descriptor.id, + isVisible: false, + shouldAnimate: false, + }, + ], + amount: patch(axis.line, inHome1.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + // is now in the last position + destination: { + droppableId: home.descriptor.id, + index: 4, + }, + }; + // home has now scrolled to the bottom + const scrolled: DroppableDimension = scrollDroppable(home, maxScroll); + + // validation of previous impact + expect(isPartiallyVisible({ + target: inHome6.page.withMargin, + destination: scrolled, + viewport: customViewport, + })).toBe(true); + expect(isPartiallyVisible({ + target: inHome5.page.withMargin, + destination: scrolled, + viewport: customViewport, + })).toBe(true); + expect(isPartiallyVisible({ + target: inHome4.page.withMargin, + destination: scrolled, + viewport: customViewport, + })).toBe(false); + expect(isPartiallyVisible({ + target: inHome3.page.withMargin, + destination: scrolled, + viewport: customViewport, + })).toBe(false); + // this one will remain invisible + expect(isPartiallyVisible({ + target: inHome2.page.withMargin, + destination: scrolled, + viewport: customViewport, + })).toBe(false); + + const expected: DragImpact = { + movement: { + // ordered by most recently impacted + displaced: [ + // shouldAnimate has not changed to false - using previous impact + { + draggableId: inHome5.descriptor.id, + isVisible: true, + shouldAnimate: true, + }, + // was not visibile - now forcing to be visible + // (within the size of the dragging item (50px) and the moving item (50px)) + { + draggableId: inHome4.descriptor.id, + isVisible: true, + shouldAnimate: false, + }, + // was not visibile - now forcing to be visible + // (within the size of the dragging item (50px) and the moving item (50px)) + { + draggableId: inHome3.descriptor.id, + isVisible: true, + shouldAnimate: false, + }, + // still not visible + // not within the 100px buffer + { + draggableId: inHome2.descriptor.id, + isVisible: false, + shouldAnimate: false, + }, + ], + amount: patch(axis.line, inHome1.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + direction: axis.direction, + // is now in the second last position + destination: { + droppableId: home.descriptor.id, + index: 3, + }, + }; + + const result: ?Result = moveToNextIndex({ + isMovingForward: false, + draggableId: inHome1.descriptor.id, + previousImpact, + // roughly correct: + previousPageCenter: inHome1.page.withoutMargin.center, + draggables, + droppable: scrolled, + }); + + if (!result) { + throw new Error('Invalid test setup'); + } + + expect(result.impact).toEqual(expected); + }); + }); }); }); @@ -449,6 +729,7 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome1.descriptor.id, previousImpact, + previousPageCenter: preset.inHome1.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -475,6 +756,7 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome2.descriptor.id, previousImpact, + previousPageCenter: preset.inHome2.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -535,6 +817,7 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome3.descriptor.id, previousImpact, + previousPageCenter: preset.inHome3.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -603,6 +886,8 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome2.descriptor.id, previousImpact, + // roughly correct + previousPageCenter: preset.inHome3.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -676,6 +961,8 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome1.descriptor.id, previousImpact, + // roughly correct + previousPageCenter: preset.inHome3.page.withoutMargin.center, draggables: preset.draggables, droppable: preset.home, }); @@ -728,80 +1015,149 @@ describe('move to next index', () => { id: 'much bigger than viewport', type: 'huge', }, + direction: axis.direction, client: getArea({ top: 0, right: 10000, bottom: 10000, left: 0, }), - direction: axis.direction, - }); - const inViewport: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'inside', - index: 0, - droppableId: droppable.descriptor.id, - }, - client: customViewport, }); - const outsideViewport: DraggableDimension = getDraggableDimension({ - descriptor: { - id: 'outside', - index: 1, - droppableId: droppable.descriptor.id, - }, - client: getArea({ - // is bottom left of the viewport - top: customViewport.bottom + 1, - right: customViewport.right + 100, - left: customViewport.right + 1, - bottom: customViewport.bottom + 100, - }), - }); - // inViewport is in its original position - const previousImpact: DragImpact = { - movement: noMovement, - direction: axis.direction, - destination: { - index: 0, - droppableId: droppable.descriptor.id, - }, - }; - const draggables: DraggableDimensionMap = { - [inViewport.descriptor.id]: inViewport, - [outsideViewport.descriptor.id]: outsideViewport, - }; - it('should not permit movement into areas that are outside the viewport', () => { + it('should request a jump scroll for movement that is outside of the viewport', () => { + const asBigAsViewport: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inside', + index: 0, + droppableId: droppable.descriptor.id, + }, + client: customViewport, + }); + const outsideViewport: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'outside', + index: 1, + droppableId: droppable.descriptor.id, + }, + client: getArea({ + // is bottom left of the viewport + top: customViewport.bottom + 1, + right: customViewport.right + 100, + left: customViewport.right + 1, + bottom: customViewport.bottom + 100, + }), + }); + // inViewport is in its original position + const previousImpact: DragImpact = { + movement: noMovement, + direction: axis.direction, + destination: { + index: 0, + droppableId: droppable.descriptor.id, + }, + }; + const draggables: DraggableDimensionMap = { + [asBigAsViewport.descriptor.id]: asBigAsViewport, + [outsideViewport.descriptor.id]: outsideViewport, + }; + const expectedCenter = moveToEdge({ + source: asBigAsViewport.page.withoutMargin, + sourceEdge: 'end', + destination: outsideViewport.page.withMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + const previousPageCenter: Position = asBigAsViewport.page.withoutMargin.center; + const expectedScrollJump: Position = subtract(expectedCenter, previousPageCenter); + const expectedImpact: DragImpact = { + movement: { + displaced: [{ + draggableId: outsideViewport.descriptor.id, + // Even though the item started in an invisible place we force + // the displacement to be visible. + isVisible: true, + shouldAnimate: true, + }], + amount: patch(axis.line, asBigAsViewport.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + destination: { + droppableId: droppable.descriptor.id, + index: 1, + }, + direction: axis.direction, + }; + const result: ?Result = moveToNextIndex({ isMovingForward: true, - draggableId: inViewport.descriptor.id, + draggableId: asBigAsViewport.descriptor.id, previousImpact, + previousPageCenter, draggables, droppable, }); - expect(result).toBe(null); + if (!result) { + throw new Error('Invalid test setup'); + } + + // not updating the page center (visually the item will not move) + expect(result.pageCenter).toEqual(previousPageCenter); + expect(result.scrollJumpRequest).toEqual(expectedScrollJump); + expect(result.impact).toEqual(expectedImpact); }); - it('should take into account any changes in the droppables scroll', () => { - // scrolling so that outsideViewport is now visible - setWindowScroll({ x: 200, y: 200 }); - const expectedCenter = moveToEdge({ - source: inViewport.page.withoutMargin, - sourceEdge: 'end', - destination: outsideViewport.page.withMargin, - destinationEdge: 'end', - destinationAxis: axis, + it('should force visible displacement when displacing an invisible item', () => { + const visible: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'inside', + index: 0, + droppableId: droppable.descriptor.id, + }, + client: getArea({ + top: 0, + left: 0, + right: customViewport.right - 100, + bottom: customViewport.bottom - 100, + }), }); + const invisible: DraggableDimension = getDraggableDimension({ + descriptor: { + id: 'partial', + index: 1, + droppableId: droppable.descriptor.id, + }, + client: getArea({ + top: customViewport.bottom + 1, + left: customViewport.right + 1, + bottom: customViewport.bottom + 100, + right: customViewport.right + 100, + }), + }); + // inViewport is in its original position + const previousImpact: DragImpact = { + movement: noMovement, + direction: axis.direction, + destination: { + index: 0, + droppableId: droppable.descriptor.id, + }, + }; + const draggables: DraggableDimensionMap = { + [visible.descriptor.id]: visible, + [invisible.descriptor.id]: invisible, + }; + const previousPageCenter: Position = visible.page.withoutMargin.center; const expectedImpact: DragImpact = { movement: { displaced: [{ - draggableId: outsideViewport.descriptor.id, + draggableId: invisible.descriptor.id, + // Even though the item started in an invisible place we force + // the displacement to be visible. isVisible: true, shouldAnimate: true, }], - amount: patch(axis.line, inViewport.page.withMargin[axis.size]), + amount: patch(axis.line, visible.page.withMargin[axis.size]), isBeyondStartPosition: true, }, destination: { @@ -813,28 +1169,29 @@ describe('move to next index', () => { const result: ?Result = moveToNextIndex({ isMovingForward: true, - draggableId: inViewport.descriptor.id, + draggableId: visible.descriptor.id, previousImpact, + previousPageCenter, draggables, droppable, }); if (!result) { - throw new Error('invalid result'); + throw new Error('Invalid test setup'); } - expect(result.pageCenter).toEqual(expectedCenter); expect(result.impact).toEqual(expectedImpact); }); }); describe('droppable visibility', () => { - it('should not permit movement into areas that outside of the droppable frame', () => { + it('should request a scroll jump into non-visible areas', () => { const droppable: DroppableDimension = getDroppableDimension({ descriptor: { id: 'much bigger than viewport', type: 'huge', }, + direction: axis.direction, client: getArea({ top: 0, left: 0, @@ -842,13 +1199,18 @@ describe('move to next index', () => { bottom: 200, right: 200, }), - frameClient: getArea({ - top: 0, - left: 0, - right: 100, - bottom: 100, - }), - direction: axis.direction, + closest: { + frameClient: getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }), + scrollHeight: 200, + scrollWidth: 200, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const inside: DraggableDimension = getDraggableDimension({ descriptor: { @@ -878,7 +1240,6 @@ describe('move to next index', () => { bottom: 180, }), }); - // inViewport is in its original position const previousImpact: DragImpact = { movement: noMovement, direction: axis.direction, @@ -891,20 +1252,50 @@ describe('move to next index', () => { [inside.descriptor.id]: inside, [outside.descriptor.id]: outside, }; + const previousPageCenter: Position = inside.page.withoutMargin.center; + const expectedCenter = moveToEdge({ + source: inside.page.withoutMargin, + sourceEdge: 'end', + destination: outside.page.withMargin, + destinationEdge: 'end', + destinationAxis: axis, + }); + const expectedScrollJump: Position = subtract(expectedCenter, previousPageCenter); + const expectedImpact: DragImpact = { + movement: { + displaced: [{ + draggableId: outside.descriptor.id, + // Even though the item started in an invisible place we force + // the displacement to be visible. + isVisible: true, + shouldAnimate: true, + }], + amount: patch(axis.line, inside.page.withMargin[axis.size]), + isBeyondStartPosition: true, + }, + destination: { + droppableId: droppable.descriptor.id, + index: 1, + }, + direction: axis.direction, + }; const result: ?Result = moveToNextIndex({ isMovingForward: true, draggableId: inside.descriptor.id, previousImpact, + previousPageCenter, draggables, droppable, }); - expect(result).toBe(null); - }); + if (!result) { + throw new Error('Invalid test setup'); + } - it.skip('should take into account any changes in the droppables scroll', () => { - // TODO + expect(result.pageCenter).toEqual(previousPageCenter); + expect(result.impact).toEqual(expectedImpact); + expect(result.scrollJumpRequest).toEqual(expectedScrollJump); }); }); }); @@ -951,6 +1342,7 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome1.descriptor.id, previousImpact, + previousPageCenter: preset.inHome1.page.withoutMargin.center, droppable: preset.foreign, draggables: preset.draggables, }); @@ -1026,6 +1418,7 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome1.descriptor.id, previousImpact, + previousPageCenter: preset.inHome1.page.withoutMargin.center, droppable: preset.foreign, draggables: preset.draggables, }); @@ -1085,6 +1478,8 @@ describe('move to next index', () => { isMovingForward: true, draggableId: preset.inHome1.descriptor.id, previousImpact, + // roughly correct + previousPageCenter: preset.inHome4.page.withoutMargin.center, droppable: preset.foreign, draggables: preset.draggables, }); @@ -1138,6 +1533,8 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome1.descriptor.id, previousImpact, + // roughly correct + previousPageCenter: preset.inForeign1.page.withoutMargin.center, droppable: preset.foreign, draggables: preset.draggables, }); @@ -1169,6 +1566,8 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome1.descriptor.id, previousImpact, + // roughly correct + previousPageCenter: preset.inForeign4.page.withoutMargin.center, droppable: preset.foreign, draggables: preset.draggables, }); @@ -1256,6 +1655,8 @@ describe('move to next index', () => { isMovingForward: false, draggableId: preset.inHome1.descriptor.id, previousImpact, + // roughly correct + previousPageCenter: preset.inForeign2.page.withoutMargin.center, droppable: preset.foreign, draggables: preset.draggables, }); diff --git a/test/unit/state/position.spec.js b/test/unit/state/position.spec.js index 6d97b55aca..cd89a8a282 100644 --- a/test/unit/state/position.spec.js +++ b/test/unit/state/position.spec.js @@ -1,6 +1,7 @@ // @flow import { add, + apply, subtract, isEqual, negate, @@ -50,6 +51,10 @@ describe('position', () => { it('should return false when two objects have different values', () => { expect(isEqual(point1, point2)).toBe(false); }); + + it('should return true when -origin is compared with +origin', () => { + expect(isEqual({ x: -0, y: -0 }, { x: 0, y: 0 })).toBe(true); + }); }); describe('negate', () => { @@ -125,4 +130,12 @@ describe('position', () => { expect(closest(origin, [option1, option2])).toEqual(distance(origin, option1)); }); }); + + describe('apply', () => { + it('should apply the function to both values', () => { + const add1 = apply((value: number) => value + 1); + + expect(add1({ x: 1, y: 2 })).toEqual({ x: 2, y: 3 }); + }); + }); }); diff --git a/test/unit/state/spacing.spec.js b/test/unit/state/spacing.spec.js index 5855660ab0..c19103a92d 100644 --- a/test/unit/state/spacing.spec.js +++ b/test/unit/state/spacing.spec.js @@ -1,10 +1,10 @@ // @flow import { - add, - addPosition, isEqual, - offset, getCorners, + expandByPosition, + offsetByPosition, + expandBySpacing, } from '../../../src/state/spacing'; import type { Position, Spacing } from '../../../src/types'; @@ -23,21 +23,9 @@ const spacing2: Spacing = { }; describe('spacing', () => { - describe('add', () => { - it('should add two spacing boxes together', () => { - const expected: Spacing = { - top: 11, - right: 26, - bottom: 37, - left: 14, - }; - expect(add(spacing1, spacing2)).toEqual(expected); - }); - }); - - describe('addPosition', () => { - it('should add a position to the right and bottom bounds of a spacing box', () => { - const spacing = { + describe('expandByPosition', () => { + it('should increase the size of the spacing', () => { + const spacing: Spacing = { top: 0, right: 10, bottom: 10, @@ -48,12 +36,32 @@ describe('spacing', () => { y: 5, }; const expected = { - top: 0, + top: -5, right: 15, bottom: 15, + left: -5, + }; + + expect(expandByPosition(spacing, position)).toEqual(expected); + }); + }); + + describe('expandBySpacing', () => { + it('should increase the size of a spacing by the size of another', () => { + const spacing: Spacing = { + top: 10, + right: 20, + bottom: 20, + left: 10, + }; + const expected: Spacing = { + top: 0, + right: 40, + bottom: 40, left: 0, }; - expect(addPosition(spacing, position)).toEqual(expected); + + expect(expandBySpacing(spacing, spacing)).toEqual(expected); }); }); @@ -72,7 +80,7 @@ describe('spacing', () => { }); }); - describe('offset', () => { + describe('offsetByPosition', () => { it('should add x/y values to top/right/bottom/left dimensions', () => { const offsetPosition: Position = { x: 10, @@ -84,7 +92,7 @@ describe('spacing', () => { bottom: 28, left: 15, }; - expect(offset(spacing1, offsetPosition)).toEqual(expected); + expect(offsetByPosition(spacing1, offsetPosition)).toEqual(expected); }); }); diff --git a/test/unit/state/visibility/is-visible-through-frame.spec.js b/test/unit/state/visibility/is-partially-visible-through-frame.spec.js similarity index 60% rename from test/unit/state/visibility/is-visible-through-frame.spec.js rename to test/unit/state/visibility/is-partially-visible-through-frame.spec.js index 7e0d3ccdec..92199ee713 100644 --- a/test/unit/state/visibility/is-visible-through-frame.spec.js +++ b/test/unit/state/visibility/is-partially-visible-through-frame.spec.js @@ -1,29 +1,29 @@ // @flow -import isVisibleThroughFrame from '../../../../src/state/visibility/is-visible-through-frame'; -import { add, offset } from '../../../../src/state/spacing'; +import isPartiallyVisibleThroughFrame from '../../../../src/state/visibility/is-partially-visible-through-frame'; +import { offsetByPosition, expandBySpacing } from '../../../../src/state/spacing'; import type { Spacing } from '../../../../src/types'; const frame: Spacing = { top: 0, left: 0, right: 100, bottom: 100, }; -describe('is visible through frame', () => { +describe('is partially visible through frame', () => { describe('subject is smaller than frame', () => { describe('completely outside frame', () => { it('should return false if subject is outside frame on any side', () => { const outside: Spacing[] = [ // outside on top - offset(frame, { x: 0, y: -101 }), + offsetByPosition(frame, { x: 0, y: -101 }), // outside on right - offset(frame, { x: 101, y: 0 }), + offsetByPosition(frame, { x: 101, y: 0 }), // outside on bottom - offset(frame, { x: 0, y: 101 }), + offsetByPosition(frame, { x: 0, y: 101 }), // outside on left - offset(frame, { x: -101, y: 0 }), + offsetByPosition(frame, { x: -101, y: 0 }), ]; outside.forEach((subject: Spacing) => { - expect(isVisibleThroughFrame(frame)(subject)).toBe(false); + expect(isPartiallyVisibleThroughFrame(frame)(subject)).toBe(false); }); }); }); @@ -37,7 +37,7 @@ describe('is visible through frame', () => { bottom: 90, }; - expect(isVisibleThroughFrame(frame)(subject)).toBe(true); + expect(isPartiallyVisibleThroughFrame(frame)(subject)).toBe(true); }); }); @@ -54,55 +54,29 @@ describe('is visible through frame', () => { right: 150, }; - expect(isVisibleThroughFrame(frame)(subject)).toBe(true); - }); - - it('should return false when only partially visible horizontally', () => { - const subject: Spacing = { - // visible - left: 50, - // not visible - top: 110, - bottom: 150, - right: 150, - }; - - expect(isVisibleThroughFrame(frame)(subject)).toBe(false); - }); - - it('should return false when only partially visible vertically', () => { - const subject: Spacing = { - // visible - top: 10, - // not visible - bottom: 110, - left: 110, - right: 150, - }; - - expect(isVisibleThroughFrame(frame)(subject)).toBe(false); + expect(isPartiallyVisibleThroughFrame(frame)(subject)).toBe(true); }); }); }); describe('subject is equal to frame', () => { it('should return true when the frame is equal to the subject', () => { - expect(isVisibleThroughFrame(frame)(frame)).toBe(true); - expect(isVisibleThroughFrame(frame)({ ...frame })).toBe(true); + expect(isPartiallyVisibleThroughFrame(frame)(frame)).toBe(true); + expect(isPartiallyVisibleThroughFrame(frame)({ ...frame })).toBe(true); }); }); describe('subject is bigger than frame', () => { - const bigSubject: Spacing = add(frame, frame); + const bigSubject: Spacing = expandBySpacing(frame, frame); it('should return false if the subject has no overlap with the frame', () => { - const subject: Spacing = offset(bigSubject, { x: 1000, y: 1000 }); + const subject: Spacing = offsetByPosition(bigSubject, { x: 1000, y: 1000 }); - expect(isVisibleThroughFrame(frame)(subject)).toBe(false); + expect(isPartiallyVisibleThroughFrame(frame)(subject)).toBe(false); }); it('should return true if subject is bigger on every side', () => { - expect(isVisibleThroughFrame(frame)(bigSubject)).toBe(true); + expect(isPartiallyVisibleThroughFrame(frame)(bigSubject)).toBe(true); }); describe('partially visible', () => { @@ -120,7 +94,7 @@ describe('is visible through frame', () => { ]; subjects.forEach((subject: Spacing) => { - expect(isVisibleThroughFrame(frame)(subject)).toBe(true); + expect(isPartiallyVisibleThroughFrame(frame)(subject)).toBe(true); }); }); @@ -134,7 +108,7 @@ describe('is visible through frame', () => { right: 500, }; - expect(isVisibleThroughFrame(frame)(subject)).toEqual(false); + expect(isPartiallyVisibleThroughFrame(frame)(subject)).toEqual(false); }); it('should return false when only partially visible vertically', () => { @@ -147,7 +121,7 @@ describe('is visible through frame', () => { right: 500, }; - expect(isVisibleThroughFrame(frame)(subject)).toEqual(false); + expect(isPartiallyVisibleThroughFrame(frame)(subject)).toEqual(false); }); }); }); diff --git a/test/unit/state/visibility/is-partially-visible.spec.js b/test/unit/state/visibility/is-partially-visible.spec.js index e69e6b46b4..daad3296aa 100644 --- a/test/unit/state/visibility/is-partially-visible.spec.js +++ b/test/unit/state/visibility/is-partially-visible.spec.js @@ -1,8 +1,9 @@ // @flow import getArea from '../../../../src/state/get-area'; -import isPartiallyVisible from '../../../../src/state/visibility/is-partially-visible'; +import { isPartiallyVisible } from '../../../../src/state/visibility/is-visible'; import { getDroppableDimension, scrollDroppable } from '../../../../src/state/dimension'; -import { offset } from '../../../../src/state/spacing'; +import { offsetByPosition } from '../../../../src/state/spacing'; +import { getClosestScrollable } from '../../../utils/dimension'; import type { Area, DroppableDimension, @@ -45,7 +46,7 @@ const notInViewport: Spacing = { bottom: 600, }; -const smallDroppable: DroppableDimension = getDroppableDimension({ +const asBigAsInViewport1: DroppableDimension = getDroppableDimension({ descriptor: { id: 'subset', type: 'TYPE', @@ -83,13 +84,13 @@ describe('is partially visible', () => { it('should return true if the item is partially visible in the viewport', () => { const partials: Spacing[] = [ // bleed over top - offset(viewport, { x: 0, y: -1 }), + offsetByPosition(viewport, { x: 0, y: -1 }), // bleed over right - offset(viewport, { x: 1, y: 0 }), + offsetByPosition(viewport, { x: 1, y: 0 }), // bleed over bottom - offset(viewport, { x: 0, y: 1 }), + offsetByPosition(viewport, { x: 0, y: 1 }), // bleed over left - offset(viewport, { x: -1, y: 0 }), + offsetByPosition(viewport, { x: -1, y: 0 }), ]; partials.forEach((partial: Spacing) => { @@ -115,6 +116,13 @@ describe('is partially visible', () => { left: viewport.left, right: viewport.right, }), + closest: { + frameClient: viewport, + scrollWidth: viewport.width, + scrollHeight: viewport.bottom + 100, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); describe('originally invisible but now invisible', () => { @@ -170,12 +178,40 @@ describe('is partially visible', () => { }); describe('droppable', () => { + const client: Area = getArea({ + top: 0, + left: 0, + right: 600, + bottom: 600, + }); + const frame: Area = getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }); + + const scrollable: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'clipped', + type: 'TYPE', + }, + client, + closest: { + frameClient: frame, + scrollHeight: client.height, + scrollWidth: client.width, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + describe('without changes in droppable scroll', () => { it('should return false if outside the droppable', () => { expect(isPartiallyVisible({ target: inViewport2, viewport, - destination: smallDroppable, + destination: asBigAsInViewport1, })).toBe(false); }); @@ -183,7 +219,7 @@ describe('is partially visible', () => { expect(isPartiallyVisible({ target: viewport, viewport, - destination: smallDroppable, + destination: asBigAsInViewport1, })).toBe(true); }); @@ -191,7 +227,7 @@ describe('is partially visible', () => { expect(isPartiallyVisible({ target: inViewport1, viewport, - destination: smallDroppable, + destination: asBigAsInViewport1, })).toBe(true); }); @@ -206,33 +242,33 @@ describe('is partially visible', () => { expect(isPartiallyVisible({ target: insideDroppable, viewport, - destination: smallDroppable, + destination: asBigAsInViewport1, })).toBe(true); }); it('should return true if partially within the droppable', () => { const partials: Spacing[] = [ // bleed over top - offset(inViewport1, { x: 0, y: -1 }), + offsetByPosition(inViewport1, { x: 0, y: -1 }), // bleed over right - offset(inViewport1, { x: 1, y: 0 }), + offsetByPosition(inViewport1, { x: 1, y: 0 }), // bleed over bottom - offset(inViewport1, { x: 0, y: 1 }), + offsetByPosition(inViewport1, { x: 0, y: 1 }), // bleed over left - offset(inViewport1, { x: -1, y: 0 }), + offsetByPosition(inViewport1, { x: -1, y: 0 }), ]; partials.forEach((partial: Spacing) => { expect(isPartiallyVisible({ target: partial, viewport, - destination: smallDroppable, + destination: asBigAsInViewport1, })).toBe(true); }); }); it('should return false if falling on clipped area of droppable', () => { - const frame: Spacing = { + const ourFrame: Spacing = { top: 10, left: 10, right: 100, @@ -245,11 +281,17 @@ describe('is partially visible', () => { type: 'TYPE', }, client: getArea({ - ...frame, + ...ourFrame, // stretches out past frame bottom: 600, }), - frameClient: getArea(frame), + closest: { + frameClient: getArea(ourFrame), + scrollHeight: 600, + scrollWidth: getArea(ourFrame).width, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, }); const inSubjectOutsideFrame: Spacing = { ...frame, @@ -266,45 +308,26 @@ describe('is partially visible', () => { }); describe('with changes in droppable scroll', () => { - const frame: Spacing = { - top: 10, - left: 10, - right: 100, - // cuts the droppable short - bottom: 100, - }; - const clippedDroppable: DroppableDimension = getDroppableDimension({ - descriptor: { - id: 'clipped', - type: 'TYPE', - }, - client: getArea({ - ...frame, - // stretches out past frame - bottom: 600, - }), - frameClient: getArea(frame), - }); - describe('originally invisible but now invisible', () => { it('should take into account the droppable scroll when detecting visibility', () => { const originallyInvisible: Spacing = { - ...frame, - top: 110, - bottom: 200, + left: frame.left, + right: frame.right, + top: frame.bottom + 10, + bottom: frame.bottom + 20, }; // originally invisible expect(isPartiallyVisible({ target: originallyInvisible, - destination: clippedDroppable, + destination: scrollable, viewport, })).toBe(false); // after scroll the target is now visible expect(isPartiallyVisible({ target: originallyInvisible, - destination: scrollDroppable(clippedDroppable, { x: 0, y: 100 }), + destination: scrollDroppable(scrollable, { x: 0, y: 100 }), viewport, })).toBe(true); }); @@ -321,14 +344,14 @@ describe('is partially visible', () => { // originally visible expect(isPartiallyVisible({ target: originallyVisible, - destination: clippedDroppable, + destination: scrollable, viewport, })).toBe(true); // after scroll the target is now invisible expect(isPartiallyVisible({ target: originallyVisible, - destination: scrollDroppable(clippedDroppable, { x: 0, y: 100 }), + destination: scrollDroppable(scrollable, { x: 0, y: 100 }), viewport, })).toBe(false); }); @@ -336,26 +359,33 @@ describe('is partially visible', () => { }); describe('with invisible subject', () => { - const frame: Spacing = { - top: 10, - left: 10, - right: 100, - bottom: 600, - }; - const droppable: DroppableDimension = getDroppableDimension({ - descriptor: { - id: 'clipped', - type: 'TYPE', - }, - client: getArea({ - ...frame, - // smaller than frame - bottom: 100, - }), - frameClient: getArea(frame), - }); - it('should return false when subject is totally invisible', () => { + // creating a droppable where the frame is bigger than the subject + const droppable: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'droppable', + type: 'TYPE', + }, + client: getArea({ + top: 0, + left: 0, + bottom: 100, + right: 100, + }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + bottom: 100, + right: 100, + }), + scrollHeight: 600, + scrollWidth: 600, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + const originallyVisible: Spacing = { ...frame, top: 10, @@ -370,14 +400,19 @@ describe('is partially visible', () => { })).toBe(true); // subject is now totally invisible - const scrolled: DroppableDimension = scrollDroppable(droppable, { x: 0, y: 101 }); + const scrolled: DroppableDimension = scrollDroppable( + droppable, + getClosestScrollable(droppable).scroll.max, + ); + // asserting frame is not visible + expect(scrolled.viewport.clipped).toBe(null); + + // now asserting that this check will fail expect(isPartiallyVisible({ target: originallyVisible, destination: scrolled, viewport, })).toBe(false); - // asserting frame is not visible - expect(scrolled.viewport.clipped).toBe(null); }); }); }); @@ -387,7 +422,7 @@ describe('is partially visible', () => { expect(isPartiallyVisible({ target: inViewport1, viewport, - destination: smallDroppable, + destination: asBigAsInViewport1, })).toBe(true); }); @@ -395,7 +430,7 @@ describe('is partially visible', () => { expect(isPartiallyVisible({ target: inViewport2, viewport, - destination: smallDroppable, + destination: asBigAsInViewport1, })).toBe(false); }); diff --git a/test/unit/state/visibility/is-totally-visible-through-frame.spec.js b/test/unit/state/visibility/is-totally-visible-through-frame.spec.js new file mode 100644 index 0000000000..d6621bbcd0 --- /dev/null +++ b/test/unit/state/visibility/is-totally-visible-through-frame.spec.js @@ -0,0 +1,82 @@ +// @flow +import isTotallyVisibleThroughFrame from '../../../../src/state/visibility/is-totally-visible-through-frame'; +import { offsetByPosition, expandBySpacing } from '../../../../src/state/spacing'; +import type { Spacing } from '../../../../src/types'; + +const frame: Spacing = { + top: 0, left: 0, right: 100, bottom: 100, +}; + +describe('is totally visible through frame', () => { + describe('subject is smaller than frame', () => { + describe('completely outside frame', () => { + it('should return false if subject is outside frame on any side', () => { + const outside: Spacing[] = [ + // outside on top + offsetByPosition(frame, { x: 0, y: -101 }), + // outside on right + offsetByPosition(frame, { x: 101, y: 0 }), + // outside on bottom + offsetByPosition(frame, { x: 0, y: 101 }), + // outside on left + offsetByPosition(frame, { x: -101, y: 0 }), + ]; + + outside.forEach((subject: Spacing) => { + expect(isTotallyVisibleThroughFrame(frame)(subject)).toBe(false); + }); + }); + }); + + describe('contained in frame', () => { + it('should return true when subject is contained within frame', () => { + const subject: Spacing = { + top: 10, + left: 10, + right: 90, + bottom: 90, + }; + + expect(isTotallyVisibleThroughFrame(frame)(subject)).toBe(true); + }); + }); + + describe('partially visible', () => { + it('should return false if partially visible horizontally and vertically', () => { + const subject: Spacing = { + // visible + top: 10, + // not visible + bottom: 110, + // visible + left: 50, + // not visible + right: 150, + }; + + expect(isTotallyVisibleThroughFrame(frame)(subject)).toBe(false); + }); + }); + }); + + describe('subject is equal to frame', () => { + it('should return true when the frame is equal to the subject', () => { + expect(isTotallyVisibleThroughFrame(frame)(frame)).toBe(true); + expect(isTotallyVisibleThroughFrame(frame)({ ...frame })).toBe(true); + }); + }); + + describe('subject is bigger than frame', () => { + const bigSubject: Spacing = expandBySpacing(frame, frame); + + it('should return false if the subject has no overlap with the frame', () => { + const subject: Spacing = offsetByPosition(bigSubject, { x: 1000, y: 1000 }); + + expect(isTotallyVisibleThroughFrame(frame)(subject)).toBe(false); + }); + + it('should return false if subject is bigger on every side', () => { + expect(isTotallyVisibleThroughFrame(frame)(bigSubject)).toBe(false); + }); + }); +}); diff --git a/test/unit/state/visibility/is-totally-visible.spec.js b/test/unit/state/visibility/is-totally-visible.spec.js new file mode 100644 index 0000000000..6c64411121 --- /dev/null +++ b/test/unit/state/visibility/is-totally-visible.spec.js @@ -0,0 +1,470 @@ +// @flow +import getArea from '../../../../src/state/get-area'; +import { isTotallyVisible, isPartiallyVisible } from '../../../../src/state/visibility/is-visible'; +import { getDroppableDimension, scrollDroppable } from '../../../../src/state/dimension'; +import { offsetByPosition } from '../../../../src/state/spacing'; +import { getClosestScrollable } from '../../../utils/dimension'; +import type { + Area, + DroppableDimension, + Spacing, +} from '../../../../src/types'; + +const viewport: Area = getArea({ + right: 800, + top: 0, + left: 0, + bottom: 600, +}); + +const asBigAsViewport: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'same-as-viewport', + type: 'TYPE', + }, + client: viewport, +}); + +const inViewport1: Spacing = { + top: 10, + left: 10, + right: 100, + bottom: 100, +}; + +const inViewport2: Spacing = { + top: 10, + left: 200, + right: 400, + bottom: 100, +}; + +const notInViewport: Spacing = { + top: 0, + right: 1000, + left: 900, + bottom: 600, +}; + +const asBigAsInViewport1: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'subset', + type: 'TYPE', + }, + client: getArea(inViewport1), +}); + +describe('is totally visible', () => { + describe('viewport', () => { + describe('without changes in droppable scroll', () => { + it('should return false if the item is not in the viewport', () => { + expect(isTotallyVisible({ + target: notInViewport, + viewport, + destination: asBigAsViewport, + })).toBe(false); + }); + + it('should return true if item takes up entire viewport', () => { + expect(isTotallyVisible({ + target: viewport, + viewport, + destination: asBigAsViewport, + })).toBe(true); + }); + + it('should return true if the item is totally visible in the viewport', () => { + expect(isTotallyVisible({ + target: inViewport1, + viewport, + destination: asBigAsViewport, + })).toBe(true); + }); + + it('should return false if the item is partially visible in the viewport', () => { + const partials: Spacing[] = [ + // bleed over top + offsetByPosition(viewport, { x: 0, y: -1 }), + // bleed over right + offsetByPosition(viewport, { x: 1, y: 0 }), + // bleed over bottom + offsetByPosition(viewport, { x: 0, y: 1 }), + // bleed over left + offsetByPosition(viewport, { x: -1, y: 0 }), + ]; + + partials.forEach((partial: Spacing) => { + expect(isTotallyVisible({ + target: partial, + viewport, + destination: asBigAsViewport, + })).toBe(false); + + // validation + expect(isPartiallyVisible({ + target: partial, + viewport, + destination: asBigAsViewport, + })).toBe(true); + }); + }); + }); + + describe('with changes in droppable scroll', () => { + const clippedByViewport: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'clipped', + type: 'TYPE', + }, + client: getArea({ + top: viewport.top, + // stretches out the bottom of the viewport + bottom: viewport.bottom + 100, + left: viewport.left, + right: viewport.right, + }), + closest: { + frameClient: viewport, + scrollWidth: viewport.width, + scrollHeight: viewport.bottom + 100, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + describe('originally invisible but now invisible', () => { + it('should take into account the droppable scroll when detecting visibility', () => { + const originallyInvisible: Spacing = { + top: viewport.bottom + 1, + bottom: viewport.bottom + 100, + right: viewport.left + 1, + left: viewport.left + 100, + }; + + // originally invisible + expect(isTotallyVisible({ + target: originallyInvisible, + destination: clippedByViewport, + viewport, + })).toBe(false); + + // after scroll the target is now visible + expect(isTotallyVisible({ + target: originallyInvisible, + destination: scrollDroppable(clippedByViewport, { x: 0, y: 100 }), + viewport, + })).toBe(true); + }); + }); + + describe('originally visible but now visible', () => { + it('should take into account the droppable scroll when detecting visibility', () => { + const originallyVisible: Spacing = { + top: viewport.top, + bottom: viewport.top + 50, + right: viewport.left + 1, + left: viewport.left + 100, + }; + + // originally visible + expect(isTotallyVisible({ + target: originallyVisible, + destination: clippedByViewport, + viewport, + })).toBe(true); + + // after scroll the target is now invisible + expect(isTotallyVisible({ + target: originallyVisible, + destination: scrollDroppable(clippedByViewport, { x: 0, y: 100 }), + viewport, + })).toBe(false); + }); + }); + }); + }); + + describe('droppable', () => { + const client: Area = getArea({ + top: 0, + left: 0, + right: 600, + bottom: 600, + }); + const frame: Area = getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, + }); + + const scrollable: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'clipped', + type: 'TYPE', + }, + client, + closest: { + frameClient: frame, + scrollHeight: client.height, + scrollWidth: client.width, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + describe('without changes in droppable scroll', () => { + it('should return false if outside the droppable', () => { + expect(isTotallyVisible({ + target: inViewport2, + viewport, + destination: asBigAsInViewport1, + })).toBe(false); + }); + + it('should return false if the target is bigger than the droppable', () => { + expect(isTotallyVisible({ + target: viewport, + viewport, + destination: asBigAsInViewport1, + })).toBe(false); + }); + + it('should return true if the same size of the droppable', () => { + expect(isTotallyVisible({ + target: inViewport1, + viewport, + destination: asBigAsInViewport1, + })).toBe(true); + }); + + it('should return true if within the droppable', () => { + const insideDroppable: Spacing = { + top: 20, + left: 20, + right: 80, + bottom: 80, + }; + + expect(isTotallyVisible({ + target: insideDroppable, + viewport, + destination: asBigAsInViewport1, + })).toBe(true); + }); + + it('should return false if partially within the droppable', () => { + const partials: Spacing[] = [ + // bleed over top + offsetByPosition(inViewport1, { x: 0, y: -1 }), + // bleed over right + offsetByPosition(inViewport1, { x: 1, y: 0 }), + // bleed over bottom + offsetByPosition(inViewport1, { x: 0, y: 1 }), + // bleed over left + offsetByPosition(inViewport1, { x: -1, y: 0 }), + ]; + + partials.forEach((partial: Spacing) => { + expect(isTotallyVisible({ + target: partial, + viewport, + destination: asBigAsInViewport1, + })).toBe(false); + + // validation + expect(isPartiallyVisible({ + target: partial, + viewport, + destination: asBigAsInViewport1, + })).toBe(true); + }); + }); + + it('should return false if falling on clipped area of droppable', () => { + const ourFrame: Spacing = { + top: 10, + left: 10, + right: 100, + // cuts the droppable short + bottom: 100, + }; + const clippedDroppable: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'clipped', + type: 'TYPE', + }, + client: getArea({ + ...ourFrame, + // stretches out past frame + bottom: 600, + }), + closest: { + frameClient: getArea(ourFrame), + scrollHeight: 600, + scrollWidth: getArea(ourFrame).width, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + const inSubjectOutsideFrame: Spacing = { + ...frame, + top: 110, + bottom: 200, + }; + + expect(isTotallyVisible({ + target: inSubjectOutsideFrame, + destination: clippedDroppable, + viewport, + })).toBe(false); + }); + }); + + describe('with changes in droppable scroll', () => { + describe('originally invisible but now invisible', () => { + it('should take into account the droppable scroll when detecting visibility', () => { + const originallyInvisible: Spacing = { + left: frame.left, + right: frame.right, + top: frame.bottom + 10, + bottom: frame.bottom + 20, + }; + + // originally invisible + expect(isTotallyVisible({ + target: originallyInvisible, + destination: scrollable, + viewport, + })).toBe(false); + + // after scroll the target is now visible + const scrolled: DroppableDimension = scrollDroppable(scrollable, { x: 0, y: 100 }); + expect(isTotallyVisible({ + target: originallyInvisible, + destination: scrolled, + viewport, + })).toBe(true); + }); + }); + + describe('originally visible but now visible', () => { + it('should take into account the droppable scroll when detecting visibility', () => { + const originallyVisible: Spacing = { + ...frame, + top: 10, + bottom: 20, + }; + + // originally visible + expect(isTotallyVisible({ + target: originallyVisible, + destination: scrollable, + viewport, + })).toBe(true); + + // after scroll the target is now invisible + expect(isTotallyVisible({ + target: originallyVisible, + destination: scrollDroppable(scrollable, { x: 0, y: 100 }), + viewport, + })).toBe(false); + }); + }); + }); + + describe('with invisible subject', () => { + it('should return false when subject is totally invisible', () => { + // creating a droppable where the frame is bigger than the subject + const droppable: DroppableDimension = getDroppableDimension({ + descriptor: { + id: 'droppable', + type: 'TYPE', + }, + client: getArea({ + top: 0, + left: 0, + bottom: 100, + right: 100, + }), + closest: { + frameClient: getArea({ + top: 0, + left: 0, + bottom: 100, + right: 100, + }), + scrollHeight: 600, + scrollWidth: 600, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + const originallyVisible: Spacing = { + ...frame, + top: 10, + bottom: 20, + }; + + // originally visible + expect(isTotallyVisible({ + target: originallyVisible, + destination: droppable, + viewport, + })).toBe(true); + + // subject is now totally invisible + const scrolled: DroppableDimension = scrollDroppable( + droppable, + getClosestScrollable(droppable).scroll.max, + ); + // asserting frame is not visible + expect(scrolled.viewport.clipped).toBe(null); + + // now asserting that this check will fail + expect(isTotallyVisible({ + target: originallyVisible, + destination: scrolled, + viewport, + })).toBe(false); + }); + }); + }); + + describe('viewport + droppable', () => { + it('should return true if visible in the viewport and the droppable', () => { + expect(isTotallyVisible({ + target: inViewport1, + viewport, + destination: asBigAsInViewport1, + })).toBe(true); + }); + + it('should return false if not visible in the droppable even if visible in the viewport', () => { + expect(isTotallyVisible({ + target: inViewport2, + viewport, + destination: asBigAsInViewport1, + })).toBe(false); + }); + + it('should return false if not visible in the viewport even if visible in the droppable', () => { + const notVisibleDroppable = getDroppableDimension({ + descriptor: { + id: 'not-visible', + type: 'TYPE', + }, + client: getArea(notInViewport), + }); + + expect(isTotallyVisible({ + // is visibile in the droppable + target: notInViewport, + // but not visible in the viewport + viewport, + destination: notVisibleDroppable, + })).toBe(false); + }); + }); +}); diff --git a/test/unit/view/annoucer.spec.js b/test/unit/view/annoucer.spec.js new file mode 100644 index 0000000000..114d9685c2 --- /dev/null +++ b/test/unit/view/annoucer.spec.js @@ -0,0 +1,107 @@ +// @flow +import createAnnouncer from '../../../src/view/announcer/announcer'; +import type { Announcer } from '../../../src/view/announcer/announcer-types'; + +describe('announcer', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + describe('mounting', () => { + it('should not create a dom node before mount is called', () => { + const announcer: Announcer = createAnnouncer(); + + const el: ?HTMLElement = document.getElementById(announcer.id); + + expect(el).not.toBeTruthy(); + }); + + it('should create a new element when mounting', () => { + const announcer: Announcer = createAnnouncer(); + + announcer.mount(); + const el: ?HTMLElement = document.getElementById(announcer.id); + + expect(el).toBeInstanceOf(HTMLElement); + }); + + it('should error if attempting to double mount', () => { + const announcer: Announcer = createAnnouncer(); + + announcer.mount(); + expect(console.error).not.toHaveBeenCalled(); + + announcer.mount(); + expect(console.error).toHaveBeenCalled(); + }); + + it('should apply the appropriate aria attributes and non visibility styles', () => { + const announcer: Announcer = createAnnouncer(); + + announcer.mount(); + const el: HTMLElement = (document.getElementById(announcer.id) : any); + + expect(el.getAttribute('aria-live')).toBe('assertive'); + expect(el.getAttribute('role')).toBe('log'); + expect(el.getAttribute('aria-atomic')).toBe('true'); + + // not checking all the styles - just enough to know we are doing something + expect(el.style.overflow).toBe('hidden'); + }); + }); + + describe('unmounting', () => { + it('should remove the element when unmounting', () => { + const announcer: Announcer = createAnnouncer(); + + announcer.mount(); + announcer.unmount(); + const el: ?HTMLElement = document.getElementById(announcer.id); + + expect(el).not.toBeTruthy(); + }); + + it('should error if attempting to unmount before mounting', () => { + const announcer: Announcer = createAnnouncer(); + + announcer.unmount(); + + expect(console.error).toHaveBeenCalled(); + }); + + it('should error if unmounting after an unmount', () => { + const announcer: Announcer = createAnnouncer(); + + announcer.mount(); + announcer.unmount(); + expect(console.error).not.toHaveBeenCalled(); + + announcer.unmount(); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('announcing', () => { + it('should error if not mounted', () => { + const announcer: Announcer = createAnnouncer(); + + announcer.announce('test'); + + expect(console.error).toHaveBeenCalled(); + }); + + it('should set the text content of the announcement element', () => { + const announcer: Announcer = createAnnouncer(); + announcer.mount(); + const el: HTMLElement = (document.getElementById(announcer.id) : any); + + announcer.announce('test'); + + expect(el.textContent).toBe('test'); + }); + }); +}); diff --git a/test/unit/view/connected-draggable.spec.js b/test/unit/view/connected-draggable.spec.js index b5b7f6280d..df521e5880 100644 --- a/test/unit/view/connected-draggable.spec.js +++ b/test/unit/view/connected-draggable.spec.js @@ -3,10 +3,10 @@ import React, { Component } from 'react'; import { mount } from 'enzyme'; import Draggable, { makeSelector } from '../../../src/view/draggable/connected-draggable'; -import { getPreset } from '../../utils/dimension'; +import { getPreset, getInitialImpact, withImpact } from '../../utils/dimension'; import { negate } from '../../../src/state/position'; import createDimensionMarshal from '../../../src/state/dimension-marshal/dimension-marshal'; -import * as state from '../../utils/simple-state-preset'; +import getStatePreset from '../../utils/get-simple-state-preset'; import { combine, withStore, @@ -29,9 +29,11 @@ import type { CurrentDragPositions, DragImpact, DraggableDimension, + DraggableLocation, } from '../../../src/types'; const preset = getPreset(); +const state = getStatePreset(); const move = (previous: State, offset: Position): State => { const clientPositions: CurrentDragPositions = { offset, @@ -88,153 +90,304 @@ describe('Connected Draggable', () => { expect(console.error).toHaveBeenCalled(); }); - it('should move the dragging item to the current offset', () => { - const selector: Selector = makeSelector(); + describe('is not over a droppable', () => { + it('should move the dragging item to the current offset', () => { + const selector: Selector = makeSelector(); + + const result: MapProps = selector( + move(state.dragging(), { x: 20, y: 30 }), + ownProps + ); - const result: MapProps = selector( - move(state.dragging(), { x: 20, y: 30 }), - ownProps - ); - - expect(result).toEqual({ - isDropAnimating: false, - isDragging: true, - offset: { x: 20, y: 30 }, - shouldAnimateDragMovement: false, - shouldAnimateDisplacement: false, - dimension: preset.inHome1, - direction: null, + expect(result).toEqual({ + isDropAnimating: false, + isDragging: true, + offset: { x: 20, y: 30 }, + shouldAnimateDragMovement: false, + shouldAnimateDisplacement: false, + dimension: preset.inHome1, + direction: null, + draggingOver: null, + }); }); - }); - it('should control whether drag movement is allowed based the current state', () => { - const selector: Selector = makeSelector(); - const previous: State = move(state.dragging(), { x: 20, y: 30 }); - - // drag animation is allowed - const allowed: State = { - ...previous, - drag: { - ...previous.drag, - current: { - // $ExpectError - not checking for null - ...previous.drag.current, - shouldAnimate: true, - }, - }, - }; - expect(selector(allowed, ownProps).shouldAnimateDragMovement).toBe(true); - - // drag animation is not allowed - const notAllowed: State = { - ...previous, - drag: { - ...previous.drag, - current: { - // $ExpectError - not checking for null - ...previous.drag.current, - shouldAnimate: false, + it('should control whether drag movement is allowed based the current state', () => { + const selector: Selector = makeSelector(); + const previous: State = move(state.dragging(), { x: 20, y: 30 }); + + // drag animation is allowed + const allowed: State = { + ...previous, + drag: { + ...previous.drag, + current: { + // $ExpectError - not checking for null + ...previous.drag.current, + shouldAnimate: true, + }, }, - }, - }; - expect(selector(notAllowed, ownProps).shouldAnimateDragMovement).toBe(false); - }); + }; + expect(selector(allowed, ownProps).shouldAnimateDragMovement).toBe(true); - it('should not break memoization on multiple calls to the same offset', () => { - const selector: Selector = makeSelector(); + // drag animation is not allowed + const notAllowed: State = { + ...previous, + drag: { + ...previous.drag, + current: { + // $ExpectError - not checking for null + ...previous.drag.current, + shouldAnimate: false, + }, + }, + }; + expect(selector(notAllowed, ownProps).shouldAnimateDragMovement).toBe(false); + }); - const result1: MapProps = selector( - move(state.dragging(), { x: 100, y: 200 }), - ownProps - ); - const result2: MapProps = selector( - move(state.dragging(), { x: 100, y: 200 }), - ownProps - ); - - expect(result1).toBe(result2); - expect(selector.recomputations()).toBe(1); - }); + it('should not break memoization on multiple calls to the same offset', () => { + const selector: Selector = makeSelector(); - it('should break memoization on multiple calls if moving to a new position', () => { - const selector: Selector = makeSelector(); + const result1: MapProps = selector( + move(state.dragging(), { x: 100, y: 200 }), + ownProps + ); + const result2: MapProps = selector( + move(state.dragging(), { x: 100, y: 200 }), + ownProps + ); - const result1: MapProps = selector( - move(state.dragging(), { x: 100, y: 200 }), - ownProps - ); - const result2: MapProps = selector( - move({ ...state.dragging() }, { x: 101, y: 200 }), - ownProps - ); - - expect(result1).not.toBe(result2); - expect(result1).not.toEqual(result2); - expect(selector.recomputations()).toBe(2); - }); + expect(result1).toBe(result2); + expect(selector.recomputations()).toBe(1); + }); - describe('drop animating', () => { - it('should log an error when there is invalid drag state', () => { - const invalid: State = { - ...state.dropAnimating(), - drop: null, - }; + it('should break memoization on multiple calls if moving to a new position', () => { const selector: Selector = makeSelector(); - const defaultMapProps: MapProps = selector(state.idle, ownProps); - const result: MapProps = selector(invalid, ownProps); + const result1: MapProps = selector( + move(state.dragging(), { x: 100, y: 200 }), + ownProps + ); + const result2: MapProps = selector( + move({ ...state.dragging() }, { x: 101, y: 200 }), + ownProps + ); - expect(result).toBe(defaultMapProps); - expect(console.error).toHaveBeenCalled(); + expect(result1).not.toBe(result2); + expect(result1).not.toEqual(result2); + expect(selector.recomputations()).toBe(2); }); - it('should move the draggable to the new offset', () => { + describe('drop animating', () => { + it('should log an error when there is invalid drag state', () => { + const invalid: State = { + ...state.dropAnimating(), + drop: null, + }; + const selector: Selector = makeSelector(); + const defaultMapProps: MapProps = selector(state.idle, ownProps); + + const result: MapProps = selector(invalid, ownProps); + + expect(result).toBe(defaultMapProps); + expect(console.error).toHaveBeenCalled(); + }); + + it('should move the draggable to the new offset', () => { + const selector: Selector = makeSelector(); + const current: State = state.dropAnimating(); + + const result: MapProps = selector( + current, + ownProps, + ); + + expect(result).toEqual({ + // no longer dragging + isDragging: false, + // is now drop animating + isDropAnimating: true, + // $ExpectError - not testing for null + offset: current.drop.pending.newHomeOffset, + dimension: preset.inHome1, + direction: null, + // animation now controlled by isDropAnimating flag + shouldAnimateDisplacement: false, + shouldAnimateDragMovement: false, + draggingOver: null, + }); + }); + }); + + describe('user cancel', () => { + it('should move the draggable to the new offset', () => { + const selector: Selector = makeSelector(); + const current: State = state.userCancel(); + + const result: MapProps = selector( + current, + ownProps, + ); + + expect(result).toEqual({ + // no longer dragging + isDragging: false, + // is now drop animating + isDropAnimating: true, + // $ExpectError - not testing for null + offset: current.drop.pending.newHomeOffset, + dimension: preset.inHome1, + direction: null, + // animation now controlled by isDropAnimating flag + shouldAnimateDisplacement: false, + shouldAnimateDragMovement: false, + draggingOver: null, + }); + }); + }); + }); + + describe('is over a droppable (test subset)', () => { + it('should move the dragging item to the current offset', () => { const selector: Selector = makeSelector(); - const current: State = state.dropAnimating(); const result: MapProps = selector( - current, - ownProps, + withImpact( + move(state.dragging(), { x: 20, y: 30 }), + getInitialImpact(preset.inHome1), + ), + ownProps ); expect(result).toEqual({ - // no longer dragging - isDragging: false, - // is now drop animating - isDropAnimating: true, - // $ExpectError - not testing for null - offset: current.drop.pending.newHomeOffset, - dimension: preset.inHome1, - direction: null, - // animation now controlled by isDropAnimating flag - shouldAnimateDisplacement: false, + isDropAnimating: false, + isDragging: true, + offset: { x: 20, y: 30 }, shouldAnimateDragMovement: false, + shouldAnimateDisplacement: false, + dimension: preset.inHome1, + direction: preset.home.axis.direction, + draggingOver: preset.home.descriptor.id, }); }); - }); - describe('user cancel', () => { - it('should move the draggable to the new offset', () => { + it('should not break memoization on multiple calls to the same offset', () => { const selector: Selector = makeSelector(); - const current: State = state.userCancel(); - const result: MapProps = selector( - current, - ownProps, + const result1: MapProps = selector( + withImpact( + move(state.dragging(), { x: 100, y: 200 }), + getInitialImpact(preset.inHome1), + ), + ownProps + ); + const result2: MapProps = selector( + withImpact( + move(state.dragging(), { x: 100, y: 200 }), + getInitialImpact(preset.inHome1), + ), + ownProps ); - expect(result).toEqual({ - // no longer dragging - isDragging: false, - // is now drop animating - isDropAnimating: true, - // $ExpectError - not testing for null - offset: current.drop.pending.newHomeOffset, - dimension: preset.inHome1, - direction: null, - // animation now controlled by isDropAnimating flag - shouldAnimateDisplacement: false, - shouldAnimateDragMovement: false, + expect(result1).toBe(result2); + expect(selector.recomputations()).toBe(1); + }); + + it('should break memoization on multiple calls if moving to a new position', () => { + const selector: Selector = makeSelector(); + + const result1: MapProps = selector( + withImpact( + move(state.dragging(), { x: 100, y: 200 }), + getInitialImpact(preset.inHome1) + ), + ownProps + ); + const result2: MapProps = selector( + withImpact( + move({ ...state.dragging() }, { x: 101, y: 200 }), + getInitialImpact(preset.inHome1), + ), + ownProps + ); + + expect(result1).not.toBe(result2); + expect(result1).not.toEqual(result2); + expect(selector.recomputations()).toBe(2); + }); + + describe('drop animating', () => { + it('should move the draggable to the new offset', () => { + const selector: Selector = makeSelector(); + const destination: DraggableLocation = { + index: preset.inHome1.descriptor.index, + droppableId: preset.inHome1.descriptor.droppableId, + }; + const current: State = withImpact( + state.dropAnimating(), + getInitialImpact(preset.inHome1) + ); + if (!current.drop || !current.drop.pending) { + throw new Error('invalid test setup'); + } + current.drop.pending.result.destination = destination; + + const result: MapProps = selector( + current, + ownProps, + ); + + expect(result).toEqual({ + // no longer dragging + isDragging: false, + // is now drop animating + isDropAnimating: true, + // $ExpectError - not testing for null + offset: current.drop.pending.newHomeOffset, + dimension: preset.inHome1, + direction: preset.home.axis.direction, + draggingOver: preset.home.descriptor.id, + // animation now controlled by isDropAnimating flag + shouldAnimateDisplacement: false, + shouldAnimateDragMovement: false, + }); + }); + }); + + describe('user cancel', () => { + it('should move the draggable to the new offset', () => { + const selector: Selector = makeSelector(); + const destination: DraggableLocation = { + index: preset.inHome1.descriptor.index, + droppableId: preset.inHome1.descriptor.droppableId, + }; + const current: State = withImpact( + state.userCancel(), + getInitialImpact(preset.inHome1) + ); + if (!current.drop || !current.drop.pending) { + throw new Error('invalid test setup'); + } + current.drop.pending.result.destination = destination; + + const result: MapProps = selector( + current, + ownProps, + ); + + expect(result).toEqual({ + // no longer dragging + isDragging: false, + // is now drop animating + isDropAnimating: true, + // $ExpectError - not testing for null + offset: current.drop.pending.newHomeOffset, + dimension: preset.inHome1, + direction: preset.home.axis.direction, + draggingOver: preset.home.descriptor.id, + // animation now controlled by isDropAnimating flag + shouldAnimateDisplacement: false, + shouldAnimateDragMovement: false, + }); }); }); }); @@ -452,6 +605,7 @@ describe('Connected Draggable', () => { shouldAnimateDragMovement: false, dimension: null, direction: null, + draggingOver: null, }); }); @@ -499,6 +653,7 @@ describe('Connected Draggable', () => { shouldAnimateDragMovement: false, dimension: null, direction: null, + draggingOver: null, }); }); @@ -585,6 +740,7 @@ describe('Connected Draggable', () => { shouldAnimateDragMovement: false, dimension: null, direction: null, + draggingOver: null, }); }); @@ -669,6 +825,7 @@ describe('Connected Draggable', () => { shouldAnimateDragMovement: false, dimension: null, direction: null, + draggingOver: null, }); }); @@ -754,6 +911,7 @@ describe('Connected Draggable', () => { shouldAnimateDragMovement: false, dimension: null, direction: null, + draggingOver: null, }); }); }); @@ -840,6 +998,7 @@ describe('Connected Draggable', () => { shouldAnimateDragMovement: false, dimension: null, direction: null, + draggingOver: null, }); }); }); @@ -888,10 +1047,11 @@ describe('Connected Draggable', () => { // so that the draggable can publish itself const marshal: DimensionMarshal = createDimensionMarshal({ cancel: () => { }, - publishDraggables: () => { }, - publishDroppables: () => { }, + publishDraggable: () => { }, + publishDroppable: () => { }, updateDroppableScroll: () => { }, updateDroppableIsEnabled: () => { }, + bulkPublish: () => { }, }); const options: Object = combine( withStore(), @@ -907,6 +1067,7 @@ describe('Connected Draggable', () => { getDimension: () => preset.home, watchScroll: () => { }, unwatchScroll: () => { }, + scroll: () => { }, }); class Person extends Component<{ name: string, provided: Provided}> { diff --git a/test/unit/view/connected-droppable.spec.js b/test/unit/view/connected-droppable.spec.js index 92bb51f510..da04623065 100644 --- a/test/unit/view/connected-droppable.spec.js +++ b/test/unit/view/connected-droppable.spec.js @@ -2,9 +2,9 @@ /* eslint-disable react/no-multi-comp */ import React, { Component } from 'react'; import { mount } from 'enzyme'; -import { withStore, combine, withDimensionMarshal } from '../../utils/get-context-options'; +import { withStore, combine, withDimensionMarshal, withStyleContext } from '../../utils/get-context-options'; import Droppable, { makeSelector } from '../../../src/view/droppable/connected-droppable'; -import * as state from '../../utils/simple-state-preset'; +import getStatePreset from '../../utils/get-simple-state-preset'; import forceUpdate from '../../utils/force-update'; import { getPreset } from '../../utils/dimension'; import type { @@ -20,6 +20,7 @@ import type { } from '../../../src/types'; const preset = getPreset(); +const state = getStatePreset(); const clone = (value: State): State => (JSON.parse(JSON.stringify(value)) : any); const getOwnProps = (dimension: DroppableDimension): OwnProps => ({ @@ -77,6 +78,7 @@ describe('Connected Droppable', () => { const selector: Selector = makeSelector(); const expected: MapProps = { isDraggingOver: false, + draggingOverWith: null, placeholder: null, }; @@ -90,6 +92,7 @@ describe('Connected Droppable', () => { const selector: Selector = makeSelector(); const expected: MapProps = { isDraggingOver: false, + draggingOverWith: null, placeholder: null, }; @@ -153,6 +156,7 @@ describe('Connected Droppable', () => { expect(result).toEqual({ isDraggingOver: true, + draggingOverWith: preset.inHome1.descriptor.id, placeholder: null, }); }); @@ -198,6 +202,7 @@ describe('Connected Droppable', () => { const selector: Selector = makeSelector(); const expected: MapProps = { isDraggingOver: true, + draggingOverWith: preset.inHome1.descriptor.id, placeholder: preset.inHome1.placeholder, }; @@ -265,7 +270,6 @@ describe('Connected Droppable', () => { drop: { result: null, pending: { - trigger: 'DROP', newHomeOffset: { x: 0, y: 0 }, impact, result: { @@ -279,6 +283,7 @@ describe('Connected Droppable', () => { index: preset.inHome1.descriptor.index, droppableId: preset.home.descriptor.id, }, + reason: 'DROP', }, }, }, @@ -342,7 +347,6 @@ describe('Connected Droppable', () => { drop: { result: null, pending: { - trigger: 'DROP', newHomeOffset: { x: 0, y: 0 }, impact, result: { @@ -353,6 +357,7 @@ describe('Connected Droppable', () => { droppableId: preset.home.descriptor.id, }, destination: impact.destination, + reason: 'DROP', }, }, }, @@ -389,13 +394,17 @@ describe('Connected Droppable', () => { }); describe('child render behavior', () => { - const contextOptions = combine(withStore(), withDimensionMarshal()); + const contextOptions = combine( + withStore(), + withDimensionMarshal(), + withStyleContext(), + ); class Person extends Component<{ name: string, provided: Provided }> { render() { const { provided, name } = this.props; return ( -
provided.innerRef(ref)}> +
provided.innerRef(ref)} {...provided.droppableProps}> hello {name}
); diff --git a/test/unit/view/dimension-marshal.spec.js b/test/unit/view/dimension-marshal.spec.js index 1a2c5a5833..8266a90ca1 100644 --- a/test/unit/view/dimension-marshal.spec.js +++ b/test/unit/view/dimension-marshal.spec.js @@ -3,7 +3,7 @@ import { getPreset } from '../../utils/dimension'; import createDimensionMarshal from '../../../src/state/dimension-marshal/dimension-marshal'; import { getDraggableDimension, getDroppableDimension } from '../../../src/state/dimension'; import getArea from '../../../src/state/get-area'; -import * as state from '../../utils/simple-state-preset'; +import getStatePreset from '../../utils/get-simple-state-preset'; import type { Callbacks, DimensionMarshal, @@ -20,14 +20,16 @@ import type { DroppableId, DraggableDescriptor, DroppableDescriptor, + ScrollOptions, Area, } from '../../../src/types'; const getCallbackStub = (): Callbacks => { const callbacks: Callbacks = { cancel: jest.fn(), - publishDraggables: jest.fn(), - publishDroppables: jest.fn(), + publishDraggable: jest.fn(), + publishDroppable: jest.fn(), + bulkPublish: jest.fn(), updateDroppableScroll: jest.fn(), updateDroppableIsEnabled: jest.fn(), }; @@ -40,6 +42,7 @@ type PopulateMarshalState = {| |} const preset = getPreset(); +const state = getStatePreset(); const defaultArgs: PopulateMarshalState = { draggables: preset.draggables, @@ -80,12 +83,13 @@ const populateMarshal = ( watches.droppable.getDimension(id); return droppable; }, - watchScroll: () => { - watches.droppable.watchScroll(id); + watchScroll: (options: ScrollOptions) => { + watches.droppable.watchScroll(id, options); }, unwatchScroll: () => { watches.droppable.unwatchScroll(id); }, + scroll: () => {}, }; marshal.registerDroppable(droppable.descriptor, callbacks); @@ -123,6 +127,30 @@ const childOfAnotherType: DraggableDimension = getDraggableDimension({ client: fakeArea, }); +const immediate: ScrollOptions = { + shouldPublishImmediately: true, +}; +const scheduled: ScrollOptions = { + shouldPublishImmediately: false, +}; + +const withScrollOptions = (current: State, scrollOptions: ScrollOptions) => { + if (!current.dimension.request) { + throw new Error('Invalid test setup'); + } + + return { + ...current, + dimension: { + ...current.dimension, + request: { + ...current.dimension.request, + scrollOptions, + }, + }, + }; +}; + describe('dimension marshal', () => { beforeAll(() => { requestAnimationFrame.reset(); @@ -159,7 +187,10 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting('some-unknown-descriptor')); + marshal.onPhaseChange(state.requesting({ + draggableId: 'some-unknown-descriptor', + scrollOptions: scheduled, + })); expect(callbacks.cancel).toHaveBeenCalled(); }); @@ -178,7 +209,10 @@ describe('dimension marshal', () => { }); // there is now no published home droppable - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); expect(callbacks.cancel).toHaveBeenCalled(); }); @@ -190,24 +224,52 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDraggables).toBeCalledWith([preset.inHome1]); - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDroppables).toBeCalledWith([preset.home]); + expect(callbacks.publishDraggable).toHaveBeenCalledTimes(1); + expect(callbacks.publishDraggable).toBeCalledWith(preset.inHome1); + expect(callbacks.publishDroppable).toHaveBeenCalledTimes(1); + expect(callbacks.publishDroppable).toBeCalledWith(preset.home); + expect(callbacks.bulkPublish).not.toHaveBeenCalled(); }); - it('should ask the home droppable to start listening to scrolling', () => { + it('should ask the home droppable to start listening to scrolling (scheduled scroll)', () => { const callbacks = getCallbackStub(); const marshal = createDimensionMarshal(callbacks); const watches = populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // it should not watch scroll on the other droppables at this stage expect(watches.droppable.watchScroll).toHaveBeenCalledTimes(1); - expect(watches.droppable.watchScroll).toHaveBeenCalledWith(preset.home.descriptor.id); + expect(watches.droppable.watchScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, + scheduled, + ); + }); + + it('should ask the home droppable to start listening to scrolling (immediate scroll)', () => { + const callbacks = getCallbackStub(); + const marshal = createDimensionMarshal(callbacks); + const watches = populateMarshal(marshal); + + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: immediate, + })); + + // it should not watch scroll on the other droppables at this stage + expect(watches.droppable.watchScroll).toHaveBeenCalledTimes(1); + expect(watches.droppable.watchScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, + immediate, + ); }); }); @@ -218,9 +280,14 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); + expect(callbacks.publishDraggable).toHaveBeenCalledTimes(1); + expect(callbacks.publishDroppable).toHaveBeenCalledTimes(1); + callbacks.publishDraggable.mockReset(); + callbacks.publishDroppable.mockReset(); // moving to idle state before moving to dragging state marshal.onPhaseChange(state.idle); @@ -229,8 +296,9 @@ describe('dimension marshal', () => { requestAnimationFrame.flush(); // nothing happened - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); + expect(callbacks.bulkPublish).not.toHaveBeenCalled(); + expect(callbacks.publishDroppable).not.toHaveBeenCalled(); + expect(callbacks.publishDraggable).not.toHaveBeenCalled(); }); }); @@ -240,11 +308,15 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); const watchers = populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); - callbacks.publishDroppables.mockClear(); - callbacks.publishDraggables.mockClear(); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); + expect(callbacks.publishDroppable).toHaveBeenCalledTimes(1); + expect(callbacks.publishDraggable).toHaveBeenCalledTimes(1); + expect(callbacks.bulkPublish).not.toHaveBeenCalled(); + callbacks.publishDroppable.mockClear(); + callbacks.publishDraggable.mockClear(); watchers.draggable.getDimension.mockClear(); watchers.droppable.getDimension.mockClear(); @@ -255,7 +327,7 @@ describe('dimension marshal', () => { // flush all timers - would normally collect and publish requestAnimationFrame.flush(); - expect(callbacks.publishDraggables).not.toHaveBeenCalled(); + expect(callbacks.publishDraggable).not.toHaveBeenCalled(); expect(watchers.droppable.getDimension).not.toHaveBeenCalled(); }); @@ -264,7 +336,10 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); const watchers = populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); expect(watchers.draggable.getDimension).toHaveBeenCalledTimes(1); expect(watchers.droppable.getDimension).toHaveBeenCalledTimes(1); watchers.draggable.getDimension.mockClear(); @@ -297,7 +372,10 @@ describe('dimension marshal', () => { droppables, }); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // clearing the initial calls watchers.draggable.getDimension.mockClear(); watchers.droppable.getDimension.mockClear(); @@ -323,7 +401,10 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); const watchers = populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // called straight away expect(watchers.draggable.getDimension) @@ -345,7 +426,10 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); const watchers = populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // called straight away expect(watchers.droppable.getDimension) @@ -367,7 +451,10 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); const watchers = populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // called straight away expect(watchers.droppable.getDimension) @@ -395,10 +482,13 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // clearing initial calls - callbacks.publishDraggables.mockClear(); - callbacks.publishDroppables.mockClear(); + callbacks.publishDraggable.mockClear(); + callbacks.publishDroppable.mockClear(); // execute collection frame marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); @@ -409,83 +499,97 @@ describe('dimension marshal', () => { requestAnimationFrame.step(); // nothing additional called - expect(callbacks.publishDraggables).not.toHaveBeenCalled(); - expect(callbacks.publishDroppables).not.toHaveBeenCalled(); + expect(callbacks.publishDraggable).not.toHaveBeenCalled(); + expect(callbacks.publishDroppable).not.toHaveBeenCalled(); + expect(callbacks.bulkPublish).not.toHaveBeenCalled(); }); - it('should publish all the collected draggables', () => { + it('should publish all the collected droppables', () => { const callbacks = getCallbackStub(); const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - // clearing initial calls - callbacks.publishDraggables.mockClear(); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); // calls are batched - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); - const result: DraggableDimension[] = callbacks.publishDraggables.mock.calls[0][0]; + expect(callbacks.bulkPublish).toHaveBeenCalledTimes(1); + const result: DroppableDimension[] = callbacks.bulkPublish.mock.calls[0][0]; // not calling for the dragging item - expect(result.length).toBe(Object.keys(preset.draggables).length - 1); + expect(result.length).toBe(Object.keys(preset.droppables).length - 1); // super explicit test // - doing it like this because the order of Object.keys is not guarenteed - Object.keys(preset.draggables).forEach((id: DraggableId) => { - if (id === preset.inHome1.descriptor.id) { - expect(result).not.toContain(preset.inHome1); + Object.keys(preset.droppables).forEach((id: DroppableId) => { + if (id === preset.home.descriptor.id) { + expect(result.includes(preset.home)).toBe(false); return; } - expect(result).toContain(preset.draggables[id]); + expect(result.includes(preset.droppables[id])).toBe(true); }); }); - it('should publish all the collected droppables', () => { + it('should publish all the collected draggables', () => { const callbacks = getCallbackStub(); const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - // clearing initial calls - callbacks.publishDroppables.mockClear(); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); // calls are batched - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); - const result: DroppableDimension[] = callbacks.publishDroppables.mock.calls[0][0]; + expect(callbacks.bulkPublish).toHaveBeenCalledTimes(1); + const result: DraggableDimension[] = callbacks.bulkPublish.mock.calls[0][1]; + // not calling for the dragging item - expect(result.length).toBe(Object.keys(preset.droppables).length - 1); + expect(result.length).toBe(Object.keys(preset.draggables).length - 1); + // super explicit test // - doing it like this because the order of Object.keys is not guarenteed - Object.keys(preset.droppables).forEach((id: DroppableId) => { - if (id === preset.home.descriptor.id) { - expect(result.includes(preset.home)).toBe(false); + Object.keys(preset.draggables).forEach((id: DraggableId) => { + if (id === preset.inHome1.descriptor.id) { + expect(result).not.toContain(preset.inHome1); return; } - expect(result.includes(preset.droppables[id])).toBe(true); + expect(result).toContain(preset.draggables[id]); }); }); - it('should request all the droppables to start listening to scroll events', () => { - const callbacks = getCallbackStub(); - const marshal = createDimensionMarshal(callbacks); - const watchers = populateMarshal(marshal); - - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - // initial droppable - expect(watchers.droppable.watchScroll).toHaveBeenCalledTimes(1); - // clearing this initial call - watchers.droppable.watchScroll.mockClear(); - - marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); - requestAnimationFrame.step(2); - - // excluding the home droppable - const expectedLength: number = Object.keys(preset.droppables).length - 1; - expect(watchers.droppable.watchScroll).toHaveBeenCalledTimes(expectedLength); + [scheduled, immediate].forEach((scrollOptions: ScrollOptions) => { + it('should request all the droppables to start listening to scroll events', () => { + const callbacks = getCallbackStub(); + const marshal = createDimensionMarshal(callbacks); + const watchers = populateMarshal(marshal); + + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions, + })); + // initial droppable + expect(watchers.droppable.watchScroll).toHaveBeenCalledTimes(1); + + marshal.onPhaseChange(withScrollOptions( + state.dragging(preset.inHome1.descriptor.id), + scrollOptions, + )); + requestAnimationFrame.step(2); + + const expectedLength: number = Object.keys(preset.droppables).length; + expect(watchers.droppable.watchScroll).toHaveBeenCalledTimes(expectedLength); + + Object.keys(preset.droppables).forEach((id: DroppableId) => { + expect(watchers.droppable.watchScroll).toHaveBeenCalledWith(id, scrollOptions); + }); + }); }); it('should not publish dimensions that where not collected', () => { @@ -501,12 +605,20 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal, { draggables, droppables }); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); - expect(callbacks.publishDroppables.mock.calls[0][0]).not.toContain(ofAnotherType); - expect(callbacks.publishDraggables.mock.calls[0][0]).not.toContain(childOfAnotherType); + expect(callbacks.bulkPublish.mock.calls[0][0]).not.toContain(ofAnotherType); + // validation + expect(callbacks.bulkPublish.mock.calls[0][0]).toContain(preset.foreign); + + expect(callbacks.bulkPublish.mock.calls[0][1]).not.toContain(childOfAnotherType); + // validation + expect(callbacks.bulkPublish.mock.calls[0][1]).toContain(preset.inHome2); }); it('should not publish draggables if there are none to publish', () => { @@ -521,19 +633,19 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal, { draggables, droppables }); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // asserting initial lift occurred - expect(callbacks.publishDraggables).toHaveBeenCalledWith([preset.inHome1]); - expect(callbacks.publishDroppables).toHaveBeenCalledWith([preset.home]); - callbacks.publishDraggables.mockReset(); - callbacks.publishDroppables.mockReset(); + expect(callbacks.publishDraggable).toHaveBeenCalledWith(preset.inHome1); + expect(callbacks.publishDroppable).toHaveBeenCalledWith(preset.home); // perform full lift marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); - expect(callbacks.publishDraggables).not.toHaveBeenCalled(); - expect(callbacks.publishDroppables).toHaveBeenCalledWith([preset.foreign]); + expect(callbacks.bulkPublish).toHaveBeenCalledWith([preset.foreign], []); }); it('should not publish droppables if there are none to publish', () => { @@ -551,19 +663,19 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal, { draggables, droppables }); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // asserting initial lift occurred - expect(callbacks.publishDraggables).toHaveBeenCalledWith([preset.inHome1]); - expect(callbacks.publishDroppables).toHaveBeenCalledWith([preset.home]); - callbacks.publishDraggables.mockReset(); - callbacks.publishDroppables.mockReset(); + expect(callbacks.publishDraggable).toHaveBeenCalledWith(preset.inHome1); + expect(callbacks.publishDroppable).toHaveBeenCalledWith(preset.home); // perform full lift marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); - expect(callbacks.publishDroppables).not.toHaveBeenCalled(); - expect(callbacks.publishDraggables).toHaveBeenCalledWith([preset.inHome2]); + expect(callbacks.bulkPublish).toHaveBeenCalledWith([], [preset.inHome2]); }); }); }); @@ -580,13 +692,16 @@ describe('dimension marshal', () => { const watchers = populateMarshal(marshal); // do initial work - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); // currently only watching Object.keys(preset.droppables).forEach((id: DroppableId) => { - expect(watchers.droppable.watchScroll).toHaveBeenCalledWith(id); + expect(watchers.droppable.watchScroll).toHaveBeenCalledWith(id, scheduled); expect(watchers.droppable.unwatchScroll).not.toHaveBeenCalledWith(id); }); @@ -608,8 +723,9 @@ describe('dimension marshal', () => { let watchers; const resetMocks = () => { - callbacks.publishDraggables.mockClear(); - callbacks.publishDroppables.mockClear(); + callbacks.publishDraggable.mockClear(); + callbacks.publishDroppable.mockClear(); + callbacks.bulkPublish.mockClear(); watchers.draggable.getDimension.mockClear(); watchers.droppable.getDimension.mockClear(); watchers.droppable.watchScroll.mockClear(); @@ -622,107 +738,129 @@ describe('dimension marshal', () => { watchers = populateMarshal(marshal); }); - const shouldHaveProcessedInitialDimensions = (): void => { - expect(callbacks.publishDroppables).toHaveBeenCalledWith([preset.home]); - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDraggables).toHaveBeenCalledWith([preset.inHome1]); - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); + const shouldHaveProcessedInitialDimensions = (scrollOptions: ScrollOptions): void => { + expect(callbacks.publishDroppable).toHaveBeenCalledWith(preset.home); + expect(callbacks.publishDroppable).toHaveBeenCalledTimes(1); + expect(callbacks.publishDraggable).toHaveBeenCalledWith(preset.inHome1); + expect(callbacks.publishDraggable).toHaveBeenCalledTimes(1); + expect(callbacks.bulkPublish).not.toHaveBeenCalled(); expect(watchers.droppable.getDimension).toHaveBeenCalledTimes(1); expect(watchers.draggable.getDimension).toHaveBeenCalledTimes(1); - expect(watchers.droppable.watchScroll).toHaveBeenCalledWith(preset.home.descriptor.id); expect(watchers.droppable.watchScroll).toHaveBeenCalledTimes(1); + expect(watchers.droppable.watchScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, scrollOptions + ); expect(watchers.droppable.unwatchScroll).not.toHaveBeenCalled(); }; const shouldNotHavePublishedDimensions = (): void => { - expect(callbacks.publishDroppables).not.toHaveBeenCalled(); - expect(callbacks.publishDroppables).not.toHaveBeenCalled(); + expect(callbacks.publishDroppable).not.toHaveBeenCalled(); + expect(callbacks.publishDroppable).not.toHaveBeenCalled(); + expect(callbacks.bulkPublish).not.toHaveBeenCalled(); }; - it('should support subsequent drags after a completed collection', () => { - Array.from({ length: 4 }).forEach(() => { - // initial publish - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - - shouldHaveProcessedInitialDimensions(); - - // resetting mock state so future assertions do not include these calls - resetMocks(); - - // collection and publish of secondary dimensions - marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); - requestAnimationFrame.step(2); + [immediate, scheduled].forEach((scrollOptions: ScrollOptions) => { + it('should support subsequent drags after a completed collection', () => { + Array.from({ length: 4 }).forEach(() => { + // initial publish + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions, + })); + + shouldHaveProcessedInitialDimensions(scrollOptions); + + // resetting mock state so future assertions do not include these calls + resetMocks(); + + // collection and publish of secondary dimensions + marshal.onPhaseChange(withScrollOptions( + state.dragging(preset.inHome1.descriptor.id), + scrollOptions, + )); + requestAnimationFrame.step(2); - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); - expect(watchers.droppable.getDimension).toHaveBeenCalledTimes(droppableCount - 1); - expect(watchers.droppable.watchScroll).toHaveBeenCalledTimes(droppableCount - 1); - expect(watchers.draggable.getDimension).toHaveBeenCalledTimes(draggableCount - 1); - expect(watchers.droppable.unwatchScroll).not.toHaveBeenCalled(); + expect(callbacks.publishDroppable).not.toHaveBeenCalled(); + expect(callbacks.publishDraggable).not.toHaveBeenCalled(); + expect(callbacks.bulkPublish).toHaveBeenCalledTimes(1); + expect(watchers.droppable.getDimension).toHaveBeenCalledTimes(droppableCount - 1); + expect(watchers.droppable.watchScroll).toHaveBeenCalledTimes(droppableCount - 1); + expect(watchers.draggable.getDimension).toHaveBeenCalledTimes(draggableCount - 1); + expect(watchers.droppable.unwatchScroll).not.toHaveBeenCalled(); - // finish the collection - marshal.onPhaseChange(state.dropComplete(preset.inHome1.descriptor.id)); + // finish the collection + marshal.onPhaseChange(state.dropComplete(preset.inHome1.descriptor.id)); - expect(watchers.droppable.unwatchScroll).toHaveBeenCalledTimes(droppableCount); + expect(watchers.droppable.unwatchScroll).toHaveBeenCalledTimes(droppableCount); - resetMocks(); + resetMocks(); + }); }); - }); - it('should support subsequent drags after a cancelled dimension request', () => { - Array.from({ length: 4 }).forEach(() => { - // start the collection - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + it('should support subsequent drags after a cancelled dimension request', () => { + Array.from({ length: 4 }).forEach(() => { + // start the collection + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions, + })); - shouldHaveProcessedInitialDimensions(); - resetMocks(); + shouldHaveProcessedInitialDimensions(scrollOptions); + resetMocks(); - // cancelled - marshal.onPhaseChange(state.idle); + // cancelled + marshal.onPhaseChange(state.idle); - shouldNotHavePublishedDimensions(); + shouldNotHavePublishedDimensions(); - resetMocks(); + resetMocks(); + }); }); - }); - it('should support subsequent drags after a cancelled drag', () => { - Array.from({ length: 4 }).forEach(() => { - // start the collection - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + it('should support subsequent drags after a cancelled drag', () => { + Array.from({ length: 4 }).forEach(() => { + // start the collection + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions, + })); - shouldHaveProcessedInitialDimensions(); - resetMocks(); + shouldHaveProcessedInitialDimensions(scrollOptions); + resetMocks(); - // drag started but collection not started - marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); + // drag started but collection not started + marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); - shouldNotHavePublishedDimensions(); + shouldNotHavePublishedDimensions(); - // cancelled - marshal.onPhaseChange(state.idle); - resetMocks(); + // cancelled + marshal.onPhaseChange(state.idle); + resetMocks(); + }); }); - }); - it('should support subsequent drags after a cancelled collection', () => { - Array.from({ length: 4 }).forEach(() => { - // start the collection - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + it('should support subsequent drags after a cancelled collection', () => { + Array.from({ length: 4 }).forEach(() => { + // start the collection + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions, + })); - shouldHaveProcessedInitialDimensions(); - resetMocks(); + shouldHaveProcessedInitialDimensions(scrollOptions); + resetMocks(); - // drag started but collection not started - marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); - // executing collection step but not publish - requestAnimationFrame.step(); + // drag started but collection not started + marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); + // executing collection step but not publish + requestAnimationFrame.step(); - shouldNotHavePublishedDimensions(); + shouldNotHavePublishedDimensions(); - // cancelled - marshal.onPhaseChange(state.idle); - resetMocks(); + // cancelled + marshal.onPhaseChange(state.idle); + resetMocks(); + }); }); }); }); @@ -732,6 +870,7 @@ describe('dimension marshal', () => { getDimension: () => preset.home, watchScroll: () => { }, unwatchScroll: () => { }, + scroll: () => {}, }; const getDraggableDimensionFn: GetDraggableDimensionFn = () => preset.inHome1; @@ -746,9 +885,12 @@ describe('dimension marshal', () => { marshal.registerDroppable(preset.home.descriptor, droppableCallbacks); marshal.registerDraggable(preset.inHome1.descriptor, getDraggableDimensionFn); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); - expect(callbacks.publishDroppables).toHaveBeenCalledWith([preset.home]); + expect(callbacks.publishDroppable).toHaveBeenCalledWith(preset.home); }); it('should overwrite an existing entry if needed', () => { @@ -759,7 +901,7 @@ describe('dimension marshal', () => { }); marshal.registerDroppable(preset.home.descriptor, droppableCallbacks); - const newDescriptor: DroppableDescriptor = { + const newHomeDescriptor: DroppableDescriptor = { id: preset.home.descriptor.id, type: preset.home.descriptor.type, }; @@ -767,13 +909,17 @@ describe('dimension marshal', () => { getDimension: () => preset.foreign, watchScroll: () => { }, unwatchScroll: () => { }, + scroll: () => { }, }; - marshal.registerDroppable(newDescriptor, newCallbacks); + marshal.registerDroppable(newHomeDescriptor, newCallbacks); marshal.registerDraggable(preset.inHome1.descriptor, getDraggableDimensionFn); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); - expect(callbacks.publishDroppables).toHaveBeenCalledWith([preset.foreign]); - expect(callbacks.publishDroppables).toHaveBeenCalledTimes(1); + expect(callbacks.publishDroppable).toHaveBeenCalledWith(preset.foreign); + expect(callbacks.publishDroppable).toHaveBeenCalledTimes(1); }); }); @@ -794,9 +940,12 @@ describe('dimension marshal', () => { marshal.registerDroppable(preset.home.descriptor, droppableCallbacks); marshal.registerDraggable(preset.inHome1.descriptor, getDraggableDimensionFn); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); - expect(callbacks.publishDraggables).toHaveBeenCalledWith([preset.inHome1]); + expect(callbacks.publishDraggable).toHaveBeenCalledWith(preset.inHome1); }); it('should overwrite an existing entry if needed', () => { @@ -817,10 +966,13 @@ describe('dimension marshal', () => { index: preset.inHome1.descriptor.index + 10, }; marshal.registerDraggable(fake, () => preset.inHome2); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); - expect(callbacks.publishDraggables).toHaveBeenCalledTimes(1); - expect(callbacks.publishDraggables).toHaveBeenCalledWith([preset.inHome2]); + expect(callbacks.publishDraggable).toHaveBeenCalledTimes(1); + expect(callbacks.publishDraggable).toHaveBeenCalledWith(preset.inHome2); }); }); }); @@ -862,9 +1014,10 @@ describe('dimension marshal', () => { expect(console.warn).not.toHaveBeenCalled(); // lift, collect and publish - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - // clearing state from original publish - callbacks.publishDroppables.mockClear(); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // execute full lift marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); @@ -872,8 +1025,10 @@ describe('dimension marshal', () => { expect(watchers.droppable.getDimension) .not.toHaveBeenCalledWith(preset.foreign.descriptor.id); - expect(callbacks.publishDroppables.mock.calls[0][0]) + expect(callbacks.bulkPublish.mock.calls[0][0]) .not.toContain(preset.foreign); + // validation + expect(callbacks.bulkPublish.mock.calls[0][0]).toContain(preset.emptyForeign); // checking we are not causing an orphan child warning expect(console.warn).not.toHaveBeenCalled(); @@ -891,7 +1046,10 @@ describe('dimension marshal', () => { // not unregistering children (bad) // lift, collect and publish - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // perform full lift marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); @@ -916,6 +1074,7 @@ describe('dimension marshal', () => { getDimension: getOldDimension, watchScroll: () => { }, unwatchScroll: () => { }, + scroll: () => { }, }); marshal.registerDraggable(preset.inHome1.descriptor, () => preset.inHome1); @@ -934,10 +1093,14 @@ describe('dimension marshal', () => { getDimension: getNewDimension, watchScroll: () => { }, unwatchScroll: () => { }, + scroll: () => { }, }); // perform full lift - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); @@ -962,7 +1125,10 @@ describe('dimension marshal', () => { marshal.unregisterDraggable(preset.inForeign1.descriptor); expect(console.error).not.toHaveBeenCalled(); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); // perform full lift marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); @@ -977,6 +1143,7 @@ describe('dimension marshal', () => { getDimension: () => preset.home, watchScroll: () => {}, unwatchScroll: () => {}, + scroll: () => {}, }); const getOldDimension: GetDraggableDimensionFn = jest.fn().mockImplementation(() => preset.inHome2); @@ -1005,7 +1172,10 @@ describe('dimension marshal', () => { marshal.unregisterDraggable(preset.inHome2.descriptor); // perform full lift - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); marshal.onPhaseChange(state.dragging(preset.inHome1.descriptor.id)); requestAnimationFrame.step(2); @@ -1034,12 +1204,15 @@ describe('dimension marshal', () => { }); // start a collection - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - callbacks.publishDraggables.mockReset(); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); + callbacks.publishDraggable.mockReset(); // now registering marshal.registerDraggable(fake.descriptor, () => fake); - expect(callbacks.publishDraggables).not.toHaveBeenCalled(); + expect(callbacks.publishDraggable).not.toHaveBeenCalled(); expect(console.warn).toHaveBeenCalled(); }); }); @@ -1060,18 +1233,22 @@ describe('dimension marshal', () => { getDimension: () => fake, watchScroll: jest.fn(), unwatchScroll: () => { }, + scroll: () => {}, }; // starting collection - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); - callbacks.publishDroppables.mockReset(); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); + callbacks.publishDroppable.mockReset(); // updating registration marshal.registerDroppable(fake.descriptor, droppableCallbacks); // warning should have been logged and nothing updated expect(console.warn).toHaveBeenCalled(); - expect(callbacks.publishDroppables).not.toHaveBeenCalled(); + expect(callbacks.publishDroppable).not.toHaveBeenCalled(); expect(droppableCallbacks.watchScroll).not.toHaveBeenCalled(); }); }); @@ -1105,7 +1282,7 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting()); marshal.updateDroppableScroll(preset.home.descriptor.id, { x: 100, y: 230 }); expect(callbacks.updateDroppableScroll) @@ -1142,7 +1319,10 @@ describe('dimension marshal', () => { const marshal = createDimensionMarshal(callbacks); populateMarshal(marshal); - marshal.onPhaseChange(state.requesting(preset.inHome1.descriptor.id)); + marshal.onPhaseChange(state.requesting({ + draggableId: preset.inHome1.descriptor.id, + scrollOptions: scheduled, + })); marshal.updateDroppableIsEnabled(preset.home.descriptor.id, false); expect(callbacks.updateDroppableIsEnabled) diff --git a/test/unit/view/drag-drop-context.spec.js b/test/unit/view/drag-drop-context.spec.js index 49551c9338..5c96a698aa 100644 --- a/test/unit/view/drag-drop-context.spec.js +++ b/test/unit/view/drag-drop-context.spec.js @@ -105,7 +105,7 @@ describe('DragDropContext', () => { { }}> {(droppableProvided: DroppableProvided) => ( -
+
{(draggableProvided: DraggableProvided) => (
@@ -141,7 +141,7 @@ describe('DragDropContext', () => { { }}> {(droppableProvided: DroppableProvided) => ( -
+
{(draggableProvided: DraggableProvided) => (
diff --git a/test/unit/view/drag-handle.spec.js b/test/unit/view/drag-handle.spec.js index 3a3bf2b947..5eaa5215ad 100644 --- a/test/unit/view/drag-handle.spec.js +++ b/test/unit/view/drag-handle.spec.js @@ -18,12 +18,12 @@ import { } from '../../utils/user-input-util'; import type { Position, DraggableId } from '../../../src/types'; import * as keyCodes from '../../../src/view/key-codes'; -import getWindowScrollPosition from '../../../src/view/get-window-scroll-position'; +import getWindowScroll from '../../../src/window/get-window-scroll'; import setWindowScroll from '../../utils/set-window-scroll'; -import forceUpdate from '../../utils/force-update'; import getArea from '../../../src/state/get-area'; import { timeForLongPress, forcePressThreshold } from '../../../src/view/drag-handle/sensor/create-touch-sensor'; import { interactiveTagNames } from '../../../src/view/drag-handle/util/should-allow-dragging-from-target'; +import type { TagNameMap } from '../../../src/view/drag-handle/util/should-allow-dragging-from-target'; import { styleContextKey, canLiftContextKey } from '../../../src/view/context-keys'; const primaryButton: number = 0; @@ -220,6 +220,32 @@ describe('drag handle', () => { expect(myMock.mock.calls[0][0]['data-react-beautiful-dnd-drag-handle']).toEqual(basicContext[styleContextKey]); }); + it('should apply a default aria roledescription containing lift instructions', () => { + const myMock = jest.fn(); + myMock.mockReturnValue(
hello world
); + + mount( + fakeDraggableRef} + canDragInteractiveElements={false} + > + {(dragHandleProps: ?DragHandleProps) => ( + myMock(dragHandleProps) + )} + , + { context: basicContext } + ); + + // $ExpectError - using lots of accessors + expect(myMock.mock.calls[0][0]['aria-roledescription']) + .toBe('Draggable item. Press space bar to lift'); + }); + describe('mouse dragging', () => { describe('initiation', () => { it('should start a drag if there was sufficient mouse movement in any direction', () => { @@ -253,7 +279,7 @@ describe('drag handle', () => { windowMouseMove(point); expect(customCallbacks.onLift) - .toHaveBeenCalledWith({ client: point, isScrollAllowed: true }); + .toHaveBeenCalledWith({ client: point, autoScrollMode: 'FLUID' }); customWrapper.unmount(); }); @@ -515,7 +541,7 @@ describe('drag handle', () => { }); describe('window scroll during drag', () => { - const originalScroll: Position = getWindowScrollPosition(); + const originalScroll: Position = getWindowScroll(); beforeEach(() => { setWindowScroll(origin, { shouldPublish: false }); @@ -1166,7 +1192,7 @@ describe('drag handle', () => { expect(callbacks.onLift).toHaveBeenCalledWith({ client: fakeCenter, - isScrollAllowed: false, + autoScrollMode: 'JUMP', }); }); @@ -1263,6 +1289,40 @@ describe('drag handle', () => { })).toBe(true); }); + it('should instantly fire a scroll action when the window scrolls', () => { + // lift + pressSpacebar(wrapper); + // scroll event + window.dispatchEvent(new Event('scroll')); + + expect(callbacksCalled(callbacks)({ + onLift: 1, + onWindowScroll: 1, + })).toBe(true); + }); + + it('should prevent using keyboard keys that modify scroll', () => { + const keys: number[] = [ + keyCodes.pageUp, + keyCodes.pageDown, + keyCodes.home, + keyCodes.end, + ]; + + // lift + pressSpacebar(wrapper); + + keys.forEach((keyCode: number) => { + const mockEvent: MockEvent = createMockEvent(); + const trigger = withKeyboard(keyCode); + + trigger(wrapper, mockEvent); + + expect(wasEventStopped(mockEvent)).toBe(true); + expect(callbacks.onWindowScroll).not.toHaveBeenCalled(); + }); + }); + it('should stop dragging if the keyboard is used after a lift and a direction is not provided', () => { const customCallbacks = getStubCallbacks(); const customWrapper = mount( @@ -1557,28 +1617,8 @@ describe('drag handle', () => { }); }); - it('should cancel when the window is resized', () => { - // lift - pressSpacebar(wrapper); - // resize event - window.dispatchEvent(new Event('resize')); - - expect(callbacksCalled(callbacks)({ - onLift: 1, - onCancel: 1, - })).toBe(true); - }); - - it('should cancel if the window is scrolled', () => { - // lift - pressSpacebar(wrapper); - // scroll event - window.dispatchEvent(new Event('scroll')); - - expect(callbacksCalled(callbacks)({ - onLift: 1, - onCancel: 1, - })).toBe(true); + it.skip('should cancel on a page visibility change', () => { + // TODO }); it('should not do anything if there is nothing dragging', () => { @@ -1737,7 +1777,10 @@ describe('drag handle', () => { touchStart(wrapper, client); jest.runTimersToTime(timeForLongPress); - expect(callbacks.onLift).toHaveBeenCalledWith({ client, isScrollAllowed: false }); + expect(callbacks.onLift).toHaveBeenCalledWith({ + client, + autoScrollMode: 'FLUID', + }); }); it('should not fire a second lift after movement that would have otherwise have started a drag', () => { @@ -1935,6 +1978,25 @@ describe('drag handle', () => { expect(event.defaultPrevented).toBe(true); }); + + it('should schedule a window scroll move on window scroll', () => { + start(); + + dispatchWindowEvent('scroll'); + dispatchWindowEvent('scroll'); + dispatchWindowEvent('scroll'); + + // not called initially + expect(callbacks.onWindowScroll).not.toHaveBeenCalled(); + + // called after a requestAnimationFrame + requestAnimationFrame.step(); + expect(callbacks.onWindowScroll).toHaveBeenCalledTimes(1); + + // should not add any additional calls + requestAnimationFrame.flush(); + expect(callbacks.onWindowScroll).toHaveBeenCalledTimes(1); + }); }); describe('dropping', () => { @@ -2052,15 +2114,6 @@ describe('drag handle', () => { })).toBe(true); }); - it('should cancel the drag after a window scroll', () => { - dispatchWindowEvent('scroll'); - - expect(callbacksCalled(callbacks)({ - onLift: 1, - onCancel: 1, - })).toBe(true); - }); - it('should cancel a drag if any keypress is made', () => { // end initial drag end(); @@ -2366,9 +2419,6 @@ describe('drag handle', () => { const controls: Control[] = [mouse, keyboard, touch]; - const getAria = (wrap?: ReactWrapper = wrapper): boolean => - Boolean(wrap.find(Child).props().dragHandleProps['aria-grabbed']); - beforeEach(() => { jest.useFakeTimers(); }); @@ -2379,37 +2429,6 @@ describe('drag handle', () => { controls.forEach((control: Control) => { describe(`control: ${control.name}`, () => { - describe('aria', () => { - it('should not set the aria attribute of dragging if not dragging', () => { - expect(getAria()).toBe(false); - }); - - it('should not set the aria attribute of dragging if a drag is pending', () => { - control.preLift(); - forceUpdate(wrapper); - - expect(getAria()).toBe(false); - }); - - it('should set the aria attribute of dragging if a drag is occurring', () => { - control.preLift(); - control.lift(); - forceUpdate(wrapper); - - expect(getAria()).toBe(true); - }); - - it('should set the aria attribute if drag is finished', () => { - control.preLift(); - control.lift(); - forceUpdate(wrapper); - control.end(); - forceUpdate(wrapper); - - expect(getAria()).toBe(false); - }); - }); - describe('window bindings', () => { it('should unbind all window listeners when drag ends', () => { jest.spyOn(window, 'addEventListener'); @@ -2441,9 +2460,9 @@ describe('drag handle', () => { }); describe('interactive element interactions', () => { - const mixedCase = (items: string[]): string[] => [ - ...items.map((i: string): string => i.toLowerCase()), - ...items.map((i: string): string => i.toUpperCase()), + const mixedCase = (map: TagNameMap): string[] => [ + ...Object.keys(map).map((tagName: string) => tagName.toLowerCase()), + ...Object.keys(map).map((tagName: string) => tagName.toUpperCase()), ]; it('should not start a drag if the target is an interactive element', () => { @@ -2484,9 +2503,12 @@ describe('drag handle', () => { }); it('should start a drag if the target is not an interactive element', () => { - const nonInteractiveTagNames: string[] = [ - 'a', 'div', 'span', 'header', - ]; + const nonInteractiveTagNames: TagNameMap = { + a: true, + div: true, + span: true, + header: true, + }; // counting call count between loops let count: number = 0; diff --git a/test/unit/view/draggable-dimension-publisher.spec.js b/test/unit/view/draggable-dimension-publisher.spec.js index 0bde413244..b1f06985e0 100644 --- a/test/unit/view/draggable-dimension-publisher.spec.js +++ b/test/unit/view/draggable-dimension-publisher.spec.js @@ -77,6 +77,7 @@ const getMarshalStub = (): DimensionMarshal => ({ unregisterDroppable: jest.fn(), updateDroppableScroll: jest.fn(), updateDroppableIsEnabled: jest.fn(), + scrollDroppable: jest.fn(), onPhaseChange: jest.fn(), }); diff --git a/test/unit/view/droppable-dimension-publisher.spec.js b/test/unit/view/droppable-dimension-publisher.spec.js index a54ddccf83..f55134c8ba 100644 --- a/test/unit/view/droppable-dimension-publisher.spec.js +++ b/test/unit/view/droppable-dimension-publisher.spec.js @@ -15,6 +15,7 @@ import type { } from '../../../src/state/dimension-marshal/dimension-marshal-types'; import type { Area, + ScrollOptions, Spacing, DroppableId, DroppableDimension, @@ -89,6 +90,86 @@ class ScrollableItem extends Component } } +type AppProps = { + droppableIsScrollable?: boolean, + parentIsScrollable?: boolean, + ignoreContainerClipping: boolean, +}; +type AppState = { + ref: ?HTMLElement, +} + +const frame: Area = getArea({ + top: 0, + left: 0, + right: 150, + bottom: 150, +}); +const client: Area = getArea({ + top: 0, + left: 0, + right: 100, + bottom: 100, +}); +const descriptor: DroppableDescriptor = { + id: 'a cool droppable', + type: 'cool', +}; + +class App extends Component { + static defaultProps = { + onPublish: () => {}, + ignoreContainerClipping: false, + } + + state = { ref: null } + setRef = ref => this.setState({ ref }) + render() { + const { + droppableIsScrollable, + parentIsScrollable, + ignoreContainerClipping, + } = this.props; + return ( +
+
+
+ +
hello world
+
+
+
+
+ ); + } +} + const getMarshalStub = (): DimensionMarshal => ({ registerDraggable: jest.fn(), unregisterDraggable: jest.fn(), @@ -97,8 +178,16 @@ const getMarshalStub = (): DimensionMarshal => ({ updateDroppableScroll: jest.fn(), updateDroppableIsEnabled: jest.fn(), onPhaseChange: jest.fn(), + scrollDroppable: jest.fn(), }); +const scheduled: ScrollOptions = { + shouldPublishImmediately: false, +}; +const immediate: ScrollOptions = { + shouldPublishImmediately: true, +}; + describe('DraggableDimensionPublisher', () => { const originalWindowScroll: Position = { x: window.pageXOffset, @@ -330,7 +419,7 @@ describe('DraggableDimensionPublisher', () => { y: 1000, }; setWindowScroll(windowScroll, { shouldPublish: false }); - const client: Area = getArea({ + const ourClient: Area = getArea({ top: 0, right: 100, bottom: 100, @@ -342,9 +431,9 @@ describe('DraggableDimensionPublisher', () => { type: 'fake', }, windowScroll, - client, + client: ourClient, }); - jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => client); + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ourClient); jest.spyOn(window, 'getComputedStyle').mockImplementation(() => noSpacing); mount( @@ -363,149 +452,138 @@ describe('DraggableDimensionPublisher', () => { expect(result).toEqual(expected); }); - it('should capture the initial scroll containers current scroll', () => { - const marshal: DimensionMarshal = getMarshalStub(); - const frameScroll: Position = { - x: 500, - y: 1000, - }; - const expected: DroppableDimension = getDroppableDimension({ - descriptor: { - id: 'my-fake-id', - type: 'fake', - }, - client: getArea({ - top: 0, - right: 100, - bottom: 200, - left: 0, - }), - frameScroll, + describe('closest scrollable', () => { + describe('no closest scrollable', () => { + it('should return null for the closest scrollable if there is no scroll container', () => { + const expected: DroppableDimension = getDroppableDimension({ + descriptor, + client, + }); + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const droppableNode = wrapper.state().ref; + jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); + + // pull the get dimension function out + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // execute it to get the dimension + const result: DroppableDimension = callbacks.getDimension(); + + expect(result).toEqual(expected); + }); }); - jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - top: expected.page.withoutMargin.top, - bottom: expected.page.withoutMargin.bottom, - left: expected.page.withoutMargin.left, - right: expected.page.withoutMargin.right, - height: expected.page.withoutMargin.height, - width: expected.page.withoutMargin.width, - })); - jest.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ - overflow: 'auto', - ...noSpacing, - })); - const wrapper = mount( - , - withDimensionMarshal(marshal) - ); - // setting initial scroll - const container: HTMLElement = wrapper.getDOMNode(); - container.scrollLeft = frameScroll.x; - container.scrollTop = frameScroll.y; - // pull the get dimension function out - const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; - // execute it to get the dimension - const result: DroppableDimension = callbacks.getDimension(); + describe('droppable is scrollable', () => { + it('should capture the frame', () => { + const expected: DroppableDimension = getDroppableDimension({ + descriptor, + client: frame, + closest: { + frameClient: frame, + scrollWidth: frame.width, + scrollHeight: frame.height, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + const marshal: DimensionMarshal = getMarshalStub(); + // both the droppable and the parent are scrollable + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const droppableNode = wrapper.state().ref; + jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => frame); - expect(result).toEqual(expected); - }); + // pull the get dimension function out + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // execute it to get the dimension + const result: DroppableDimension = callbacks.getDimension(); - describe('calculating the frame', () => { - const frame: Area = getArea({ - top: 0, - left: 0, - right: 150, - bottom: 150, - }); - const client: Area = getArea({ - top: 0, - left: 0, - right: 100, - bottom: 100, + expect(result).toEqual(expected); + }); }); - const descriptor: DroppableDescriptor = { - id: 'a cool droppable', - type: 'cool', - }; - const dimensionWithoutScrollParent: DroppableDimension = getDroppableDimension({ - descriptor, - client, - }); - const dimensionWithScrollParent: DroppableDimension = getDroppableDimension({ - descriptor, - client, - frameClient: frame, + describe('parent of droppable is scrollable', () => { + it('should capture the frame', () => { + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const parentNode = wrapper.getDOMNode(); + const droppableNode = wrapper.state().ref; + jest.spyOn(parentNode, 'getBoundingClientRect').mockImplementation(() => frame); + jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); + const expected: DroppableDimension = getDroppableDimension({ + descriptor, + client, + closest: { + frameClient: frame, + scrollWidth: frame.width, + scrollHeight: frame.height, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + // pull the get dimension function out + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // execute it to get the dimension + const result: DroppableDimension = callbacks.getDimension(); + + expect(result).toEqual(expected); + }); }); - type AppProps = { - droppableIsScrollable?: boolean, - parentIsScrollable?: boolean, - ignoreContainerClipping: boolean, - }; - type AppState = { - ref: ?HTMLElement, - } - - class App extends Component { - static defaultProps = { - onPublish: () => {}, - ignoreContainerClipping: false, - } - - state = { ref: null } - setRef = ref => this.setState({ ref }) - render() { - const { - droppableIsScrollable, - parentIsScrollable, - ignoreContainerClipping, - } = this.props; - return ( -
-
-
- -
hello world
-
-
-
-
+ describe('both droppable and parent is scrollable', () => { + it('should only consider the closest scrollable - which is the droppable', () => { + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), ); - } - } + const parentNode = wrapper.getDOMNode(); + const droppableNode = wrapper.state().ref; + jest.spyOn(parentNode, 'getBoundingClientRect').mockImplementation(() => frame); + jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); + const expected: DroppableDimension = getDroppableDimension({ + descriptor, + client, + closest: { + frameClient: client, + scrollWidth: client.width, + scrollHeight: client.height, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); + + // pull the get dimension function out + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // execute it to get the dimension + const result: DroppableDimension = callbacks.getDimension(); + + expect(result).toEqual(expected); + }); + }); - it('should detect a scrollable parent', () => { + it('should capture the initial scroll of the scrollest scrollable', () => { + // in this case the parent of the droppable is the closest scrollable + const frameScroll: Position = { x: 10, y: 20 }; const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( { />, withDimensionMarshal(marshal), ); - const parentNode = wrapper.getDOMNode(); const droppableNode = wrapper.state().ref; + const parentNode = wrapper.getDOMNode(); + // manually setting the scroll of the parent node + parentNode.scrollTop = frameScroll.y; + parentNode.scrollLeft = frameScroll.x; jest.spyOn(parentNode, 'getBoundingClientRect').mockImplementation(() => frame); jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); - - // pull the get dimension function out + const expected: DroppableDimension = getDroppableDimension({ + descriptor, + client, + closest: { + frameClient: frame, + scrollWidth: frame.width, + scrollHeight: frame.height, + scroll: frameScroll, + shouldClipSubject: true, + }, + }); + + // pull the get dimension function out const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; // execute it to get the dimension const result: DroppableDimension = callbacks.getDimension(); - expect(result).toEqual(dimensionWithScrollParent); + expect(result).toEqual(expected); }); - it('should ignore any parents if they are not scroll containers', () => { + it('should indicate if subject clipping is permitted based on the ignoreContainerClipping prop', () => { + // in this case the parent of the droppable is the closest scrollable const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( , withDimensionMarshal(marshal), ); - const parentNode = wrapper.getDOMNode(); const droppableNode = wrapper.state().ref; + const parentNode = wrapper.getDOMNode(); jest.spyOn(parentNode, 'getBoundingClientRect').mockImplementation(() => frame); jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); - - // pull the get dimension function out + const expected: DroppableDimension = getDroppableDimension({ + descriptor, + client, + closest: { + frameClient: frame, + scrollWidth: frame.width, + scrollHeight: frame.height, + scroll: { x: 0, y: 0 }, + shouldClipSubject: false, + }, + }); + + // pull the get dimension function out const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; // execute it to get the dimension const result: DroppableDimension = callbacks.getDimension(); - expect(result).toEqual(dimensionWithoutScrollParent); + expect(result).toEqual(expected); }); + }); + }); - it('should use itself as the frame if the droppable is scrollable', () => { + describe('scroll watching', () => { + const scroll = (el: HTMLElement, target: Position) => { + el.scrollTop = target.y; + el.scrollLeft = target.x; + el.dispatchEvent(new Event('scroll')); + }; + + describe('should immediately publish updates', () => { + it('should immediately publish the scroll offset of the closest scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); - // both the droppable and the parent are scrollable const wrapper = mount( - , + , withDimensionMarshal(marshal), ); - const parentNode = wrapper.getDOMNode(); - const droppableNode = wrapper.state().ref; - jest.spyOn(parentNode, 'getBoundingClientRect').mockImplementation(() => frame); - jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); + const container: HTMLElement = wrapper.getDOMNode(); + + if (!container.classList.contains('scroll-container')) { + throw new Error('incorrect dom node collected'); + } - // pull the get dimension function out + // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; - // execute it to get the dimension - const result: DroppableDimension = callbacks.getDimension(); + // watch scroll will only be called after the dimension is requested + callbacks.getDimension(); + callbacks.watchScroll(immediate); - expect(result).toEqual(dimensionWithoutScrollParent); + scroll(container, { x: 500, y: 1000 }); + + expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, { x: 500, y: 1000 }, + ); }); - it('should return ignore the parent frame when ignoreContainerClipping is set', () => { + it('should not fire a scroll if the value has not changed since the previous call', () => { + // this can happen if you scroll backward and forward super quick const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( - , + , withDimensionMarshal(marshal), ); - const parentNode = wrapper.getDOMNode(); - const droppableNode = wrapper.state().ref; - jest.spyOn(parentNode, 'getBoundingClientRect').mockImplementation(() => frame); - jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); - - // pull the get dimension function out + const container: HTMLElement = wrapper.getDOMNode(); + // tell the droppable to watch for scrolling const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; - // execute it to get the dimension - const result: DroppableDimension = callbacks.getDimension(); - expect(result).toEqual(dimensionWithoutScrollParent); + // watch scroll will only be called after the dimension is requested + callbacks.getDimension(); + callbacks.watchScroll(immediate); + + // first event + scroll(container, { x: 500, y: 1000 }); + expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); + expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, { x: 500, y: 1000 } + ); + marshal.updateDroppableScroll.mockReset(); + + // second event - scroll to same spot + scroll(container, { x: 500, y: 1000 }); + expect(marshal.updateDroppableScroll).not.toHaveBeenCalled(); + + // third event - new value + scroll(container, { x: 500, y: 1001 }); + expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, { x: 500, y: 1001 } + ); }); }); - }); - describe('scroll watching', () => { - const scroll = (el: HTMLElement, target: Position) => { - el.scrollTop = target.y; - el.scrollLeft = target.x; - el.dispatchEvent(new Event('scroll')); - }; + describe('should schedule publish updates', () => { + it('should publish the scroll offset of the closest scrollable', () => { + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const container: HTMLElement = wrapper.getDOMNode(); - it('should publish the scroll offset of the closest scrollable', () => { - const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount( - , - withDimensionMarshal(marshal), - ); - const container: HTMLElement = wrapper.getDOMNode(); + if (!container.classList.contains('scroll-container')) { + throw new Error('incorrect dom node collected'); + } - if (!container.classList.contains('scroll-container')) { - throw new Error('incorrect dom node collected'); - } + // tell the droppable to watch for scrolling + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // watch scroll will only be called after the dimension is requested + callbacks.getDimension(); + callbacks.watchScroll(scheduled); - // tell the droppable to watch for scrolling - const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; - // watch scroll will only be called after the dimension is requested - callbacks.getDimension(); - callbacks.watchScroll(); + scroll(container, { x: 500, y: 1000 }); + // release the update animation frame + requestAnimationFrame.step(); - scroll(container, { x: 500, y: 1000 }); - // release the update animation frame - requestAnimationFrame.step(); + expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, { x: 500, y: 1000 }, + ); + }); - expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( - preset.home.descriptor.id, { x: 500, y: 1000 }, - ); - }); + it('should throttle multiple scrolls into a animation frame', () => { + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const container: HTMLElement = wrapper.getDOMNode(); + // tell the droppable to watch for scrolling + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; - it('should throttle multiple scrolls into a animation frame', () => { - const marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount( - , - withDimensionMarshal(marshal), - ); - const container: HTMLElement = wrapper.getDOMNode(); - // tell the droppable to watch for scrolling - const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // watch scroll will only be called after the dimension is requested + callbacks.getDimension(); + callbacks.watchScroll(scheduled); - // watch scroll will only be called after the dimension is requested - callbacks.getDimension(); - callbacks.watchScroll(); + // first event + scroll(container, { x: 500, y: 1000 }); + // second event in same frame + scroll(container, { x: 200, y: 800 }); - // first event - scroll(container, { x: 500, y: 1000 }); - // second event in same frame - scroll(container, { x: 200, y: 800 }); + // release the update animation frame + requestAnimationFrame.step(); - // release the update animation frame - requestAnimationFrame.step(); + expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); + expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, { x: 200, y: 800 }, + ); - expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); - expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( - preset.home.descriptor.id, { x: 200, y: 800 }, - ); + // also checking that no loose frames are stored up + requestAnimationFrame.flush(); + expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); + }); - // also checking that no loose frames are stored up - requestAnimationFrame.flush(); - expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); - }); + 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 marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const container: HTMLElement = wrapper.getDOMNode(); + // tell the droppable to watch for scrolling + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; - 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 marshal: DimensionMarshal = getMarshalStub(); - const wrapper = mount( - , - withDimensionMarshal(marshal), - ); - const container: HTMLElement = wrapper.getDOMNode(); - // tell the droppable to watch for scrolling - const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // watch scroll will only be called after the dimension is requested + callbacks.getDimension(); + callbacks.watchScroll(scheduled); + + // first event + scroll(container, { x: 500, y: 1000 }); + // release the frame + requestAnimationFrame.step(); + expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); + expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( + preset.home.descriptor.id, { x: 500, y: 1000 } + ); + marshal.updateDroppableScroll.mockReset(); - // watch scroll will only be called after the dimension is requested - callbacks.getDimension(); - callbacks.watchScroll(); + // second event + scroll(container, { x: 501, y: 1001 }); + // no frame to release change yet - // first event - scroll(container, { x: 500, y: 1000 }); - // release the frame - requestAnimationFrame.step(); - expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); - expect(marshal.updateDroppableScroll).toHaveBeenCalledWith( - preset.home.descriptor.id, { x: 500, y: 1000 } - ); - marshal.updateDroppableScroll.mockReset(); + // third event - back to original value + scroll(container, { x: 500, y: 1000 }); + // release the frame + requestAnimationFrame.step(); + expect(marshal.updateDroppableScroll).not.toHaveBeenCalled(); + }); - // second event - scroll(container, { x: 501, y: 1001 }); - // no frame to release change yet + it('should not publish a scroll update after requested not to update while an animation frame is occurring', () => { + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const container: HTMLElement = wrapper.getDOMNode(); + // tell the droppable to watch for scrolling + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; - // third event - back to original value - scroll(container, { x: 500, y: 1000 }); - // release the frame - requestAnimationFrame.step(); - expect(marshal.updateDroppableScroll).not.toHaveBeenCalled(); + // watch scroll will only be called after the dimension is requested + callbacks.getDimension(); + callbacks.watchScroll(scheduled); + + // first event + scroll(container, { x: 500, y: 1000 }); + requestAnimationFrame.step(); + expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); + marshal.updateDroppableScroll.mockReset(); + + // second event + scroll(container, { x: 400, y: 100 }); + // no animation frame to release event fired yet + + // unwatching before frame fired + callbacks.unwatchScroll(); + + // flushing any frames + requestAnimationFrame.flush(); + expect(marshal.updateDroppableScroll).not.toHaveBeenCalled(); + }); }); it('should stop watching scroll when no longer required to publish', () => { @@ -710,12 +866,10 @@ describe('DraggableDimensionPublisher', () => { // watch scroll will only be called after the dimension is requested callbacks.getDimension(); - callbacks.watchScroll(); + callbacks.watchScroll(immediate); // first event scroll(container, { x: 500, y: 1000 }); - // release the frame - requestAnimationFrame.step(); expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); marshal.updateDroppableScroll.mockReset(); @@ -723,12 +877,11 @@ describe('DraggableDimensionPublisher', () => { // scroll event after no longer watching scroll(container, { x: 190, y: 400 }); - // let any frames go that want to - requestAnimationFrame.flush(); expect(marshal.updateDroppableScroll).not.toHaveBeenCalled(); }); - it('should not publish a scroll update after requested not to update while an animation frame is occurring', () => { + it('should stop watching for scroll events when the component is unmounted', () => { + jest.spyOn(console, 'warn').mockImplementation(() => { }); const marshal: DimensionMarshal = getMarshalStub(); const wrapper = mount( , @@ -740,52 +893,113 @@ describe('DraggableDimensionPublisher', () => { // watch scroll will only be called after the dimension is requested callbacks.getDimension(); - callbacks.watchScroll(); - - // first event - scroll(container, { x: 500, y: 1000 }); - requestAnimationFrame.step(); - expect(marshal.updateDroppableScroll).toHaveBeenCalledTimes(1); - marshal.updateDroppableScroll.mockReset(); - - // second event - scroll(container, { x: 400, y: 100 }); - // no animation frame to release event fired yet + callbacks.watchScroll(immediate); - // unwatching before frame fired - callbacks.unwatchScroll(); + wrapper.unmount(); - // flushing any frames - requestAnimationFrame.flush(); + // second event - will not fire any updates + scroll(container, { x: 100, y: 300 }); expect(marshal.updateDroppableScroll).not.toHaveBeenCalled(); + // also logs a warning + expect(console.warn).toHaveBeenCalled(); + + // cleanup + console.warn.mockRestore(); }); + }); - it('should stop watching for scroll events when the component is unmounted', () => { - jest.spyOn(console, 'warn').mockImplementation(() => { }); + describe('forced scroll', () => { + it('should not do anything if the droppable has no closest scrollable', () => { const marshal: DimensionMarshal = getMarshalStub(); + // no scroll parent const wrapper = mount( - , + , withDimensionMarshal(marshal), ); - const container: HTMLElement = wrapper.getDOMNode(); - // tell the droppable to watch for scrolling - const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + const parentNode = wrapper.getDOMNode(); + const droppableNode = wrapper.state().ref; + jest.spyOn(parentNode, 'getBoundingClientRect').mockImplementation(() => frame); + jest.spyOn(droppableNode, 'getBoundingClientRect').mockImplementation(() => client); - // watch scroll will only be called after the dimension is requested + // validating no initial scroll + expect(parentNode.scrollTop).toBe(0); + expect(parentNode.scrollLeft).toBe(0); + expect(droppableNode.scrollTop).toBe(0); + expect(droppableNode.scrollLeft).toBe(0); + + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // request the droppable start listening for scrolling callbacks.getDimension(); - callbacks.watchScroll(); + callbacks.watchScroll(scheduled); + expect(console.error).not.toHaveBeenCalled(); - wrapper.unmount(); + // ask it to scroll + callbacks.scroll({ x: 100, y: 100 }); - // second event - will not fire any updates - scroll(container, { x: 100, y: 300 }); - requestAnimationFrame.step(); - expect(marshal.updateDroppableScroll).not.toHaveBeenCalled(); - // also logs a warning - expect(console.warn).toHaveBeenCalled(); + expect(parentNode.scrollTop).toBe(0); + expect(parentNode.scrollLeft).toBe(0); + expect(droppableNode.scrollTop).toBe(0); + expect(droppableNode.scrollLeft).toBe(0); + expect(console.error).toHaveBeenCalled(); + }); - // cleanup - console.warn.mockRestore(); + describe('there is a closest scrollable', () => { + it('should update the scroll of the closest scrollable', () => { + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const container: HTMLElement = wrapper.getDOMNode(); + + if (!container.classList.contains('scroll-container')) { + throw new Error('incorrect dom node collected'); + } + + expect(container.scrollTop).toBe(0); + expect(container.scrollLeft).toBe(0); + + // tell the droppable to watch for scrolling + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + // watch scroll will only be called after the dimension is requested + callbacks.getDimension(); + callbacks.watchScroll(scheduled); + + callbacks.scroll({ x: 500, y: 1000 }); + + expect(container.scrollLeft).toBe(500); + expect(container.scrollTop).toBe(1000); + }); + + it('should not scroll if scroll is not currently being watched', () => { + const marshal: DimensionMarshal = getMarshalStub(); + const wrapper = mount( + , + withDimensionMarshal(marshal), + ); + const container: HTMLElement = wrapper.getDOMNode(); + + if (!container.classList.contains('scroll-container')) { + throw new Error('incorrect dom node collected'); + } + + expect(container.scrollTop).toBe(0); + expect(container.scrollLeft).toBe(0); + + // tell the droppable to watch for scrolling + const callbacks: DroppableCallbacks = marshal.registerDroppable.mock.calls[0][1]; + callbacks.getDimension(); + // not watching scroll yet + + callbacks.scroll({ x: 500, y: 1000 }); + + expect(container.scrollLeft).toBe(0); + expect(container.scrollTop).toBe(0); + expect(console.error).toHaveBeenCalled(); + }); }); }); diff --git a/test/unit/view/style-marshal.spec.js b/test/unit/view/style-marshal.spec.js index 5399b95f77..5bb36af768 100644 --- a/test/unit/view/style-marshal.spec.js +++ b/test/unit/view/style-marshal.spec.js @@ -1,10 +1,12 @@ // @flow import createStyleMarshal from '../../../src/view/style-marshal/style-marshal'; import getStyles, { type Styles } from '../../../src/view/style-marshal/get-styles'; +import getStatePreset from '../../utils/get-simple-state-preset'; import type { StyleMarshal } from '../../../src/view/style-marshal/style-marshal-types'; -import * as state from '../../utils/simple-state-preset'; import type { State } from '../../../src/types'; +const state = getStatePreset(); + const getStyleTagSelector = (context: string) => `style[data-react-beautiful-dnd="${context}"]`; diff --git a/test/unit/view/unconnected-draggable.spec.js b/test/unit/view/unconnected-draggable.spec.js index f1ebf028ee..451b8bcd81 100644 --- a/test/unit/view/unconnected-draggable.spec.js +++ b/test/unit/view/unconnected-draggable.spec.js @@ -37,7 +37,7 @@ import getArea from '../../../src/state/get-area'; import { combine, withStore, withDroppableId, withStyleContext, withDimensionMarshal, withCanLift } from '../../utils/get-context-options'; import { dispatchWindowMouseEvent, mouseEvent } from '../../utils/user-input-util'; import setWindowScroll from '../../utils/set-window-scroll'; -import getWindowScrollPosition from '../../../src/view/get-window-scroll-position'; +import getWindowScroll from '../../../src/window/get-window-scroll'; class Item extends Component<{ provided: Provided }> { render() { @@ -123,6 +123,7 @@ const defaultMapProps: MapProps = { offset: origin, dimension: null, direction: null, + draggingOver: null, }; const somethingElseDraggingMapProps: MapProps = defaultMapProps; @@ -136,7 +137,7 @@ const draggingMapProps: MapProps = { // this may or may not be set during a drag dimension, direction: null, - + draggingOver: null, }; const dropAnimatingMapProps: MapProps = { @@ -147,6 +148,7 @@ const dropAnimatingMapProps: MapProps = { shouldAnimateDragMovement: false, dimension, direction: null, + draggingOver: null, }; const dropCompleteMapProps: MapProps = defaultMapProps; @@ -169,16 +171,18 @@ const mountDraggable = ({ // registering the droppable so that publishing the dimension will work correctly const dimensionMarshal: DimensionMarshal = createDimensionMarshal({ cancel: () => { }, - publishDraggables: () => { }, - publishDroppables: () => { }, + publishDraggable: () => { }, + publishDroppable: () => { }, updateDroppableScroll: () => { }, updateDroppableIsEnabled: () => { }, + bulkPublish: () => { }, }); dimensionMarshal.registerDroppable(droppable.descriptor, { getDimension: () => droppable, watchScroll: () => { }, unwatchScroll: () => { }, + scroll: () => {}, }); const wrapper: ReactWrapper = mount( @@ -206,7 +210,7 @@ const mountDraggable = ({ const mouseDown = mouseEvent.bind(null, 'mousedown'); const windowMouseMove = dispatchWindowMouseEvent.bind(null, 'mousemove'); -const originalWindowScroll: Position = getWindowScrollPosition(); +const originalWindowScroll: Position = getWindowScroll(); type StartDrag = {| selection?: Position, @@ -228,12 +232,14 @@ const executeOnLift = (wrapper: ReactWrapper) => ({ selection = origin, center = origin, windowScroll = origin, - isScrollAllowed = false, }: StartDrag = {}) => { setWindowScroll(windowScroll); stubArea(center); - wrapper.find(DragHandle).props().callbacks.onLift({ client: selection, isScrollAllowed }); + wrapper.find(DragHandle).props().callbacks.onLift({ + client: selection, + autoScrollMode: 'FLUID', + }); }; // $ExpectError - not checking type of mock @@ -419,13 +425,12 @@ describe('Draggable - unconnected', () => { center, }; const windowScroll = { x: 100, y: 30 }; - const isScrollAllowed: boolean = true; - executeOnLift(wrapper)({ selection, center, windowScroll, isScrollAllowed }); + executeOnLift(wrapper)({ selection, center, windowScroll }); // $ExpectError - mock property on lift function expect(dispatchProps.lift.mock.calls[0]).toEqual([ - draggableId, initial, windowScroll, isScrollAllowed, + draggableId, initial, windowScroll, 'FLUID', ]); }); }); @@ -1016,6 +1021,42 @@ describe('Draggable - unconnected', () => { const snapshot: StateSnapshot = getLastCall(myMock)[0].snapshot; expect(snapshot.isDragging).toBe(true); }); + + it('should let consumers know if draggging and over a droppable', () => { + // $ExpectError - using spread + const mapProps: MapProps = { + ...draggingMapProps, + draggingOver: 'foobar', + }; + + const myMock = jest.fn(); + + mountDraggable({ + mapProps, + WrappedComponent: getStubber(myMock), + }); + + const snapshot: StateSnapshot = getLastCall(myMock)[0].snapshot; + expect(snapshot.draggingOver).toBe('foobar'); + }); + + it('should let consumers know if dragging and not over a droppable', () => { + // $ExpectError - using spread + const mapProps: MapProps = { + ...draggingMapProps, + draggingOver: null, + }; + + const myMock = jest.fn(); + + mountDraggable({ + mapProps, + WrappedComponent: getStubber(myMock), + }); + + const snapshot: StateSnapshot = getLastCall(myMock)[0].snapshot; + expect(snapshot.draggingOver).toBe(null); + }); }); describe('drop animating', () => { diff --git a/test/unit/view/unconnected-droppable.spec.js b/test/unit/view/unconnected-droppable.spec.js index 49551b1978..e09cbd5717 100644 --- a/test/unit/view/unconnected-droppable.spec.js +++ b/test/unit/view/unconnected-droppable.spec.js @@ -5,10 +5,19 @@ import { mount } from 'enzyme'; import type { ReactWrapper } from 'enzyme'; import Droppable from '../../../src/view/droppable/droppable'; import Placeholder from '../../../src/view/placeholder/'; -import { withStore, combine, withDimensionMarshal } from '../../utils/get-context-options'; +import { withStore, combine, withDimensionMarshal, withStyleContext } from '../../utils/get-context-options'; import { getPreset } from '../../utils/dimension'; -import type { DroppableId, DraggableDimension } from '../../../src/types'; -import type { MapProps, OwnProps, Provided, StateSnapshot } from '../../../src/view/droppable/droppable-types'; +import type { + DraggableId, + DroppableId, + DraggableDimension, +} from '../../../src/types'; +import type { + MapProps, + OwnProps, + Provided, + StateSnapshot, +} from '../../../src/view/droppable/droppable-types'; const getStubber = (mock: Function) => class Stubber extends Component<{provided: Provided, snapshot: StateSnapshot}> { @@ -23,12 +32,15 @@ const getStubber = (mock: Function) => } }; const defaultDroppableId: DroppableId = 'droppable-1'; +const draggableId: DraggableId = 'draggable-1'; const notDraggingOverMapProps: MapProps = { isDraggingOver: false, + draggingOverWith: null, placeholder: null, }; const isDraggingOverHomeMapProps: MapProps = { isDraggingOver: true, + draggingOverWith: draggableId, placeholder: null, }; @@ -37,6 +49,7 @@ const inHome1: DraggableDimension = data.inHome1; const isDraggingOverForeignMapProps: MapProps = { isDraggingOver: true, + draggingOverWith: 'draggable-1', placeholder: inHome1.placeholder, }; @@ -66,7 +79,11 @@ const mountDroppable = ({ )} , - combine(withStore(), withDimensionMarshal()) + combine( + withStore(), + withDimensionMarshal(), + withStyleContext(), + ) ); describe('Droppable - unconnected', () => { @@ -82,6 +99,7 @@ describe('Droppable - unconnected', () => { const snapshot: StateSnapshot = myMock.mock.calls[0][0].snapshot; expect(provided.innerRef).toBeInstanceOf(Function); expect(snapshot.isDraggingOver).toBe(true); + expect(snapshot.draggingOverWith).toBe(draggableId); expect(provided.placeholder).toBe(null); }); }); @@ -98,6 +116,7 @@ describe('Droppable - unconnected', () => { const snapshot: StateSnapshot = myMock.mock.calls[0][0].snapshot; expect(provided.innerRef).toBeInstanceOf(Function); expect(snapshot.isDraggingOver).toBe(true); + expect(snapshot.draggingOverWith).toBe(draggableId); // $ExpectError - type property of placeholder expect(provided.placeholder.type).toBe(Placeholder); // $ExpectError - props property of placeholder @@ -117,6 +136,7 @@ describe('Droppable - unconnected', () => { const snapshot: StateSnapshot = myMock.mock.calls[0][0].snapshot; expect(provided.innerRef).toBeInstanceOf(Function); expect(snapshot.isDraggingOver).toBe(false); + expect(snapshot.draggingOverWith).toBe(null); expect(provided.placeholder).toBe(null); }); }); diff --git a/test/utils/dimension.js b/test/utils/dimension.js index d607edc8db..1d1689e99a 100644 --- a/test/utils/dimension.js +++ b/test/utils/dimension.js @@ -1,10 +1,15 @@ // @flow import getArea from '../../src/state/get-area'; +import { noMovement } from '../../src/state/no-impact'; import { getDroppableDimension, getDraggableDimension } from '../../src/state/dimension'; import { vertical } from '../../src/state/axis'; import type { + Area, Axis, + DragImpact, + State, Position, + ClosestScrollable, Spacing, DroppableDimension, DraggableDimension, @@ -12,17 +17,127 @@ import type { DroppableDimensionMap, } from '../../src/types'; -export const getPreset = (axis?: Axis = vertical) => { - const margin: Spacing = { top: 10, left: 10, bottom: 5, right: 5 }; - const padding: Spacing = { top: 2, left: 2, bottom: 2, right: 2 }; - const windowScroll: Position = { x: 50, y: 100 }; - const crossAxisStart: number = 0; - const crossAxisEnd: number = 100; - const foreignCrossAxisStart: number = 100; - const foreignCrossAxisEnd: number = 200; - const emptyForeignCrossAxisStart: number = 200; - const emptyForeignCrossAxisEnd: number = 300; +const margin: Spacing = { top: 10, left: 10, bottom: 5, right: 5 }; +const padding: Spacing = { top: 2, left: 2, bottom: 2, right: 2 }; +const windowScroll: Position = { x: 50, y: 100 }; +const crossAxisStart: number = 0; +const crossAxisEnd: number = 100; +const foreignCrossAxisStart: number = 100; +const foreignCrossAxisEnd: number = 200; +const emptyForeignCrossAxisStart: number = 200; +const emptyForeignCrossAxisEnd: number = 300; + +export const makeScrollable = (droppable: DroppableDimension, amount?: number = 20) => { + const axis: Axis = droppable.axis; + const client: Area = droppable.client.withoutMargin; + + const horizontalGrowth: number = axis === vertical ? 0 : amount; + const verticalGrowth: number = axis === vertical ? amount : 0; + + // is 10px smaller than the client on the main axis + // this will leave 10px of scrollable area. + // only expanding on one axis + const newClient: Area = getArea({ + top: client.top, + left: client.left, + // growing the client to account for the scrollable area + right: client.right + horizontalGrowth, + bottom: client.bottom + verticalGrowth, + }); + + // add scroll space on the main axis + const scrollSize = { + width: client.width + horizontalGrowth, + height: client.height + verticalGrowth, + }; + + return getDroppableDimension({ + descriptor: droppable.descriptor, + direction: axis.direction, + padding, + margin, + windowScroll, + client: newClient, + closest: { + frameClient: client, + scrollWidth: scrollSize.width, + scrollHeight: scrollSize.height, + scroll: { x: 0, y: 0 }, + shouldClipSubject: true, + }, + }); +}; + +export const getInitialImpact = (draggable: DraggableDimension, axis?: Axis = vertical) => { + const impact: DragImpact = { + movement: noMovement, + direction: axis.direction, + destination: { + index: draggable.descriptor.index, + droppableId: draggable.descriptor.droppableId, + }, + }; + return impact; +}; + +export const withImpact = (state: State, impact: DragImpact) => { + // while dragging + if (state.drag) { + return { + ...state, + drag: { + ...state.drag, + impact, + }, + }; + } + // while drop animating + if (state.drop && state.drop.pending) { + return { + ...state, + drop: { + ...state.drop, + pending: { + ...state.drop.pending, + impact, + }, + }, + }; + } + + throw new Error('unable to apply impact'); +}; + +export const addDroppable = (base: State, droppable: DroppableDimension): State => ({ + ...base, + dimension: { + ...base.dimension, + droppable: { + ...base.dimension.droppable, + [droppable.descriptor.id]: droppable, + }, + }, +}); + +export const addDraggable = (base: State, draggable: DraggableDimension): State => ({ + ...base, + dimension: { + ...base.dimension, + draggable: { + ...base.dimension.draggable, + [draggable.descriptor.id]: draggable, + }, + }, +}); + +export const getClosestScrollable = (droppable: DroppableDimension): ClosestScrollable => { + if (!droppable.viewport.closestScrollable) { + throw new Error('Cannot get closest scrollable'); + } + return droppable.viewport.closestScrollable; +}; +export const getPreset = (axis?: Axis = vertical) => { const home: DroppableDimension = getDroppableDimension({ descriptor: { id: 'home', diff --git a/test/utils/get-context-options.js b/test/utils/get-context-options.js index c8bc59bb30..8624f2f107 100644 --- a/test/utils/get-context-options.js +++ b/test/utils/get-context-options.js @@ -54,10 +54,11 @@ export const withDimensionMarshal = (marshal?: DimensionMarshal): Object => ({ context: { [dimensionMarshalKey]: marshal || createDimensionMarshal({ cancel: () => { }, - publishDraggables: () => { }, - publishDroppables: () => { }, + publishDraggable: () => { }, + publishDroppable: () => { }, updateDroppableScroll: () => { }, updateDroppableIsEnabled: () => { }, + bulkPublish: () => { }, }), }, childContextTypes: { diff --git a/test/utils/get-simple-state-preset.js b/test/utils/get-simple-state-preset.js new file mode 100644 index 0000000000..938e11136f --- /dev/null +++ b/test/utils/get-simple-state-preset.js @@ -0,0 +1,291 @@ +// @flow +import { getPreset } from './dimension'; +import noImpact from '../../src/state/no-impact'; +import { vertical } from '../../src/state/axis'; +import type { + Axis, + State, + DraggableDescriptor, + DroppableDescriptor, + DimensionState, + DraggableDimension, + DroppableDimension, + CurrentDragPositions, + InitialDragPositions, + LiftRequest, + Position, + DragState, + DropResult, + PendingDrop, + DropReason, + DraggableId, + DragImpact, + ScrollOptions, +} from '../../src/types'; + +const scheduled: ScrollOptions = { + shouldPublishImmediately: false, +}; + +export default (axis?: Axis = vertical) => { + const preset = getPreset(axis); + + const getDimensionState = (request: LiftRequest): DimensionState => { + const draggable: DraggableDimension = preset.draggables[request.draggableId]; + const home: DroppableDimension = preset.droppables[draggable.descriptor.droppableId]; + + const result: DimensionState = { + request, + draggable: { [draggable.descriptor.id]: draggable }, + droppable: { [home.descriptor.id]: home }, + }; + return result; + }; + + const idle: State = { + phase: 'IDLE', + drag: null, + drop: null, + dimension: { + request: null, + draggable: {}, + droppable: {}, + }, + }; + + const preparing: State = { + ...idle, + phase: 'PREPARING', + }; + + const defaultLiftRequest: LiftRequest = { + draggableId: preset.inHome1.descriptor.id, + scrollOptions: { + shouldPublishImmediately: false, + }, + }; + const requesting = (request?: LiftRequest = defaultLiftRequest): State => { + const result: State = { + phase: 'COLLECTING_INITIAL_DIMENSIONS', + drag: null, + drop: null, + dimension: { + request, + draggable: {}, + droppable: {}, + }, + }; + return result; + }; + + const origin: Position = { x: 0, y: 0 }; + + const dragging = ( + id?: DraggableId = preset.inHome1.descriptor.id, + selection?: Position, + ): State => { + // will populate the dimension state with the initial dimensions + const draggable: DraggableDimension = preset.draggables[id]; + // either use the provided selection or use the draggable's center + const clientSelection: Position = selection || draggable.client.withMargin.center; + const initialPosition: InitialDragPositions = { + selection: clientSelection, + center: clientSelection, + }; + const clientPositions: CurrentDragPositions = { + selection: clientSelection, + center: clientSelection, + offset: origin, + }; + + const drag: DragState = { + initial: { + descriptor: draggable.descriptor, + autoScrollMode: 'FLUID', + client: initialPosition, + page: initialPosition, + windowScroll: origin, + }, + current: { + client: clientPositions, + page: clientPositions, + windowScroll: origin, + shouldAnimate: false, + hasCompletedFirstBulkPublish: true, + }, + impact: noImpact, + scrollJumpRequest: null, + }; + + const result: State = { + phase: 'DRAGGING', + drag, + drop: null, + dimension: getDimensionState({ + draggableId: id, + scrollOptions: scheduled, + }), + }; + + return result; + }; + + const scrollJumpRequest = (request: Position): State => { + const id: DraggableId = preset.inHome1.descriptor.id; + // will populate the dimension state with the initial dimensions + const draggable: DraggableDimension = preset.draggables[id]; + // either use the provided selection or use the draggable's center + const initialPosition: InitialDragPositions = { + selection: draggable.client.withMargin.center, + center: draggable.client.withMargin.center, + }; + const clientPositions: CurrentDragPositions = { + selection: draggable.client.withMargin.center, + center: draggable.client.withMargin.center, + offset: origin, + }; + + const impact: DragImpact = { + movement: { + displaced: [], + amount: origin, + isBeyondStartPosition: false, + }, + direction: preset.home.axis.direction, + destination: { + index: preset.inHome1.descriptor.index, + droppableId: preset.inHome1.descriptor.droppableId, + }, + }; + + const drag: DragState = { + initial: { + descriptor: draggable.descriptor, + autoScrollMode: 'JUMP', + client: initialPosition, + page: initialPosition, + windowScroll: origin, + }, + current: { + client: clientPositions, + page: clientPositions, + windowScroll: origin, + shouldAnimate: true, + hasCompletedFirstBulkPublish: true, + }, + impact, + scrollJumpRequest: request, + }; + + const result: State = { + phase: 'DRAGGING', + drag, + drop: null, + dimension: getDimensionState({ + draggableId: id, + scrollOptions: scheduled, + }), + }; + + return result; + }; + + const getDropAnimating = (id: DraggableId, reason: DropReason): State => { + const descriptor: DraggableDescriptor = preset.draggables[id].descriptor; + const home: DroppableDescriptor = preset.droppables[descriptor.droppableId].descriptor; + const pending: PendingDrop = { + newHomeOffset: origin, + impact: noImpact, + result: { + draggableId: descriptor.id, + type: home.type, + source: { + droppableId: home.id, + index: descriptor.index, + }, + destination: null, + reason, + }, + }; + + const result: State = { + phase: 'DROP_ANIMATING', + drag: null, + drop: { + pending, + result: null, + }, + dimension: getDimensionState({ + draggableId: descriptor.id, + scrollOptions: scheduled, + }), + }; + return result; + }; + + const dropAnimating = ( + id?: DraggableId = preset.inHome1.descriptor.id + ): State => getDropAnimating(id, 'DROP'); + + const userCancel = ( + id?: DraggableId = preset.inHome1.descriptor.id + ): State => getDropAnimating(id, 'CANCEL'); + + const dropComplete = ( + id?: DraggableId = preset.inHome1.descriptor.id + ): State => { + const descriptor: DraggableDescriptor = preset.draggables[id].descriptor; + const home: DroppableDescriptor = preset.droppables[descriptor.droppableId].descriptor; + const result: DropResult = { + draggableId: descriptor.id, + type: home.type, + source: { + droppableId: home.id, + index: descriptor.index, + }, + destination: null, + reason: 'DROP', + }; + + const value: State = { + phase: 'DROP_COMPLETE', + drag: null, + drop: { + pending: null, + result, + }, + dimension: { + request: null, + draggable: {}, + droppable: {}, + }, + }; + return value; + }; + + const allPhases = (id? : DraggableId = preset.inHome1.descriptor.id): State[] => [ + idle, + preparing, + requesting({ + draggableId: id, + scrollOptions: scheduled, + }), + dragging(id), + dropAnimating(id), + userCancel(id), + dropComplete(id), + ]; + + return { + idle, + preparing, + requesting, + dragging, + scrollJumpRequest, + dropAnimating, + userCancel, + dropComplete, + allPhases, + }; +}; + diff --git a/test/utils/set-viewport.js b/test/utils/set-viewport.js new file mode 100644 index 0000000000..880dfc9183 --- /dev/null +++ b/test/utils/set-viewport.js @@ -0,0 +1,47 @@ +// @flow +import type { Area } from '../../src/types'; +import getArea from '../../src/state/get-area'; + +const getDoc = (): HTMLElement => { + const el: ?HTMLElement = document.documentElement; + + if (!el) { + throw new Error('Unable to get document.documentElement'); + } + + return el; +}; + +const setViewport = (custom: Area) => { + if (custom.top !== 0 || custom.left !== 0) { + throw new Error('not setting window scroll with setViewport. Use set-window-scroll'); + } + + if (window.pageXOffset !== 0 || window.pageYOffset !== 0) { + throw new Error('Setting viewport on scrolled window'); + } + + window.pageYOffset = 0; + window.pageXOffset = 0; + + const doc: HTMLElement = getDoc(); + doc.clientWidth = custom.width; + doc.clientHeight = custom.height; +}; + +export const getCurrent = (): Area => { + const doc: HTMLElement = getDoc(); + + return getArea({ + top: window.pageYOffset, + left: window.pageXOffset, + width: doc.clientWidth, + height: doc.clientHeight, + }); +}; + +const original: Area = getCurrent(); + +export const resetViewport = () => setViewport(original); + +export default setViewport; diff --git a/test/utils/set-window-scroll-size.js b/test/utils/set-window-scroll-size.js new file mode 100644 index 0000000000..624feca4ae --- /dev/null +++ b/test/utils/set-window-scroll-size.js @@ -0,0 +1,34 @@ +// @flow + +type Args = {| + scrollHeight: number, + scrollWidth: number, +|} + +const setWindowScrollSize = ({ scrollHeight, scrollWidth }: Args): void => { + const el: ?HTMLElement = document.documentElement; + + if (!el) { + throw new Error('Unable to find document element'); + } + + el.scrollHeight = scrollHeight; + el.scrollWidth = scrollWidth; +}; + +const original: Args = (() => { + const el: ?HTMLElement = document.documentElement; + + if (!el) { + throw new Error('Unable to find document element'); + } + + return { + scrollWidth: el.scrollWidth, + scrollHeight: el.scrollHeight, + }; +})(); + +export const resetWindowScrollSize = () => setWindowScrollSize(original); + +export default setWindowScrollSize; diff --git a/test/utils/set-window-scroll.js b/test/utils/set-window-scroll.js index f8e779ce3d..332af8050c 100644 --- a/test/utils/set-window-scroll.js +++ b/test/utils/set-window-scroll.js @@ -9,10 +9,20 @@ const defaultOptions: Options = { shouldPublish: true, }; -export default (point: Position, options?: Options = defaultOptions) => { +const setWindowScroll = (point: Position, options?: Options = defaultOptions) => { window.pageXOffset = point.x; window.pageYOffset = point.y; + if (options.shouldPublish) { window.dispatchEvent(new Event('scroll')); } }; + +const original: Position = { + x: window.pageXOffset, + y: window.pageYOffset, +}; + +export const resetWindowScroll = () => setWindowScroll(original); + +export default setWindowScroll; diff --git a/test/utils/simple-state-preset.js b/test/utils/simple-state-preset.js deleted file mode 100644 index 49f338ce70..0000000000 --- a/test/utils/simple-state-preset.js +++ /dev/null @@ -1,188 +0,0 @@ -// @flow -import { getPreset } from './dimension'; -import noImpact from '../../src/state/no-impact'; -import type { - State, - DraggableDescriptor, - DroppableDescriptor, - DimensionState, - DraggableDimension, - DroppableDimension, - CurrentDragPositions, - InitialDragPositions, - Position, - DragState, - DropResult, - PendingDrop, - DropTrigger, - DraggableId, -} from '../../src/types'; - -const preset = getPreset(); - -const getDimensionState = (request: DraggableId): DimensionState => { - const draggable: DraggableDimension = preset.draggables[request]; - const home: DroppableDimension = preset.droppables[draggable.descriptor.droppableId]; - - const result: DimensionState = { - request, - draggable: { [draggable.descriptor.id]: draggable }, - droppable: { [home.descriptor.id]: home }, - }; - return result; -}; - -export const idle: State = { - phase: 'IDLE', - drag: null, - drop: null, - dimension: { - request: null, - draggable: {}, - droppable: {}, - }, -}; - -export const preparing: State = { - ...idle, - phase: 'PREPARING', -}; - -export const requesting = (request?: DraggableId = preset.inHome1.descriptor.id): State => { - const result: State = { - phase: 'COLLECTING_INITIAL_DIMENSIONS', - drag: null, - drop: null, - dimension: { - request, - draggable: {}, - droppable: {}, - }, - }; - return result; -}; - -const origin: Position = { x: 0, y: 0 }; - -export const dragging = ( - id?: DraggableId = preset.inHome1.descriptor.id -): State => { - // will populate the dimension state with the initial dimensions - const draggable: DraggableDimension = preset.draggables[id]; - const client: Position = draggable.client.withMargin.center; - const initialPosition: InitialDragPositions = { - selection: client, - center: client, - }; - const clientPositions: CurrentDragPositions = { - selection: client, - center: client, - offset: origin, - }; - - const drag: DragState = { - initial: { - descriptor: draggable.descriptor, - isScrollAllowed: true, - client: initialPosition, - page: initialPosition, - windowScroll: origin, - }, - current: { - client: clientPositions, - page: clientPositions, - windowScroll: origin, - shouldAnimate: false, - }, - impact: noImpact, - }; - - const result: State = { - phase: 'DRAGGING', - drag, - drop: null, - dimension: getDimensionState(id), - }; - - return result; -}; - -const getDropAnimating = (id: DraggableId, trigger: DropTrigger): State => { - const descriptor: DraggableDescriptor = preset.draggables[id].descriptor; - const home: DroppableDescriptor = preset.droppables[descriptor.droppableId].descriptor; - const pending: PendingDrop = { - trigger, - newHomeOffset: origin, - impact: noImpact, - result: { - draggableId: descriptor.id, - type: home.type, - source: { - droppableId: home.id, - index: descriptor.index, - }, - destination: null, - }, - }; - - const result: State = { - phase: 'DROP_ANIMATING', - drag: null, - drop: { - pending, - result: null, - }, - dimension: getDimensionState(descriptor.id), - }; - return result; -}; - -export const dropAnimating = ( - id?: DraggableId = preset.inHome1.descriptor.id -): State => getDropAnimating(id, 'DROP'); - -export const userCancel = ( - id?: DraggableId = preset.inHome1.descriptor.id -): State => getDropAnimating(id, 'CANCEL'); - -export const dropComplete = ( - id?: DraggableId = preset.inHome1.descriptor.id -): State => { - const descriptor: DraggableDescriptor = preset.draggables[id].descriptor; - const home: DroppableDescriptor = preset.droppables[descriptor.droppableId].descriptor; - const result: DropResult = { - draggableId: descriptor.id, - type: home.type, - source: { - droppableId: home.id, - index: descriptor.index, - }, - destination: null, - }; - - const value: State = { - phase: 'DROP_COMPLETE', - drag: null, - drop: { - pending: null, - result, - }, - dimension: { - request: null, - draggable: {}, - droppable: {}, - }, - }; - return value; -}; - -export const allPhases = (id? : DraggableId = preset.inHome1.descriptor.id): State[] => [ - idle, - preparing, - requesting(id), - dragging(id), - dropAnimating(id), - userCancel(id), - dropComplete(id), -]; -