_Fast drag and drop for any experience on any tech stack_
[📖 **Documentation**](https://atlassian.design/components/pragmatic-drag-and-drop) | [🤹 **Examples**](https://atlassian.design/components/pragmatic-drag-and-drop/examples) | [🎥 **How it works**](https://www.youtube.com/watch?v=5SQkOyzZLHM)

## About
Pragmatic drag and drop is a low level drag and drop toolchain that enables safe and successful usage of the browsers built in drag and drop functionality. Pragmatic drag and drop can be used with any view layer ([`react`](https://react.dev/), [`svelte`](https://svelte.dev/), [`vue`](https://vuejs.org/), [`angular`](https://angular.io/) and so on). Pragmatic drag and drop is powering some of the biggest products on the web, including [Trello](https://trello.com), [Jira](https://www.atlassian.com/software/jira) and [Confluence](https://www.atlassian.com/software/confluence).
Capabilities
Pragmatic drag and drop consists of a few high level pieces:
1. **Low level drag and drop behavior**
Pragmatic drag and drop contains a core package, and a number of optional packages, that provide you the pieces to create _any_ drag and drop experience.
These pieces are unopinionated about visual language or accessibility, and have no dependency on the Atlassian Design System.
- _Tiny_: ~`4.7kB` core
- _Incremental_: Only use the pieces that you need
- _Headless_: Full rendering and style control
- _Framework agnostic_: Works with any frontend framework
- _Deferred compatible_: Delay the loading the core packages and optional packages in order to further improve page load speeds
- _Flexible_: create any experience you want, make any changes you want during a drag operation.
- _Works everywhere_: Full feature support in Firefox, Safari, and Chrome, iOS and Android
- _Virtualization support_: create any virtual experience you want!
2. **Optional visual outputs**
We have created optional visual outputs (eg our drop indicator) to make it super fast for us to build consistent Atlassian user experiences. Non Atlassian consumers are welcome to use these outputs, create their own that copy the visual styling, or go a totally different direction.
3. **Optional assistive technology controls**
Not all users can achieve pointer based drag and drop experiences. In order to achieve fantastic experiences for assistive technology users, we provide a toolchain to allow you to quickly wire up performant assistive technology friendly flows for any experience.
The optional assistive controls we provide are based on the Atlassian Design System. If you do not want to use the Atlassian Design System, you can use our guidelines and substitute our components with your own components, or you can go about accessibility in a different way if you choose.
## What is this repository?
This repository is currently one way mirror from our internal monorepo that contains all the code for Pragmatic drag and drop.
The intention of this repository is to make public our code, but not to accept code contributions (at this stage). In the future we could explore setting up a two way mirror so that contributions to this repo can also make their way back to our monorepo. You are still welcome to raise issues or suggestions on this repository!
All documentation and `npm` packages are public and available for use by everyone.
## Can I use this with my own Design System?
Yep! Pragmatic drag and drop as a [small core package](https://atlassian.design/components/pragmatic-drag-and-drop/core-package), and then a range of [optional packages](https://atlassian.design/components/pragmatic-drag-and-drop/optional-packages). Some of the optional packages have dependencies on styling solutions (eg `emotion`), view libraries (eg `react`) or on some additional Atlassian outputs (eg `@atlaskit/tokens`). We have separated out optional packages that have other dependencies so they can be easily swapped with your own pieces that use your own tech stack and visual outputs.
## Can I use my own design language?
Yep! We have created some design guidelines which embody how we want to achieve drag and drop in our products, and some of those decisions are embodied in some optional packages. However, you are free to use whatever design language you like, including ours!
## What is `@atlaskit`?
The Pragmatic drag and drop packages are published under the `@atlaskit` namespace on `npm`
```ts
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
```
`@atlaskit` is the `npm` namespace that we publish all of our public packages on from inside our internal monorepo. We _could_ look at creating a separate namespace in the future just for Pragmatic drag and drop. If we do that, we'll release some tooling to help folks automatically switch over.
## `npm` release and repo sync timing
This mirror repository is currently being synced with our internal repository once a day. We publish packages to `npm` immediately as we merge new versions into the internal repository. This means that code can be released onto `npm` up to 24 hours _before_ it is available in this mirror repository.
## Credits
Made with love by:
- [Alex Reardon](https://twitter.com/alexandereardon)
- [Declan Warn](https://twitter.com/DeclanWarn)
- [Lewis Healey](https://twitter.com/lewishealey)
- [Eleni Misthos](https://www.linkedin.com/in/elenimisthos/)
- [Jesse Bauer](https://soundcloud.com/jessebauer)
- [Mitch Gavan](https://twitter.com/MitchG23)
- [Michael Abrahamian](https://twitter.com/michaelguitars7)
- [Tim Keir](https://twitter.com/ReDrUmNZ)
- [Greta Ritchard](https://www.linkedin.com/in/gretarit/)
- [Many other folks at Atlassian](https://www.atlassian.com/)
- Logo created by [Michelle Holik](https://twitter.com/michelleholik) and [Vojta Holik](https://twitter.com/vojta_holik)
Pragmatic drag and drop stands on the shoulders of giants, including the folks who created the [drag and drop specifications](https://html.spec.whatwg.org/multipage/dnd.html), implemented drag and drop in browsers, and the many drag and drop libraries that came before this.
================================================
FILE: jest.config.js
================================================
================================================
FILE: package.json
================================================
{
"name": "pragmatic-drag-and-drop",
"private": true,
"version": "1.0.0",
"description": "Pragmatic drag and drop is a low level toolchain that enables fast and successful usage of the browsers built in drag and drop capabilities, for everyone",
"author": "Atlassian Pty Ltd",
"license": "Apache-2.0",
"scripts": {
"postinstall": "patch-package",
"test": "jest --forceExit"
},
"workspaces": [
"packages/*"
],
"dependencies": {
"tslib": "^2.4.0"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"typescript": "5.9.2"
}
}
================================================
FILE: packages/auto-scroll/.npmignore
================================================
src/
examples-utils/
examples/
index.ts
docs/
build/
__tests__/
tsconfig.json
tsconfig.app.json
tsconfig.dev.json
================================================
FILE: packages/auto-scroll/CHANGELOG.md
================================================
# @atlaskit/pragmatic-drag-and-drop-auto-scroll
## 2.1.5
### Patch Changes
- [`d3ed1b65a2181`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/d3ed1b65a2181) -
Add @atlassian/a11y-jest-testing to devDependencies.
## 2.1.4
### Patch Changes
- [`e4b717d8304e8`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/e4b717d8304e8) -
Add @atlassian/a11y-jest-testing to devDependencies.
## 2.1.3
### Patch Changes
- [`aa9ff75020fcb`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/aa9ff75020fcb) -
Add @atlassian/a11y-jest-testing to devDependencies.
## 2.1.2
### Patch Changes
- [`beaa6ee463aa8`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/beaa6ee463aa8) -
Internal changes to how border radius is applied.
- Updated dependencies
## 2.1.1
### Patch Changes
- [#164244](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/164244)
[`65021fc0267e2`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/65021fc0267e2) -
The cleanup functions returned by the following utilities now only work on the first call. This
was done to prevent unexpected side effects of calling a cleanup function multiple times.
- `@atlaskit/pragmatic-drag-and-drop/adapter/element`
- `draggable`
- `dropTargetForElements`
- `monitorForElements`
- `@atlaskit/pragmatic-drag-and-drop/adapter/text-selection`
- `dropTargetForTextSelection`
- `monitorForTextSelection`
- `@atlaskit/pragmatic-drag-and-drop/adapter/external`
- `dropTargetForExternal`
- `monitorForExternal`
- `@atlaskit/pragmatic-drag-and-drop-auto-scroll/element`
- `autoScrollForElements`
- `autoScrollWindowForElements`
- `@atlaskit/pragmatic-drag-and-drop-auto-scroll/external`
- `autoScrollForExternal`
- `autoScrollWindowForExternal`
- `@atlaskit/pragmatic-drag-and-drop-auto-scroll/text-selection`
- `autoScrollForTextSelection`
- `autoScrollWindowForTextSelection`
- Updated dependencies
## 2.1.0
### Minor Changes
- [#172374](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/172374)
[`4ca6346256c8a`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/4ca6346256c8a) -
Minor increase of time dampening duration. After lots of explorations, we have increased the value
to make it easier for people to avoid the impacts of rapid scroll speed spikes when lifting or
entering into a high scroll speed area.
## 2.0.0
### Major Changes
- [#170839](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/170839)
[`1534389dcb75b`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/1534389dcb75b) -
In order to improve clarity, we have renamed the `from*Edge` (eg `fromTopEdge`) argument
properties to `for*Edge` (eg `forTopEdge`) for the overflow scroller. If you are not using
overflow scrolling, there is nothing you need to do.
```diff
- fromTopEdge
+ forTopEdge
- fromRightEdge
+ forRightEdge
- fromBottomEdge
+ forBottomEdge
- fromLeftEdge
+ forLeftEdge
```
```diff
const unbind = unsafeOverflowAutoScrollForElements({
element,
getOverflow: () => ({
- fromTopEdge: {
+ forTopEdge: {
top: 6000,
right: 6000,
left: 6000,
},
- fromRightEdge: {
+ forRightEdge: {
top: 6000,
right: 6000,
bottom: 6000,
},
- fromBottomEdge: {
+ forBottomEdge: {
right: 6000,
bottom: 6000,
left: 6000,
},
- fromLeftEdge: {
+ forLeftEdge: {
top: 6000,
left: 6000,
bottom: 6000,
},
}),
});
```
We thought that `for*` more accurately represented what was being provided, as these are the
overflow definitions _for_ a defined edge.
We have also improved the types for `for*Edge` so that you do not need to provide redundant cross
axis information if you only want to overflow scroll on the main axis.
```diff
const unbind = unsafeOverflowAutoScrollForElements({
element,
getOverflow: () => ({
forTopEdge: {
top: 100,
+ // no longer need to pass `0` for the cross axis if you don't need it
- right: 0,
- left: 0,
},
forRightEdge: {
right: 100,
- top: 0,
- bottom: 0,
},
forBottomEdge: {
bottom: 100,
- right: 0,
- left: 0,
},
forLeftEdge: {
left: 100,
- top: 0,
- bottom: 0,
},
}),
});
```
When declaring overflow scrolling for an edge, you cannot provide how deep the scrolling should
occur into the element (that is defined by the "over element" overflow scroller). Providing
redundant information for an edge will now also give a type error rather than providing no
feedback.
```ts
const unbind = unsafeOverflowAutoScrollForElements({
element,
getOverflow: () => ({
forTopEdge: {
top: 100,
bottom: 30, // ❌ now a type error
},
forRightEdge: {
right: 100,
left: 10, // ❌ now a type error
},
forBottomEdge: {
bottom: 100,
top: 200, // ❌ now a type error
},
forLeftEdge: {
left: 100,
right: 20, // ❌ now a type error
},
}),
});
```
## 1.4.0
### Minor Changes
- [#116572](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/116572)
[`98c65e7ff719c`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/98c65e7ff719c) -
🍯 Introducing "the honey pot fix" which is an improved workaround for a
[painful browser bug](https://issues.chromium.org/issues/41129937).
**Background**
The browser bug causes the browser to think the users pointer is continually depressed at the
point that the user started a drag. This could lead to incorrect events being triggered, and
incorrect styles being applied to elements that the user is not currently over during a drag.
**Outcomes**
- Elements will no longer receive `MouseEvent`s (eg `"mouseenter"` and `"mouseleave"`) during a
drag (which is a violation of the
[drag and drop specification](https://html.spec.whatwg.org/multipage/dnd.html#drag-and-drop-processing-model))
- Elements will no longer apply `:hover` or `:active` styles during a drag. Previously consumers
would need to disable these style rules during a drag to prevent these styles being applied.
- Dramatically improved post drop performance. Our prior solution could require a noticeable delay
due to a large style recalculation after a drop.
### Patch Changes
- Updated dependencies
## 1.3.0
### Minor Changes
- [#95426](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/95426)
[`a58266bf88e6`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/a58266bf88e6) -
Adding axis locking functionality
```diff
+ // `getAllowedAxis` added to element, text selection and external auto scrollers
autoScrollForElements({
element: myElement,
+ getAllowedAxis: (args: ElementGetFeedbackArgs) => 'horizontal' | 'vertical' | 'all',
});
autoScrollWindowForElements({
+ getAllowedAxis: (args: WindowGetFeedbackArgs) => 'horizontal' | 'vertical' | 'all',
});
unsafeOverflowAutoScrollForElements({
+ getAllowedAxis?: (args: ElementGetFeedbackArgs) => AllowedAxis;
})
```
## 1.2.0
### Minor Changes
> `1.2.0` is deprecated on `npm` and should not be used. Shortly after release we decided to change
> this API
- [#94103](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/94103)
[`4e3fb63eb288`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/4e3fb63eb288) -
Added axis locking functionality.
```diff
autoScrollForElements({
element: myElement,
getConfiguration: () => ({
maxScrollSpeed: 'fast' | 'standard',
+ allowedAxis: 'horizontal' | 'vertical' | 'all',
}),
})
```
## 1.1.0
### Minor Changes
- [#94454](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/94454)
[`4b40eb010074`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/4b40eb010074) -
Exposing the unsafe overflow auto scroller for external drags
(`unsafeOverflowAutoScrollForExternal()`). This already existed, but it was not exposed publicly
🤦♂️.
```diff
import {unsafeOverflowAutoScrollForElements from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element';
import {unsafeOverflowAutoScrollForTextSelection} from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/text-selection';
+ import {unsafeOverflowAutoScrollForExternal} from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/external';
```
## 1.0.4
### Patch Changes
- [#94316](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/94316)
[`35fd5ed8e1d7`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/35fd5ed8e1d7) -
Upgrading internal dependency `bind-event-listener` to `@^3.0.0`
## 1.0.3
### Patch Changes
- [#84398](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/84398)
[`77694db987fc`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/77694db987fc) -
Public release of Pragmatic drag and drop documentation
## 1.0.2
### Patch Changes
- [#83702](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/83702)
[`4d9e25ab4eaa`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/4d9e25ab4eaa) -
Updating the descriptions of Pragmatic drag and drop packages, so they each provide a consistent
description to various consumers, and so they are consistently formed amongst each other.
- `package.json` `description`
- `README.md`
- Website documentation
## 1.0.1
### Patch Changes
- [#83116](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/83116)
[`8d4e99057fe0`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/8d4e99057fe0) -
Upgrade Typescript from `4.9.5` to `5.4.2`
## 1.0.0
### Major Changes
- [#70616](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/70616)
[`42e57ea65fee`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/42e57ea65fee) -
This is our first `major` release (`1.0`) for all Pragmatic drag and drop packages.
For a detailed explanation of these changes, and how to upgrade (automatically) to `1.0` please
see our
[1.0 upgrade guide](http://atlassian.design/components/pragmatic-drag-and-drop/core-package/upgrade-guides/upgrade-guide-for-1.0)
### Patch Changes
- Updated dependencies
## 0.8.1
### Patch Changes
- Updated dependencies
## 0.8.0
### Minor Changes
- [#57337](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/57337)
[`4ad3fa749a5c`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/4ad3fa749a5c) -
Adding the ability to increase the maximum automatic scroll speed.
```diff
autoScrollForElements({
element: myElement,
+ getConfiguration: () => ({maxScrollSpeed: 'fast' | 'standard'}),
})
```
`getConfiguration()` is a new _optional_ argument be used with all auto scrolling registration
functions:
- `autoScrollForElements`
- `autoScrollWindowForElements`
- `autoScrollForFiles`
- `autoScrollWindowForFiles`
- `unsafeOverflowForElements`
- `unsafeOverflowForFiles`
```ts
autoScrollForElements({
element: myElement,
getConfiguration: () => ({ maxScrollSpeed : 'fast' })
}),
```
We recommend using the default `"standard"` max scroll speed for most experiences. However, on
_some_ larger experiences, a faster max scroll speed (`"fast"`) can feel better.
## 0.7.0
### Minor Changes
- [#42774](https://bitbucket.org/atlassian/atlassian-frontend/pull-requests/42774)
[`66d9475437e`](https://bitbucket.org/atlassian/atlassian-frontend/commits/66d9475437e) - Internal
refactoring to improve clarity and safety
## 0.6.0
### Minor Changes
- [#42668](https://bitbucket.org/atlassian/atlassian-frontend/pull-requests/42668)
[`0a4e3f44ba3`](https://bitbucket.org/atlassian/atlassian-frontend/commits/0a4e3f44ba3) - We have
landed a few fixes for "overflow scrolling"
- Fix: Time dampening could be incorrectly reset when transitioning from "over element" auto
scrolling to "overflow" auto scrolling for certain element configurations.
- Fix: Parent "overflow scrolling" registrations could prevent overflow scrolling on children
elements, if the parent was registered first.
- Fix: "overflow scrolling" `canScroll() => false` would incorrectly opt out of "overflow
scrolling" for younger registrations.
## 0.5.0
### Minor Changes
- [#39935](https://bitbucket.org/atlassian/atlassian-frontend/pull-requests/39935)
[`20a91012629`](https://bitbucket.org/atlassian/atlassian-frontend/commits/20a91012629) - First
public release of this package. Please refer to documentation for usage and API information.
### Patch Changes
- Updated dependencies
## 0.4.0
### Minor Changes
- [#39303](https://bitbucket.org/atlassian/atlassian-frontend/pull-requests/39303)
[`a6d9f3bb566`](https://bitbucket.org/atlassian/atlassian-frontend/commits/a6d9f3bb566) - Adding
optional overflow scrolling API. API information shared directly with Trello
## 0.3.2
### Patch Changes
- Updated dependencies
## 0.3.1
### Patch Changes
- Updated dependencies
## 0.3.0
### Minor Changes
- [#38658](https://bitbucket.org/atlassian/atlassian-frontend/pull-requests/38658)
[`7803a90e9c6`](https://bitbucket.org/atlassian/atlassian-frontend/commits/7803a90e9c6) - This
change makes it so that distance dampening is based on the size of the hitbox and not the
container. Now that we clamp the size of the hitbox, our distance dampening needs to be based on
the size of the hitbox, and not the container.
## 0.2.0
### Minor Changes
- [#38630](https://bitbucket.org/atlassian/atlassian-frontend/pull-requests/38630)
[`5c643ce004d`](https://bitbucket.org/atlassian/atlassian-frontend/commits/5c643ce004d) - Limiting
the max size of auto scrolling hitboxes. This prevents large elements having giant auto scroll
hitboxes
## 0.1.0
### Minor Changes
- [#38525](https://bitbucket.org/atlassian/atlassian-frontend/pull-requests/38525)
[`693af8c5775`](https://bitbucket.org/atlassian/atlassian-frontend/commits/693af8c5775) - Early
release of our new optional drag and drop package for Pragmatic drag and drop. Package release is
only for early integration with Trello.
### Patch Changes
- Updated dependencies
================================================
FILE: packages/auto-scroll/LICENSE.md
================================================
Copyright 2023 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in
compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is
distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing permissions and limitations under the
License.
================================================
FILE: packages/auto-scroll/README.md
================================================
## Pragmatic drag and drop
An optional Pragmatic drag and drop package that enables automatic scrolling during a drag operation. This package works with any configuration of scrollable entities, and you can change the configuration of your scrollable entities in any way you like during a drag.
[📖 Documentation](https://atlassian.design/components/pragmatic-drag-and-drop/)
================================================
FILE: packages/auto-scroll/__tests__/playwright/over-element-smoke-test.spec.tsx
================================================
import invariant from 'tiny-invariant';
import { expect, test } from '@af/integration-testing';
test.describe('over element automatic scrolling', () => {
test('should scroll a scrollable element forwards', async ({ page }) => {
await page.visitExample('pragmatic-drag-and-drop', 'auto-scroll', 'over-element');
const columnTestId = 'column-0';
const card = page.getByTestId('column-0::item-0');
const column = page.getByTestId(columnTestId);
// first check: ensure the column is not scrolled yet
expect(await column.evaluate((element) => element.scrollTop)).toBe(0);
const columnRect = await column.boundingBox();
invariant(columnRect, 'Could not obtain bounding box from column');
await card.hover();
await page.mouse.down();
// Using 'move' rather than 'hover' as 'hover' also does it's own scrolling
await page.mouse.move(
columnRect.x + columnRect.width / 2,
// Going up a bit from the bottom so we know it is
// our auto scroller and not the browsers built in one
columnRect.y + columnRect.height - 30,
// having one step so we don't trigger the browsers built in auto scroller
{ steps: 1 },
);
await page.waitForFunction((testId) => {
const element = document.querySelector(`[data-testid="${testId}"]`);
if (!element) {
throw new Error(`Unable to find element with test id "${testId}"`);
}
return element.scrollTop > 0;
}, columnTestId);
});
});
================================================
FILE: packages/auto-scroll/__tests__/playwright/unsafe-overflow-smoke-test.spec.tsx
================================================
import invariant from 'tiny-invariant';
import { expect, test } from '@af/integration-testing';
test.describe('over element automatic scrolling', () => {
test('should scroll a scrollable element forwards', async ({ page }) => {
await page.visitExample('pragmatic-drag-and-drop', 'auto-scroll', 'unsafe-overflow-only');
const columnTestId = 'column-0';
const card = page.getByTestId('column-0::item-0');
const column = page.getByTestId(columnTestId);
// first check: ensure the column is not scrolled yet
expect(await column.evaluate((element) => element.scrollTop)).toBe(0);
const columnRect = await column.boundingBox();
invariant(columnRect, 'Could not obtain bounding box from column');
await card.hover();
await page.mouse.down();
// Using 'move' rather than 'hover' as 'hover' also does it's own scrolling
await page.mouse.move(
columnRect.x + columnRect.width / 2,
// Going below the column a bit so we know we are triggering the overflow scroller
columnRect.y + columnRect.height + 100,
{ steps: 20 },
);
await page.waitForFunction((testId) => {
const element = document.querySelector(`[data-testid="${testId}"]`);
if (!element) {
throw new Error(`Unable to find element with test id "${testId}"`);
}
return element.scrollTop > 0;
}, columnTestId);
});
});
================================================
FILE: packages/auto-scroll/__tests__/unit/_util.ts
================================================
import { fireEvent } from '@testing-library/dom';
import invariant from 'tiny-invariant';
import {
type CleanupFn,
type DragLocation,
type DragLocationHistory,
type DropTargetRecord,
type Input,
type Position,
} from '@atlaskit/pragmatic-drag-and-drop/types';
import { type Axis, type Edge } from '../../src/internal-types';
export function getDefaultInput(overrides: Partial = {}): Input {
const defaults: Input = {
// user input
altKey: false,
button: 0,
buttons: 0,
ctrlKey: false,
metaKey: false,
shiftKey: false,
// coordinates
clientX: 0,
clientY: 0,
pageX: 0,
pageY: 0,
};
return {
...defaults,
...overrides,
};
}
export function appendToBody(...elements: Element[]): CleanupFn {
elements.forEach((element) => {
document.body.appendChild(element);
});
return function removeFromBody() {
elements.forEach((element) => {
document.body.removeChild(element);
});
};
}
export function getEmptyHistory(input: Input = getDefaultInput()): DragLocationHistory {
const noWhere: DragLocation = {
input,
dropTargets: [],
};
return {
initial: noWhere,
previous: {
dropTargets: noWhere.dropTargets,
},
current: noWhere,
};
}
export function getInitialHistory(
dropTargets: DropTargetRecord[],
input: Input = getDefaultInput(),
): DragLocationHistory {
const location: DragLocation = {
input,
dropTargets,
};
return {
initial: location,
current: location,
previous: {
dropTargets: [],
},
};
}
export function setBoundingClientRect(el: HTMLElement, rect: DOMRect): CleanupFn {
const original = el.getBoundingClientRect;
el.getBoundingClientRect = () => rect;
return () => {
el.getBoundingClientRect = original;
};
}
export function tryGetRect(box: Partial[0]>): DOMRect {
const { top, right, bottom, left } = box;
invariant(typeof top === 'number');
invariant(typeof right === 'number');
invariant(typeof bottom === 'number');
invariant(typeof left === 'number');
return getRect({ top, right, bottom, left });
}
export function getRect(box: {
top: number;
right: number;
bottom: number;
left: number;
}): DOMRect {
return {
top: box.top,
right: box.right,
bottom: box.bottom,
left: box.left,
// calculated
height: box.bottom - box.top,
width: box.right - box.left,
x: box.left,
y: box.top,
toJSON: function () {
return JSON.stringify(this);
},
};
}
// usage: const [A, B, C, D, F] = getElements();
export function getElements(tagName: keyof HTMLElementTagNameMap = 'div'): Iterable {
const iterator = {
next() {
return {
done: false,
value: document.createElement(tagName),
};
},
[Symbol.iterator]() {
return iterator;
},
};
return iterator;
}
/**
* Returns a connected tree of elements
* `[grandChild, parent, grandParent]`
*/
export function getBubbleOrderedTree(
tagName: keyof HTMLElementTagNameMap = 'div',
): Iterable {
let last: HTMLElement | null;
const iterator = {
next() {
const element = document.createElement(tagName);
if (last) {
element.appendChild(last);
}
last = element;
return {
done: false,
value: element,
};
},
[Symbol.iterator]() {
return iterator;
},
};
return iterator;
}
export const userEvent = {
lift(target: HTMLElement, input?: Partial): void {
const final: Input = { ...getDefaultInput(), ...input };
// accurate representation of events:
firePointer.down(target, final);
firePointer.move(target, { ...final, clientX: final.clientX + 10 });
// will fire `onGenerateDragPreview`
fireEvent.dragStart(target, final);
firePointer.cancel(target, final);
// after an animation frame we fire `onDragStart`
advanceTimersToNextFrame();
},
drop(target: Element): void {
fireEvent.drop(target);
},
cancel(target: Element = document.body): void {
// A "cancel" (drop on nothing, or pressing "Escape") will
// cause a "dragleave" and then a "dragend"
fireEvent.dragLeave(target);
fireEvent.dragEnd(target);
},
leaveWindow(): void {
fireEvent.dragLeave(document.documentElement, { relatedTarget: null });
},
startExternalDrag({
types,
target = document.body,
}: {
types: string[];
target?: Element;
}): void {
const event = new DragEvent('dragenter', {
cancelable: true,
bubbles: true,
});
for (const type of types) {
// @ts-expect-error
event.dataTransfer?.types.push(type);
}
target.dispatchEvent(event);
advanceTimersToNextFrame();
},
rougePointerMoves(): void {
// first 20 are ignored due to firefox issue
// 21st pointermove will cancel a drag
for (let i = 0; i < 21; i++) {
fireEvent.pointerMove(document.body);
}
},
};
/** Cleanup function to unbind all event listeners */
export async function reset(): Promise {
// cleanup any pending drags
fireEvent.dragEnd(window);
// cleanup honey pot fix
fireEvent.pointerMove(window);
}
export function getBubbleOrderedPath(path: Element[]): Element[] {
const last = path[path.length - 1];
// will happen if you pass in an empty array
if (!last) {
return path;
}
// exit condition: no more parents
if (!last.parentElement) {
return path;
}
// bubble ordered
return getBubbleOrderedPath([...path, last.parentElement]);
}
export function setElementFromPointWithPath(path: Element[]): CleanupFn {
const originalElementFromPoint = document.elementFromPoint;
const originalElementsFromPoint = document.elementsFromPoint;
document.elementsFromPoint = () => path;
document.elementFromPoint = () => path[0] ?? null;
return () => {
document.elementFromPoint = originalElementFromPoint;
document.elementsFromPoint = originalElementsFromPoint;
};
}
export function setElementFromPoint(element: Element | null): CleanupFn {
const path = element ? getBubbleOrderedPath([element]) : [];
return setElementFromPointWithPath(path);
}
/** Release a pending scrollBy (they are scheduled for the next task) */
export function stepScrollBy(): void {
jest.advanceTimersByTime(1);
}
let startTime: number | null = null;
/** Record the initial (mocked) system start time
*
* This is no longer needed once `jest.advanceTimersToNextFrame()` is available
* https://github.com/jestjs/jest/pull/14598
*/
export function setStartSystemTime(): void {
startTime = Date.now();
}
/** Step forward a single animation frame
*
* This is no longer needed once `jest.advanceTimersToNextFrame()` is available
* https://github.com/jestjs/jest/pull/14598
*/
export function advanceTimersToNextFrame(): void {
invariant(
startTime != null,
'Must call `setStartSystemTime` before using `advanceTimersToNextFrame()`',
);
// Stealing logic from sinon fake timers
// https://github.com/sinonjs/fake-timers/blob/fc312b9ce96a4ea2c7e60bb0cccd2c604b75cdbd/src/fake-timers-src.js#L1102-L1105
const timePassedInFrame = (Date.now() - startTime) % 16;
const timeToNextFrame = 16 - timePassedInFrame;
jest.advanceTimersByTime(timeToNextFrame);
}
type BasicElementArgs = {
width: number;
height: number;
x?: number;
y?: number;
id?: string;
};
export function setupNestedScrollContainers(bubbleOrdered: BasicElementArgs[]): HTMLElement[] {
// argument validation
for (let i = 0; i < bubbleOrdered.length - 1; i++) {
const current = bubbleOrdered[i];
const parent = bubbleOrdered[i + 1];
invariant(
current.height >= parent.height,
`validation error: a child's height (${current.height}) was bigger than it's parent (${current.height})`,
);
invariant(
current.width >= parent.width,
`validation error: a child's width (${current.width}) was bigger than it's parent (${current.width})`,
);
}
type Item = { args: BasicElementArgs; element: HTMLElement };
// Making all elements first so we can link everything correctly.
const items: Item[] = bubbleOrdered.map(
(args): Item => ({
args,
element: document.createElement('div'),
}),
);
for (let i = 0; i < items.length; i++) {
const item = items[i];
const parent: Item | undefined = items[i + 1];
const isInnerMost = i === 0;
// Establish parent relationship
if (parent) {
parent.element.appendChild(item.element);
}
// helpful for logging
item.element.id = item.args.id ?? `element-index-${i}-in-${items.length - 1}`;
Object.assign(item.element.style, {
// enabling scrolling in both directions if not the inner most
overflowX: isInnerMost ? undefined : 'auto',
overflowY: isInnerMost ? undefined : 'auto',
height: `${item.args.height}px`,
width: `${item.args.width}px`,
});
item.element.getBoundingClientRect = () => {
// for simplicity, all elements are currently drawn from 0,0
const start: Position = {
x: item.args.x ?? 0,
y: item.args.y ?? 0,
};
const box = DOMRect.fromRect({
x: start.x,
y: start.y,
width: item.args.width,
height: item.args.height,
});
if (!parent) {
return box;
}
// The border box of an element will be shifted by:
// 1. the scroll of a parent
// 2. changes in the x/y of the parent
const parentRect = parent.element.getBoundingClientRect();
// What is the difference between the original parent.getBoundingClientRect() and where it is now?
// Given that we know an element is always starting at `x: 0, y: 0`, the value of `x` and `y` can
// only have changed if a parent was scrolled
const parentChange: Position = {
x: parentRect.x,
y: parentRect.y,
};
const shiftedByParents = DOMRect.fromRect({
x: box.x - parent.element.scrollLeft + parentChange.x,
y: box.y - parent.element.scrollTop + parentChange.y,
width: box.width,
height: box.height,
});
return shiftedByParents;
};
// scroll properties will be based on children
// TODO: could find the maximum height of any child
const child: Item | undefined = items[i - 1];
Object.defineProperties(item.element, {
scrollHeight: {
value: child ? child.args.height : item.args.height,
writable: false,
},
scrollWidth: {
value: child ? child.args.width : item.args.width,
writable: false,
},
});
// Note: these only measure paddingBox, but we are
// not currently using borders so we are all good
Object.defineProperties(item.element, {
clientHeight: {
value: item.args.height,
writable: false,
},
clientWidth: {
value: item.args.width,
writable: false,
},
});
}
return items.map((item) => item.element);
}
export function setupBasicScrollContainer({
scrollContainer = { width: 1000, height: 1000 },
child = { width: 10000, height: 10000 },
}: {
scrollContainer?: { width: number; height: number };
child?: { width: number; height: number };
} = {}): { parentScrollContainer: HTMLElement; child: HTMLElement } {
const elements = setupNestedScrollContainers([child, scrollContainer]);
return {
child: elements[0],
parentScrollContainer: elements[1],
};
}
export type Point = Position & { label: string };
export function getInsidePoints(rect: DOMRect): Point[] {
return [
{ label: 'top left', x: rect.left, y: rect.top },
{ label: 'top right', x: rect.right, y: rect.top },
{ label: 'bottom right', x: rect.right, y: rect.bottom },
{ label: 'bottom left', x: rect.left, y: rect.bottom },
{
label: 'center',
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
},
];
}
export function getOutsidePoints(rect: DOMRect): Point[] {
return [
{ label: 'left of top left', x: rect.left - 1, y: rect.top },
{ label: 'top of top left', x: rect.left, y: rect.top - 1 },
{ label: 'right of top right', x: rect.right + 1, y: rect.top },
{ label: 'top of top right', x: rect.right, y: rect.top - 1 },
{ label: 'right of bottom right', x: rect.right + 1, y: rect.bottom },
{ label: 'bottom of bottom right', x: rect.right, y: rect.bottom + 1 },
{ label: 'left of bottom left', x: rect.left - 1, y: rect.bottom },
{ label: 'bottom of bottom left', x: rect.left, y: rect.bottom + 1 },
];
}
export const mainAxisForSide: { [T in Edge]: Axis } = {
bottom: 'vertical',
top: 'vertical',
left: 'horizontal',
right: 'horizontal',
};
export type AxisScroll = Record;
export type AxisMovement = Record;
export type Event = { type: string } & Partial;
export type Scenario = {
label: string;
startPosition: Position;
endPosition: Position;
expectedMovement: AxisMovement;
};
export function getScenarios(rect: DOMRect, offset: number = 0): Scenario[] {
return [
{
label: 'top left',
startPosition: {
x: rect.left,
y: rect.top,
},
endPosition: {
x: rect.left - offset,
y: rect.top - offset,
},
expectedMovement: { horizontal: true, vertical: true },
},
{
label: 'top',
startPosition: {
x: rect.left + rect.width / 2,
y: rect.top,
},
endPosition: {
x: rect.left + rect.width / 2,
y: rect.top - offset,
},
expectedMovement: { horizontal: false, vertical: true },
},
{
label: 'top right',
startPosition: {
x: rect.right,
y: rect.top,
},
endPosition: {
x: rect.right + offset,
y: rect.top - offset,
},
expectedMovement: { horizontal: true, vertical: true },
},
{
label: 'right',
startPosition: {
x: rect.right,
y: rect.top + rect.height / 2,
},
endPosition: {
x: rect.right + offset,
y: rect.top + rect.height / 2,
},
expectedMovement: { horizontal: true, vertical: false },
},
{
label: 'bottom right',
startPosition: {
x: rect.right,
y: rect.bottom,
},
endPosition: {
x: rect.right + offset,
y: rect.bottom + offset,
},
expectedMovement: { horizontal: true, vertical: true },
},
{
label: 'bottom',
startPosition: {
x: rect.left + rect.width / 2,
y: rect.bottom,
},
endPosition: {
x: rect.left + rect.width / 2,
y: rect.bottom + offset,
},
expectedMovement: { horizontal: false, vertical: true },
},
{
label: 'bottom left',
startPosition: {
x: rect.left,
y: rect.bottom,
},
endPosition: {
x: rect.left - offset,
y: rect.bottom + offset,
},
expectedMovement: { horizontal: true, vertical: true },
},
{
label: 'left',
startPosition: {
x: rect.left,
y: rect.top + rect.height / 2,
},
endPosition: {
x: rect.left - offset,
y: rect.top + rect.height / 2,
},
expectedMovement: { horizontal: true, vertical: false },
},
];
}
export function getAxisScroll(container: HTMLElement): AxisScroll {
return {
horizontal: container.scrollLeft,
vertical: container.scrollTop,
};
}
export function hasAxisScrolled(container: HTMLElement, previousScroll: AxisScroll): AxisMovement {
return {
horizontal: container.scrollLeft !== previousScroll.horizontal,
vertical: container.scrollTop !== previousScroll.vertical,
};
}
export function isExpectingScrollEvent(movement: AxisMovement): boolean {
return Object.values(movement).some(Boolean);
}
export function getExpectedEvents(movement: AxisMovement): Event[] {
return isExpectingScrollEvent(movement)
? [
{
type: 'scroll event',
...movement,
},
]
: [];
}
export const firePointer = (() => {
type TTarget = Element | Window | Document;
function makeDispatch(eventName: string) {
return function dispatch(target: TTarget, input: Partial = {}): void {
const inputWithDefaults = {
...getDefaultInput(),
...input,
};
target.dispatchEvent(
new MouseEvent(eventName, {
bubbles: true,
cancelable: true,
...inputWithDefaults,
}),
);
};
}
return {
down: makeDispatch('pointerdown'),
up: makeDispatch('pointerup'),
move: makeDispatch('pointermove'),
cancel: makeDispatch('pointercancel'),
};
})();
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/allowed-axis.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { autoScrollForElements } from '../../../src/entry-point/element';
import { type AllowedAxis } from '../../../src/internal-types';
import {
advanceTimersToNextFrame,
appendToBody,
type AxisScroll,
type Event,
getAxisScroll,
getExpectedEvents,
getScenarios,
hasAxisScrolled,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
setupNestedScrollContainers,
stepScrollBy,
userEvent,
} from '../_util';
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
describe('allowed axis', () => {
const { child, parentScrollContainer: parent } = setupBasicScrollContainer();
const originalScrollTop = parent.scrollTop;
const originalScrollLeft = parent.scrollLeft;
afterEach(() => {
parent.scrollTop = originalScrollTop;
parent.scrollLeft = originalScrollLeft;
});
getScenarios(parent.getBoundingClientRect()).forEach(
({ label, startPosition, expectedMovement }) => {
it(`should only scroll on axis that are allowed - ${label}`, () => {
const events: Event[] = [];
let allowedAxis: AllowedAxis = 'all';
let axisScroll: AxisScroll;
const cleanup = combine(
appendToBody(parent),
draggable({
element: child,
onDragStart: () => events.push({ type: 'draggable:start' }),
}),
dropTargetForElements({
element: child,
onDragStart: () => events.push({ type: 'dropTarget:start' }),
}),
autoScrollForElements({
element: parent,
getAllowedAxis: () => allowedAxis,
}),
setElementFromPoint(child),
bind(parent, {
type: 'scroll',
listener: (event) => {
events.push({
type: 'scroll event',
...hasAxisScrolled(parent, axisScroll),
});
axisScroll = getAxisScroll(parent);
},
}),
);
// Scroll container is now looking over the center of the element
parent.scrollTop = 500;
parent.scrollLeft = 500;
axisScroll = getAxisScroll(parent);
userEvent.lift(child, {
clientX: startPosition.x,
clientY: startPosition.y,
});
expect(events).toEqual([{ type: 'draggable:start' }, { type: 'dropTarget:start' }]);
events.length = 0;
// First frame: allowedAxis is all.
// Expecting no scroll to occur.
// We don't know what the scroll speed should be until a single frame has passed.
advanceTimersToNextFrame();
stepScrollBy();
expect(events).toEqual([]);
// Second frame: allowedAxis is all.
// Expecting a scroll to occur on expected axis.
advanceTimersToNextFrame();
stepScrollBy();
const movement = { ...expectedMovement };
const expectedEvents = getExpectedEvents(movement);
expect(events).toEqual(expectedEvents);
events.length = 0;
// Third frame: allowedAxis is vertical.
// Expecting a scroll to occur on expected axis, except horizontal.
// If neither are expected, expect no scroll.
allowedAxis = 'vertical';
advanceTimersToNextFrame();
stepScrollBy();
const verticalMovement = {
...expectedMovement,
horizontal: false,
};
const expectedVerticalEvents = getExpectedEvents(verticalMovement);
expect(events).toEqual(expectedVerticalEvents);
events.length = 0;
// Fourth frame: allowedAxis is horizontal.
// Expecting a scroll to occur on expected axis, except vertical.
// If neither are expected, expect no scroll.
allowedAxis = 'horizontal';
advanceTimersToNextFrame();
stepScrollBy();
const horizontalMovement = {
...expectedMovement,
vertical: false,
};
const expectedHorizontalEvents = getExpectedEvents(horizontalMovement);
expect(events).toEqual(expectedHorizontalEvents);
cleanup();
});
},
);
});
it('should scroll on available parent axis if child axis are not allowed', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
{ width: 10000, height: 10000 },
{ width: 1000, height: 1000 },
{ width: 1000, height: 1000 },
]);
const events: Event[] = [];
let parentAllowedAxis: AllowedAxis = 'all';
let parentAxisScroll: AxisScroll;
let grandParentAllowedAxis: AllowedAxis = 'all';
let grandParentAxisScroll: AxisScroll;
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => events.push({ type: 'draggable:start' }),
}),
dropTargetForElements({
element: child,
onDragStart: () => events.push({ type: 'dropTarget:start' }),
}),
dropTargetForElements({
element: parent,
onDragStart: () => events.push({ type: 'parent:start' }),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => events.push({ type: 'grandParent:start' }),
}),
autoScrollForElements({
element: parent,
getAllowedAxis: () => parentAllowedAxis,
}),
autoScrollForElements({
element: grandParent,
getAllowedAxis: () => grandParentAllowedAxis,
}),
setElementFromPoint(child),
bind(parent, {
type: 'scroll',
listener: (event) => {
events.push({
type: 'parent:scroll',
...hasAxisScrolled(parent, parentAxisScroll),
});
parentAxisScroll = getAxisScroll(parent);
},
}),
bind(grandParent, {
type: 'scroll',
listener: (event) => {
events.push({
type: 'grandParent:scroll',
...hasAxisScrolled(grandParent, grandParentAxisScroll),
});
grandParentAxisScroll = getAxisScroll(grandParent);
},
}),
);
// Set some initial scroll on the scroll containers
parent.scrollTop = 60;
parent.scrollLeft = 60;
parentAxisScroll = getAxisScroll(parent);
grandParent.scrollTop = 120;
grandParent.scrollLeft = 120;
grandParentAxisScroll = getAxisScroll(grandParent);
// Lifting the shared top left corner
userEvent.lift(child, {
clientX: grandParent.getBoundingClientRect().left,
clientY: grandParent.getBoundingClientRect().top,
});
expect(events).toEqual([
{ type: 'draggable:start' },
{ type: 'dropTarget:start' },
{ type: 'parent:start' },
{ type: 'grandParent:start' },
]);
events.length = 0;
// First frame: parent allowedAxis is all, grandparent allowedAxis is all.
// Expecting no scroll to occur.
// We don't know what the scroll speed should be until a single frame has passed.
advanceTimersToNextFrame();
stepScrollBy();
expect(events).toEqual([]);
// Second frame: parent allowedAxis is all, grandparent allowedAxis is all.
// Expecting a scroll to occur on both parent axis.
advanceTimersToNextFrame();
stepScrollBy();
expect(events).toEqual([{ type: 'parent:scroll', horizontal: true, vertical: true }]);
events.length = 0;
// Third frame: parent allowedAxis is vertical, grandparent allowedAxis is all.
// Expecting a scroll to occur on parent vertical axis, but not horizontal.
// Expecting a scroll to occur on grandparent horizontal axis, but not vertical.
parentAllowedAxis = 'vertical';
grandParentAllowedAxis = 'all';
advanceTimersToNextFrame();
stepScrollBy();
expect(events).toEqual([
{ type: 'parent:scroll', horizontal: false, vertical: true },
{ type: 'grandParent:scroll', horizontal: true, vertical: false },
]);
events.length = 0;
// Fourth frame: parent allowedAxis is horizontal, grandparent allowedAxis is all.
// Expecting a scroll to occur on parent horizontal axis, but not vertical.
// Expecting a scroll to occur on grandparent vertical axis, but not horizontal.
parentAllowedAxis = 'horizontal';
grandParentAllowedAxis = 'all';
advanceTimersToNextFrame();
stepScrollBy();
expect(events).toEqual([
{ type: 'parent:scroll', horizontal: true, vertical: false },
{ type: 'grandParent:scroll', horizontal: false, vertical: true },
]);
events.length = 0;
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/can-scroll.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { autoScrollForElements } from '../../../src/entry-point/element';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
beforeEach(reset);
it('should not scroll scroll containers that have canScroll: () => false', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
let isAutoScrollingAllowed: boolean = true;
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
}),
autoScrollForElements({
element: parentScrollContainer,
canScroll: () => isAutoScrollingAllowed,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push('scroll event'),
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = 500;
parentScrollContainer.scrollLeft = 500;
userEvent.lift(child, { clientX: 1, clientY: 1 });
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: an auto scroll will occur
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// disabling auto scrolling for the third frame
// expecting no scroll will occur
isAutoScrollingAllowed = false;
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// enabling auto scrolling for the third frame
// expecting a scroll to occur
isAutoScrollingAllowed = true;
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/distance-dampening.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { autoScrollForElements } from '../../../src/entry-point/element';
import { type Axis, type Side } from '../../../src/internal-types';
import { axisLookup } from '../../../src/shared/axis';
import { getInternalConfig } from '../../../src/shared/configuration';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
tryGetRect,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
const defaultConfig = getInternalConfig();
const maxScrollPerFrame = defaultConfig.maxPixelScrollPerSecond / 60;
type Scenario = {
side: Side;
axis: Axis;
};
const scenarios: Scenario[] = [
{
axis: 'vertical',
side: 'start',
},
{
axis: 'vertical',
side: 'end',
},
{
axis: 'horizontal',
side: 'start',
},
{
axis: 'horizontal',
side: 'end',
},
];
scenarios.forEach(({ axis, side }) => {
const scrollProperty = axis === 'vertical' ? 'scrollTop' : 'scrollLeft';
const mainAxisClientPoint = axis === 'vertical' ? 'clientY' : 'clientX';
const crossAxisClientPoint = mainAxisClientPoint === 'clientY' ? 'clientX' : 'clientY';
const { mainAxis, crossAxis } = axisLookup[axis];
it(`should dampen acceleration based on the distance away from an edge [axis: ${axis}, side: ${side}]`, () => {
const { parentScrollContainer, child } = setupBasicScrollContainer({
scrollContainer: { width: 1000, height: 1000 },
// giving us enough room to slowly move down through the hitbox
child: { width: 20000, height: 20000 },
});
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// setting initial scroll
parentScrollContainer[scrollProperty] = child.getBoundingClientRect()[mainAxis.size] / 2;
const parentRect = parentScrollContainer.getBoundingClientRect();
userEvent.lift(child, {
// start or end point on main axis
[mainAxisClientPoint]: parentRect[mainAxis[side]],
// half way point
[crossAxisClientPoint]: parentRect[crossAxis.start] + parentRect[crossAxis.size] / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// on second frame we get our first auto scroll
{
const before = parentScrollContainer[scrollProperty];
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer[scrollProperty];
// end side = scroll amount will increase
// start side = scroll amount will decrease
expect(after - before).toBe(side === 'end' ? 1 : -1);
}
// fast forward so there is no more time dampening
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
ordered.length = 0;
// now expecting to be moving at the maximum speed
const maxScrollInDirection = side === 'end' ? maxScrollPerFrame : -maxScrollPerFrame;
{
const before = parentScrollContainer[scrollProperty];
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer[scrollProperty];
// end side = scroll amount will increase
// start side = scroll amount will decrease
expect(after - before).toBe(maxScrollInDirection);
}
// Okay, we now know that the time dampening is finished
// Let's start our actual test! 😅
const autoScrollHitbox = tryGetRect({
[crossAxis.start]: parentRect[crossAxis.start],
[crossAxis.end]: parentRect[crossAxis.end],
[mainAxis.start]:
side === 'start'
? parentRect[mainAxis.start]
: parentRect[mainAxis.end] - defaultConfig.maxMainAxisHitboxSize,
[mainAxis.end]:
side === 'start'
? parentRect[mainAxis.start] + defaultConfig.maxMainAxisHitboxSize
: parentRect[mainAxis.end],
});
// 1. scroll up to the `startHitboxAtPercentageRemainingOfElement`
// - expect scroll to get bigger as we get closer to edge
const maxSpeedBuffer =
autoScrollHitbox[mainAxis.size] *
defaultConfig.maxScrollAtPercentageRemainingOfHitbox[mainAxis[side]];
// we are currently at the max scroll speed
let previousChange = maxScrollInDirection;
// side: 'start' → moving from the end of the hitbox upwards
// side: 'end' → moving from the start of the hitbox downwards
let currentMainAxisClientPoint =
side === 'start' ? autoScrollHitbox[mainAxis.end] : autoScrollHitbox[mainAxis.start];
const casesHit = {
first: false,
accelerating: [] as number[],
inMaxSpeedBuffer: false,
};
// Move through the hitbox
// side: 'start' → moving from the end of the hitbox upwards
// side: 'end' → moving from the start of the hitbox downwards
while (
side === 'start'
? autoScrollHitbox[mainAxis.start] <= currentMainAxisClientPoint
: currentMainAxisClientPoint <= autoScrollHitbox[mainAxis.end]
) {
fireEvent.dragOver(child, {
[mainAxisClientPoint]: currentMainAxisClientPoint,
[crossAxisClientPoint]: parentRect[crossAxis.start] + parentRect[crossAxis.size] / 2,
});
// the new drag location will not be picked up until the second frame
// after a user input change.
// frame 1: dragOver is throttled
{
const before = parentScrollContainer[scrollProperty];
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer[scrollProperty];
const diff = after - before;
// diff has not changed as we are still using the old input for this frame
expect(previousChange).toBe(diff);
}
// second frame: a scroll should occur based on the updated input
{
const before = parentScrollContainer[scrollProperty];
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer[scrollProperty];
const diff = after - before;
const situation = (() => {
if (!casesHit.first) {
return 'first';
}
// accelerating forwards
if (
side === 'end' &&
currentMainAxisClientPoint < autoScrollHitbox[mainAxis.end] - maxSpeedBuffer
) {
return 'accelerating';
}
// accelerating backwards
if (
side === 'start' &&
currentMainAxisClientPoint > autoScrollHitbox[mainAxis.start] + maxSpeedBuffer
) {
return 'accelerating';
}
return 'in-max-speed-buffer';
})();
// first hit: starting from the smallest scroll amount
if (situation === 'first') {
// side: start → scrolling backwards
// side: start → scrolling forwards
expect(diff).toBe(side === 'start' ? -1 : 1);
casesHit.first = true;
// when the acceleration percentage is less than 1,
// we will roll up to the minimum scroll change of 1px
} else if (situation === 'accelerating') {
casesHit.accelerating.push(diff);
// Using 'or equal to' because scrolls under 1% can result in the minimum scroll of 1px
// There is an assertion outside of this loop to ensure that an acceleration occurred
// scrolling forwards: scroll value is growing
if (side === 'end') {
expect(diff).toBeGreaterThanOrEqual(previousChange);
} else {
// scrolling forwards: scroll value is shrinking
expect(diff).toBeLessThanOrEqual(previousChange);
}
// third case: we are in the max speed buffer
} else {
casesHit.inMaxSpeedBuffer = true;
expect(diff).toBe(maxScrollInDirection);
}
previousChange = diff;
// side: end → scrolling forwards and moving forwards through hitbox
if (side === 'end') {
currentMainAxisClientPoint++;
// side: start → scrolling backwards and moving forwards through hitbox
} else {
currentMainAxisClientPoint--;
}
}
}
expect(casesHit.first).toBe(true);
expect(casesHit.accelerating.length).toBeGreaterThan(0);
expect(casesHit.inMaxSpeedBuffer).toBe(true);
// asserting that acceleration occurred, and that we where not just on a single speed
{
const uniques = Array.from(new Set(casesHit.accelerating.map((value) => Math.round(value))));
const expected = Array.from({ length: maxScrollPerFrame }, (_, index) => {
const value = index + 1;
return side === 'end' ? value : -value;
});
expect(uniques).toEqual(expected);
}
cleanup();
});
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/hitbox.spec.ts
================================================
import { bind } from 'bind-event-listener';
import invariant from 'tiny-invariant';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { autoScrollForElements } from '../../../src/entry-point/element';
import type { Axis, Side } from '../../../src/internal-types';
import { axisLookup } from '../../../src/shared/axis';
import { getInternalConfig } from '../../../src/shared/configuration';
import {
advanceTimersToNextFrame,
appendToBody,
getInsidePoints,
getOutsidePoints,
getRect,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
type Scenario = {
side: Side;
hitbox: DOMRect;
axis: Axis;
};
type Group = {
label: string;
child: HTMLElement;
parentScrollContainer: HTMLElement;
scenarios: Scenario[];
};
const defaultConfig = getInternalConfig();
const smallGroup: Group = (() => {
const { child, parentScrollContainer } = setupBasicScrollContainer({
// where a hitbox would be less than `defaultConfig.max
scrollContainer: { width: 400, height: 400 },
child: { width: 100000, height: 100000 },
});
const rect = parentScrollContainer.getBoundingClientRect();
const scenarios: Scenario[] = [
{
side: 'start',
axis: 'vertical',
hitbox: getRect({
top: rect.top,
left: rect.left,
bottom:
rect.top + rect.height * defaultConfig.startHitboxAtPercentageRemainingOfElement['top'],
right: rect.right,
}),
},
{
side: 'end',
axis: 'vertical',
hitbox: getRect({
top:
rect.bottom -
rect.height * defaultConfig.startHitboxAtPercentageRemainingOfElement['bottom'],
left: rect.left,
bottom: rect.bottom,
right: rect.right,
}),
},
{
side: 'start',
axis: 'horizontal',
hitbox: getRect({
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right:
rect.left + rect.width * defaultConfig.startHitboxAtPercentageRemainingOfElement['top'],
}),
},
{
side: 'end',
axis: 'horizontal',
hitbox: getRect({
top: rect.top,
left:
rect.right -
rect.width * defaultConfig.startHitboxAtPercentageRemainingOfElement['bottom'],
bottom: rect.bottom,
right: rect.right,
}),
},
];
// validating all hitboxes are less than 200px in size
scenarios.forEach((scenario) => {
const { mainAxis } = axisLookup[scenario.axis];
const size =
scenario.hitbox[mainAxis.size] *
defaultConfig.startHitboxAtPercentageRemainingOfElement[mainAxis[scenario.side]];
invariant(
size < defaultConfig.maxMainAxisHitboxSize,
'Expected hitbox to be less than the max hitbox size',
);
});
return {
label: 'Small scroll container',
scenarios,
child,
parentScrollContainer,
};
})();
const largeGroup: Group = (() => {
const { child, parentScrollContainer } = setupBasicScrollContainer({
scrollContainer: { width: 10000, height: 10000 },
child: { width: 100000, height: 100000 },
});
const rect = parentScrollContainer.getBoundingClientRect();
const scenarios: Scenario[] = [
{
side: 'start',
axis: 'vertical',
hitbox: getRect({
top: rect.top,
left: rect.left,
bottom: rect.top + defaultConfig.maxMainAxisHitboxSize,
right: rect.right,
}),
},
{
side: 'end',
axis: 'vertical',
hitbox: getRect({
top: rect.bottom - defaultConfig.maxMainAxisHitboxSize,
left: rect.left,
bottom: rect.bottom,
right: rect.right,
}),
},
{
side: 'start',
axis: 'horizontal',
hitbox: getRect({
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.left + defaultConfig.maxMainAxisHitboxSize,
}),
},
{
side: 'end',
axis: 'horizontal',
hitbox: getRect({
top: rect.top,
left: rect.right - defaultConfig.maxMainAxisHitboxSize,
bottom: rect.bottom,
right: rect.right,
}),
},
];
// validating all hitboxes would be greater than `defaultConfig.maxMainAxisHitboxSize`
// (and so will be capped to defaultConfig.maxMainAxisHitboxSize later)
scenarios.forEach((scenario) => {
const { mainAxis } = axisLookup[scenario.axis];
const potentialHitboxSize =
rect[mainAxis.size] *
defaultConfig.startHitboxAtPercentageRemainingOfElement[mainAxis[scenario.side]];
invariant(
potentialHitboxSize > defaultConfig.maxMainAxisHitboxSize,
`hitbox size: (${potentialHitboxSize}) is not > (${defaultConfig.maxMainAxisHitboxSize})`,
);
});
return {
label: `Large scroll container (will be capped to ${defaultConfig.maxMainAxisHitboxSize}px hitbox)`,
scenarios,
child,
parentScrollContainer,
};
})();
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
[smallGroup, largeGroup].forEach(({ label, parentScrollContainer, child, scenarios }) => {
describe(`Group: ${label}`, () => {
const originalScrollTop = parentScrollContainer.scrollTop;
const originalScrollLeft = parentScrollContainer.scrollLeft;
afterEach(() => {
parentScrollContainer.scrollTop = originalScrollTop;
parentScrollContainer.scrollLeft = originalScrollLeft;
});
scenarios.forEach((scenario) => {
const { mainAxis } = axisLookup[scenario.axis];
const scrollProperty = scenario.axis === 'vertical' ? 'scrollTop' : 'scrollLeft';
describe(`axis: ${scenario.axis}, side: ${scenario.side} [on boundary]`, () => {
getInsidePoints(scenario.hitbox).forEach((point) => {
test(`point: [${point.label}] {x: ${point.x}, y: ${point.y}}`, () => {
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// Scroll container is now looking over the center of the element
const initialScrollValue = child.getBoundingClientRect()[mainAxis.size] / 2;
parentScrollContainer[scrollProperty] = initialScrollValue;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX: point.x,
clientY: point.y,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer[scrollProperty]).toBe(initialScrollValue);
// Triggering another auto scroll - should be the minimum scroll
{
const before = parentScrollContainer[scrollProperty];
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer[scrollProperty];
// only a single scroll event
expect(ordered).toEqual(['scroll event']);
// Scrolling up → scroll value will get lower
// Scrolling down → scroll value will get higher
// Scrolling on the "start" will scroll backwards
// Scrolling on the "end" will scroll forwards
expect(after - before).toBe(scenario.side === 'start' ? -1 : 1);
}
cleanup();
});
});
});
describe(`axis: ${scenario.axis}, side: ${scenario.side} [outside boundary]`, () => {
getOutsidePoints(scenario.hitbox).forEach((point) => {
test(`point: [${point.label}] {x: ${point.x}, y: ${point.y}}`, () => {
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// Scroll container is now looking over the center of the element
const initialScrollValue = child.getBoundingClientRect()[mainAxis.size] / 2;
parentScrollContainer[scrollProperty] = initialScrollValue;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX: point.x,
clientY: point.y,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer[scrollProperty]).toBe(initialScrollValue);
// No auto scroll should be triggered in the next frame
{
const before = parentScrollContainer[scrollProperty];
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer[scrollProperty];
// only a single scroll event
// expect(ordered).toEqual(['scroll event']);
// Scrolling up → scroll value will get lower
// Scrolling down → scroll value will get higher
// Scrolling on the "start" will scroll backwards
// Scrolling on the "end" will scroll forwards
expect(after - before).toBe(0);
}
cleanup();
});
});
});
});
});
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/max-speed.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { replaceRaf } from 'raf-stub';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { autoScrollForElements } from '../../../src/entry-point/element';
import { getInternalConfig } from '../../../src/shared/configuration';
import { appendToBody, reset, setElementFromPoint, setupBasicScrollContainer } from '../_util';
// need to use "legacy" timers so we can control
// the exact amount of time that passes for a frame.
// For "modern" jest timers, the frame rate is locked at 60fps
jest.useFakeTimers({ legacyFakeTimers: true });
replaceRaf();
const startTime = 0;
let currentTime: number = startTime;
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
beforeEach(reset);
beforeEach(() => {
// @ts-expect-error: raf-stub
requestAnimationFrame.reset();
currentTime = startTime;
});
function stepFrame({ frameDuration }: { frameDuration: number }) {
currentTime += frameDuration;
// @ts-expect-error: raf-stub
requestAnimationFrame.step(1, frameDuration);
}
function stepScrollBy() {
// setTimeout(fn, 0) is released by `jest.advanceTimersByTime(0)` for "legacy" timers
jest.advanceTimersByTime(0);
}
const defaultConfig = getInternalConfig();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
it('should not scroll faster than the target 60fps scroll change on higher frame rate devices', () => {
const frameDuration120fps = 1000 / 120;
const { child, parentScrollContainer } = setupBasicScrollContainer({
child: { height: 10000, width: 10000 },
scrollContainer: { height: 500, width: 500 },
});
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push('scroll event');
},
}),
);
fireEvent.dragStart(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
});
// @ts-expect-error: raf-stub
requestAnimationFrame.step(1, frameDuration120fps);
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame after starting, the auto scrolling will be collecting how long the frame took
// but there will be no scroll
stepFrame({ frameDuration: frameDuration120fps });
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: an auto scroll will occur
stepFrame({ frameDuration: frameDuration120fps });
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// ensure time dampening is finished
{
const loopStartTime = Date.now();
const hit = jest.fn();
while (Date.now() - loopStartTime <= defaultConfig.timeDampeningDurationMs) {
hit();
stepFrame({ frameDuration: frameDuration120fps });
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
}
expect(hit).toHaveBeenCalled();
}
const before = {
scrollTop: parentScrollContainer.scrollTop,
now: Date.now(),
};
stepFrame({ frameDuration: frameDuration120fps });
stepScrollBy();
const after = {
scrollTop: parentScrollContainer.scrollTop,
now: Date.now(),
};
// a scroll occurred
expect(ordered).toEqual(['scroll event']);
// slower than the 60fps speed
expect(after.scrollTop - before.scrollTop).toBeLessThan(
(defaultConfig.maxPixelScrollPerSecond / 1000) * 60,
);
// adjusted the speed for 120fps
const targetScrollPerMs = defaultConfig.maxPixelScrollPerSecond / 1000;
expect(after.scrollTop - before.scrollTop).toBe(
Math.ceil(targetScrollPerMs * frameDuration120fps),
);
cleanup();
});
it('should not make a scroll change bigger than the target 60fps scroll change when running at lower than 60fps', () => {
const frameDuration30fps = 1000 / 30;
const { child, parentScrollContainer } = setupBasicScrollContainer({
child: { height: 10000, width: 10000 },
scrollContainer: { height: 500, width: 500 },
});
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push('scroll event');
},
}),
);
fireEvent.dragStart(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
});
stepFrame({ frameDuration: frameDuration30fps });
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame after starting, the auto scrolling will be collecting how long the frame took
// but there will be no scroll
stepFrame({ frameDuration: frameDuration30fps });
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: an auto scroll will occur
stepFrame({ frameDuration: frameDuration30fps });
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// ensure time dampening is finished
{
const loopStartTime = Date.now();
const hit = jest.fn();
while (Date.now() - loopStartTime <= defaultConfig.timeDampeningDurationMs) {
hit();
stepFrame({ frameDuration: frameDuration30fps });
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
}
expect(hit).toHaveBeenCalled();
}
const before = {
scrollTop: parentScrollContainer.scrollTop,
now: Date.now(),
};
stepFrame({ frameDuration: frameDuration30fps });
stepScrollBy();
const after = {
scrollTop: parentScrollContainer.scrollTop,
now: Date.now(),
};
// a scroll occurred
expect(ordered).toEqual(['scroll event']);
// the same scroll change as if scrolling at 60fps
const targetScrollPerMs = defaultConfig.maxPixelScrollPerSecond / 1000;
const frameDuration60fps = 1000 / 60;
// Accounting for javascript precision inaccuracy caused by `1000 / 60`
expect(after.scrollTop - before.scrollTop).toBeCloseTo(targetScrollPerMs * frameDuration60fps, 1);
cleanup();
});
it('should allow the max speed to be configured', () => {
const frameDuration60fps = 1000 / 60;
const config = getInternalConfig({ maxScrollSpeed: 'fast' });
let maxScrollSpeed: 'fast' | 'standard' = 'fast';
// validation that our scroll speed will be faster
expect(config.maxPixelScrollPerSecond).toBeGreaterThan(defaultConfig.maxPixelScrollPerSecond);
const { child, parentScrollContainer } = setupBasicScrollContainer({
child: { height: 10000, width: 10000 },
scrollContainer: { height: 500, width: 500 },
});
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
}),
autoScrollForElements({
element: parentScrollContainer,
getConfiguration: () => ({
maxScrollSpeed,
}),
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push('scroll event');
},
}),
);
fireEvent.dragStart(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
});
// @ts-expect-error: raf-stub
requestAnimationFrame.step(1, frameDuration60fps);
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame after starting, the auto scrolling will be collecting how long the frame took
// but there will be no scroll
stepFrame({ frameDuration: frameDuration60fps });
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: an auto scroll will occur
stepFrame({ frameDuration: frameDuration60fps });
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// ensure time dampening is finished
{
const loopStartTime = Date.now();
const hit = jest.fn();
while (Date.now() - loopStartTime <= defaultConfig.timeDampeningDurationMs) {
hit();
stepFrame({ frameDuration: frameDuration60fps });
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
}
expect(hit).toHaveBeenCalled();
}
{
const before = {
scrollTop: parentScrollContainer.scrollTop,
};
stepFrame({ frameDuration: frameDuration60fps });
stepScrollBy();
const after = {
scrollTop: parentScrollContainer.scrollTop,
};
// a scroll occurred
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// running at maximum "fast" speed
const targetScrollPerMs = config.maxPixelScrollPerSecond / 1000;
expect(after.scrollTop - before.scrollTop).toBeCloseTo(
// Accounting for javascript precision inaccuracy caused by:
// - `1000 / 60`
// - `after.scrollTop - before.scrollTop`
targetScrollPerMs * frameDuration60fps,
1,
);
}
// changing from "fast" to "standard" scroll speed during a drag
{
maxScrollSpeed = 'standard';
const before = {
scrollTop: parentScrollContainer.scrollTop,
};
stepFrame({ frameDuration: frameDuration60fps });
stepScrollBy();
const after = {
scrollTop: parentScrollContainer.scrollTop,
};
// a scroll occurred
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// running at maximum "standard" speed
const targetScrollPerMs = defaultConfig.maxPixelScrollPerSecond / 1000;
expect(after.scrollTop - before.scrollTop).toBeCloseTo(
// Accounting for javascript precision inaccuracy caused by:
// - `1000 / 60`
// - `after.scrollTop - before.scrollTop`
targetScrollPerMs * frameDuration60fps,
1,
);
}
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/nested-scroll-containers.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { type Position } from '@atlaskit/pragmatic-drag-and-drop/types';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { autoScrollForElements } from '../../../src/entry-point/element';
import { isWithin } from '../../../src/shared/is-within'; // this internal util is helpful for what we are trying to do
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupNestedScrollContainers,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
it('should scroll inner elements before outer elements [single axis]', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
{ width: 5000, height: 5000 },
// grandparent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
autoScrollForElements({
element: parent,
}),
autoScrollForElements({
element: grandParent,
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
// Set some initial scroll on the scroll containers
// These are in the range where auto scrolling will occur on both
parent.scrollTop = 60;
grandParent.scrollTop = 120;
// lifting the mid point of the top edge
userEvent.lift(child, {
clientX:
grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width / 2,
clientY: grandParent.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
{
const hit = jest.fn();
while (parent.scrollTop > 0) {
hit();
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['parent:scroll']);
ordered.length = 0;
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
// Now it will scroll the grand parent until it's finished
{
const hit = jest.fn();
while (grandParent.scrollTop > 0) {
hit();
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['grandParent:scroll']);
ordered.length = 0;
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
cleanup();
});
it('should scroll inner elements before outer elements [both axis at a time]', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
{ width: 5000, height: 5000 },
// grandParent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
autoScrollForElements({
element: parent,
}),
autoScrollForElements({
element: grandParent,
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
// Set some initial scroll on the scroll containers
parent.scrollTop = 60;
parent.scrollLeft = 60;
grandParent.scrollTop = 120;
grandParent.scrollLeft = 120;
// lifting the shared top left corner
userEvent.lift(child, {
clientX: grandParent.getBoundingClientRect().left,
clientY: grandParent.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
{
const hit = jest.fn();
while (parent.scrollTop > 0) {
hit();
const before = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const after = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
// only the parent scrolled
expect(ordered).toEqual(['parent:scroll']);
ordered.length = 0;
// asserting we scrolled in both directions
expect(before.scrollTop).toBeGreaterThan(after.scrollTop);
expect(before.scrollLeft).toBeGreaterThan(after.scrollLeft);
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
// Now it will scroll the grand parent until it's finished
{
const hit = jest.fn();
while (grandParent.scrollTop > 0) {
hit();
const before = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const after = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
// only the grandParent scrolled
expect(ordered).toEqual(['grandParent:scroll']);
ordered.length = 0;
// asserting we scrolled in both directions
expect(before.scrollTop).toBeGreaterThan(after.scrollTop);
expect(before.scrollLeft).toBeGreaterThan(after.scrollLeft);
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
cleanup();
});
it('should only scroll one scroll container per axis [case: inner is scrolling on vertical, parent on both]', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
{ width: 5000, height: 5000 },
// grandparent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
autoScrollForElements({
element: parent,
}),
autoScrollForElements({
element: grandParent,
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
// These values are more magic than I originally planned 😅
// Small amount of scroll on the parent (should finish first)
parent.scrollTop = 10;
// → No available scroll on the left
// Larger amount on the grand parent
// (due to lift point this will accelerate faster than parent)
grandParent.scrollTop = 80;
grandParent.scrollLeft = 80;
const client: Position = {
x: grandParent.getBoundingClientRect().left,
y: grandParent.getBoundingClientRect().top,
};
// validating setup
expect(isWithin({ client, clientRect: parent.getBoundingClientRect() })).toBe(true);
expect(isWithin({ client, clientRect: grandParent.getBoundingClientRect() })).toBe(true);
// lifting the top left corner
userEvent.lift(child, {
clientX: client.x,
clientY: client.y,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// scroll the parent until it cannot scroll any more
{
const hit = jest.fn();
while (parent.scrollTop > 0) {
hit();
const parentBefore = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
const grandParentBefore = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const parentAfter = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
const grandParentAfter = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
// we scroll inner most elements outwards (bubble ordering)
expect(ordered).toEqual(['parent:scroll', 'grandParent:scroll']);
ordered.length = 0;
// parent scrolled on the top, but not on the left
expect(parentBefore.scrollTop).toBeGreaterThan(parentAfter.scrollTop);
expect(parentBefore.scrollLeft).toBe(parentAfter.scrollLeft);
// grand parent not permitted to scroll on the top, but can scroll on left
expect(grandParentBefore.scrollTop).toBe(grandParentAfter.scrollTop);
expect(grandParentBefore.scrollLeft).toBeGreaterThan(grandParentAfter.scrollLeft);
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
// finish off scrolling the grand parent
{
const hit = jest.fn();
while (grandParent.scrollTop > 0 || grandParent.scrollLeft > 0) {
hit();
const grandParentBefore = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const grandParentAfter = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
// only the grandParent scrolled
expect(ordered).toEqual(['grandParent:scroll']);
ordered.length = 0;
expect(grandParentBefore.scrollTop).toBeGreaterThan(grandParentAfter.scrollTop);
// There was already some scroll on the left, so expecting left
// will finish scrolling before the top
expect(grandParentBefore.scrollLeft).toBeGreaterThanOrEqual(grandParentAfter.scrollLeft);
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
cleanup();
});
it('should only scroll one scroll container per axis [case: inner is scrolling on horizontal, parent on both]', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
{ width: 5000, height: 5000 },
// grandparent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
autoScrollForElements({
element: parent,
}),
autoScrollForElements({
element: grandParent,
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
// These values are more magic than I originally planned 😅
// Small amount of scroll on the parent (should finish first)
parent.scrollLeft = 10;
// → No available scroll on the top
// Larger amount on the grand parent
// (due to lift point this will accelerate faster than parent)
grandParent.scrollTop = 80;
grandParent.scrollLeft = 80;
const client: Position = {
x: grandParent.getBoundingClientRect().left,
y: grandParent.getBoundingClientRect().top,
};
// validating setup
expect(isWithin({ client, clientRect: parent.getBoundingClientRect() })).toBe(true);
expect(isWithin({ client, clientRect: grandParent.getBoundingClientRect() })).toBe(true);
// lifting the top left corner
userEvent.lift(child, {
clientX: client.x,
clientY: client.y,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// scroll the parent until it cannot scroll any more
{
const hit = jest.fn();
while (parent.scrollLeft > 0) {
hit();
const parentBefore = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
const grandParentBefore = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const parentAfter = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
const grandParentAfter = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
// we scroll inner most elements outwards (bubble ordering)
expect(ordered).toEqual(['parent:scroll', 'grandParent:scroll']);
ordered.length = 0;
// parent scrolled on the left, but not on the top
expect(parentBefore.scrollLeft).toBeGreaterThan(parentAfter.scrollLeft);
expect(parentBefore.scrollTop).toBe(parentAfter.scrollTop);
// grand parent not permitted to scroll on the left, but can scroll on top
expect(grandParentBefore.scrollLeft).toBe(grandParentAfter.scrollLeft);
expect(grandParentBefore.scrollTop).toBeGreaterThan(grandParentAfter.scrollTop);
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
// finish off scrolling the grand parent
{
const hit = jest.fn();
while (grandParent.scrollTop > 0 || grandParent.scrollLeft > 0) {
hit();
const grandParentBefore = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const grandParentAfter = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
// only the grandParent scrolled
expect(ordered).toEqual(['grandParent:scroll']);
ordered.length = 0;
expect(grandParentBefore.scrollLeft).toBeGreaterThan(grandParentAfter.scrollLeft);
// There was already some scroll on the top, so expecting top
// will finish scrolling before the top
expect(grandParentBefore.scrollTop).toBeGreaterThanOrEqual(grandParentAfter.scrollTop);
}
expect(hit.mock.calls.length).toBeGreaterThan(1);
}
cleanup();
});
it('should ignore scroll containers that have `canScroll: () => false` [case: inner is scrolling on horizontal, parent on both]', () => {
const [child, parent, grandParent, greatGrandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
{ width: 8000, height: 8000 },
// grandparent,
{ width: 5000, height: 5000 },
// great grandparent,
{ width: 2000, height: 2000 },
]);
const ordered: string[] = [];
const cleanup = combine(
appendToBody(greatGrandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
dropTargetForElements({
element: greatGrandParent,
onDragStart: () => ordered.push('greatGrandParent:start'),
onDrop: () => ordered.push('greatGrandParent:drop'),
}),
autoScrollForElements({
element: parent,
}),
autoScrollForElements({
element: grandParent,
canScroll: () => false,
}),
autoScrollForElements({
element: greatGrandParent,
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === greatGrandParent) {
ordered.push('greatGrandParent:scroll');
return;
}
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
parent.scrollLeft = 20;
// no scroll available on the top side of the parent
grandParent.scrollTop = 30;
grandParent.scrollLeft = 30;
greatGrandParent.scrollTop = 40;
greatGrandParent.scrollLeft = 40;
// lifting the top left corner
userEvent.lift(child, {
clientX: greatGrandParent.getBoundingClientRect().left,
clientY: greatGrandParent.getBoundingClientRect().top,
});
expect(ordered).toEqual([
'draggable:start',
'child:start',
'parent:start',
'grandParent:start',
'greatGrandParent:start',
]);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// scroll the parent until it cannot scroll any more
const parentBefore = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
const grandParentBefore = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
const greatGrandParentBefore = {
scrollTop: greatGrandParent.scrollTop,
scrollLeft: greatGrandParent.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const parentAfter = {
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft,
};
const grandParentAfter = {
scrollTop: grandParent.scrollTop,
scrollLeft: grandParent.scrollLeft,
};
const greatGrandParentAfter = {
scrollTop: greatGrandParent.scrollTop,
scrollLeft: greatGrandParent.scrollLeft,
};
// we scroll inner most elements outwards (bubble ordering)
// grandParent is disabled, so it will be skipped
expect(ordered).toEqual(['parent:scroll', 'greatGrandParent:scroll']);
ordered.length = 0;
// parent scrolled on the left, but not on the top
expect(parentBefore.scrollLeft).toBeGreaterThan(parentAfter.scrollLeft);
expect(parentBefore.scrollTop).toBe(parentAfter.scrollTop);
// no changes to grandParent as scrolling is disabled
expect(grandParentBefore.scrollTop).toBe(grandParentAfter.scrollTop);
expect(grandParentBefore.scrollLeft).toBe(grandParentAfter.scrollLeft);
// great grand parent not permitted to scroll on the left, but can scroll on top
expect(greatGrandParentBefore.scrollLeft).toBe(greatGrandParentAfter.scrollLeft);
expect(greatGrandParentBefore.scrollTop).toBeGreaterThan(greatGrandParentAfter.scrollTop);
cleanup();
});
// TODO: could also add similar tests for scrolling forward, but they are proving extremely difficult to setup well
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/registration.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { autoScrollForElements } from '../../../src/entry-point/element';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
it('should not scroll scrollable elements that are not registered', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
// not marking outerScrollContainer as a scroll container
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// setting an initial scroll
parentScrollContainer.scrollTop = 500;
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// on second frame - there would be a scroll is there was a registered scroll container
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
cleanup();
});
it('should not scroll scrollable elements that are no longer registered', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
const unbindAutoScrolling = autoScrollForElements({
element: parentScrollContainer,
});
// setting an initial scroll
parentScrollContainer.scrollTop = 500;
// top center of scroll container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// on second frame we will get a scroll
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// we will no longer get scroll updates after unregistered
unbindAutoScrolling();
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
cleanup();
});
it('should scroll scrollable elements are registered mid drag', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// setting an initial scroll
parentScrollContainer.scrollTop = 500;
// top center of scroll container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// on second frame there is no scroll
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
ordered.length = 0;
// there will be a registration for the third frame, so we will get a scroll
const unbindAutoScrolling = autoScrollForElements({
element: parentScrollContainer,
});
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
unbindAutoScrolling();
cleanup();
});
it('should warn if an elements is registered but are not scrollable', () => {
const { child } = setupBasicScrollContainer();
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const cleanup = autoScrollForElements({
element: child,
});
expect(warn).toHaveBeenCalled();
cleanup();
warn.mockRestore();
});
it('should log a warning if an existing registration exists for an element', () => {
const { parentScrollContainer } = setupBasicScrollContainer();
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const cleanup1 = autoScrollForElements({
element: parentScrollContainer,
});
expect(warn).not.toHaveBeenCalled();
const cleanup2 = autoScrollForElements({
element: parentScrollContainer,
});
expect(warn).toHaveBeenCalled();
cleanup1();
cleanup2();
warn.mockRestore();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/start.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { autoScrollForElements } from '../../../src/entry-point/element';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
jest.useFakeTimers();
setStartSystemTime();
beforeEach(reset);
it('should start automatically scrolling when a drag starts', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = 500;
parentScrollContainer.scrollLeft = 500;
userEvent.lift(child, { clientX: 1, clientY: 1 });
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: an auto scroll will occur
advanceTimersToNextFrame();
stepScrollBy();
// Scroll backwards on both axis by 1px
expect(ordered).toEqual(['scroll event {scrollLeft: 499, scrollTop: 499}']);
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/stop.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { autoScrollForElements } from '../../../src/entry-point/element';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
it('should stop scrolling when a drag ends', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = 500;
parentScrollContainer.scrollLeft = 500;
userEvent.lift(child, { clientX: 1, clientY: 1 });
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: an auto scroll will occur
advanceTimersToNextFrame();
stepScrollBy();
// Scroll backwards on both axis by 1px
expect(ordered).toEqual(['scroll event {scrollLeft: 499, scrollTop: 499}']);
ordered.length = 0;
// Third frame: auto scrolling should occur again
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event {scrollLeft: 498, scrollTop: 498}']);
ordered.length = 0;
// End the drag
userEvent.drop(child);
expect(ordered).toEqual(['draggable:drop', 'dropTarget:drop']);
ordered.length = 0;
// Fourth frame: no auto scroll should occur
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
cleanup();
});
it('should not start scrolling if the drag is cancelled in the first frame', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = 500;
parentScrollContainer.scrollLeft = 500;
userEvent.lift(child, { clientX: 1, clientY: 1 });
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
// End the drag
userEvent.drop(child);
expect(ordered).toEqual(['draggable:drop', 'dropTarget:drop']);
ordered.length = 0;
// Second frame: an auto scroll will normally have occurred, but the drag was cancelled.
advanceTimersToNextFrame();
stepScrollBy();
// no scroll events should have occurred
expect(ordered).toEqual([]);
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/time-dampening.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import {
autoScrollForElements,
autoScrollWindowForElements,
} from '../../../src/entry-point/element';
import { getInternalConfig } from '../../../src/shared/configuration';
import {
advanceTimersToNextFrame,
appendToBody,
getBubbleOrderedTree,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
const defaultConfig = getInternalConfig();
const maxScrollPerFrame = defaultConfig.maxPixelScrollPerSecond / 60;
beforeEach(() => {
// resetting document scroll
document.documentElement.scrollTop = 0;
document.documentElement.scrollLeft = 0;
});
// Splitting up, right, down and left into separate cases rather than a loop, because doing it
// in one helper loop was super messy and difficult to follow
it('should dampen the acceleration of auto scrolling [new drag] - up', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = child.getBoundingClientRect().height / 2;
const initialScrollTop = parentScrollContainer.scrollTop;
const initialScrollLeft = parentScrollContainer.scrollLeft;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY:
// when on the 'top' side we are scrolling up
parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
let lastScrollTop = parentScrollContainer.scrollTop;
let lastScrollChangeSize = 0;
let engagementStart: number | null = null;
// tracking the various cases to make sure we are actually hitting them
const casesHit = {
// first few scrolls will just be 1px
'initial-acceleration': false,
acceleration: false,
'time-dampening-finished': false,
'time-dampening-finished-last-scroll': false,
};
// Keep going until we cannot scroll any more
while (parentScrollContainer.scrollTop > 0) {
advanceTimersToNextFrame();
stepScrollBy();
// asserting that one scroll event has occurred
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// Engagement not set until first active scroll
if (!engagementStart) {
engagementStart = Date.now();
}
const currentScrollTop = parentScrollContainer.scrollTop;
/**
* Sometimes minus can run into IEEE 754 floating point math issues.
* Example: `256.4 - 241.4` is `14.99999999999972` and not `15` 😮💨
* Never use `.toBe()` with `scrollChange`, only `.toBeCloseTo()`,
* or other _not strictly equal_ assertions (eg `toBeGreaterThan()`)
**/
const scrollChange = currentScrollTop - lastScrollTop;
// we are scrolling backwards so our change will be negative
expect(scrollChange).toBeLessThan(0);
const scrollChangeSize = Math.abs(scrollChange);
lastScrollTop = currentScrollTop;
const now = Date.now();
const duration = now - engagementStart;
// Case 1: in the time dampening period
if (duration < defaultConfig.timeDampeningDurationMs) {
// We are still not at the max scroll speed
expect(scrollChangeSize).not.toBeGreaterThan(defaultConfig.maxPixelScrollPerSecond);
if (scrollChangeSize === 1) {
expect(scrollChangeSize).toBe(1);
casesHit['initial-acceleration'] = true;
} else {
// Each scroll is bigger than the last
expect(scrollChangeSize).toBeGreaterThan(lastScrollChangeSize);
casesHit.acceleration = true;
}
lastScrollChangeSize = scrollChangeSize;
continue;
}
// Case 2: scrolling at max speed, but not finished scrolling
// Expecting max scroll speed
if (parentScrollContainer.scrollTop !== 0) {
expect(scrollChangeSize).toBeCloseTo(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished'] = true;
continue;
}
// Case 3: the last scroll finished the scrolling of the element.
// The last scroll could be slightly less than the max scroll amount
// as there might not have been the max scroll amount left to scroll
expect(scrollChangeSize).toBeLessThanOrEqual(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished-last-scroll'] = true;
// We can finish here (even though the exit condition would catch us too)
break;
}
// scroll container has been scrolled all the way to the top
expect(parentScrollContainer.scrollTop).toBe(0);
// asserting all our cases where hit
expect(casesHit['initial-acceleration']).toBe(true);
expect(casesHit.acceleration).toBe(true);
expect(casesHit['time-dampening-finished']).toBe(true);
expect(casesHit['time-dampening-finished-last-scroll']).toBe(true);
// scrollLeft should not have changed
expect(parentScrollContainer.scrollLeft).toBe(initialScrollLeft);
cleanup();
});
it('should dampen the acceleration of auto scrolling [new drag] - right', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollLeft = child.getBoundingClientRect().width / 2;
const initialScrollLeft = parentScrollContainer.scrollLeft;
const initialScrollTop = parentScrollContainer.scrollTop;
// lifting the mid point of the right edge
userEvent.lift(child, {
clientX: parentScrollContainer.getBoundingClientRect().right,
clientY:
parentScrollContainer.getBoundingClientRect().top +
parentScrollContainer.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollLeft).toBe(initialScrollLeft);
let lastScrollLeft = parentScrollContainer.scrollLeft;
let lastScrollChange = 0;
let engagementStart: number | null = null;
// tracking the various cases to make sure we are actually hitting them
const casesHit = {
// first few scrolls will just be 1px
'initial-acceleration': false,
acceleration: false,
'time-dampening-finished': false,
'time-dampening-finished-last-scroll': false,
};
const maxScrollLeft = parentScrollContainer.scrollWidth - parentScrollContainer.clientWidth;
// Keep going until we cannot scroll any more
while (parentScrollContainer.scrollLeft <= maxScrollLeft) {
advanceTimersToNextFrame();
stepScrollBy();
// asserting that one scroll event has occurred
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// Engagement not set until first active scroll
if (!engagementStart) {
engagementStart = Date.now();
}
const currentScrollLeft = parentScrollContainer.scrollLeft;
/**
* Sometimes minus can run into IEEE 754 floating point math issues.
* Example: `256.4 - 241.4` is `14.99999999999972` and not `15` 😮💨
* Never use `.toBe()` with `scrollChange`, only `.toBeCloseTo()`
* or other _not strictly equal_ assertions (eg `toBeGreaterThan()`)
**/
const scrollChange = currentScrollLeft - lastScrollLeft;
// we are scrolling forward so our change will be positive
expect(scrollChange).toBeGreaterThan(0);
lastScrollLeft = currentScrollLeft;
const now = Date.now();
const duration = now - engagementStart;
// Case 1: in the time dampening period
if (duration < defaultConfig.timeDampeningDurationMs) {
// We are still not at the max scroll speed
expect(scrollChange).not.toBeGreaterThan(defaultConfig.maxPixelScrollPerSecond);
if (scrollChange === 1) {
casesHit['initial-acceleration'] = true;
expect(scrollChange).toBe(1);
} else {
// Each scroll is bigger than the last
casesHit.acceleration = true;
expect(scrollChange).toBeGreaterThan(lastScrollChange);
}
lastScrollChange = scrollChange;
continue;
}
// Case 2: scrolling at max speed, but not finished scrolling
// Expecting max scroll speed
if (parentScrollContainer.scrollLeft < maxScrollLeft) {
expect(scrollChange).toBeCloseTo(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished'] = true;
continue;
}
// Case 3: the last scroll finished the scrolling of the element.
// The last scroll could be slightly less than the max scroll amount
// as there might not have been the max scroll amount left to scroll
expect(scrollChange).toBeLessThanOrEqual(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished-last-scroll'] = true;
// We can finish here (even though the exit condition would catch us too)
break;
}
// scroll container has been scrolled all the way to the top
expect(parentScrollContainer.scrollLeft).toBe(maxScrollLeft);
// asserting all our cases where hit
expect(casesHit['initial-acceleration']).toBe(true);
expect(casesHit.acceleration).toBe(true);
expect(casesHit['time-dampening-finished']).toBe(true);
expect(casesHit['time-dampening-finished-last-scroll']).toBe(true);
// scrollTop should not have changed
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
cleanup();
});
it('should dampen the acceleration of auto scrolling [new drag] - down', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = child.getBoundingClientRect().height / 2;
const initialScrollTop = parentScrollContainer.scrollTop;
const initialScrollLeft = parentScrollContainer.scrollLeft;
// lifting on the mid point of the bottom edge
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
let lastScrollTop = parentScrollContainer.scrollTop;
let lastScrollChange = 0;
let engagementStart: number | null = null;
// tracking the various cases to make sure we are actually hitting them
const casesHit = {
// first few scrolls will just be 1px
'initial-acceleration': false,
acceleration: false,
'time-dampening-finished': false,
'time-dampening-finished-last-scroll': false,
};
const maxScrollTop = parentScrollContainer.scrollHeight - parentScrollContainer.clientHeight;
// Keep going until we cannot scroll any more
while (parentScrollContainer.scrollTop <= maxScrollTop) {
advanceTimersToNextFrame();
stepScrollBy();
// asserting that one scroll event has occurred
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// Engagement not set until first active scroll
if (!engagementStart) {
engagementStart = Date.now();
}
const currentScrollTop = parentScrollContainer.scrollTop;
/**
* Sometimes minus can run into IEEE 754 floating point math issues.
* Example: `256.4 - 241.4` is `14.99999999999972` and not `15` 😮💨
* Never use `.toBe()` with `scrollChange`, only `.toBeCloseTo()`
* or other _not strictly equal_ assertions (eg `toBeGreaterThan()`)
**/
const scrollChange = currentScrollTop - lastScrollTop;
// we are scrolling forward so our change will be positive
expect(scrollChange).toBeGreaterThan(0);
lastScrollTop = currentScrollTop;
const now = Date.now();
const duration = now - engagementStart;
// Case 1: in the time dampening period
if (duration < defaultConfig.timeDampeningDurationMs) {
// We are still not at the max scroll speed
expect(scrollChange).not.toBeGreaterThan(defaultConfig.maxPixelScrollPerSecond);
if (scrollChange === 1) {
expect(scrollChange).toBe(1);
casesHit['initial-acceleration'] = true;
} else {
// Each scroll is bigger than the last
expect(scrollChange).toBeGreaterThan(lastScrollChange);
casesHit.acceleration = true;
}
lastScrollChange = scrollChange;
continue;
}
// Case 2: scrolling at max speed, but not finished scrolling
// Expecting max scroll speed
if (parentScrollContainer.scrollTop < maxScrollTop) {
expect(scrollChange).toBeCloseTo(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished'] = true;
continue;
}
// Case 3: the last scroll finished the scrolling of the element.
// The last scroll could be slightly less than the max scroll amount
// as there might not have been the max scroll amount left to scroll
expect(scrollChange).toBeLessThanOrEqual(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished-last-scroll'] = true;
// We can finish here (even though the exit condition would catch us too)
break;
}
// scroll container has been scrolled all the way to the top
expect(parentScrollContainer.scrollTop).toBe(maxScrollTop);
// asserting all our cases where hit
expect(casesHit['initial-acceleration']).toBe(true);
expect(casesHit.acceleration).toBe(true);
expect(casesHit['time-dampening-finished']).toBe(true);
expect(casesHit['time-dampening-finished-last-scroll']).toBe(true);
// scrollLeft should not have changed
expect(parentScrollContainer.scrollLeft).toBe(initialScrollLeft);
cleanup();
});
it('should dampen the acceleration of auto scrolling [new drag] - left', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollLeft = child.getBoundingClientRect().width / 2;
const initialScrollLeft = parentScrollContainer.scrollLeft;
const initialScrollTop = parentScrollContainer.scrollTop;
// lifting on the vertical midpoint of the left edge of the container
userEvent.lift(child, {
clientX: parentScrollContainer.getBoundingClientRect().left,
clientY:
parentScrollContainer.getBoundingClientRect().top +
parentScrollContainer.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollLeft).toBe(initialScrollLeft);
let lastScrollLeft = parentScrollContainer.scrollLeft;
let lastScrollChangeSize = 0;
let engagementStart: number | null = null;
// tracking the various cases to make sure we are actually hitting them
const casesHit = {
// first few scrolls will just be 1px
'initial-acceleration': false,
acceleration: false,
'time-dampening-finished': false,
'time-dampening-finished-last-scroll': false,
};
// Keep going until we cannot scroll any more
while (parentScrollContainer.scrollLeft > 0) {
advanceTimersToNextFrame();
stepScrollBy();
// asserting that one scroll event has occurred
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
// Engagement not set until first active scroll
if (!engagementStart) {
engagementStart = Date.now();
}
const currentScrollLeft = parentScrollContainer.scrollLeft;
/**
* Sometimes minus can run into IEEE 754 floating point math issues.
* Example: `256.4 - 241.4` is `14.99999999999972` and not `15` 😮💨
* Never use `.toBe()` with `scrollChange`, only `.toBeCloseTo()`
* or other _not strictly equal_ assertions (eg `toBeGreaterThan()`)
**/
const scrollChange = currentScrollLeft - lastScrollLeft;
// we are scrolling backwards so our change will be negative
expect(scrollChange).toBeLessThan(0);
const scrollChangeSize = Math.abs(scrollChange);
lastScrollLeft = currentScrollLeft;
const now = Date.now();
const duration = now - engagementStart;
// Case 1: in the time dampening period
if (duration < defaultConfig.timeDampeningDurationMs) {
if (scrollChangeSize === 1) {
expect(scrollChangeSize).toBe(1);
casesHit['initial-acceleration'] = true;
} else {
// Each scroll is bigger than the last
expect(scrollChangeSize).toBeGreaterThan(lastScrollChangeSize);
casesHit.acceleration = true;
}
lastScrollChangeSize = scrollChangeSize;
continue;
}
// Case 2: scrolling at max speed, but not finished scrolling
// Expecting max scroll speed
if (parentScrollContainer.scrollLeft !== 0) {
expect(scrollChangeSize).toBeCloseTo(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished'] = true;
continue;
}
// Case 3: the last scroll finished the scrolling of the element.
// The last scroll could be slightly less than the max scroll amount
// as there might not have been the max scroll amount left to scroll
expect(scrollChangeSize).toBeLessThanOrEqual(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished-last-scroll'] = true;
// We can finish here (even though the exit condition would catch us too)
break;
}
// scroll container has been scrolled all the way to the top
expect(parentScrollContainer.scrollLeft).toBe(0);
// asserting all our cases where hit
expect(casesHit['initial-acceleration']).toBe(true);
expect(casesHit.acceleration).toBe(true);
expect(casesHit['time-dampening-finished']).toBe(true);
expect(casesHit['time-dampening-finished-last-scroll']).toBe(true);
// scrollTop should not have changed
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
cleanup();
});
it('should dampen the acceleration of auto scrolling [entering into a new drop target]', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const [original] = getBubbleOrderedTree();
const originalRect = DOMRect.fromRect({
x: parentScrollContainer.getBoundingClientRect().x + 20,
y: parentScrollContainer.getBoundingClientRect().y + 20,
width: 100,
height: 100,
});
original.getBoundingClientRect = () => originalRect;
const ordered: string[] = [];
let unsetElementFromPoint = setElementFromPoint(original);
const cleanup = combine(
appendToBody(original),
appendToBody(parentScrollContainer),
draggable({
element: original,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Scroll container is now looking over the center of the element
const initialScrollTop = child.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// lifting in center of original
userEvent.lift(original, {
clientX: originalRect.left + originalRect.width / 2,
clientY: originalRect.top + originalRect.height / 2,
});
// not over the inner element
expect(ordered).toEqual(['draggable:start']);
ordered.length = 0;
// we are expecting no auto scrolling as we are currently not over the drop target
for (let i = 0; i < 10; i++) {
advanceTimersToNextFrame();
stepScrollBy();
}
// also just being safe and ensuring we are totally outside any initial time dampening
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
stepScrollBy();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
// dragging over the top center of our scroll container
// while over the 'inner' element
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(child);
fireEvent.dragEnter(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
// we are now over the drop target
expect(ordered).toEqual(['dropTarget:enter']);
// no scrolling has occurred yet
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
let lastScrollTop = parentScrollContainer.scrollTop;
let lastScrollChange = 0;
let engagementStart: number | null = null;
// tracking the various cases to make sure we are actually hitting them
const casesHit = {
acceleration: false,
'time-dampening-finished': false,
'time-dampening-finished-last-scroll': false,
};
// Keep going until we cannot scroll any more
while (parentScrollContainer.scrollTop > 0) {
advanceTimersToNextFrame();
stepScrollBy();
// Engagement not set until first active scroll
if (!engagementStart) {
engagementStart = Date.now();
}
const currentScrollTop = parentScrollContainer.scrollTop;
/**
* Sometimes minus can run into IEEE 754 floating point math issues.
* Example: `256.4 - 241.4` is `14.99999999999972` and not `15` 😮💨
* Never use `.toBe()` with `scrollChange`, only `.toBeCloseTo()`
* or other _not strictly equal_ assertions (eg `toBeGreaterThan()`)
**/
const scrollChange = Math.abs(currentScrollTop - lastScrollTop);
lastScrollTop = currentScrollTop;
const now = Date.now();
const duration = now - engagementStart;
// Case 1: in the time dampening period
if (duration < defaultConfig.timeDampeningDurationMs) {
// We are still not at the max scroll speed
expect(scrollChange).not.toBeGreaterThan(defaultConfig.maxPixelScrollPerSecond);
// Each scroll is bigger than the last
expect(scrollChange).toBeGreaterThan(lastScrollChange);
casesHit.acceleration = true;
continue;
}
// Case 2: scrolling at max speed, but not finished scrolling
// Expecting max scroll speed
if (parentScrollContainer.scrollTop !== 0) {
expect(scrollChange).toBeCloseTo(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished'] = true;
continue;
}
// Case 3: the last scroll finished the scrolling of the element.
// The last scroll could be slightly less than the max scroll amount
// as there might not have been the max scroll amount left to scroll
expect(scrollChange).toBeLessThanOrEqual(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished-last-scroll'] = true;
// We can finish here
break;
}
// scroll container has been scrolled all the way to the top
expect(parentScrollContainer.scrollTop).toBe(0);
// asserting all our cases where hit
expect(casesHit.acceleration).toBe(true);
expect(casesHit['time-dampening-finished']).toBe(true);
expect(casesHit['time-dampening-finished-last-scroll']).toBe(true);
// TODO: pull out into separate test?
// checking that no more scrolls will occur
ordered.length = 0;
jest.advanceTimersByTime(defaultConfig.maxPixelScrollPerSecond * 2);
expect(ordered).toEqual([]);
cleanup();
unsetElementFromPoint();
});
it('should start time dampening from when the element is dragged over, even if auto scrolling is not being triggered', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push('scroll event'),
}),
);
// Scroll container is now looking over the center of the element
const initialScrollTop = child.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// lifting in center of the the scroll container, this should not trigger any auto scrolling
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY:
parentScrollContainer.getBoundingClientRect().top +
parentScrollContainer.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// first frame: no scroll expected even if we were auto scrolling
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// second frame: if we were in a hitbox for auto scrolling, scroll would occur.
// not expecting any scroll event as we are not in a hitbox for auto scrolling.
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// ensuring we are out of the time dampening period
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
stepScrollBy();
// still expecting no scroll events
expect(ordered).toEqual([]);
// moving over the bottom center - this should trigger an auto scroll
fireEvent.dragOver(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
});
// not expecting the change to be picked up until the frame after the current frame
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// now scrolling at max speed as time dampening is finished
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(after - before).toBe(maxScrollPerFrame);
expect(ordered).toEqual(['scroll event']);
}
cleanup();
});
it('should reset time dampening when re-entering a scrollable element', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
let unsetElementFromPoint = setElementFromPoint(child);
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Scroll container is now looking over the center of the element
const initialScrollTop = child.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
function execute() {
let lastScrollTop = parentScrollContainer.scrollTop;
let lastScrollChange = 0;
let engagementStart: number | null = null;
// tracking the various cases to make sure we are actually hitting them
const casesHit = {
acceleration: false,
'time-dampening-finished': false,
'time-dampening-finished-last-scroll': false,
};
// Keep going until we cannot scroll any more
while (parentScrollContainer.scrollTop > 0) {
advanceTimersToNextFrame();
stepScrollBy();
// Engagement not set until first active scroll
if (!engagementStart) {
engagementStart = Date.now();
}
const currentScrollTop = parentScrollContainer.scrollTop;
/**
* Sometimes minus can run into IEEE 754 floating point math issues.
* Example: `256.4 - 241.4` is `14.99999999999972` and not `15` 😮💨
* Never use `.toBe()` with `scrollChange`, only `.toBeCloseTo()`
* or other _not strictly equal_ assertions (eg `toBeGreaterThan()`)
**/
const scrollChange = Math.abs(currentScrollTop - lastScrollTop);
lastScrollTop = currentScrollTop;
const now = Date.now();
const duration = now - engagementStart;
// Case 1: in the time dampening period
if (duration < defaultConfig.timeDampeningDurationMs) {
// We are still not at the max scroll speed
expect(scrollChange).not.toBeGreaterThan(defaultConfig.maxPixelScrollPerSecond);
// Each scroll is bigger than the last
expect(scrollChange).toBeGreaterThan(lastScrollChange);
casesHit.acceleration = true;
continue;
}
// Case 2: scrolling at max speed, but not finished scrolling
// Expecting max scroll speed
if (parentScrollContainer.scrollTop !== 0) {
expect(scrollChange).toBeCloseTo(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished'] = true;
continue;
}
// Case 3: the last scroll finished the scrolling of the element.
// The last scroll could be slightly less than the max scroll amount
// as there might not have been the max scroll amount left to scroll
expect(scrollChange).toBeLessThanOrEqual(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished-last-scroll'] = true;
// We can finish here
break;
}
// scroll container has been scrolled all the way to the top
expect(parentScrollContainer.scrollTop).toBe(0);
// asserting all our cases where hit
expect(casesHit.acceleration).toBe(true);
expect(casesHit['time-dampening-finished']).toBe(true);
expect(casesHit['time-dampening-finished-last-scroll']).toBe(true);
}
// first auto scroll
execute();
ordered.length = 0;
// leaving the drop target
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
fireEvent.dragEnter(document.body);
expect(ordered).toEqual(['dropTarget:leave']);
ordered.length = 0;
// let some time pass
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs * 2);
// no scrolling has occurred in this time
expect(ordered).toEqual([]);
// let's drag back over the drop target
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(child);
fireEvent.dragEnter(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
// we are now over the drop target
expect(ordered).toEqual(['dropTarget:enter']);
ordered.length = 0;
// our auto scroll should be in action
// TODO: be a bit more elegant here?
parentScrollContainer.scrollTop = initialScrollTop;
execute();
cleanup();
unsetElementFromPoint();
});
it('should not reset time dampening if an element is re-registered (in the same frame)', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
);
let unbindAutoScrolling = autoScrollForElements({
element: parentScrollContainer,
});
// Scroll container is now looking over the center of the element
const initialScrollTop = child.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has now been scrolled
expect(parentScrollContainer.scrollTop).toBeLessThan(initialScrollTop);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before - after).toBe(maxScrollPerFrame);
}
// Rebinding the auto scroll element
unbindAutoScrolling();
unbindAutoScrolling = autoScrollForElements({
element: parentScrollContainer,
});
// Triggering another auto scroll - should be at the max speed
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before - after).toBe(maxScrollPerFrame);
}
cleanup();
});
it('should not reset time dampening if window scrolling is re-registered (in the same frame)', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
appendToBody(element),
setElementFromPoint(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
const initialScrollTop = document.documentElement.scrollTop;
let unbindAutoScrolling = autoScrollWindowForElements();
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(document.documentElement.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has now been scrolled
expect(document.documentElement.scrollTop).toBeGreaterThan(initialScrollTop);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after - before).toBe(maxScrollPerFrame);
}
// Re-registering window scrolling
unbindAutoScrolling();
unbindAutoScrolling = autoScrollWindowForElements();
// Triggering another auto scroll - should be at the max speed
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after - before).toBe(maxScrollPerFrame);
}
unbindAutoScrolling();
cleanup();
});
it('should reset time dampening if a element is re-registered in a future frame', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
let unbindAutoScrolling = autoScrollForElements({
element: parentScrollContainer,
});
// Scroll container is now looking over the center of the element
const initialScrollTop = child.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has now been scrolled
expect(parentScrollContainer.scrollTop).toBeLessThan(initialScrollTop);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before - after).toBe(maxScrollPerFrame);
}
// Unbinding the auto scroll element
unbindAutoScrolling();
// Triggering a scroll - should not scroll the scroll container
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before).toBe(after);
}
// Binding the scrollable element again
unbindAutoScrolling = autoScrollForElements({
element: parentScrollContainer,
});
// Triggering another auto scroll - should be the minimum scroll
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before - after).toBe(1);
}
cleanup();
unbindAutoScrolling();
});
it('should reset time dampening if a element scroll is disabled and re-enabled in a future frame', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
let isAutoScrollingAllowed: boolean = true;
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
canScroll: () => isAutoScrollingAllowed,
}),
);
// Scroll container is now looking over the center of the element
const initialScrollTop = child.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
// scroll container has still not scrolled
expect(ordered).toEqual([]);
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has now been scrolled
expect(parentScrollContainer.scrollTop).toBeLessThan(initialScrollTop);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before - after).toBe(maxScrollPerFrame);
}
// No longer allowing auto scrolling
isAutoScrollingAllowed = false;
// Triggering a scroll - should not scroll the scroll container
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before).toBe(after);
}
// Binding the scrollable element again
isAutoScrollingAllowed = true;
// Triggering another auto scroll - should be the minimum scroll
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before - after).toBe(1);
}
cleanup();
});
it('should reset time dampening if a window scrolling is re-registered in a future frame', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
appendToBody(element),
setElementFromPoint(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
const initialScrollTop = document.documentElement.scrollTop;
let unbindAutoScrolling = autoScrollWindowForElements();
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(document.documentElement.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has now been scrolled
expect(document.documentElement.scrollTop).toBeGreaterThan(initialScrollTop);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after - before).toBe(maxScrollPerFrame);
}
// un-registering window scrolling
unbindAutoScrolling();
// Triggering another auto scroll - should not scroll
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBe(before);
}
unbindAutoScrolling = autoScrollWindowForElements();
// Triggering another auto scroll - should be the minimum scroll
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after - before).toBe(1);
}
unbindAutoScrolling();
cleanup();
});
it('should reset time dampening if a window scrolling is re-enabled in a future frame', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
let isAutoScrollingAllowed: boolean = true;
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
appendToBody(element),
setElementFromPoint(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollWindowForElements({
canScroll: () => isAutoScrollingAllowed,
}),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
const initialScrollTop = document.documentElement.scrollTop;
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
// scroll container has still not scrolled
expect(ordered).toEqual([]);
expect(document.documentElement.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has now been scrolled
expect(document.documentElement.scrollTop).toBeGreaterThan(initialScrollTop);
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after - before).toBe(maxScrollPerFrame);
}
// disabling auto scroll
isAutoScrollingAllowed = false;
// Triggering another auto scroll - should not scroll
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBe(before);
}
// re-enabling auto scrolling
isAutoScrollingAllowed = true;
// Triggering another auto scroll - should be the minimum scroll
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after - before).toBe(1);
}
cleanup();
});
it('should not dampen time after the time dampening period has finished [on original axis]', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Scroll container is now looking over the center of the element
const initialScrollTop = parentScrollContainer.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// just checking I got the math right
expect(initialScrollTop).toBe(500);
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has still been scrolled
expect(parentScrollContainer.scrollTop).toBeLessThan(initialScrollTop);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// just asserting we have a setup that will execute the test correctly
const beforeScrollTop = parentScrollContainer.scrollTop;
expect(beforeScrollTop).toBeLessThan(initialScrollTop);
// our next scroll should have room for more than the max scroll
// (otherwise we are not testing what we expect)
expect(beforeScrollTop).toBeGreaterThan(maxScrollPerFrame);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
advanceTimersToNextFrame();
stepScrollBy();
const afterScrollTop = parentScrollContainer.scrollTop;
expect(beforeScrollTop - afterScrollTop).toBe(maxScrollPerFrame);
cleanup();
});
it('should not dampen time after the time dampening period has finished [on different axis]', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Initial scrolling on the top and left
const initialScrollTop = parentScrollContainer.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
const initialScrollLeft = parentScrollContainer.getBoundingClientRect().width / 2;
parentScrollContainer.scrollLeft = initialScrollLeft;
// checking our initial math is correct
expect(parentScrollContainer.scrollLeft).toBe(500);
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has been scrolled on the top
expect(parentScrollContainer.scrollTop).toBeLessThan(initialScrollTop);
// scroll container has not been scrolled on the left yet
expect(parentScrollContainer.scrollLeft).toBe(initialScrollLeft);
// ensuring we have enough room to do a max scroll
expect(parentScrollContainer.scrollLeft).toBeGreaterThan(maxScrollPerFrame);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
expect(parentScrollContainer.scrollLeft).toBe(initialScrollLeft);
const rect = parentScrollContainer.getBoundingClientRect();
// mid center on left edge
fireEvent.dragOver(child, {
clientX: rect.left,
clientY: rect.top + rect.height,
});
// first frame: this will update the 'input' for a drag (update changes are throttled inside of a frame)
advanceTimersToNextFrame();
stepScrollBy();
expect(initialScrollLeft).toBe(parentScrollContainer.scrollLeft);
// this should now trigger an auto scroll of the max scroll (no time dampening)
advanceTimersToNextFrame();
stepScrollBy();
expect(initialScrollLeft - parentScrollContainer.scrollLeft).toBe(maxScrollPerFrame);
cleanup();
});
// only checking forward direction, as code path is the same as scroll containers
it('should reset time dampening when doing repeated drag operations', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
);
function dragOperation() {
// Scroll container is now looking over the center of the element
const initialScrollTop = child.getBoundingClientRect().height / 2;
parentScrollContainer.scrollTop = initialScrollTop;
// lifting on the top vertical edge of the container
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().top,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll container has still not scrolled
expect(parentScrollContainer.scrollTop).toBe(initialScrollTop);
// on the second frame we are performing our initial scroll
// which will mark the first engagement
// (and will also perform the first scroll)
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has now been scrolled
expect(parentScrollContainer.scrollTop).toBeLessThan(initialScrollTop);
// Complete the time dampening duration
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
// Triggering another frame
// we are expecting the scroll change to be the maximum allowed
{
const before = parentScrollContainer.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = parentScrollContainer.scrollTop;
expect(before - after).toBe(maxScrollPerFrame);
}
userEvent.drop(child);
expect(ordered).toEqual(['draggable:drop', 'dropTarget:drop']);
ordered.length = 0;
}
// Let's do a few drag operations and ensure that the behaviour is the same
dragOperation();
dragOperation();
dragOperation();
dragOperation();
cleanup();
});
it('should apply time dampening for window scrolling', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollWindowForElements(),
setElementFromPoint(element),
bind(window, {
type: 'scroll',
listener(event) {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
options: { capture: true },
}),
);
// setting a large vertical amount of available scroll
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const initialScrollTop = document.documentElement.scrollTop;
const initialScrollLeft = document.documentElement.scrollLeft;
expect(initialScrollTop).toBe(0);
expect(initialScrollLeft).toBe(0);
// starting a drag
userEvent.lift(element);
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// in the first frame before auto scrolling has started
// we are updating our drag so that we are over
fireEvent.dragEnter(document.body, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientTop + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['dropTarget:leave']);
ordered.length = 0;
// in the first frame, there will be no auto scroll
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// checking window has not been scrolled
expect(document.documentElement.scrollTop).toBe(initialScrollTop);
let lastScrollTop = initialScrollTop;
let lastScrollChange = 0;
let engagementStart: number | null = null;
// tracking the various cases to make sure we are actually hitting them
const casesHit = {
acceleration: false,
'time-dampening-finished': false,
'time-dampening-finished-last-scroll': false,
};
const maxScrollTop =
document.documentElement.scrollHeight - document.documentElement.clientHeight;
// Keep going until we cannot scroll any more
while (document.documentElement.scrollTop <= maxScrollTop) {
advanceTimersToNextFrame();
stepScrollBy();
// asserting that one scroll event has occurred
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
// Engagement not set until first active scroll
if (!engagementStart) {
engagementStart = Date.now();
}
const currentScrollTop = document.documentElement.scrollTop;
/**
* Sometimes minus can run into IEEE 754 floating point math issues.
* Example: `256.4 - 241.4` is `14.99999999999972` and not `15` 😮💨
* Never use `.toBe()` with `scrollChange`, only `.toBeCloseTo()`
* or other _not strictly equal_ assertions (eg `toBeGreaterThan()`)
* (which takes into account floating point issues).
**/
const scrollChange = currentScrollTop - lastScrollTop;
// we are scrolling forward so our change will be positive
expect(scrollChange).toBeGreaterThan(0);
lastScrollTop = currentScrollTop;
const now = Date.now();
const duration = now - engagementStart;
// Case 1: in the time dampening period
if (duration < defaultConfig.timeDampeningDurationMs) {
// We are still not at the max scroll speed
expect(scrollChange).not.toBeGreaterThan(defaultConfig.maxPixelScrollPerSecond);
// Each scroll is bigger than the last
expect(scrollChange).toBeGreaterThan(lastScrollChange);
casesHit.acceleration = true;
continue;
}
// Case 2: scrolling at max speed, but not finished scrolling
// Expecting max scroll speed
if (document.documentElement.scrollTop < maxScrollTop) {
expect(scrollChange).toBeCloseTo(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished'] = true;
continue;
}
// Case 3: the last scroll finished the scrolling of the element.
// The last scroll could be slightly less than the max scroll amount
// as there might not have been the max scroll amount left to scroll
expect(scrollChange).toBeLessThanOrEqual(defaultConfig.maxPixelScrollPerSecond / 60);
casesHit['time-dampening-finished-last-scroll'] = true;
// We can finish here (even though the exit condition would catch us too)
break;
}
// scroll container has been scrolled all the way to the top
expect(document.documentElement.scrollTop).toBe(maxScrollTop);
// asserting all our cases where hit
expect(casesHit.acceleration).toBe(true);
expect(casesHit['time-dampening-finished']).toBe(true);
expect(casesHit['time-dampening-finished-last-scroll']).toBe(true);
// scrollLeft should not have changed
expect(document.documentElement.scrollLeft).toBe(initialScrollLeft);
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/over-element/window-scrolling.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import {
autoScrollForElements,
autoScrollWindowForElements,
} from '../../../src/entry-point/element';
import {
advanceTimersToNextFrame,
appendToBody,
getBubbleOrderedTree,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
beforeEach(() => {
document.documentElement.scrollTop = 0;
});
function canScrollOnBottom(element: HTMLElement): boolean {
return Math.ceil(element.scrollTop) + element.clientHeight < element.scrollHeight;
}
test('validating initial properties are set by jsdom', () => {
expect(document.documentElement.clientHeight).toBe(768);
expect(document.documentElement.clientWidth).toBe(1024);
expect(document.documentElement.clientTop).toBe(0);
expect(document.documentElement.clientLeft).toBe(0);
expect(document.documentElement.scrollTop).toBe(0);
expect(document.documentElement.scrollLeft).toBe(0);
});
it('should scroll the window if it is scrollable', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
appendToBody(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
autoScrollWindowForElements(),
setElementFromPoint(element),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// next frame should scroll the window
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBeGreaterThan(before);
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
cleanup();
});
it('should not warn if there are multiple registrations', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const cleanup = combine(
autoScrollWindowForElements(),
autoScrollWindowForElements(),
autoScrollWindowForElements(),
);
expect(warn).not.toHaveBeenCalled();
cleanup();
warn.mockRestore();
});
it('should only scroll the window once, even if there are other registrations', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
// multiple registrations
const unregister1 = autoScrollWindowForElements();
const unregister2 = autoScrollWindowForElements();
const cleanup = combine(
appendToBody(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(element),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// next frame should scroll the window
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBeGreaterThan(before);
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
}
// removing one registration, scroll should still occur
unregister1();
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBeGreaterThan(before);
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
}
// removing final registration, scroll should not occur
unregister2();
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBe(before);
expect(ordered).toEqual([]);
ordered.length = 0;
}
cleanup();
});
it('should only scroll the window if there are any registrations that have canScroll: () => true', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
appendToBody(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
autoScrollWindowForElements({
canScroll: () => {
ordered.push('1. canScroll: false');
return false;
},
}),
autoScrollWindowForElements({
canScroll: () => {
ordered.push('2. canScroll: true');
return true;
},
}),
autoScrollWindowForElements({
canScroll: () => {
ordered.push('3. canScroll: false');
return false;
},
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(element),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// next frame should scroll the window
{
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBeGreaterThan(before);
expect(ordered).toEqual([
'1. canScroll: false',
'2. canScroll: true',
// '3. canScroll: false' should not be called as it was not needed
'window:scroll',
]);
ordered.length = 0;
}
cleanup();
});
it('should not scroll the window if there are no active registrations', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
let unregisterAutoScrolling = autoScrollWindowForElements();
const cleanup = combine(
appendToBody(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(element),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// next frame should scroll the window
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
// now un registering our auto scroll
unregisterAutoScrolling();
// an auto scroll on the window should not occur in the next frame
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// enabling auto scrolling again
unregisterAutoScrolling = autoScrollWindowForElements();
// an auto scroll should occur in the next frame
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['window:scroll']);
unregisterAutoScrolling();
cleanup();
});
it('should not scroll the window if no registrations are allowing scrolling', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
let allowScrolling: boolean = true;
const cleanup = combine(
appendToBody(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
autoScrollWindowForElements({
canScroll: () => allowScrolling,
}),
autoScrollWindowForElements({
canScroll: () => allowScrolling,
}),
autoScrollWindowForElements({
canScroll: () => allowScrolling,
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(element),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientLeft + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// next frame should scroll the window
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
// now un registering our auto scroll
allowScrolling = false;
// an auto scroll on the window should not occur in the next frame
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// scrolling allowed again
allowScrolling = true;
// we should now expect to see auto scrolling enabled again
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['window:scroll']);
cleanup();
});
it('should scroll the window once, even if there are multiple registrations', () => {
const [element] = getBubbleOrderedTree();
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
// multiple registrations - only one allowing scrolling
autoScrollWindowForElements(),
autoScrollWindowForElements(),
autoScrollWindowForElements(),
autoScrollWindowForElements(),
autoScrollWindowForElements(),
appendToBody(element),
draggable({
element: element,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: element,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(element),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
userEvent.lift(element, {
clientX: document.documentElement.clientLeft + document.documentElement.clientWidth / 2,
clientY: document.documentElement.clientTop + document.documentElement.clientHeight,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// next frame should scroll the window
const before = document.documentElement.scrollTop;
advanceTimersToNextFrame();
stepScrollBy();
const after = document.documentElement.scrollTop;
expect(after).toBeGreaterThan(before);
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
cleanup();
});
it('should scroll a scroll container before the window', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer({
child: {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight * 20,
},
scrollContainer: {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
},
});
const ordered: string[] = [];
// Setting some large scroll height on the window
Object.defineProperties(document.documentElement, {
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
autoScrollWindowForElements(),
autoScrollForElements({
element: parentScrollContainer,
}),
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
if (event.target === parentScrollContainer) {
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
// scroll the scroll parent until it is finished
// (window should not be scrolled yet)
{
const hit = jest.fn();
while (canScrollOnBottom(parentScrollContainer)) {
hit();
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['parent:scroll']);
ordered.length = 0;
}
expect(hit).toHaveBeenCalled();
}
// window should now scroll
{
const hit = jest.fn();
while (canScrollOnBottom(document.documentElement)) {
hit();
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['window:scroll']);
ordered.length = 0;
}
expect(hit).toHaveBeenCalled();
}
// asserting there is nothing left to scroll
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
cleanup();
});
it('should not scroll the window if a scroll container absorbed all the scroll axis', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer({
child: {
// won't be scrolling the scroll parent on the horizontal axis
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight * 20,
},
scrollContainer: {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
},
});
const ordered: string[] = [];
// Setting some large scroll width and height on the window
Object.defineProperties(document.documentElement, {
scrollWidth: {
value: document.documentElement.clientWidth * 10,
writable: false,
},
scrollHeight: {
value: document.documentElement.clientHeight * 10,
writable: false,
},
});
const cleanup = combine(
autoScrollWindowForElements(),
autoScrollForElements({
element: parentScrollContainer,
}),
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === document.documentElement) {
ordered.push('window:scroll');
return;
}
if (event.target === parentScrollContainer) {
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
// lifting on bottom right
// expecting to scroll vertically on the scroll container
// and horizontally on the window
userEvent.lift(child, {
clientX: parentScrollContainer.getBoundingClientRect().right,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
expect(ordered).toEqual([]);
const parentBefore = {
scrollTop: parentScrollContainer.scrollTop,
scrollLeft: parentScrollContainer.scrollLeft,
};
const windowBefore = {
scrollTop: document.documentElement.scrollTop,
scrollLeft: document.documentElement.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const parentAfter = {
scrollTop: parentScrollContainer.scrollTop,
scrollLeft: parentScrollContainer.scrollLeft,
};
const windowAfter = {
scrollTop: document.documentElement.scrollTop,
scrollLeft: document.documentElement.scrollLeft,
};
expect(ordered).toEqual(['parent:scroll', 'window:scroll']);
ordered.length = 0;
// expecting vertical scroll on the scroll container,
// and because the scroll container cannot scroll horizontally,
// we should see some horizontal scrolling on the window
expect(parentAfter.scrollTop).toBeGreaterThan(parentBefore.scrollTop);
expect(parentAfter.scrollLeft).toBe(parentBefore.scrollLeft);
expect(windowAfter.scrollLeft).toBeGreaterThan(windowBefore.scrollLeft);
expect(windowAfter.scrollTop).toBe(windowBefore.scrollTop);
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/overflow/allowed-axis.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { unsafeOverflowAutoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { type AllowedAxis } from '../../../src/internal-types';
import {
advanceTimersToNextFrame,
appendToBody,
type AxisScroll,
type Event,
getAxisScroll,
getExpectedEvents,
getScenarios,
hasAxisScrolled,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
const OUTSIDE_OFFSET = 10;
describe('allowed axis', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const originalScrollTop = parentScrollContainer.scrollTop;
const originalScrollLeft = parentScrollContainer.scrollLeft;
afterEach(() => {
parentScrollContainer.scrollTop = originalScrollTop;
parentScrollContainer.scrollLeft = originalScrollLeft;
});
getScenarios(parentScrollContainer.getBoundingClientRect(), OUTSIDE_OFFSET).forEach(
({ label, startPosition, endPosition, expectedMovement }) => {
it(`should only scroll on axis that are allowed - ${label}`, () => {
const events: Event[] = [];
let allowedAxis: AllowedAxis = 'all';
let axisScroll: AxisScroll;
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => events.push({ type: 'draggable:start' }),
}),
dropTargetForElements({
element: child,
onDragStart: () => events.push({ type: 'dropTarget:start' }),
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
getOverflow: () => ({
forTopEdge: {
top: 100,
right: 100,
left: 100,
},
forRightEdge: {
top: 100,
right: 100,
bottom: 100,
},
forBottomEdge: {
right: 100,
bottom: 100,
left: 100,
},
forLeftEdge: {
top: 100,
left: 100,
bottom: 100,
},
}),
getAllowedAxis: () => allowedAxis,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener: (_event) => {
events.push({
type: 'scroll event',
...hasAxisScrolled(parentScrollContainer, axisScroll),
});
axisScroll = getAxisScroll(parentScrollContainer);
},
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = 500;
parentScrollContainer.scrollLeft = 500;
axisScroll = getAxisScroll(parentScrollContainer);
userEvent.lift(child, {
clientX: startPosition.x,
clientY: startPosition.y,
});
expect(events).toEqual([{ type: 'draggable:start' }, { type: 'dropTarget:start' }]);
events.length = 0;
// First frame: allowedAxis is all.
// Expecting no scroll to occur.
// We don't know what the scroll speed should be until a single frame has passed.
advanceTimersToNextFrame();
stepScrollBy();
expect(events).toEqual([]);
fireEvent.dragOver(document.body, {
clientX: endPosition.x,
clientY: endPosition.y,
});
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
// Second frame: allowedAxis is all.
// Expecting a scroll to occur on expected axis.
advanceTimersToNextFrame();
stepScrollBy();
const movement = {
...expectedMovement,
};
const expectedEvents = getExpectedEvents(movement);
expect(events).toEqual(expectedEvents);
events.length = 0;
// Third frame: allowedAxis is vertical.
// Expecting a scroll to occur on expected axis, except horizontal.
// If neither are expected, expect no scroll.
allowedAxis = 'vertical';
advanceTimersToNextFrame();
stepScrollBy();
const verticalMovement = {
...expectedMovement,
horizontal: false,
};
const expectedVerticalEvents = getExpectedEvents(verticalMovement);
expect(events).toEqual(expectedVerticalEvents);
events.length = 0;
// Fourth frame: allowedAxis is horizontal.
// Expecting a scroll to occur on expected axis, except vertical.
// If neither are expected, expect no scroll.
allowedAxis = 'horizontal';
advanceTimersToNextFrame();
stepScrollBy();
const horizontalMovement = {
...expectedMovement,
vertical: false,
};
const expectedHorizontalEvents = getExpectedEvents(horizontalMovement);
expect(events).toEqual(expectedHorizontalEvents);
cleanup();
});
},
);
});
================================================
FILE: packages/auto-scroll/__tests__/unit/overflow/can-scroll.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { unsafeOverflowAutoScrollForElements } from '../../../src/entry-point/unsafe-overflow/element';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
setupNestedScrollContainers,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
it('should not scroll scroll containers that have canScroll: () => false', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
let isAutoScrollingAllowed: boolean = true;
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
canScroll: () => isAutoScrollingAllowed,
getOverflow: () => ({
forBottomEdge: {
left: 0,
right: 0,
bottom: 100,
},
}),
}),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push('scroll event'),
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = 500;
parentScrollContainer.scrollLeft = 500;
// lifting on mid point
// This will not cause auto scrolling as we have not setup the "over element" auto scroller
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width,
clientY:
parentScrollContainer.getBoundingClientRect().top +
parentScrollContainer.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: no auto scroll will occur as we have not registered "over element" overflow scrolling
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
ordered.length = 0;
fireEvent.dragOver(document.body, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width,
clientY: parentScrollContainer.getBoundingClientRect().bottom + 1,
});
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
// expecting overflow auto scroll in next frame
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
isAutoScrollingAllowed = false;
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// re-enabling
isAutoScrollingAllowed = true;
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
cleanup();
});
it('should allow earlier registrations to scroll when a later registration has canScroll: () => false', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
{ width: 5000, height: 5000 },
// grandparent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
let isParentScrollingAllowed: boolean = true;
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
// Important for this test: grandParent is registered before parent
// We are checking that blocking scrolling on `parent` does not stop `grandParent`
// from scrolling.
unsafeOverflowAutoScrollForElements({
element: grandParent,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
unsafeOverflowAutoScrollForElements({
element: parent,
canScroll: () => isParentScrollingAllowed,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// Set some initial scroll on the scroll containers
// These are in the range where auto scrolling will occur on both
parent.scrollTop = 60;
grandParent.scrollTop = 120;
// lifting the mid point
userEvent.lift(child, {
clientX:
grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width / 2,
clientY:
grandParent.getBoundingClientRect().top + grandParent.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// on second frame there will be no auto scrolling as we have not set up "over element"
// auto scrolling
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
fireEvent.dragOver(document.body, {
clientX: grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width,
clientY: grandParent.getBoundingClientRect().top - 1,
});
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
// expecting to now scroll both
advanceTimersToNextFrame();
stepScrollBy();
// grand parent will scroll first as it was registered first
expect(ordered).toEqual(['grandParent:scroll', 'parent:scroll']);
ordered.length = 0;
isParentScrollingAllowed = false;
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['grandParent:scroll']);
cleanup();
});
it('should allow later registrations to scroll when an earlier registration has canScroll: () => false', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
{ width: 5000, height: 5000 },
// grandparent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
let isParentScrollingAllowed: boolean = true;
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
// Important for this test: `parent` is registered before `grandParent`.
// We are validating that blocking scrolling on `parent` should not stop
// the scrolling of `grandParent`.
unsafeOverflowAutoScrollForElements({
element: parent,
canScroll: () => isParentScrollingAllowed,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
unsafeOverflowAutoScrollForElements({
element: grandParent,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// Set some initial scroll on the scroll containers
// These are in the range where auto scrolling will occur on both
parent.scrollTop = 60;
grandParent.scrollTop = 120;
// lifting the mid point
userEvent.lift(child, {
clientX:
grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width / 2,
clientY:
grandParent.getBoundingClientRect().top + grandParent.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// on second frame there will be no auto scrolling as we have not set up "over element"
// auto scrolling
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
fireEvent.dragOver(document.body, {
clientX: grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width,
clientY: grandParent.getBoundingClientRect().top - 1,
});
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
// expecting to now scroll both
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['parent:scroll', 'grandParent:scroll']);
ordered.length = 0;
isParentScrollingAllowed = false;
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['grandParent:scroll']);
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/overflow/cross-axis-hitbox.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { unsafeOverflowAutoScrollForElements } from '../../../src/entry-point/unsafe-overflow/element';
import { type Axis, type Edge, type Side } from '../../../src/internal-types';
import { axisLookup } from '../../../src/shared/axis';
import { getInternalConfig } from '../../../src/shared/configuration';
import { getOverElementHitbox } from '../../../src/shared/get-over-element-hitbox';
import { mainAxisSideLookup } from '../../../src/shared/side';
import {
advanceTimersToNextFrame,
appendToBody,
getInsidePoints,
getOutsidePoints,
getRect,
mainAxisForSide,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
const { child, parentScrollContainer } = setupBasicScrollContainer();
const defaultConfig = getInternalConfig();
beforeEach(() => {
// setting some initial scroll so the element
// can be scrolled in any direction
parentScrollContainer.scrollTop = 10;
parentScrollContainer.scrollLeft = 10;
});
type Scenario = {
edge: Edge;
hitbox: DOMRect;
};
const overflowSizeOnMainAxis: number = 100;
const overflowSizeOnEachSideOfCrossAxis: number = 200;
const overflow: Required<
ReturnType[0]['getOverflow']>
> = {
forBottomEdge: {
right: overflowSizeOnEachSideOfCrossAxis,
left: overflowSizeOnEachSideOfCrossAxis,
bottom: overflowSizeOnMainAxis,
},
forTopEdge: {
right: overflowSizeOnEachSideOfCrossAxis,
left: overflowSizeOnEachSideOfCrossAxis,
top: overflowSizeOnMainAxis,
},
forRightEdge: {
right: overflowSizeOnMainAxis,
top: overflowSizeOnEachSideOfCrossAxis,
bottom: overflowSizeOnEachSideOfCrossAxis,
},
forLeftEdge: {
left: overflowSizeOnMainAxis,
top: overflowSizeOnEachSideOfCrossAxis,
bottom: overflowSizeOnEachSideOfCrossAxis,
},
};
const parentRect: DOMRect = parentScrollContainer.getBoundingClientRect();
function getOverElementMainHitboxSize(edge: Edge) {
const overElementHitbox = getOverElementHitbox[edge]({
clientRect: parentRect,
config: defaultConfig,
});
const axis: Axis = mainAxisForSide[edge];
const { mainAxis } = axisLookup[axis];
return overElementHitbox[mainAxis.size];
}
const scenarios: Scenario[] = [
{
edge: 'top',
hitbox: getRect({
// Main axis
top: parentRect.top - overflowSizeOnMainAxis,
// pushing down into the element
bottom: parentRect.top + getOverElementMainHitboxSize('top'),
// Cross axis
right: parentRect.right + overflowSizeOnEachSideOfCrossAxis,
left: parentRect.left - overflowSizeOnEachSideOfCrossAxis,
}),
},
{
edge: 'bottom',
hitbox: getRect({
// Main axis
bottom: parentRect.bottom + overflowSizeOnMainAxis,
// pulling up into the element
top: parentRect.bottom - getOverElementMainHitboxSize('bottom'),
// Cross axis
right: parentRect.right + overflowSizeOnEachSideOfCrossAxis,
left: parentRect.left - overflowSizeOnEachSideOfCrossAxis,
}),
},
{
edge: 'left',
hitbox: getRect({
// main axis
left: parentRect.left - overflowSizeOnMainAxis,
// push into the element
right: parentRect.left + getOverElementMainHitboxSize('left'),
// cross axis
top: parentRect.top - overflowSizeOnEachSideOfCrossAxis,
bottom: parentRect.bottom + overflowSizeOnEachSideOfCrossAxis,
}),
},
{
edge: 'right',
hitbox: getRect({
// main axis
right: parentRect.right + overflowSizeOnMainAxis,
// pull back into the element
left: parentRect.right - getOverElementMainHitboxSize('right'),
// cross axis
top: parentRect.top - overflowSizeOnEachSideOfCrossAxis,
bottom: parentRect.bottom + overflowSizeOnEachSideOfCrossAxis,
}),
},
];
scenarios.forEach((scenario) => {
const axis: Axis = mainAxisForSide[scenario.edge];
const side: Side = mainAxisSideLookup[scenario.edge];
const mainAxisScrollProperty = axis === 'vertical' ? 'scrollTop' : 'scrollLeft';
const crossAxisScrollProperty =
mainAxisScrollProperty === 'scrollTop' ? 'scrollLeft' : 'scrollTop';
describe(`Scenario edge: ${scenario.edge}`, () => {
getInsidePoints(scenario.hitbox)
// We don't want to include the 'center' as that could be
// over the element, which would not trigger an overflow scroll
.filter((point) => point.label !== 'center')
.forEach((point) => {
it(`should scroll the main axis when in the cross axis hitbox for that edge. Point: [${point.label}]`, () => {
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: parentScrollContainer,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
getOverflow: () => overflow,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push(`scroll event`),
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// lifting in middle of element, should not trigger auto scrolling
userEvent.lift(child, {
clientX: parentRect.left + parentRect.width / 2,
clientY: parentRect.top + parentRect.height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll,
// as we don't know what the scroll speed should be
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// not expecting auto scrolling on the second frame as we are
// over the second of the element
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// updating where we are to trigger auto scrolling
// we will now be outside the drop target
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
fireEvent.dragEnter(document.body, {
clientX: point.x,
clientY: point.y,
});
expect(ordered).toEqual(['dropTarget:leave']);
ordered.length = 0;
// the drop target changed will be picked up in the next frame
const before = {
scrollTop: parentScrollContainer.scrollTop,
scrollLeft: parentScrollContainer.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const after = {
scrollTop: parentScrollContainer.scrollTop,
scrollLeft: parentScrollContainer.scrollLeft,
};
// only a single scroll event occurred
expect(ordered).toEqual(['scroll event']);
// scrolling forward when on the "end" edge
if (side === 'end') {
expect(after[mainAxisScrollProperty]).toBeGreaterThan(before[mainAxisScrollProperty]);
// Should be scrolling backwards on the "start" edge
} else {
expect(after[mainAxisScrollProperty]).toBeLessThan(before[mainAxisScrollProperty]);
}
// scroll axis should not have been scrolled
expect(before[crossAxisScrollProperty]).toBe(after[crossAxisScrollProperty]);
cleanup();
});
});
getOutsidePoints(scenario.hitbox).forEach((point) => {
it(`should not scroll the main axis when outside cross axis overflow for that edge. Point: [${point.label}]`, () => {
const ordered: string[] = [];
const fromEdge: ReturnType<
Parameters[0]['getOverflow']
> = (() => {
if (scenario.edge === 'top') {
return { forTopEdge: overflow.forTopEdge };
}
if (scenario.edge === 'bottom') {
return { forBottomEdge: overflow.forBottomEdge };
}
if (scenario.edge === 'left') {
return { forLeftEdge: overflow.forLeftEdge };
}
if (scenario.edge === 'right') {
return { forRightEdge: overflow.forRightEdge };
}
throw Error('unhandled');
})();
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: parentScrollContainer,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
getOverflow: () => fromEdge,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push(`scroll event`),
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// lifting in middle of element, should not trigger auto scrolling
userEvent.lift(child, {
clientX: parentRect.left + parentRect.width / 2,
clientY: parentRect.top + parentRect.height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll,
// as we don't know what the scroll speed should be
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// not expecting auto scrolling on the second frame as we are
// over the second of the element
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// updating where we are to trigger auto scrolling
// we will now be outside the drop target
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
fireEvent.dragEnter(document.body, {
clientX: point.x,
clientY: point.y,
});
expect(ordered).toEqual(['dropTarget:leave']);
ordered.length = 0;
// the drop target changed will be picked up in the next frame
// we are expecting no scroll to have occurred
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
cleanup();
});
});
});
});
================================================
FILE: packages/auto-scroll/__tests__/unit/overflow/main-axis-hitbox.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import invariant from 'tiny-invariant';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { unsafeOverflowAutoScrollForElements } from '../../../src/entry-point/unsafe-overflow/element';
import { type Axis, type Edge, type Side } from '../../../src/internal-types';
import { isWithin } from '../../../src/shared/is-within';
import { mainAxisSideLookup } from '../../../src/shared/side';
import {
advanceTimersToNextFrame,
appendToBody,
getInsidePoints,
getOutsidePoints,
getRect,
mainAxisForSide,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
const { child, parentScrollContainer } = setupBasicScrollContainer();
beforeEach(() => {
// setting some initial scroll so the element
// can be scrolled in any direction
parentScrollContainer.scrollTop = 10;
parentScrollContainer.scrollLeft = 10;
});
type Scenario = {
edge: Edge;
hitbox: DOMRect;
};
const overflowSizeOnMainAxis: number = 100;
const overflow: Required<
ReturnType[0]['getOverflow']>
> = {
forBottomEdge: {
right: 0,
left: 0,
bottom: overflowSizeOnMainAxis,
},
forTopEdge: {
right: 0,
left: 0,
top: overflowSizeOnMainAxis,
},
forRightEdge: {
right: overflowSizeOnMainAxis,
top: 0,
bottom: 0,
},
forLeftEdge: {
left: overflowSizeOnMainAxis,
top: 0,
bottom: 0,
},
};
const parentRect: DOMRect = parentScrollContainer.getBoundingClientRect();
const scenarios: Scenario[] = [
{
edge: 'top',
hitbox: getRect({
top: parentRect.top - overflowSizeOnMainAxis,
right: parentRect.right,
// (the first pixel is "cut out" by the "over element" auto scroller)
// bottom: parentRect.top,
bottom: parentRect.top - 1,
left: parentRect.left,
}),
},
{
edge: 'bottom',
hitbox: getRect({
// (the first pixel is "cut out" by the "over element" auto scroller)
top: parentRect.bottom + 1,
right: parentRect.right,
bottom: parentRect.bottom + overflowSizeOnMainAxis,
left: parentRect.left,
}),
},
{
edge: 'left',
hitbox: getRect({
top: parentRect.top,
// (the first pixel is "cut out" by the "over element" auto scroller)
right: parentRect.left - 1,
bottom: parentRect.bottom,
left: parentRect.left - overflowSizeOnMainAxis,
}),
},
{
edge: 'right',
hitbox: getRect({
top: parentRect.top,
right: parentRect.right + overflowSizeOnMainAxis,
bottom: parentRect.bottom,
// (the first pixel is "cut out" by the "over element" auto scroller)
left: parentRect.right + 1,
}),
},
];
scenarios.forEach((scenario) => {
const axis: Axis = mainAxisForSide[scenario.edge];
const side: Side = mainAxisSideLookup[scenario.edge];
const mainAxisScrollProperty = axis === 'vertical' ? 'scrollTop' : 'scrollLeft';
const crossAxisScrollProperty =
mainAxisScrollProperty === 'scrollTop' ? 'scrollLeft' : 'scrollTop';
describe(`Scenario edge: ${scenario.edge}`, () => {
getInsidePoints(scenario.hitbox).forEach((point) => {
it(`should scroll when in the main axis overflow. Point: [${point.label}]`, () => {
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: parentScrollContainer,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
getOverflow: () => overflow,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push(`scroll event`),
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// lifting in middle of element, should not trigger auto scrolling
userEvent.lift(child, {
clientX: parentRect.left + parentRect.width / 2,
clientY: parentRect.top + parentRect.height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll,
// as we don't know what the scroll speed should be
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// not expecting auto scrolling on the second frame as we are
// over the second of the element
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// updating where we are to trigger auto scrolling
// we will now be outside the drop target
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
fireEvent.dragEnter(document.body, {
clientX: point.x,
clientY: point.y,
});
expect(ordered).toEqual(['dropTarget:leave']);
ordered.length = 0;
// the drop target changed will be picked up in the next frame
const before = {
scrollTop: parentScrollContainer.scrollTop,
scrollLeft: parentScrollContainer.scrollLeft,
};
advanceTimersToNextFrame();
stepScrollBy();
const after = {
scrollTop: parentScrollContainer.scrollTop,
scrollLeft: parentScrollContainer.scrollLeft,
};
// scrolling forward when on the "end" edge
if (side === 'end') {
expect(after[mainAxisScrollProperty]).toBeGreaterThan(before[mainAxisScrollProperty]);
// Should be scrolling backwards on the "start" edge
} else {
expect(after[mainAxisScrollProperty]).toBeLessThan(before[mainAxisScrollProperty]);
}
// scroll axis should not have been scrolled
expect(before[crossAxisScrollProperty]).toBe(after[crossAxisScrollProperty]);
// only a single scroll event occurred
expect(ordered).toEqual(['scroll event']);
cleanup();
});
});
// need to exclude points that would be over the main parent rect and would usually
// be excluded by our `elementFromPoint()` check.
const relevantOutsidePoints = getOutsidePoints(scenario.hitbox).filter((point) => {
const isOverParent = isWithin({
client: point,
clientRect: parentRect,
});
return !isOverParent;
});
invariant(relevantOutsidePoints.length > 1);
relevantOutsidePoints.forEach((point) => {
it(`should not scroll when outside the main axis overflow. Point: [${point.label}]`, () => {
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: parentScrollContainer,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
getOverflow: () => overflow,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push(`scroll event`),
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// lifting in middle of element, should not trigger auto scrolling
userEvent.lift(child, {
clientX: parentRect.left + parentRect.width / 2,
clientY: parentRect.top + parentRect.height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll,
// as we don't know what the scroll speed should be
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// not expecting auto scrolling on the second frame as we are
// over the second of the element
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// updating where we are to trigger auto scrolling
// we will now be outside the drop target
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(document.body);
fireEvent.dragEnter(document.body, {
clientX: point.x,
clientY: point.y,
});
expect(ordered).toEqual(['dropTarget:leave']);
ordered.length = 0;
// the drop target changed will be picked up in the next frame
// we are expecting no scroll to have occurred
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
cleanup();
});
});
});
});
================================================
FILE: packages/auto-scroll/__tests__/unit/overflow/nested-elements.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { type Position } from '@atlaskit/pragmatic-drag-and-drop/types';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { unsafeOverflowAutoScrollForElements } from '../../../src/entry-point/unsafe-overflow/element';
import { isWithin } from '../../../src/shared/is-within';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupNestedScrollContainers,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
test('a parent should allow a child to be overflow scrolled (when the parent is registered before the child)', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
// We are making space above the the `parent` so
// that it can have overflow scrolling inside of the `grandParent`.
// We want to check that the `grandParent` does not block
// auto scrolling of it's children
{ width: 5000, height: 5000, x: 0, y: 10 },
// grandparent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
// Registering the `grandParent` first.
// We are testing that when `grandParent` cannot be scrolled,
// `parent` still can be.
unsafeOverflowAutoScrollForElements({
element: grandParent,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
unsafeOverflowAutoScrollForElements({
element: parent,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// Set some initial scroll on the scroll containers
// These are in the range where auto scrolling will occur on both
parent.scrollTop = 2;
grandParent.scrollTop = 2;
// lifting the mid point visible area
userEvent.lift(child, {
clientX:
grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width / 2,
clientY:
grandParent.getBoundingClientRect().top + grandParent.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// on second frame there will be no auto scrolling as we have not set up "over element"
// auto scrolling
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
const aboveParent: Position = {
x: grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width / 2,
// over grandParent, but in the overflow scroll area of parent
y: grandParent.getBoundingClientRect().top + 2,
};
// validating our point is where we expect
expect(
isWithin({
client: aboveParent,
clientRect: grandParent.getBoundingClientRect(),
}),
).toBe(true);
expect(
isWithin({
client: aboveParent,
clientRect: parent.getBoundingClientRect(),
}),
).toBe(false);
// moving above parent sibling
fireEvent.dragOver(grandParent, {
clientX: aboveParent.x,
clientY: aboveParent.y,
});
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(grandParent);
// now expecting just the parent to scroll
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['parent:scroll']);
ordered.length = 0;
cleanup();
});
test('a parent should allow a child to be overflow scrolled (when the parent is registered after the child)', () => {
const [child, parent, grandParent] = setupNestedScrollContainers([
// child
{ width: 10000, height: 10000 },
// parent
// We are making space above the the `parent` so
// that it can have overflow scrolling inside of the `grandParent`.
// We want to check that the `grandParent` does not block
// auto scrolling of it's children
{ width: 5000, height: 5000, x: 0, y: 10 },
// grandparent,
{ width: 1000, height: 1000 },
]);
const ordered: string[] = [];
const cleanup = combine(
appendToBody(grandParent),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('child:start'),
onDrop: () => ordered.push('child:drop'),
}),
dropTargetForElements({
element: parent,
onDragStart: () => ordered.push('parent:start'),
onDrop: () => ordered.push('parent:drop'),
}),
dropTargetForElements({
element: grandParent,
onDragStart: () => ordered.push('grandParent:start'),
onDrop: () => ordered.push('grandParent:drop'),
}),
// Registering the `parent` first.
// We are testing that when `grandParent` cannot be scrolled,
// `parent` still can be.
unsafeOverflowAutoScrollForElements({
element: parent,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
unsafeOverflowAutoScrollForElements({
element: grandParent,
getOverflow: () => ({
forTopEdge: {
left: 0,
right: 0,
top: 100,
},
}),
}),
setElementFromPoint(child),
bind(window, {
type: 'scroll',
listener: (event) => {
if (event.target === grandParent) {
ordered.push('grandParent:scroll');
return;
}
if (event.target === parent) {
// console.log('parent', parent.scrollTop, parent.scrollLeft);
ordered.push('parent:scroll');
return;
}
ordered.push('unknown:scroll');
},
// scroll events do not bubble, so leveraging the capture phase
options: { capture: true },
}),
);
let unsetElementFromPoint = setElementFromPoint(child);
// Set some initial scroll on the scroll containers
// These are in the range where auto scrolling will occur on both
parent.scrollTop = 2;
grandParent.scrollTop = 2;
// lifting the mid point visible area
userEvent.lift(child, {
clientX:
grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width / 2,
clientY:
grandParent.getBoundingClientRect().top + grandParent.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'child:start', 'parent:start', 'grandParent:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// on second frame there will be no auto scrolling as we have not set up "over element"
// auto scrolling
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
const aboveParent: Position = {
x: grandParent.getBoundingClientRect().left + grandParent.getBoundingClientRect().width / 2,
// over grandParent, but in the overflow scroll area of parent
y: grandParent.getBoundingClientRect().top + 2,
};
// validating our point is where we expect
expect(
isWithin({
client: aboveParent,
clientRect: grandParent.getBoundingClientRect(),
}),
).toBe(true);
expect(
isWithin({
client: aboveParent,
clientRect: parent.getBoundingClientRect(),
}),
).toBe(false);
// moving above parent sibling
fireEvent.dragOver(grandParent, {
clientX: aboveParent.x,
clientY: aboveParent.y,
});
unsetElementFromPoint();
unsetElementFromPoint = setElementFromPoint(grandParent);
// now expecting just the parent to scroll
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['parent:scroll']);
ordered.length = 0;
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/overflow/over-element-should-not-scroll.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { skipAutoA11yFile } from '@atlassian/a11y-jest-testing';
import { unsafeOverflowAutoScrollForElements } from '../../../src/entry-point/unsafe-overflow/element';
import { getInternalConfig } from '../../../src/shared/configuration';
import {
advanceTimersToNextFrame,
appendToBody,
getInsidePoints,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
// This file exposes one or more accessibility violations. Testing is currently skipped but violations need to
// be fixed in a timely manner or result in escalation. Once all violations have been fixed, you can remove
// the next line and associated import. For more information, see go/afm-a11y-tooling:jest
skipAutoA11yFile();
beforeEach(reset);
const { child, parentScrollContainer } = setupBasicScrollContainer();
beforeEach(() => {
parentScrollContainer.scrollTop = 0;
parentScrollContainer.scrollLeft = 0;
});
const defaultConfig = getInternalConfig();
getInsidePoints(parentScrollContainer.getBoundingClientRect()).forEach((point) => {
it(`should not scroll an element when over an element [${point.label}]`, () => {
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
setElementFromPoint(child),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
getOverflow: () => ({
forTopEdge: {
top: 10000,
left: 10000,
right: 1000,
},
forRightEdge: {
top: 10000,
bottom: 10000,
right: 10000,
},
forBottomEdge: {
right: 10000,
bottom: 10000,
left: 10000,
},
forLeftEdge: {
top: 10000,
bottom: 10000,
left: 10000,
},
}),
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
},
}),
);
// setting some initial scroll
parentScrollContainer.scrollLeft = 100;
parentScrollContainer.scrollTop = 100;
// lifting in middle of element, should not trigger auto scrolling
userEvent.lift(child, {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY:
parentScrollContainer.getBoundingClientRect().top +
parentScrollContainer.getBoundingClientRect().height / 2,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll,
// as we don't know what the scroll speed should be
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// not expecting auto scrolling on the second frame as we are
// over the second of the element
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// updating where we are
fireEvent.dragOver(child, point);
// update won't be picked up until after the next frame
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// just being safe and checking nothing will happen
jest.advanceTimersByTime(defaultConfig.timeDampeningDurationMs);
expect(ordered).toEqual([]);
cleanup();
});
});
// TODO: outside edge on main axis
// TODO: inside edge on main axis (but outside of the element)
================================================
FILE: packages/auto-scroll/__tests__/unit/overflow/provided-hitbox-spacing-type.spec.ts
================================================
import { type unsafeOverflowAutoScrollForElements } from '../../../src/entry-point/unsafe-overflow/element';
const isEqual = (a: T): void => {
expect(true).toBe(true);
};
type GetOverflowFn = Parameters[0]['getOverflow'];
type ProvidedHitboxSpacing = ReturnType;
it('should allow to main axis edge', () => {
isEqual({ forTopEdge: { top: 5 } });
isEqual({ forRightEdge: { right: 5 } });
isEqual({ forBottomEdge: { bottom: 5 } });
isEqual({ forLeftEdge: { left: 5 } });
});
it('should allow cross axis definitions', () => {
isEqual({ forTopEdge: { top: 100, left: 2, right: 3 } });
isEqual({ forRightEdge: { right: 100, top: 2, bottom: 3 } });
isEqual({ forBottomEdge: { bottom: 100, left: 2, right: 3 } });
isEqual({ forLeftEdge: { left: 100, top: 2, bottom: 3 } });
});
it('should not allow stretching back into the element', () => {
// @ts-expect-error
isEqual({ forTopEdge: { top: 100, bottom: 10 } });
// @ts-expect-error
isEqual({ forRightEdge: { right: 100, left: 2 } });
// @ts-expect-error
isEqual({ forBottomEdge: { bottom: 100, top: 3 } });
// @ts-expect-error
isEqual({ forLeftEdge: { left: 100, right: 4 } });
});
================================================
FILE: packages/auto-scroll/__tests__/unit/shared/_util.ts
================================================
import invariant from 'tiny-invariant';
const honeyPotSelector = '[data-pdnd-honey-pot]';
export function findHoneyPot(): Element | null {
return document.querySelector(honeyPotSelector);
}
export function getHoneyPot(): HTMLElement {
const possible = document.querySelectorAll(honeyPotSelector);
invariant(possible.length !== 0, `No honey pot element found`);
invariant(possible.length === 1, `Multiple honey pot elements found (expected 1)`);
const [element] = possible;
invariant(element instanceof HTMLElement, 'Honey pot is not a HTMLElement');
return element;
}
================================================
FILE: packages/auto-scroll/__tests__/unit/shared/async-loading.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
beforeEach(reset);
it('should start auto scrolling if imported after a drag has started', async () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
}),
setElementFromPoint(child),
bind(parentScrollContainer, {
type: 'scroll',
listener: () => ordered.push('scroll event'),
}),
);
const onCenterBottom = {
clientX:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
clientY: parentScrollContainer.getBoundingClientRect().bottom,
};
// lifting on mid bottom
userEvent.lift(child, onCenterBottom);
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame: no scroll would occur as we are waiting to know the frame duration
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// on first frame: no scroll as we are not registered for auto scrolling
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// just being safe and checking nothing is pending
fireEvent.dragOver(child, onCenterBottom);
jest.advanceTimersByTime(1000);
expect(ordered).toEqual([]);
// Okay, let's load in our auto scroller
// eslint-disable-next-line import/dynamic-import-chunkname
const { autoScrollForElements } = await import('../../../src/entry-point/element');
const unbindAutoScrolling = autoScrollForElements({
element: parentScrollContainer,
});
// triggering a "dragover" event with no input changes
// this is need for the monitor "onDrag" function to fire
fireEvent.dragOver(child, onCenterBottom);
// first frame: waiting for scheduled `dragOver` event to be released in "onDrag"
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// second frame: waiting for a first frame to finish to know our frame duration
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// third frame: we should get an auto scroll
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
unbindAutoScrolling();
cleanup();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/shared/ignore-honey-pot.spec.ts
================================================
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { autoScrollForElements } from '../../../src/entry-point/element';
import {
advanceTimersToNextFrame,
appendToBody,
getBubbleOrderedPath,
reset,
setElementFromPoint,
setElementFromPointWithPath,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
import { getHoneyPot } from './_util';
jest.useFakeTimers();
setStartSystemTime();
afterEach(reset);
it('should not consider the honey pot when looking up the element the user is over', () => {
const { child, parentScrollContainer } = setupBasicScrollContainer();
const ordered: string[] = [];
const cleanup = combine(
setElementFromPoint(child),
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
}),
dropTargetForElements({
element: child,
onDragStart: () => ordered.push('dropTarget:start'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(
`scroll event {scrollLeft: ${parentScrollContainer.scrollLeft}, scrollTop: ${parentScrollContainer.scrollTop}}`,
);
},
}),
);
// Scroll container is now looking over the center of the element
parentScrollContainer.scrollTop = 500;
parentScrollContainer.scrollLeft = 500;
userEvent.lift(child, { clientX: 1, clientY: 1 });
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
const honeyPot = getHoneyPot();
const path = getBubbleOrderedPath([honeyPot, child, parentScrollContainer]);
const cleanupElementFrom = setElementFromPointWithPath(path);
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual([]);
// Second frame: an auto scroll will occur
advanceTimersToNextFrame();
stepScrollBy();
// Scroll backwards on both axis by 1px
expect(ordered).toEqual(['scroll event {scrollLeft: 499, scrollTop: 499}']);
cleanup();
cleanupElementFrom();
});
================================================
FILE: packages/auto-scroll/__tests__/unit/shared/monitor-binding.spec.ts
================================================
import { reset, setStartSystemTime, setupNestedScrollContainers } from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
beforeEach(reset);
it('should share a single monitor binding between imports', () => {
jest.isolateModules(() => {
jest.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter');
const { monitorForElements } = require('@atlaskit/pragmatic-drag-and-drop/element/adapter');
const { combine } = require('@atlaskit/pragmatic-drag-and-drop/combine');
const [_, parent, grandParent] = setupNestedScrollContainers([
{ width: 10000, height: 10000 },
{ width: 5000, height: 5000 },
{ width: 2000, height: 2000 },
]);
expect(monitorForElements).not.toHaveBeenCalled();
// first import will cause the monitor to be bound to
const {
autoScrollForElements: autoScrollForElements1,
} = require('../../../src/entry-point/element');
expect(monitorForElements).toHaveBeenCalledTimes(1);
// second import will not cause another monitor binding
const {
autoScrollForElements: autoScrollForElements2,
} = require('../../../src/entry-point/element');
expect(monitorForElements).toHaveBeenCalledTimes(1);
// registration will not cause another monitor binding;
const unbind = combine(
autoScrollForElements1({
element: grandParent,
}),
autoScrollForElements2({
element: parent,
}),
);
expect(monitorForElements).toHaveBeenCalledTimes(1);
unbind();
});
});
it('should share a monitor binding between standard and overflow scrolling', () => {
jest.isolateModules(() => {
jest.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter');
const { monitorForElements } = require('@atlaskit/pragmatic-drag-and-drop/element/adapter');
const { combine } = require('@atlaskit/pragmatic-drag-and-drop/combine');
const [_, parent, grandParent] = setupNestedScrollContainers([
{ width: 10000, height: 10000 },
{ width: 5000, height: 5000 },
{ width: 2000, height: 2000 },
]);
expect(monitorForElements).not.toHaveBeenCalled();
// first import will cause the monitor to be bound to
const { autoScrollForElements } = require('../../../src/entry-point/element');
expect(monitorForElements).toHaveBeenCalledTimes(1);
const {
unsafeOverflowAutoScrollForElements,
} = require('../../../src/entry-point/unsafe-overflow/element');
expect(monitorForElements).toHaveBeenCalledTimes(1);
// registration will not cause another monitor binding;
const unbind = combine(
autoScrollForElements({
element: grandParent,
}),
unsafeOverflowAutoScrollForElements({
element: parent,
}),
);
expect(monitorForElements).toHaveBeenCalledTimes(1);
unbind();
});
});
================================================
FILE: packages/auto-scroll/__tests__/unit/shared/time-dampening-handover.spec.ts
================================================
import { fireEvent } from '@testing-library/dom';
import { bind } from 'bind-event-listener';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { type Position } from '@atlaskit/pragmatic-drag-and-drop/types';
import { autoScrollForElements } from '../../../src/entry-point/element';
import { unsafeOverflowAutoScrollForElements } from '../../../src/entry-point/unsafe-overflow/element';
import { getInternalConfig } from '../../../src/shared/configuration';
import {
advanceTimersToNextFrame,
appendToBody,
reset,
setElementFromPoint,
setStartSystemTime,
setupBasicScrollContainer,
stepScrollBy,
userEvent,
} from '../_util';
// Using modern timers as it is important that the system clock moves in sync with the frames.
// We need this as we are keeping track of when a drop target is entered into.
jest.useFakeTimers();
setStartSystemTime();
beforeEach(reset);
const defaultConfig = getInternalConfig();
const maxScrollPerFrame = defaultConfig.maxPixelScrollPerSecond / 60;
it('should dampen the acceleration of auto scrolling [new drag] - up', () => {
const { parentScrollContainer, child } = setupBasicScrollContainer();
const ordered: string[] = [];
const scrollHistory: number[] = [parentScrollContainer.scrollTop];
let unsetElementAtPoint = setElementFromPoint(child);
const cleanup = combine(
appendToBody(parentScrollContainer),
draggable({
element: child,
onDragStart: () => ordered.push('draggable:start'),
onDrop: () => ordered.push('draggable:drop'),
}),
dropTargetForElements({
element: parentScrollContainer,
onDragStart: () => ordered.push('dropTarget:start'),
onDrop: () => ordered.push('dropTarget:drop'),
onDragEnter: () => ordered.push('dropTarget:enter'),
onDragLeave: () => ordered.push('dropTarget:leave'),
}),
autoScrollForElements({
element: parentScrollContainer,
}),
unsafeOverflowAutoScrollForElements({
element: parentScrollContainer,
getOverflow: () => ({
forBottomEdge: {
bottom: 1000,
left: 0,
right: 0,
},
}),
}),
bind(parentScrollContainer, {
type: 'scroll',
listener() {
ordered.push(`scroll event`);
scrollHistory.push(parentScrollContainer.scrollTop);
},
}),
);
const onBottomEdge: Position = {
x:
parentScrollContainer.getBoundingClientRect().left +
parentScrollContainer.getBoundingClientRect().width / 2,
y: parentScrollContainer.getBoundingClientRect().bottom,
};
const belowBottomEdge: Position = {
x: onBottomEdge.x,
y: onBottomEdge.y + 10,
};
// lifting on the mid point of the bottom edge
userEvent.lift(child, {
clientX: onBottomEdge.x,
clientY: onBottomEdge.y,
});
expect(ordered).toEqual(['draggable:start', 'dropTarget:start']);
ordered.length = 0;
// on first frame, there is no auto scroll as
// we don't know what the scroll speed should be until
// a single frame has passed
advanceTimersToNextFrame();
stepScrollBy();
// scroll container has still not scrolled
expect(ordered).toEqual([]);
expect(parentScrollContainer.scrollTop).toBe(scrollHistory.at(-1));
// expecting scroll on second frame
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
function dragBelowParent() {
unsetElementAtPoint();
unsetElementAtPoint = setElementFromPoint(document.body);
fireEvent.dragEnter(document.body, {
clientX: belowBottomEdge.x,
clientY: belowBottomEdge.y,
});
expect(ordered).toEqual(['dropTarget:leave']);
ordered.length = 0;
}
function dragOntoParentBottomEdge() {
unsetElementAtPoint();
unsetElementAtPoint = setElementFromPoint(child);
fireEvent.dragEnter(child, {
clientX: onBottomEdge.x,
clientY: onBottomEdge.y,
});
expect(ordered).toEqual(['dropTarget:enter']);
ordered.length = 0;
}
// engagement will be recorded during the first scroll event
const engagementStart = Date.now();
function isInTimeDampeningPeriod() {
return Date.now() - engagementStart < defaultConfig.timeDampeningDurationMs;
}
let lastScrollChange = 0;
const hit = jest.fn();
const actions = [dragBelowParent, dragOntoParentBottomEdge];
while (isInTimeDampeningPeriod()) {
actions.forEach((action) => {
// the first action might have taken us over the time dampening period
if (!isInTimeDampeningPeriod()) {
return;
}
hit();
const before = parentScrollContainer.scrollTop;
// okay, let's leave the drop target
action();
// The next frame will be scrolled by the over flow auto scroller
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
const after = parentScrollContainer.scrollTop;
const scrollChange = after - before;
expect(scrollChange).toBeGreaterThan(lastScrollChange);
lastScrollChange = scrollChange;
});
}
// expecting each action to have been called at least once each
expect(hit.mock.calls.length).toBeGreaterThan(actions.length);
// Based on what the tim dampening period it is, we might be mid way through
// the actions. This is being a bit resilient by checking what
// action we are up to and continuing from there
const nextActionIndex = hit.mock.calls.length % actions.length;
const nextAction = actions[nextActionIndex];
const next = [
nextAction,
nextAction === dragBelowParent ? dragOntoParentBottomEdge : dragBelowParent,
];
// now that we are outside of the time dampening period, expecting no time dampening
next.forEach((action) => {
const before = parentScrollContainer.scrollTop;
// okay, let's leave the drop target
action();
// The next frame will be scrolled by the over flow auto scroller
advanceTimersToNextFrame();
stepScrollBy();
expect(ordered).toEqual(['scroll event']);
ordered.length = 0;
const after = parentScrollContainer.scrollTop;
const scrollChange = after - before;
expect(scrollChange).toBe(maxScrollPerFrame);
});
cleanup();
});
================================================
FILE: packages/auto-scroll/afm-jira/tsconfig.json
================================================
{
"extends": "../../../../tsconfig.local-consumption.json",
"compilerOptions": {
"target": "es5",
"outDir": "../../../../../jira/tsDist/@atlaskit__pragmatic-drag-and-drop-auto-scroll/app",
"rootDir": "../",
"composite": true,
"noCheck": true
},
"include": [
"../src/**/*.ts",
"../src/**/*.tsx"
],
"exclude": [
"../src/**/__tests__/*",
"../src/**/*.test.*",
"../src/**/test.*",
"../src/**/examples.*",
"../src/**/examples/*",
"../src/**/examples/**/*",
"../src/**/*.stories.*",
"../src/**/stories/*",
"../src/**/stories/**/*"
],
"references": [
{
"path": "../../core/afm-jira/tsconfig.json"
}
]
}
================================================
FILE: packages/auto-scroll/afm-products/tsconfig.json
================================================
{
"extends": "../../../../tsconfig.local-consumption.json",
"compilerOptions": {
"target": "es5",
"outDir": "../../../../../tsDist/@atlaskit__pragmatic-drag-and-drop-auto-scroll/app",
"rootDir": "../",
"composite": true,
"noCheck": true
},
"include": [
"../src/**/*.ts",
"../src/**/*.tsx"
],
"exclude": [
"../src/**/__tests__/*",
"../src/**/*.test.*",
"../src/**/test.*",
"../src/**/examples.*",
"../src/**/examples/*",
"../src/**/examples/**/*",
"../src/**/*.stories.*",
"../src/**/stories/*",
"../src/**/stories/**/*"
],
"references": [
{
"path": "../../core/afm-products/tsconfig.json"
}
]
}
================================================
FILE: packages/auto-scroll/constellation/index/about.mdx
================================================
---
order: 0
title: Auto scroll
description: An optional package that enables automatic scrolling during a drag operation
---
import SectionMessage from '@atlaskit/section-message';
import OverElementExample from '../../examples/over-element';
import LazyLoadedExample from '../../examples/lazy-loaded';
import AxisLocking from '../../examples/axis-locking';
This package works with any configuration of scrollable entities, and you can change the
configuration of your scrollable entities in any way you like during a drag.
This package depends on [the core package](/components/pragmatic-drag-and-drop/core-package).
This package has no dependency on any view library (eg `react`), or on the Atlassian Design System.
## Registering auto scrolling for scrollable elements
Elements that are _registered_ for auto scrolling will be scrolled as a user drags close to the
edges of the element.
```ts
// each adapter type has its own auto scroller
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { autoScrollForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/external';
import { autoScrollForTextSelection } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/text-selection';
// enable better auto scrolling
const cleanup = autoScrollForElements({
element: myScrollableElement,
});
// disable better auto scrolling
cleanup();
```
A slightly fuller example of a `react` list that is a _drop target_, and has auto scrolling
```tsx
import { useRef, ReactElement } from 'react';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import invariant from 'tiny-invariant';
function ScrollableList({ children }: { children: ReactElement }) {
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
invariant(element, 'Element ref not set');
return combine(
dropTargetForElements({
element,
}),
// A scrollable element does not need to be a drop target,
// but in this case it is.
// We can add auto scrolling to an element along side our other
// Pragmatic drag and drop bindings
autoScrollForElements({
element,
}),
);
});
return (
{children}
);
}
```
### Element scrolling rules and behaviour
- You can position and style your scrollable elements however you like.
- Your scroll containers can have as many levels of nesting as you like.
- You have to register an element (eg with `autoScrollForElements`) to enable auto scrolling
(otherwise the default auto scrolling will apply).
- A _registered_ scrollable element does not need to be drop target.
- Auto scrolling is registered for particular entity types. For example, `autoScrollForElements` is
a drop target for elements, and `autoScrollForExternal` is for native drags.
- During a drag operation, you can:
- Register new scrollable elements
- Unregister scrollable elements
- Change the styling, layout or dimensions of any scrollable element
## `autoScrollFor*` arguments
- `element`: the `HTMLElement` you want to add auto scrolling too. The `element` does not need to be
a drop target. The `element` is the unique key for an auto scrolling registration.
- _(optional)_: `canScroll: (args: ElementGetFeedbackArgs) => boolean`: whether or not auto
scrolling should occur. Disabling auto scrolling with `canScroll` will _not_ prevent the browsers
built in auto scrolling, or manual user scrolling during a drag. Unfortunately, there is no way to
opt out of the platforms built in auto scrolling. We included `canScroll` because it is helpful to
disable this package's auto scrolling, as it is much easier for users to trigger than the
platforms built in auto scrolling.
`canScroll` is a helpful way to only enable auto scrolling for particular entity types.
```ts
autoScrollForElements({
element: myElement,
// only enable auto scrolling when a Card is being dragged
canScroll: ({ source }) => source.data.type === 'card',
}),
```
```ts
export type ElementGetFeedbackArgs = {
/**
* The users _current_ input
*/
input: Input;
/**
* The data associated with the entity being dragged
*/
source: DragType['payload'];
/**
* The element trying to be scrolled
*/
element: Element;
};
```
- _(optional)_: `getAllowedAxis: (args: ElementGetFeedbackArgs) => AllowedAxis`: used to enable auto
scrolling only on a particular axis. [See Axis locking guide](#axis-locking).
- _(optional)_: `getConfiguration: (args: ElementGetFeedbackArgs) => PublicConfig`: used to control
some aspects of auto scrolling
```ts
autoScrollForElements({
element: myElement,
getConfiguration: () => ({
maxScrollSpeed: 'fast',
})
}),
```
We are intentionally only exposing a limited amount of configuration in order to promote
consistency. Right now we only expose a single simple configuration option:
- `maxScrollSpeed`: `'fast' | 'standard'`. We recommend using the default `"standard"` max scroll
speed for most experiences. However, on _some_ larger experiences, a faster max scroll speed
`"fast"` _can_ feel better.
## Registering auto scrolling for the `window`
```ts
import { autoScrollWindowForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { autoScrollWindowForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/external';
import { autoScrollWindowForInternalUncontrolled } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/internal-uncontrolled';
// enable better auto scrolling on the window during drag operations
const cleanup = autoScrollWindowForElements();
// disable better auto scrolling on the window
cleanup();
```
A slightly fuller example of a `react` board that has window auto scrolling
```tsx
import { useRef, ReactElement } from 'react';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import invariant from 'tiny-invariant';
function Board({ children }: { children: ReactElement }) {
useEffect(() => {
return autoScrollWindowForElements();
});
return
{children}
;
}
```
### Window auto scrolling rules and behaviour
- You have to register window auto scrolling (eg with `autoScrollWindowForElements`) to enable auto
scrolling (otherwise the default auto scrolling will apply).
- Auto scrolling is registered for particular entity types. For example,
`autoScrollWindowForElements` will do auto scrolling when an element is being dragged, and
`autoScrollWindowForExternal` will do auto scrolling when something from outside the `window` is
being dragged over the `window`
- You can have multiple registrations for `window` auto scrolling, but only one registration will be
needed for `window` auto scrolling to occur.
- If there are no active registrations for `window` auto scrolling, then no `window` auto scrolling
will occur (except for the built in one).
- During a drag operation:
- You can register or unregister window auto scrolling
- You can change the content of the `document` so that the `window` grows or shrinks
## `autoScrollWindowFor*` arguments
- _(optional)_: `canScroll: (args: WindowGetFeedbackArgs) => boolean`: whether or not auto scrolling
should occur. Disabling auto scrolling with `canScroll` will _not_ prevent the browsers built in
auto scrolling, or manual user scrolling during a drag. Unfortunately, there is no way to opt out
of the platforms built in auto scrolling. We included `canScroll` because it is helpful to disable
this packages auto scrolling, as it is much easier for users to trigger than the platforms built
in auto scrolling.
`canScroll` is a helpful way to only enable auto scrolling for particular entity types.
```tsx
autoScrollWindowForElements({
// only enable auto scrolling when a Card is being dragged
canScroll: ({ source }) => source.data.type === 'card',
}),
```
```ts
export type ElementGetFeedbackArgs = {
/**
* The users _current_ input
*/
input: Input;
/**
* The data associated with the entity being dragged
*/
source: DragType['payload'];
};
```
## Scroll speed dampening
We slow down the scroll speed based on two factors: time over an element, and closeness to an edge.
### Time dampening
The longer a user drags over a scrollable entity, the faster the scroll speed will be (up to a
limit).
Time dampening helps a user to avoid loosing context by scrolling too quickly when:
- Lifting a draggable element inside of a scrollable element
- Dragging into a scrollable element
Time dampening is reset when you leave an element, so if you re-renter an element again, time
dampening starts again.
Our time dampening value has been tuned to balance:
1. Trying to avoid losing context
2. Letting the user get stuff done quickly
Time dampening is shared between "over element" and "overflow" auto scroll regions.
Time dampening is reset when:
- A scrollable entity is unregistered for more than one frame
- A scrollable entity is no longer being dragged over (except for the `window` - see below)
- A drag operation is finished
The `window` time dampening timer does not reset if leaving the `window`. Currently no
`onDragLeaveWindow` and `onDragEnterWindow` events are published by Pragmatic drag and drop. If we
did publish those events, then we _could_ reset the auto scrolling acceleration timer for the
`window` when entering the `window`.
### Distance dampening
The closer a user's pointer is to the edge of a scrollable entity (element or window), the faster
the scroll will be. Distance dampening allows a user to control the scroll speed by moving closer /
further away from a scrollable edge
The max speed can be reached a distance away from the actual edge of a scrollable element. This is
so that users don't have to move right onto the edge to get the max speed - they can get the max
speed from a small distance out from the edge
## Dynamic scroll speed
In order to facilitate a great experience for all users, on all devices, we dynamically adjust the
speed of scroll changes based on the devices frame rate (measured in frames per second - `fps`)
**Devices running at `60fps`**
We can scroll up to our maximum target scroll change in a frame
**Devices running at higher frame rates (eg `120fps` displays)**
We lower the max scroll change per frame to ensure we don’t scroll too fast.
If we made the same scroll change per frame on a `120fps` device as we did on a 60fps device, then
the `120fps` device would be scrolling twice as fast
The auto scroller ensures that a `120fps` display scrolls at the same visible speed as a 60fps
devices.
**Devices running at slower than `60fps`**
You might think we would do the inverse of what we do for the `120fps` devices - increase the max
change per frame so that the overall speed would match a `60fps` device. However, this can lead to
large scroll changes in a single frame causing the experience to feel janky.
For lower frame rate devices, we cap the max scroll change per frame to match would it would be if
the device was running at `60fps`. This can result in a slower over all scroll speed, but the scroll
will always feel smooth.
**Dynamic switching**
These rules are applied on a _per frame_ basis. One device might move between all three categories
in the same drag operation.
## Scroll bubbling
In order to match the browser as closely as possible, as well as to provide an experience that feels
great, we have landed on the following algorithm for scrolling:
- Only scroll scrollable entities that the user is currently dragging over with their pointer.
- We scroll the inner most scrollable entity first, and then work upwards. This is known as _bubble
ordering_ and it is the same order that Pragmatic drag and drop events flow.
- Only scroll one scrollable entity per axis (vertical / horizontal) in a frame. In order for a
scrollable entity to be scrolled on an axis, it needs to have some available scroll in the
applicable direction (forwards / backwards).
### Bubbling examples
For these examples, we have two elements that are both scrollable: `child` and `parent`
```html
```
**Scenario:** Based on hitboxes, both `child` and `parent` could be scrolled forwards vertically and
horizontally `child` and `parent` both have available scroll vertically and horizontally.
- `child` is scrolled forwards vertically and horizontally
- `parent` is not scrolled
**Scenario:** Based on hitboxes, both `child` and `parent` could be scrolled forwards vertically and
horizontally `child` has no available scroll vertically, but has scroll available horizontally
`parent` has available scroll vertically and horizontally
- `child` is scrolled horizontally (`child` has no available scroll vertically)
- `parent` is scrolled vertically (`child` has already been scrolled horizontally so `parent` can
only be scrolled vertically)
## Deferred loading
This package supports being loaded in asyncronously, and can be loaded even after a drag has
started.
```ts
const { autoScrollForElements } =
await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element');
```
In this example, we start loading the auto scroller code after the drag has started, but you could
load in the auto scroller whenever you like.
See our
[deferred loading guide](/components/pragmatic-drag-and-drop/core-package/recipes/deferred-loading/index)
for more information.
## Axis Locking
This package provides support for axis locking, which allows you to disable auto scrolling on a
specific axis. However, there are some important considerations to keep in mind.
```typescript
autoScrollForElements({
element: myElement,
getAllowedAxis: () => 'vertical',
}),
```
Browsers have built in auto scrolling during a drag operation, which does not provide a great
experience and it cannot be disabled. This package has been designed to complement built in auto
scrolling. Additionally, a user can manually scroll any scroll container during a drag.
Due to the inability to disable the browser's built-in auto scroller, full axis locking
functionality cannot be provided by this package alone. To achieve complete axis locking, you must
modify the scroll container to restrict scrolling to a single direction. This is typically
accomplished by setting `overflowX` or `overflowY` to `hidden` on the scroll container.
Please note that even with the `allowedAxis` prop set to a specific axis, the browser's built-in
auto scroller will continue to scroll on all scrollable axes. The `allowedAxis` prop only restricts
the axis from the perspective of this package, not the browser's perspective.
One important aspect to note is that time dampening is not cancelled when changing the allowed axis
mid-drag. This is different from the `canScroll` function, where changing it's return value does
reset time dampening. This means that if you switch the allowed axis during a drag, the auto
scroller will continue at its current speed on the newly allowed axis. We decided not to reset time
dampening on allowed axis changes for now as it would introduce a decent amount of internal
complexity.
## Declarative vs automatic scrollable entity registration
This package works by declaratively registering scrollable entities. An alternative would be to
automatically apply auto scrolling to everything that is scrollable during a drag.
We chose declarative registrations for a few reasons:
- They allow for per item configuration (for example, adjusting the speed and auto scroll hitboxes
for specific entities).
- They allow us to easily enable / disable scrolling during a drag (through `canScroll()`)
- They align with the existing declarative Pragmatic drag and drop API
- Registrations are a cheap way of identifying what is scrollable, otherwise we have to check all
elements (through `window.getComputedStyles(element)`) to check what is scrollable, which has poor
performance characteristics.
================================================
FILE: packages/auto-scroll/constellation/index/props.mdx
================================================
================================================
FILE: packages/auto-scroll/constellation/index/unsafe-overflow-scrolling.mdx
================================================
---
order: 1
title: Unsafe overflow scrolling
description: Continuing to scroll after leaving a scrollable element
---
import SectionMessage from '@atlaskit/section-message';
import UnsafeOverflowExample from '../../examples/unsafe-overflow';
Unsafe overflow scrolling allows the user to scroll a scrollable element when they are no longer
over the element.
## Challenges with overflow scrolling
These challenges are why overflow scrolling is considered unsafe. It should only be used when there
is a strong justification.
### It can be confusing for users
Overflow scrolling can feel great, but it does not play well with the web platform's drag and drop
capabilities. In order to update the drop target for a drag operation, the user needs to drag over
an element. With overflow scrolling, the interface can be changing, but the drop target might not be
as the user might not be over the drop target. This can be confusing for users
### It is easy to create strange experiences
When you enable overflow scrolling, it is easy to have multiple scrollable elements scrolling at the
same time in unexpected ways. This is because we can no longer rely on the DOM hierarchy to help us
provide an scrolling experience that will always feel great. This package gives you the control to
setup auto scrolling how you want it, but you will need to be careful to ensure that the settings
you have chosen work well for the experience you are making.
### It is more expensive than standard overflow scrolling
When doing standard 'over element' auto scrolling, we can leverage the DOM hierarchy to quickly
search for relevant scroll containers. We cannot do that for overflow scrolling. For every overflow
scrolling registration (eg `unsafeOverflowForElements()`), we need to do a
`element.getBoundingClientRect()` in each animation frame to see if we are over the element (we have
to re-create hitbox testing). Doing excessive amounts of of `getBoundingClientRect()` can get
expensive, so we suggest you don't add too many overflow scrolling registrations.
## Registering unsafe overflow scrolling for scrollable elements
Registering unsafe overflow scrolling will only enable scrolling when outside of the element. If you
want the element to be scrollable when over the element, then you will also need to add 'over
element' auto scrolling as well (eg `autoScrollForElements()`).
```ts
// each adapter type has its own overflow auto scroller
import { unsafeOverflowForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element';
import { unsafeOverflowForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/external';
import { unsafeOverflowForTextSelection } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/text-selection';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
const cleanup = combine(
// Enabling scrolling when over an element
autoScrollForElements({
element,
}),
// Enabling scrolling when outside an element - in the overflow
unsafeOverflowForElements({
element,
}),
);
// disable auto scrolling
cleanup();
```
A slightly fuller example of a `react` list that is a drop target, and has auto scrolling:
```tsx
import { useRef, ReactElement } from 'react';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { unsafeOverflowForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import invariant from 'tiny-invariant';
function ScrollableList({ children }: { children: ReactElement }) {
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
invariant(element, 'Element ref not set');
return combine(
dropTargetForElements({
element,
}),
// Enabling scrolling when "over" an element
autoScrollForElements({
element,
}),
// Enabling overflow auto scrolling
unsafeOverflowForElements({
element,
}),
);
});
return (
{children}
);
}
```
### Element scrolling rules and behaviour
Overflow scrolling has the same flexible rules as 'over element' auto scrolling - you can have any
setup you want, and change anything you want during a drag.
## `unsafeOverflowAutoScrollFor*` arguments
- `element`: the `HTMLElement` you want to add auto scrolling too. The `element` does not need to be
a `dropTarget`. The `element` is the unique key for an auto scrolling registration.
- `getOverflow: () => ProvidedHitboxSpacing`. `ProvidedHitboxSpacing` allows you to specify how
overflow scrolling should occur for a element.
`getOverflow()` allows you to specify how overflow scrolling should work for each edge of an
element.

```ts
// Only allow overflow scrolling above and below an element by 400px
unsafeOverflowAutoScrollForElements({
element,
getOverflow: () => ({
forTopEdge: {
top: 400,
},
forBottomEdge: {
bottom: 400,
},
}),
});
unsafeOverflowAutoScrollForElements({
element,
getOverflow: () => ({
forTopEdge: {
// Allow the top element to be overflow scrolled up to
// 2000px away from the element
top: 2000,
// Allow the top element to be overflow scrolled when
// up to 200px on the left or right of the top edge.
// The hitbox for scrolling will extend down below the top edge
// to match the "over element" hitbox for the top edge.
// See the diagram for more details.
left: 200,
right: 200,
// The "bottom" edge definition for the "top" edge is
// handled by the "over element" auto scroller.
},
}),
});
```
- _(optional)_: `canScroll: (args: ElementGetFeedbackArgs) => boolean`: whether or not auto
scrolling should occur. Disabling auto scrolling with `canScroll` will _not_ prevent the browser's
built in auto scrolling, or manual user scrolling during a drag. Unfortunately, there is no way to
opt out of the platform's built in auto scrolling. We included `canScroll` because it is helpful
to disable this package's auto scrolling, as it is much easier for users to trigger than the
platform's built in auto scrolling.
## Scroll acceleration
- Builds on 'over element' auto scrolling.
- When outside of the element on the main axis of an edge (eg vertical axis for the top edge), then
the max scroll speed is applied.
- Time dampening timer is shared with 'over element' auto scrolling. This means that time dampening
will carry over correctly when moving between 'over element' and 'overflow scrolling'.
- Distance dampening is applied when in the "cross axis" component of an edges hitbox (see the
checkered area of the diagram below). This is so that the scroll speed inside an edge is the same
when inside the element, or when outside the element.

## Window scrolling
Overflow scrolling is not applicable for window scrolling as the browser stops publishing events to
the document once the user has left the window. If you want to have window scrolling, then use our
standard approach for [registering auto scrolling for the window](../about).
================================================
FILE: packages/auto-scroll/examples/axis-locking.tsx
================================================
import React from 'react';
import { Table } from './pieces/table';
export default function AxisLocking(): React.JSX.Element {
return
{items.map((item) => (
))}
);
}
const cellStyles = xcss({
padding: 'space.0',
width: 'size.600',
borderColor: 'color.border',
borderWidth: 'border.width',
borderStyle: 'solid',
});
const stickyStyles = xcss({
position: 'sticky',
left: '0',
backgroundColor: 'elevation.surface',
});
const rowStyles = xcss({
height: 'size.400',
position: 'relative',
borderColor: 'color.border',
borderWidth: 'border.width',
borderStyle: 'solid',
});
const wrapperStyles = xcss({
overflow: 'auto',
display: 'flex',
maxHeight: '500px',
});
const scrollLockStyles = xcss({
overflowX: 'hidden',
});
const tableStyles = xcss({
tableLayout: 'fixed',
borderWidth: 'border.width',
borderColor: 'color.border',
borderStyle: 'solid',
borderRadius: 'radius.small',
backgroundColor: 'elevation.surface.sunken',
});
================================================
FILE: packages/auto-scroll/examples/unsafe-overflow-box.tsx
================================================
import React, { useEffect, useRef, useState } from 'react';
import invariant from 'tiny-invariant';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
// eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- to be migrated to @atlaskit/primitives/compiled – go/akcss
import { Box, xcss } from '@atlaskit/primitives';
import { unsafeOverflowAutoScrollForElements } from '../src/entry-point/unsafe-overflow/element';
function getHugeContent(): string {
return Array.from({ length: 10000 }, (_, index) => `index:${index}`).join(' ');
}
const scrollContainerStyles = xcss({
overflowX: 'scroll',
overflowY: 'scroll',
width: '400px',
height: '400px',
borderWidth: 'border.width',
borderStyle: 'solid',
borderColor: 'color.border',
padding: 'space.200',
gap: 'space.200',
});
const containerStyles = xcss({
height: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
});
const draggableStyles = xcss({
padding: 'space.100',
backgroundColor: 'color.background.accent.blue.subtle',
});
const contentStyles = xcss({
width: '1000px',
});
export default function Example(): React.JSX.Element {
const [content] = useState(() => getHugeContent());
const scrollableRef = useRef(null);
const draggableRef = useRef(null);
useEffect(() => {
const scrollableEl = scrollableRef.current;
invariant(scrollableEl);
scrollableEl.scrollTop = scrollableEl.scrollHeight / 2;
scrollableEl.scrollLeft = scrollableEl.scrollWidth / 2;
}, []);
useEffect(() => {
const scrollableEl = scrollableRef.current;
const draggableEl = draggableRef.current;
invariant(scrollableEl && draggableEl);
return combine(
draggable({
element: draggableEl,
}),
unsafeOverflowAutoScrollForElements({
element: scrollableEl,
getOverflow() {
return {
forTopEdge: {
top: 100,
left: 100,
right: 100,
},
};
},
}),
);
}, []);
return (
{content}
Drag me
);
}
================================================
FILE: packages/auto-scroll/examples/unsafe-overflow-only.tsx
================================================
import React from 'react';
import { type autoScrollForElements } from '../src/entry-point/element';
import { unsafeOverflowAutoScrollForElements } from '../src/entry-point/unsafe-overflow/element';
import { Board } from './pieces/board';
import { BoardContext, type TBoardContext } from './pieces/board-context';
const context: TBoardContext = {
autoScrollBoard: (
args: Parameters[0],
): ReturnType => {
return unsafeOverflowAutoScrollForElements({
...args,
// allow auto scrolling all around the board
getOverflow: () => ({
forTopEdge: {
top: 6000,
right: 6000,
left: 6000,
},
forRightEdge: {
top: 6000,
right: 6000,
bottom: 6000,
},
forBottomEdge: {
right: 6000,
bottom: 6000,
left: 6000,
},
forLeftEdge: {
top: 6000,
left: 6000,
bottom: 6000,
},
}),
});
},
autoScrollColumn: (
args: Parameters[0],
): ReturnType => {
return unsafeOverflowAutoScrollForElements({
...args,
// allow auto scrolling above and below the column
getOverflow: () => ({
forTopEdge: {
top: 6000,
right: 0,
left: 0,
},
forBottomEdge: {
right: 0,
bottom: 6000,
left: 0,
},
}),
});
},
};
export default function UnsafeOverflowOnly(): React.JSX.Element {
return (
);
}
================================================
FILE: packages/auto-scroll/examples/unsafe-overflow.tsx
================================================
import React from 'react';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { autoScrollForElements } from '../src/entry-point/element';
import { unsafeOverflowAutoScrollForElements } from '../src/entry-point/unsafe-overflow/element';
import { Board } from './pieces/board';
import { BoardContext, type TBoardContext } from './pieces/board-context';
const context: TBoardContext = {
autoScrollBoard: (
args: Parameters[0],
): ReturnType => {
return combine(
autoScrollForElements(args),
unsafeOverflowAutoScrollForElements({
...args,
// allow auto scrolling all around the board
getOverflow: () => ({
forTopEdge: {
top: 6000,
right: 6000,
left: 6000,
},
forRightEdge: {
top: 6000,
right: 6000,
bottom: 6000,
},
forBottomEdge: {
right: 6000,
bottom: 6000,
left: 6000,
},
forLeftEdge: {
top: 6000,
left: 6000,
bottom: 6000,
},
}),
}),
);
},
autoScrollColumn: (
args: Parameters[0],
): ReturnType => {
return combine(
autoScrollForElements(args),
unsafeOverflowAutoScrollForElements({
...args,
// allow auto scrolling above and below the column
getOverflow: () => ({
forTopEdge: {
top: 6000,
right: 0,
left: 0,
},
forBottomEdge: {
right: 0,
bottom: 6000,
left: 0,
},
}),
}),
);
},
};
export default function UnsafeOverflow(): React.JSX.Element {
return (
);
}
================================================
FILE: packages/auto-scroll/examples/window-scroll-with-scroll-container.tsx
================================================
/* eslint-disable @atlaskit/design-system/no-nested-styles */
/**
* @jsxRuntime classic
* @jsx jsx
*/
import React, { Fragment, useEffect, useRef } from 'react';
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles, @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766
import { css, Global, jsx } from '@emotion/react';
import invariant from 'tiny-invariant';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
// eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- to be migrated to @atlaskit/primitives/compiled – go/akcss
import { Box, Stack, xcss } from '@atlaskit/primitives';
import { autoScrollForElements, autoScrollWindowForElements } from '../src/entry-point/element';
const globalStyles = css({
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-unsafe-selectors -- Ignored via go/DSP-18766
':root': {
'--grid': '8px',
},
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors -- Ignored via go/DSP-18766
body: {
height: '150vh',
},
});
type ItemType = {
id: string;
};
const itemStyles = xcss({
padding: 'space.050',
borderWidth: 'border.width',
borderStyle: 'solid',
borderColor: 'color.border.brand',
borderRadius: 'radius.small',
backgroundColor: 'color.background.accent.lime.subtlest',
width: '300px',
});
function Item({ item }: { item: ItemType }) {
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
invariant(element);
return combine(
draggable({
element,
getInitialData: () => item,
}),
dropTargetForElements({ element, getIsSticky: () => true }),
);
}, [item]);
return (
{item.id}
);
}
const items: ItemType[] = Array.from({ length: 200 }).map((_, i) => ({
id: `id:${i}`,
}));
const scrollContainerStyles = xcss({
height: '100vh',
overflowY: 'scroll',
});
const rootStyles = xcss({
backgroundColor: 'color.background.accent.blue.subtle',
height: '200vh',
width: '200vw',
});
export default function WindowScroll(): React.JSX.Element {
const scrollContainerRef = useRef(null);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
invariant(scrollContainer);
return combine(
autoScrollWindowForElements(),
autoScrollForElements({
element: scrollContainer,
}),
);
});
return (
{items.map((item) => (
))}
);
}
================================================
FILE: packages/auto-scroll/examples/window-scroll.tsx
================================================
/* eslint-disable @atlaskit/design-system/no-nested-styles */
/**
* @jsxRuntime classic
* @jsx jsx
*/
import React, { Fragment, useEffect, useRef } from 'react';
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles, @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766
import { css, Global, jsx } from '@emotion/react';
import invariant from 'tiny-invariant';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
// eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- to be migrated to @atlaskit/primitives/compiled – go/akcss
import { Box, xcss } from '@atlaskit/primitives';
import { autoScrollWindowForElements } from '../src/entry-point/element';
const globalStyles = css({
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-unsafe-selectors -- Ignored via go/DSP-18766
':root': {
'--grid': '8px',
},
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors -- Ignored via go/DSP-18766
body: {
height: '150vh',
},
});
type ItemType = {
id: string;
};
const itemStyles = xcss({
padding: 'space.050',
borderWidth: 'border.width',
borderStyle: 'solid',
borderColor: 'color.border.brand',
borderRadius: 'radius.small',
backgroundColor: 'color.background.accent.lime.subtlest',
width: '300px',
});
function Item({ item }: { item: ItemType }) {
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
invariant(element);
return combine(
draggable({
element,
getInitialData: () => item,
}),
dropTargetForElements({ element, getIsSticky: () => true }),
);
}, [item]);
return (
{item.id}
);
}
const items: ItemType[] = Array.from({ length: 200 }).map((_, i) => ({
id: `id:${i}`,
}));
const listStyles = xcss({
display: 'flex',
flexDirection: 'column',
gap: 'space.050',
width: '200vw',
});
export default function WindowScroll(): React.JSX.Element {
useEffect(() => {
return autoScrollWindowForElements();
});
return (
{items.map((item) => (
))}
);
}
================================================
FILE: packages/auto-scroll/package.json
================================================
{
"name": "@atlaskit/pragmatic-drag-and-drop-auto-scroll",
"version": "2.1.5",
"description": "An optional Pragmatic drag and drop package that enables automatic scrolling during a drag operation",
"author": "Atlassian Pty Ltd",
"license": "Apache-2.0",
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"repository": "https://github.com/atlassian/pragmatic-drag-and-drop",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"module:es2019": "dist/es2019/index.js",
"types": "dist/types/index.d.ts",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./element": "./src/entry-point/element.ts",
"./external": "./src/entry-point/external.ts",
"./text-selection": "./src/entry-point/text-selection.ts",
"./unsafe-overflow/element": "./src/entry-point/unsafe-overflow/element.ts",
"./unsafe-overflow/external": "./src/entry-point/unsafe-overflow/external.ts",
"./unsafe-overflow/text-selection": "./src/entry-point/unsafe-overflow/text-selection.ts"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.0",
"@babel/runtime": "^7.0.0"
},
"devDependencies": {
"@testing-library/dom": "^10.1.0",
"bind-event-listener": "^3.0.0",
"raf-stub": "^2.0.1",
"tiny-invariant": "^1.2.0"
},
"homepage": "https://atlassian.design/components/pragmatic-drag-and-drop/"
}
================================================
FILE: packages/auto-scroll/src/entry-point/element.ts
================================================
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { makeApi } from '../over-element/make-api';
const api = makeApi({ monitor: monitorForElements });
export const autoScrollForElements = api.autoScroll;
export const autoScrollWindowForElements = api.autoScrollWindow;
================================================
FILE: packages/auto-scroll/src/entry-point/external.ts
================================================
import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
import { makeApi } from '../over-element/make-api';
const api = makeApi({ monitor: monitorForExternal });
export const autoScrollForExternal = api.autoScroll;
export const autoScrollWindowForExternal = api.autoScrollWindow;
================================================
FILE: packages/auto-scroll/src/entry-point/text-selection.ts
================================================
import { monitorForTextSelection } from '@atlaskit/pragmatic-drag-and-drop/text-selection/adapter';
import { makeApi } from '../over-element/make-api';
const api = makeApi({ monitor: monitorForTextSelection });
export const autoScrollForTextSelection = api.autoScroll;
export const autoScrollWindowForTextSelection = api.autoScrollWindow;
================================================
FILE: packages/auto-scroll/src/entry-point/unsafe-overflow/element.ts
================================================
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { makeApi } from '../../unsafe-overflow/make-api';
const api = makeApi({ monitor: monitorForElements });
export const unsafeOverflowAutoScrollForElements = api.unsafeOverflowAutoScroll;
================================================
FILE: packages/auto-scroll/src/entry-point/unsafe-overflow/external.ts
================================================
import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
import { makeApi } from '../../unsafe-overflow/make-api';
const api = makeApi({ monitor: monitorForExternal });
export const unsafeOverflowAutoScrollForExternal = api.unsafeOverflowAutoScroll;
================================================
FILE: packages/auto-scroll/src/entry-point/unsafe-overflow/text-selection.ts
================================================
import { monitorForTextSelection } from '@atlaskit/pragmatic-drag-and-drop/text-selection/adapter';
import { makeApi } from '../../unsafe-overflow/make-api';
const api = makeApi({ monitor: monitorForTextSelection });
export const unsafeOverflowAutoScrollForTextSelection = api.unsafeOverflowAutoScroll;
================================================
FILE: packages/auto-scroll/src/index.ts
================================================
export default {};
================================================
FILE: packages/auto-scroll/src/internal-types.ts
================================================
import type { AllDragTypes, Input } from '@atlaskit/pragmatic-drag-and-drop/types';
export type ElementGetFeedbackArgs = {
/**
* The users _current_ input
*/
input: Input;
/**
* The data associated with the entity being dragged
*/
source: DragType['payload'];
/**
* The element trying to be scrolled
*/
element: Element;
};
export type WindowGetFeedbackArgs = Omit<
ElementGetFeedbackArgs,
'element'
>;
export type Spacing = {
top: number;
right: number;
bottom: number;
left: number;
};
export type Edge = keyof Spacing;
export type EngagementHistoryEntry = {
timeOfEngagementStart: number;
};
export type InternalConfig = {
startHitboxAtPercentageRemainingOfElement: Spacing;
maxScrollAtPercentageRemainingOfHitbox: Spacing;
maxPixelScrollPerSecond: number;
timeDampeningDurationMs: number;
maxMainAxisHitboxSize: number;
};
export type PublicConfig = Partial<{
maxScrollSpeed: 'standard' | 'fast';
}>;
export type ElementAutoScrollArgs = {
element: Element;
canScroll?: (args: ElementGetFeedbackArgs) => boolean;
getAllowedAxis?: (args: ElementGetFeedbackArgs) => AllowedAxis;
getConfiguration?: (args: ElementGetFeedbackArgs) => PublicConfig;
};
export type WindowAutoScrollArgs = {
canScroll?: (args: WindowGetFeedbackArgs) => boolean;
getAllowedAxis?: (args: WindowGetFeedbackArgs) => AllowedAxis;
getConfiguration?: (args: WindowGetFeedbackArgs) => PublicConfig;
};
export type Side = 'start' | 'end';
export type Axis = 'vertical' | 'horizontal';
export type AllowedAxis = Axis | 'all';
================================================
FILE: packages/auto-scroll/src/over-element/data-attributes.ts
================================================
import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/types';
export const dataAttribute = 'data-auto-scrollable';
export const selector = `[${dataAttribute}="true"]`;
export function addScrollableAttribute(element: Element): CleanupFn {
element.setAttribute(dataAttribute, 'true');
return () => element.removeAttribute(dataAttribute);
}
================================================
FILE: packages/auto-scroll/src/over-element/get-scroll-by.ts
================================================
import type { Input, Position } from '@atlaskit/pragmatic-drag-and-drop/types';
import type {
AllowedAxis,
Axis,
Edge,
EngagementHistoryEntry,
InternalConfig,
} from '../internal-types';
import { canScrollOnEdge } from '../shared/can-scroll-on-edge';
import { edgeAxisLookup, edges } from '../shared/edges';
import { getOverElementHitbox } from '../shared/get-over-element-hitbox';
import { getScrollChange } from '../shared/get-scroll-change';
import { isAxisAllowed } from '../shared/is-axis-allowed';
import { isWithin } from '../shared/is-within';
type ScrollableEdge = {
edge: Edge;
hitbox: DOMRect;
};
function getRectDefault(element: Element) {
return element.getBoundingClientRect();
}
export function getScrollBy({
element,
input,
timeSinceLastFrame,
engagement,
config,
allowedAxis,
getRect = getRectDefault,
}: {
element: Element;
input: Input;
engagement: EngagementHistoryEntry;
timeSinceLastFrame: number;
allowedAxis: AllowedAxis;
config: InternalConfig;
getRect?: (element: Element) => DOMRect;
}): Required> {
const client: Position = {
x: input.clientX,
y: input.clientY,
};
const clientRect: DOMRect = getRect(element);
const scrollableEdges: Map = edges.reduce((map, edge) => {
const hitbox = getOverElementHitbox[edge]({ clientRect, config });
const axis = edgeAxisLookup[edge];
// Note: changing the allowed axis during a drag will not
// reset time dampening. It was decided it would be too
// complex to implement initially, and we can add it
// later if needed.
if (!isAxisAllowed(axis, allowedAxis)) {
return map;
}
if (!isWithin({ client, clientRect: hitbox })) {
return map;
}
if (!canScrollOnEdge[edge](element)) {
return map;
}
map.set(edge, { edge, hitbox });
return map;
}, new Map());
const left: number = (() => {
const axis: Axis = 'horizontal';
const leftEdge = scrollableEdges.get('left');
if (leftEdge) {
return getScrollChange({
client,
edge: leftEdge.edge,
hitbox: leftEdge.hitbox,
axis,
timeSinceLastFrame,
engagement,
isDistanceDampeningEnabled: true,
config,
});
}
const rightEdge = scrollableEdges.get('right');
if (rightEdge) {
return getScrollChange({
client,
edge: rightEdge.edge,
hitbox: rightEdge.hitbox,
axis,
timeSinceLastFrame,
engagement,
isDistanceDampeningEnabled: true,
config,
});
}
return 0;
})();
const top: number = (() => {
const axis: Axis = 'vertical';
const bottomEdge = scrollableEdges.get('bottom');
if (bottomEdge) {
return getScrollChange({
client,
edge: bottomEdge.edge,
hitbox: bottomEdge.hitbox,
axis,
timeSinceLastFrame,
engagement,
isDistanceDampeningEnabled: true,
config,
});
}
const topEdge = scrollableEdges.get('top');
if (topEdge) {
return getScrollChange({
client,
edge: topEdge.edge,
hitbox: topEdge.hitbox,
axis,
timeSinceLastFrame,
engagement,
isDistanceDampeningEnabled: true,
config,
});
}
return 0;
})();
return {
left,
top,
};
}
================================================
FILE: packages/auto-scroll/src/over-element/make-api.ts
================================================
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { once } from '@atlaskit/pragmatic-drag-and-drop/once';
import type {
AllDragTypes,
BaseEventPayload,
CleanupFn,
MonitorArgs,
} from '@atlaskit/pragmatic-drag-and-drop/types';
import type { ElementAutoScrollArgs, WindowAutoScrollArgs } from '../internal-types';
import { getScheduler } from '../shared/scheduler';
import { addScrollableAttribute } from './data-attributes';
import { tryScroll } from './try-scroll';
export function makeApi({
monitor,
}: {
monitor: (args: MonitorArgs) => CleanupFn;
}) {
const elementRegistry: Map> = new Map();
const windowRegistry: Set> = new Set();
function autoScroll(args: ElementAutoScrollArgs): CleanupFn {
// Warn during development if trying to add auto scroll to an element
// that is not scrollable.
// Note: this can produce a false positive when a scroll container is not
// scrollable initially, but becomes scrollable during a drag.
// I thought of adding the warning as I think it would be a more common pitfall
// to accidentally register auto scrolling on the wrong element
// If requested, we could provide a mechanism to opt out of this warning
if (process.env.NODE_ENV !== 'production') {
const { overflowX, overflowY }: CSSStyleDeclaration = window.getComputedStyle(args.element);
const isScrollable =
overflowX === 'auto' ||
overflowX === 'scroll' ||
overflowY === 'auto' ||
overflowY === 'scroll';
if (!isScrollable) {
// eslint-disable-next-line no-console
console.warn(
'Auto scrolling has been attached to an element that appears not to be scrollable',
{ element: args.element, overflowX, overflowY },
);
}
}
// Warn if there is an existing registration
if (process.env.NODE_ENV !== 'production') {
const existing = elementRegistry.get(args.element);
if (existing) {
// eslint-disable-next-line no-console
console.warn('You have already registered autoScrolling on the same element', {
existing,
proposed: args,
});
}
}
elementRegistry.set(args.element, args);
const cleanup = combine(addScrollableAttribute(args.element), () =>
elementRegistry.delete(args.element),
);
return once(cleanup);
}
function autoScrollWindow(args: WindowAutoScrollArgs = {}): CleanupFn {
// Putting `args` in a unique object so that
// each call will create a unique entry, even if a consumer
// shares the `args` object between calls.
// Just being safe here.
const unique = { ...args };
windowRegistry.add(unique);
function cleanup() {
windowRegistry.delete(unique);
}
return once(cleanup);
}
function findEntry(element: Element): ElementAutoScrollArgs | null {
return elementRegistry.get(element) ?? null;
}
function getWindowScrollEntries(): WindowAutoScrollArgs[] {
return Array.from(windowRegistry);
}
function onFrame({
latestArgs,
underUsersPointer,
timeSinceLastFrame,
}: {
latestArgs: BaseEventPayload;
underUsersPointer: Element | null;
timeSinceLastFrame: number;
}) {
tryScroll({
input: latestArgs.location.current.input,
source: latestArgs.source,
findEntry,
underUsersPointer,
timeSinceLastFrame,
getWindowScrollEntries,
});
}
getScheduler(monitor).onFrame(onFrame);
return {
autoScroll,
autoScrollWindow,
};
}
================================================
FILE: packages/auto-scroll/src/over-element/try-scroll.ts
================================================
import type { AllDragTypes, Input } from '@atlaskit/pragmatic-drag-and-drop/types';
import {
type AllowedAxis,
type ElementAutoScrollArgs,
type ElementGetFeedbackArgs,
type EngagementHistoryEntry,
type InternalConfig,
type WindowAutoScrollArgs,
} from '../internal-types';
import { getInternalConfig } from '../shared/configuration';
import { markAndGetEngagement } from '../shared/engagement-history';
import { selector } from './data-attributes';
import { getScrollBy } from './get-scroll-by';
type AvailableScrollDirection = { top: boolean; left: boolean };
function isScrollingAvailable(value: AvailableScrollDirection): boolean {
return Boolean(value.top || value.left);
}
function tryScrollElements({
target,
input,
source,
findEntry,
timeSinceLastFrame,
available = { top: true, left: true },
}: {
target: Element | null;
input: Input;
source: DragType['payload'];
timeSinceLastFrame: number;
findEntry: (element: Element) => ElementAutoScrollArgs | null;
available?: AvailableScrollDirection;
}): AvailableScrollDirection {
// we cannot do any more scrolling
if (!isScrollingAvailable(available)) {
return available;
}
// run out of parents to search
if (!target) {
return available;
}
const element = target.closest(selector);
// cannot find any more scroll containers
if (!element) {
return available;
}
const container = findEntry(element);
// cannot find registration, this is bad.
// fail and just exit
if (!container) {
return available;
}
function continueSearchUp() {
return tryScrollElements({
target: element?.parentElement ?? null,
findEntry,
source,
timeSinceLastFrame,
input,
available,
});
}
const feedback: ElementGetFeedbackArgs = {
input,
source,
element,
};
// Engagement is not marked if scrolling is explicitly not allowed
if (container.canScroll && !container.canScroll(feedback)) {
return continueSearchUp();
}
// Marking engagement even if no edges are scrollable.
// We are marking engagement as soon as the element is scrolled over
const engagement = markAndGetEngagement(element);
const config: InternalConfig = getInternalConfig(container.getConfiguration?.(feedback));
const allowedAxis: AllowedAxis = container.getAllowedAxis?.(feedback) ?? 'all';
const scrollBy = getScrollBy({
element,
engagement,
input,
timeSinceLastFrame,
allowedAxis,
config,
});
// Only allow scrolling in directions that have not already been used
const scroll = { top: 0, left: 0 };
if (available.top && scrollBy.top !== 0) {
scroll.top = scrollBy.top;
// can no longer scroll on the top after this
available.top = false;
}
if (available.left && scrollBy.left !== 0) {
scroll.left = scrollBy.left;
// can no longer scroll on the left after this
available.left = false;
}
// Only scroll if there is something to scroll
if (scroll.top !== 0 || scroll.left !== 0) {
element.scrollBy(scroll);
}
return continueSearchUp();
}
function tryScrollWindow({
input,
timeSinceLastFrame,
available,
source,
entries,
}: {
input: Input;
timeSinceLastFrame: number;
available: AvailableScrollDirection;
source: DragType['payload'];
entries: WindowAutoScrollArgs[];
}): void {
const element = document.documentElement;
const feedback: ElementGetFeedbackArgs = {
input,
source,
element,
};
for (const entry of entries) {
// this entry is not allowing scrolling, we need to look for another
if (entry.canScroll && !entry.canScroll(feedback)) {
continue;
}
// Note: if we had an event for when the user is leaving a tab
// we _could_ conceptually reset the engagement
const engagement: EngagementHistoryEntry = markAndGetEngagement(element);
const config: InternalConfig = getInternalConfig(entry.getConfiguration?.(feedback));
const allowedAxis: AllowedAxis = entry.getAllowedAxis?.(feedback) ?? 'all';
const scrollBy = getScrollBy({
element,
engagement,
input,
config,
allowedAxis,
getRect: (element: Element) =>
DOMRect.fromRect({
y: 0,
x: 0,
width: element.clientWidth,
height: element.clientHeight,
}),
timeSinceLastFrame,
});
const scroll = {
top: available.top ? scrollBy.top : 0,
left: available.left ? scrollBy.left : 0,
};
// only trigger a scroll if we are actually scrolling
if (scroll.top !== 0 || scroll.left !== 0) {
element.scrollBy(scroll);
}
// We only want the window to scroll once
break;
}
}
export function tryScroll({
input,
findEntry,
timeSinceLastFrame,
source,
getWindowScrollEntries,
underUsersPointer,
}: {
input: Input;
timeSinceLastFrame: number;
source: DragType['payload'];
findEntry: (element: Element) => ElementAutoScrollArgs | null;
getWindowScrollEntries: () => WindowAutoScrollArgs[];
underUsersPointer: Element | null;
}): void {
// We are matching browser behaviour and scrolling inner elements
// before outer ones. So we try to scroll scroller containers before
// the window.
const remainder: AvailableScrollDirection = tryScrollElements({
target: underUsersPointer,
timeSinceLastFrame,
input,
source,
findEntry,
});
// Check if we can do any window scrolling
if (!isScrollingAvailable(remainder)) {
return;
}
tryScrollWindow({
input,
source,
entries: getWindowScrollEntries(),
timeSinceLastFrame,
available: remainder,
});
}
================================================
FILE: packages/auto-scroll/src/shared/axis.ts
================================================
const vertical = {
start: 'top',
end: 'bottom',
point: 'y',
size: 'height',
} as const;
const horizontal = {
start: 'left',
end: 'right',
point: 'x',
size: 'width',
} as const;
export const axisLookup = {
vertical: {
mainAxis: vertical,
crossAxis: horizontal,
},
horizontal: {
mainAxis: horizontal,
crossAxis: vertical,
},
} as const;
================================================
FILE: packages/auto-scroll/src/shared/can-scroll-on-edge.ts
================================================
import { type Edge } from '../internal-types';
export const canScrollOnEdge: {
[key in Edge]: (element: Element) => boolean;
} = {
// Notes:
//
// 🌏 Chrome 115.0: uses fractional units for `scrollLeft` and `scrollTop`
// (and fractional units don't reach true integer maximum when zoomed in / out)
// 🍎 Safari 16.5.2: no fractional units
// 🦊 Firefox 115.0: no fractional units
// we have some scroll we can move backwards into
top: (element) => element.scrollTop > 0,
// We have some scroll we can move forward into
right: (element) => Math.ceil(element.scrollLeft) + element.clientWidth < element.scrollWidth,
// We have some scroll we can move forwards into
bottom: (element) => Math.ceil(element.scrollTop) + element.clientHeight < element.scrollHeight,
// we have some scroll we can move back into.
left: (element) => element.scrollLeft > 0,
};
================================================
FILE: packages/auto-scroll/src/shared/configuration.ts
================================================
import type { InternalConfig, PublicConfig } from '../internal-types';
const baseConfig = {
startHitboxAtPercentageRemainingOfElement: {
top: 0.25,
right: 0.25,
bottom: 0.25,
left: 0.25,
},
maxScrollAtPercentageRemainingOfHitbox: {
top: 0.5,
right: 0.5,
bottom: 0.5,
left: 0.5,
},
timeDampeningDurationMs: 400,
// Too big and it's too easy to trigger auto scrolling
// Too small and it's too hard 😅
maxMainAxisHitboxSize: 180,
};
/** What the max scroll should be per second. Using "per second" rather than "per frame"
* as we want a consistent scroll speed regardless of frame rate.
*
*
* I explored trying to make the max scroll speed dynamic based on particular factors.
* However, it ended up being difficult to find a _perfect_ formula.
*
* Likely the perfect answer would involve:
* - the size of the scrollable element
* - the size of the scrollable element relative to the screen size
* - the size of the drag preview
* - the size of elements being scrolled in scrollable elements (expensive and difficult to compute)
*/
const maxPixelScrollPerSecond: {
[Key in Required['maxScrollSpeed']]: number;
} = {
// What the value would be if we were scrolling at 15px per frame at 60fps.
// This is the default as it works well for most experiences.
// In certain scenarios though it can feel a bit slow.
standard: 15 * 60,
// What the value would be if we were scrolling at 25px per frame at 60fps.
// This is not the default as it feels too fast for a lot of experiences.
fast: 25 * 60,
};
export function getInternalConfig(provided?: PublicConfig | undefined): InternalConfig {
return {
...baseConfig,
// only allowing limited control over the config at this stage
maxPixelScrollPerSecond: maxPixelScrollPerSecond[provided?.maxScrollSpeed ?? 'standard'],
};
}
================================================
FILE: packages/auto-scroll/src/shared/edges.ts
================================================
import { type Axis, type Edge } from '../internal-types';
export const edges: Edge[] = ['top', 'right', 'bottom', 'left'];
export const edgeAxisLookup: Record = {
top: 'vertical',
right: 'horizontal',
bottom: 'vertical',
left: 'horizontal',
};
================================================
FILE: packages/auto-scroll/src/shared/engagement-history.ts
================================================
import { type EngagementHistoryEntry } from '../internal-types';
const ledger: Map = new Map();
const requested: Set = new Set();
export function markAndGetEngagement(element: Element): EngagementHistoryEntry {
markEngagement(element);
const entry = ledger.get(element);
if (entry) {
return entry;
}
const fresh: EngagementHistoryEntry = {
timeOfEngagementStart: Date.now(),
};
ledger.set(element, fresh);
return fresh;
}
export function markEngagement(element: Element): void {
requested.add(element);
}
export function clearUnusedEngagements(fn: () => void): void {
// make sure previous engagement requests don't linger
requested.clear();
// perform the required work
fn();
// if engagements where not requested, purge it
ledger.forEach((_, element) => {
if (!requested.has(element)) {
ledger.delete(element);
}
});
// cleaning up after ourselves
requested.clear();
}
export function clearEngagementHistory(): void {
ledger.clear();
}
================================================
FILE: packages/auto-scroll/src/shared/get-over-element-hitbox.ts
================================================
import type { Axis, Edge, InternalConfig, Side } from '../internal-types';
import { axisLookup } from './axis';
import { mainAxisSideLookup } from './side';
function makeGetHitbox({ edge, axis }: { edge: Edge; axis: Axis }) {
return function hitbox({ clientRect, config }: { clientRect: DOMRect; config: InternalConfig }) {
const { mainAxis, crossAxis } = axisLookup[axis];
const side: Side = mainAxisSideLookup[edge];
const mainAxisHitboxSize: number = Math.min(
// scale the size of the hitbox down for smaller elements
config.startHitboxAtPercentageRemainingOfElement[edge] * clientRect[mainAxis.size],
// Don't let the hitbox grow too big for big elements
config.maxMainAxisHitboxSize,
);
return DOMRect.fromRect({
[mainAxis.point]:
side === 'start'
? // begin from the start edge and grow inwards
clientRect[mainAxis.point]
: // begin from inside the end edge and grow towards the end edge
clientRect[mainAxis.point] + clientRect[mainAxis.size] - mainAxisHitboxSize,
[crossAxis.point]: clientRect[crossAxis.point],
[mainAxis.size]: mainAxisHitboxSize,
[crossAxis.size]: clientRect[crossAxis.size],
});
};
}
export const getOverElementHitbox: {
[Key in Edge]: (args: { clientRect: DOMRect; config: InternalConfig }) => DOMRect;
} = {
top: makeGetHitbox({
axis: 'vertical',
edge: 'top',
}),
right: makeGetHitbox({
axis: 'horizontal',
edge: 'right',
}),
bottom: makeGetHitbox({
axis: 'vertical',
edge: 'bottom',
}),
left: makeGetHitbox({
axis: 'horizontal',
edge: 'left',
}),
};
================================================
FILE: packages/auto-scroll/src/shared/get-percentage-in-range.ts
================================================
export function getPercentageInRange({
startOfRange,
endOfRange,
value,
}: {
startOfRange: number;
endOfRange: number;
value: number;
}): number {
// checking inputs
const isValid: boolean = startOfRange < endOfRange;
if (!isValid) {
return 0;
}
if (value < startOfRange) {
return 0;
}
if (value > endOfRange) {
return 1;
}
const range: number = endOfRange - startOfRange;
return (value - startOfRange) / range;
}
================================================
FILE: packages/auto-scroll/src/shared/get-scroll-change.ts
================================================
import { type Position } from '@atlaskit/pragmatic-drag-and-drop/types';
import type { Axis, Edge, EngagementHistoryEntry, InternalConfig } from '../internal-types';
import { axisLookup } from './axis';
import { getPercentageInRange } from './get-percentage-in-range';
import { mainAxisSideLookup } from './side';
// We want a consistent scroll speed across devices, regardless of framerate
function getMaxScrollChange({
timeSinceLastFrame,
config,
}: {
timeSinceLastFrame: number;
config: InternalConfig;
}): number {
const targetScrollPerMs = config.maxPixelScrollPerSecond / 1000;
// Adjusting out target scroll rate to match the frame rate of the target device
// This will pull the scroll speed down on high frame rate devices
// so we get a consistent visual scroll speed regardless of device.
const proposed = Math.ceil(targetScrollPerMs * timeSinceLastFrame);
// If lots of time as passed since that last frame (such on lower frame rate devices)
// we don't want the scroll speed to be too fast, otherwise it can feel jumpy
// We are capping the scroll speed at what it would be if we were hitting 60fps
const maximum = config.maxPixelScrollPerSecond / 60;
return Math.min(proposed, maximum);
}
function getDistanceDampening({
client,
axis,
edge,
hitbox,
config,
}: {
client: Position;
axis: Axis;
edge: Edge;
hitbox: DOMRect;
config: InternalConfig;
}): number {
const { mainAxis } = axisLookup[axis];
const side = mainAxisSideLookup[edge];
// We want to hit the max speed before the edge of the hitbox
const maxSpeedBuffer =
hitbox[mainAxis.size] * config.maxScrollAtPercentageRemainingOfHitbox[edge];
if (side === 'end') {
return getPercentageInRange({
startOfRange: hitbox[mainAxis.start],
endOfRange: hitbox[mainAxis.end] - maxSpeedBuffer,
value: client[mainAxis.point],
});
}
// Moving towards start edge
const raw = getPercentageInRange({
startOfRange: hitbox[mainAxis.start] + maxSpeedBuffer,
endOfRange: hitbox[mainAxis.end],
value: client[mainAxis.point],
});
// When moving near start edge
// - the 'end' edge is where we start scrolling
// - the 'start' edge is where we reach max speed
// So we need to invert the percentage when moving backwards
return 1 - raw;
}
export function getScrollChange({
client,
timeSinceLastFrame,
engagement,
axis,
hitbox,
edge,
isDistanceDampeningEnabled,
config,
}: {
timeSinceLastFrame: number;
axis: Axis;
engagement: EngagementHistoryEntry;
client: Position;
hitbox: DOMRect;
edge: Edge;
isDistanceDampeningEnabled: boolean;
config: InternalConfig;
}): number {
// We have two forms of speed dampening:
// 1. 🗺️ Distance
// The closer you are to a hitbox edge, the faster the scroll speed will be
// 2. ⏱️ Time
// When first entering a scroll container we want to dampening all scrolling
// This is to prevent super fast auto scrolling when first entering into
// a scroll container, or when lifting in a scroll container
const maxScroll = getMaxScrollChange({
timeSinceLastFrame,
config,
});
const percentageDistanceDampening: number = isDistanceDampeningEnabled
? getDistanceDampening({
client,
edge,
hitbox,
axis,
config,
})
: 1;
// Dampen speed by time
const percentageThroughTimeDampening = getPercentageInRange({
startOfRange: engagement.timeOfEngagementStart,
endOfRange: engagement.timeOfEngagementStart + config.timeDampeningDurationMs,
value: Date.now(),
});
// Calculate how much of the max scroll we should apply based on dampening
const percentageOfMaxScroll = percentageDistanceDampening * percentageThroughTimeDampening;
// We _could_ ease this update (`Math.pow(percentageOfMaxSpeed, 2)`)
// But linear is feeling really good
// Always scrolling by at least one pixel, otherwise the scroll does nothing
const scroll = Math.max(maxScroll * percentageOfMaxScroll, 1);
const side = mainAxisSideLookup[edge];
// When moving backwards, we will be scrolling backwards
return side === 'end' ? scroll : -1 * scroll;
}
================================================
FILE: packages/auto-scroll/src/shared/is-axis-allowed.ts
================================================
import { type AllowedAxis, type Axis } from '../internal-types';
export function isAxisAllowed(axis: Axis, allowedAxis: AllowedAxis): boolean {
return allowedAxis === 'all' || axis === allowedAxis;
}
================================================
FILE: packages/auto-scroll/src/shared/is-within.ts
================================================
import type { Position } from '@atlaskit/pragmatic-drag-and-drop/types';
export function isWithin({
client,
clientRect,
}: {
client: Position;
clientRect: DOMRect;
}): boolean {
return (
// is within horizontal bounds
client.x >= clientRect.x &&
client.x <= clientRect.x + clientRect.width &&
// is within vertical bounds
client.y >= clientRect.y &&
client.y <= clientRect.y + clientRect.height
);
}
================================================
FILE: packages/auto-scroll/src/shared/scheduler.ts
================================================
import { getElementFromPointWithoutHoneypot } from '@atlaskit/pragmatic-drag-and-drop/private/get-element-from-point-without-honey-pot';
import {
type AllDragTypes,
type BaseEventPayload,
type CleanupFn,
type MonitorArgs,
} from '@atlaskit/pragmatic-drag-and-drop/types';
import { clearEngagementHistory, clearUnusedEngagements } from './engagement-history';
type State =
| {
type: 'idle';
}
| {
// When the auto scroller first starts, we need to wait
// for a single frame before we can start scrolling.
// This is so that we can always have an accurate `timeSinceLastFrame`.
// `timeSinceLastFrame` is used to dynamically change the max
// scroll speed based on the frame rate.
type: 'initializing';
frameId: number;
latestArgs: BaseEventPayload;
}
| {
type: 'running';
frameId: number;
timeLastFrameFinished: DOMHighResTimeStamp;
latestArgs: BaseEventPayload;
};
type OnFrameFn = (args: {
// This is a shared starting point between the
// "overflow" and "over element auto scroller's.
// This is important to ensure that there is a clean handover between the auto scroller's
underUsersPointer: Element | null;
latestArgs: BaseEventPayload;
timeSinceLastFrame: number;
}) => void;
type Scheduler = {
onFrame: (fn: OnFrameFn) => void;
};
// We keep this map so that "over element" scrolling and "overflow" scrolling
// can leverage the same scheduler.
// The 'monitor' is the key for looking up schedulers
const schedulers: Map<
(args: MonitorArgs) => CleanupFn,
Scheduler
> = new Map();
export function getScheduler(
monitor: (args: MonitorArgs) => CleanupFn,
): Scheduler {
const scheduler = schedulers.get(monitor);
if (scheduler) {
// @ts-expect-error: I don't know how to link the DragType generic between the key and the value when the
// monitor itself is the key
return scheduler;
}
const created = makeScheduler(monitor);
schedulers.set(monitor, created);
return created;
}
function makeScheduler(
monitor: (args: MonitorArgs) => CleanupFn,
): Scheduler {
let state: State = { type: 'idle' };
const callbacks: OnFrameFn[] = [];
function loop(timeLastFrameFinished: DOMHighResTimeStamp) {
if (state.type !== 'running') {
return;
}
const timeSinceLastFrame = timeLastFrameFinished - state.timeLastFrameFinished;
const { latestArgs } = state;
// A common starting lookup point for determining
// which auto scroller should be used, and what should be scrolled.
const underUsersPointer = getElementFromPointWithoutHoneypot({
x: latestArgs.location.current.input.clientX,
y: latestArgs.location.current.input.clientY,
});
clearUnusedEngagements(() => {
callbacks.forEach((onFrame) =>
onFrame({ underUsersPointer, latestArgs, timeSinceLastFrame }),
);
});
state.timeLastFrameFinished = timeLastFrameFinished;
state.frameId = requestAnimationFrame(loop);
}
function reset() {
if (state.type === 'idle') {
return;
}
cancelAnimationFrame(state.frameId);
clearEngagementHistory();
state = { type: 'idle' };
}
function start(args: BaseEventPayload) {
if (state.type !== 'idle') {
return;
}
state = {
// Waiting a frame so we can accurately determine `timeSinceLastFrame`.
type: 'initializing',
latestArgs: args,
frameId: requestAnimationFrame((timeLastFrameFinished) => {
if (state.type !== 'initializing') {
return;
}
state = {
type: 'running',
timeLastFrameFinished,
latestArgs: state.latestArgs,
frameId: requestAnimationFrame(loop),
};
}),
};
}
// this module might have been imported after a drag has started
// We are starting the auto scroller if we get an update event and
// the auto scroller has not started yet
function update(args: BaseEventPayload) {
if (state.type === 'idle') {
start(args);
return;
}
state.latestArgs = args;
}
// Not exposing a way to stop listening
monitor({
onDragStart: start,
onDropTargetChange: update,
onDrag: update,
onDrop: reset,
});
const api: Scheduler = {
onFrame(fn: OnFrameFn) {
callbacks.push(fn);
},
};
return api;
}
================================================
FILE: packages/auto-scroll/src/shared/side.ts
================================================
import type { Edge, Side } from '../internal-types';
export const mainAxisSideLookup: { [Key in Edge]: Side } = {
top: 'start',
right: 'end',
bottom: 'end',
left: 'start',
};
================================================
FILE: packages/auto-scroll/src/unsafe-overflow/get-scroll-by.ts
================================================
import type { AllDragTypes, Input, Position } from '@atlaskit/pragmatic-drag-and-drop/types';
import type { AllowedAxis, Axis, Edge, InternalConfig, Spacing } from '../internal-types';
import { canScrollOnEdge } from '../shared/can-scroll-on-edge';
import { edgeAxisLookup, edges } from '../shared/edges';
import { markAndGetEngagement } from '../shared/engagement-history';
import { getScrollChange } from '../shared/get-scroll-change';
import { isAxisAllowed } from '../shared/is-axis-allowed';
import { isWithin } from '../shared/is-within';
import { getHitbox } from './hitbox';
import {
type HitboxSpacing,
type ProvidedHitboxSpacing,
type UnsafeOverflowAutoScrollArgs,
} from './types';
export type HitboxForEdge = {
edge: Edge;
type: 'inside-of-edge' | 'outside-of-edge';
hitbox: DOMRect;
};
// Distance dampening is enabled when we are inside the edge
// In order to match "over element" scrolling
function getIsDistanceDampeningEnabled(value: HitboxForEdge): boolean {
return value.type === 'inside-of-edge';
}
function getSpacingFromProvided(value: Partial | undefined): Spacing {
return {
top: value?.top ?? 0,
right: value?.right ?? 0,
bottom: value?.bottom ?? 0,
left: value?.left ?? 0,
};
}
function getHitboxSpacing(provided: ProvidedHitboxSpacing): HitboxSpacing {
return {
top: getSpacingFromProvided(provided.forTopEdge),
right: getSpacingFromProvided(provided.forRightEdge),
bottom: getSpacingFromProvided(provided.forBottomEdge),
left: getSpacingFromProvided(provided.forLeftEdge),
};
}
export function getScrollBy({
entry,
timeSinceLastFrame,
input,
config,
allowedAxis,
}: {
entry: UnsafeOverflowAutoScrollArgs;
input: Input;
allowedAxis: AllowedAxis;
timeSinceLastFrame: number;
config: InternalConfig;
}): Pick | null {
const client: Position = {
x: input.clientX,
y: input.clientY,
};
// 🔥
// For each registered item we need to do `getBoundingClientRect()` which is not great
// Why?
// 1. The hitbox can extend outside of an elements bounds
// 2. We want overflow scrolling to start before the user has entered the bounds of the element
// Otherwise we could search upwards in the DOM from the `elementFromPoint`
const clientRect: DOMRect = entry.element.getBoundingClientRect();
const overflow = getHitboxSpacing(entry.getOverflow());
const inHitboxForEdge: HitboxForEdge[] = edges
.map((edge): HitboxForEdge | false => {
const { insideOfEdge, outsideOfEdge } = getHitbox[edge]({
clientRect,
overflow,
config,
});
/** Note:
* Intentionally _not_ doing an explicit check to
* see if `client` is with within the `overElementHitbox`.
*
* **Why?**
*
* 1. 🥱 Redundant
* This check is already achieved by `element.contains(underUsersPointer)`
*
* 2. 📐 Overlap on boundaries
* Two elements can share the same `{x,y}` points on shared edges.
* It's not clear which of the two will be picked by
* `const underUsersPointer = document.elementFromPoint(x,y)`
* The edge of an "outside" element, can have shared `{x,y}`
* values along the edge of an "inside element".
* So when `underUsersPointer` is the "outer" element, the `client`
* point might actually be also within the "inner" element.
* We are exclusively relying on `underUsersPointer` make the decision
* on what we are "over" so we should not be doing "over element" hitbox
* testing here.
* https://twitter.com/alexandereardon/status/1721758766507638996
*
*
* 3. 🐞 Chrome bug
* `document.elementFromPoint(x, y)` can return an element that does not contain `{x,y}`,
* In these cases, `isWithin({client, clientRect: overElementHitbox})` can return `false`.
* https://bugs.chromium.org/p/chromium/issues/detail?id=1500073
*/
if (isWithin({ client, clientRect: outsideOfEdge })) {
return {
edge,
hitbox: outsideOfEdge,
type: 'outside-of-edge',
};
}
if (isWithin({ client, clientRect: insideOfEdge })) {
return {
edge,
hitbox: insideOfEdge,
type: 'inside-of-edge',
};
}
return false;
})
.filter((value): value is HitboxForEdge => Boolean(value));
if (!inHitboxForEdge.length) {
return null;
}
// Even if no edges are scrollable, we are marking the element
// as being engaged with to start applying time dampening
const engagement = markAndGetEngagement(entry.element);
// Note: changing the allowed axis during a drag will not
// reset time dampening. It was decided it would be too
// complex to implement initially, and we can add it
// later if needed.
const scrollableEdges: HitboxForEdge[] = inHitboxForEdge.filter(
(value) =>
isAxisAllowed(edgeAxisLookup[value.edge], allowedAxis) &&
canScrollOnEdge[value.edge](entry.element),
);
// Nothing can be scrolled
if (!scrollableEdges.length) {
return null;
}
const lookup = new Map(scrollableEdges.map((value) => [value.edge, value]));
const left: number = (() => {
const axis: Axis = 'horizontal';
const leftEdge = lookup.get('left');
if (leftEdge) {
return getScrollChange({
client,
isDistanceDampeningEnabled: getIsDistanceDampeningEnabled(leftEdge),
hitbox: leftEdge.hitbox,
edge: 'left',
axis,
timeSinceLastFrame,
engagement,
config,
});
}
const rightEdge = lookup.get('right');
if (rightEdge) {
return getScrollChange({
client,
isDistanceDampeningEnabled: getIsDistanceDampeningEnabled(rightEdge),
hitbox: rightEdge.hitbox,
edge: 'right',
axis,
timeSinceLastFrame,
engagement,
config,
});
}
return 0;
})();
const top: number = (() => {
const axis: Axis = 'vertical';
const bottomEdge = lookup.get('bottom');
if (bottomEdge) {
return getScrollChange({
client,
isDistanceDampeningEnabled: getIsDistanceDampeningEnabled(bottomEdge),
hitbox: bottomEdge.hitbox,
edge: 'bottom',
axis,
timeSinceLastFrame,
engagement,
config,
});
}
const topEdge = lookup.get('top');
if (topEdge) {
return getScrollChange({
client,
isDistanceDampeningEnabled: getIsDistanceDampeningEnabled(topEdge),
hitbox: topEdge.hitbox,
edge: 'top',
axis,
timeSinceLastFrame,
engagement,
config,
});
}
return 0;
})();
return {
left,
top,
};
}
================================================
FILE: packages/auto-scroll/src/unsafe-overflow/hitbox.ts
================================================
import type { Axis, Edge, InternalConfig, Side } from '../internal-types';
import { axisLookup } from '../shared/axis';
// Borrowing the hitbox calculation from over-element
// So we can be sure that the 'insideEdge' calculations
// line up perfectly with the 'over element' edge calculations
import { getOverElementHitbox } from '../shared/get-over-element-hitbox';
import { type HitboxSpacing } from './types';
function makeGetHitbox({ axis, side }: { axis: Axis; side: Side }) {
return function hitbox({
clientRect,
overflow,
config,
}: {
clientRect: DOMRect;
overflow: HitboxSpacing;
config: InternalConfig;
}): {
insideOfEdge: DOMRect;
outsideOfEdge: DOMRect;
} {
const { mainAxis, crossAxis } = axisLookup[axis];
const edge: Edge = mainAxis[side];
const spacingForEdge = overflow[edge];
const overElementHitbox = getOverElementHitbox[edge]({
clientRect,
config,
});
// Same as the over element hitbox,
// but we are stretching out on the cross axis (if needed)
const insideOfEdge = DOMRect.fromRect({
[mainAxis.point]: overElementHitbox[mainAxis.point],
[mainAxis.size]: overElementHitbox[mainAxis.size],
// pull the cross axis backwards
[crossAxis.point]: overElementHitbox[crossAxis.point] - spacingForEdge[crossAxis.start],
// grow the cross axis
[crossAxis.size]:
overElementHitbox[crossAxis.size] +
spacingForEdge[crossAxis.start] +
spacingForEdge[crossAxis.end],
});
// Note: this will be "cut out" by the "overElementHitbox"
const outsideOfEdge = DOMRect.fromRect({
[mainAxis.point]:
side === 'start'
? // begin from before the start edge and growing forward
clientRect[mainAxis.point] - spacingForEdge[mainAxis.start]
: // begin from on the end edge and go outwards
clientRect[mainAxis.end],
[crossAxis.point]: clientRect[crossAxis.point] - spacingForEdge[crossAxis.start],
[mainAxis.size]:
side === 'start' ? spacingForEdge[mainAxis.start] : spacingForEdge[mainAxis.end],
[crossAxis.size]:
spacingForEdge[crossAxis.start] +
clientRect[crossAxis.size] +
spacingForEdge[crossAxis.end],
});
return { insideOfEdge, outsideOfEdge };
};
}
export const getHitbox: {
[Key in Edge]: (args: {
clientRect: DOMRect;
overflow: HitboxSpacing;
config: InternalConfig;
}) => {
insideOfEdge: DOMRect;
outsideOfEdge: DOMRect;
};
} = {
top: makeGetHitbox({
axis: 'vertical',
side: 'start',
}),
right: makeGetHitbox({
axis: 'horizontal',
side: 'end',
}),
bottom: makeGetHitbox({
axis: 'vertical',
side: 'end',
}),
left: makeGetHitbox({
axis: 'horizontal',
side: 'start',
}),
};
================================================
FILE: packages/auto-scroll/src/unsafe-overflow/make-api.ts
================================================
import type {
AllDragTypes,
BaseEventPayload,
CleanupFn,
MonitorArgs,
} from '@atlaskit/pragmatic-drag-and-drop/types';
import { getScheduler } from '../shared/scheduler';
import { tryOverflowScrollElements } from './try-overflow-scroll';
import { type UnsafeOverflowAutoScrollArgs } from './types';
export function makeApi({
monitor,
}: {
monitor: (args: MonitorArgs) => CleanupFn;
}) {
const ledger: Map> = new Map();
function unsafeOverflowAutoScroll(args: UnsafeOverflowAutoScrollArgs): CleanupFn {
ledger.set(args.element, args);
return () => ledger.delete(args.element);
}
function onFrame({
latestArgs,
underUsersPointer,
timeSinceLastFrame,
}: {
latestArgs: BaseEventPayload;
timeSinceLastFrame: number;
underUsersPointer: Element | null;
}) {
tryOverflowScrollElements({
input: latestArgs.location.current.input,
source: latestArgs.source,
entries: Array.from(ledger).map(([_, args]) => args),
underUsersPointer,
timeSinceLastFrame,
});
}
// scheduler is never cleaned up
getScheduler(monitor).onFrame(onFrame);
return {
unsafeOverflowAutoScroll,
};
}
================================================
FILE: packages/auto-scroll/src/unsafe-overflow/try-overflow-scroll.ts
================================================
import type { AllDragTypes, Input } from '@atlaskit/pragmatic-drag-and-drop/types';
import { type ElementGetFeedbackArgs } from '../internal-types';
import { getInternalConfig } from '../shared/configuration';
import { getScrollBy } from './get-scroll-by';
import { type UnsafeOverflowAutoScrollArgs } from './types';
export function tryOverflowScrollElements({
input,
source,
entries,
timeSinceLastFrame,
underUsersPointer,
}: {
input: Input;
timeSinceLastFrame: number;
underUsersPointer: Element | null;
source: DragType['payload'];
entries: UnsafeOverflowAutoScrollArgs[];
}): void {
// For now we are auto scrolling any element that wants to.
// Otherwise it's hard to know what should scroll first as we might
// be scrolling elements that have no hierarchical relationship
for (const entry of entries) {
// "overflow" scrolling not relevant when directly over the element
// "over element" scrolling is responsible for scrolling when over an element
// 1. If we are over the element, then we want to exit and let the "overflow" scroller take over
// 2. The overflow hitbox area for an edge actually stretches over the element
// This check is used to "mask" or "cut out" the element hitbox from the overflow hitbox
if (entry.element.contains(underUsersPointer)) {
continue;
}
const feedback: ElementGetFeedbackArgs = {
input,
source,
element: entry.element,
};
// Scrolling not allowed for this entity
// Note: not marking engagement if an entity is opting out of scrolling
if (entry.canScroll && !entry.canScroll(feedback)) {
continue;
}
const config = getInternalConfig(entry.getConfiguration?.(feedback));
const allowedAxis = entry.getAllowedAxis?.(feedback) ?? 'all';
const scrollBy = getScrollBy({
entry,
input,
timeSinceLastFrame,
allowedAxis,
config,
});
if (scrollBy) {
entry.element.scrollBy(scrollBy);
}
}
}
================================================
FILE: packages/auto-scroll/src/unsafe-overflow/types.ts
================================================
import type { AllDragTypes } from '@atlaskit/pragmatic-drag-and-drop/types';
import { type Edge, type ElementAutoScrollArgs, type Spacing } from '../internal-types';
type VerticalEdges = ['top' | 'bottom'];
type HorizontalEdges = ['left' | 'right'];
type CrossAxisEdges = T extends VerticalEdges[number]
? HorizontalEdges
: VerticalEdges;
/** Specify outward reach of a scroll container */
export type HitboxSpacing = {
[TEdge in keyof Spacing]: Spacing;
};
/** The public type for specifying the outward reach of a scroll container */
export type ProvidedHitboxSpacing = {
[TEdge in keyof Spacing as `for${Capitalize}Edge`]?: {
// Allow
// Example for "top" edge: { top: 5 }
[TKey in TEdge]: number;
} & {
// Optional edges for the cross axis
// Example for "top" edge: {top: 5, left: 1, right: 2}
[TKey in CrossAxisEdges[number]]?: number;
};
// Disallowing (by not including) the opposite edge.
// Example for "top" edge: {top: 5, bottom: 2} is not allowed
};
export type UnsafeOverflowAutoScrollArgs =
ElementAutoScrollArgs & {
getOverflow: () => ProvidedHitboxSpacing;
};
================================================
FILE: packages/auto-scroll/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"include": [
"__tests__/**/*.ts",
"__tests__/**/*.tsx",
"docs/**/*.ts",
"docs/**/*.tsx",
"examples/**/*.ts",
"examples/**/*.tsx",
"src/**/*.ts",
"src/**/*.tsx",
"**/stories.ts",
"**/stories.tsx",
"**/stories/*.ts",
"**/stories/*.tsx",
"**/stories/**/*.ts",
"**/stories/**/*.tsx"
]
}
================================================
FILE: packages/core/.npmignore
================================================
src/
examples-utils/
examples/
index.ts
docs/
build/
__tests__/
tsconfig.json
tsconfig.app.json
tsconfig.dev.json
================================================
FILE: packages/core/CHANGELOG.md
================================================
# @atlaskit/pragmatic-drag-and-drop
## 1.7.9
### Patch Changes
- [`acb61d1d6efd9`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/acb61d1d6efd9) -
Add dependency for a11y testing.
## 1.7.8
### Patch Changes
- [`6d87d08be8526`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/6d87d08be8526) -
Add dependency for a11y testing.
## 1.7.7
### Patch Changes
- [`5d0b8ba5e6f7f`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/5d0b8ba5e6f7f) -
Internal changes to how borders are applied.
## 1.7.6
### Patch Changes
- [`6c430bfbb035d`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/6c430bfbb035d) -
Updated code to fix typescript issues during adoption of local consumption for adminhub
## 1.7.5
### Patch Changes
- [`beaa6ee463aa8`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/beaa6ee463aa8) -
Internal changes to how border radius is applied.
## 1.7.4
### Patch Changes
- [#174472](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/174472)
[`fda983a832f81`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/fda983a832f81) -
On Android `pointerOutsideOfPreview` will now also center the drag preview under the users
pointer. Technically this was already existing observed behaviour as Android always centers the
drag preview under the users pointer. We now make this behaviour explicit in the code, and call
this out behaviour in jsdoc and documentation.
## 1.7.3
### Patch Changes
- [#173859](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/173859)
[`d6f17206f8859`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/d6f17206f8859) -
In order to provide the best experience for iOS, the optional function `pointerOutsideOfPreview`
will now center the drag preview under the users pointer on iOS.
Some more detail (in case you are interested):
**Borders**
_(Existing behaviour)_
In `pointerOutsideOfPreview` we use transparent borders to push the preview away from the users
pointer. On iOS these borders will always be black. So we don't use transparent border on iOS.
**Placement**
_(Improvement)_
During a drag on iOS the drag preview will shift under the center of the users pointer, even if we
start the drag with the users pointer on the top left or top right corner of the drag preview. So
now `pointerOutsideOfPreview` will always put the preview under the center of the users pointer in
order to avoid the drag preview position shifting as the drag is starting.
## 1.7.2
### Patch Changes
- [#164244](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/164244)
[`65021fc0267e2`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/65021fc0267e2) -
The cleanup functions returned by the following utilities now only work on the first call. This
was done to prevent unexpected side effects of calling a cleanup function multiple times.
- `@atlaskit/pragmatic-drag-and-drop/adapter/element`
- `draggable`
- `dropTargetForElements`
- `monitorForElements`
- `@atlaskit/pragmatic-drag-and-drop/adapter/text-selection`
- `dropTargetForTextSelection`
- `monitorForTextSelection`
- `@atlaskit/pragmatic-drag-and-drop/adapter/external`
- `dropTargetForExternal`
- `monitorForExternal`
- `@atlaskit/pragmatic-drag-and-drop-auto-scroll/element`
- `autoScrollForElements`
- `autoScrollWindowForElements`
- `@atlaskit/pragmatic-drag-and-drop-auto-scroll/external`
- `autoScrollForExternal`
- `autoScrollWindowForExternal`
- `@atlaskit/pragmatic-drag-and-drop-auto-scroll/text-selection`
- `autoScrollForTextSelection`
- `autoScrollWindowForTextSelection`
## 1.7.1
### Patch Changes
- [#162456](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/162456)
[`f916af5aab898`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/f916af5aab898) -
Removes `@atlaskit/platform-feature-flags` as a dependency. Removes `@atlaskit/link` as a runtime
dependency, although it is still used in examples.
## 1.7.0
### Minor Changes
- [#157071](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/157071)
[`a149a0b1559ec`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/a149a0b1559ec) -
We are testing the migration to the ADS Link component behind a feature flag. If this fix is
successful it will be available in a later release.
### Patch Changes
- Updated dependencies
## 1.6.1
### Patch Changes
- [#150602](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/150602)
[`f83b03a3b239e`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/f83b03a3b239e) -
`pointerOutsideOfPreview` will no longer push the preview away from the users pointer on iOS due
to platform limitations. On iOS the preview will start the drag on the top left corner (or top
right corner for right to left interfaces). While dragging, iOS will shift the drag preview under
the center of the users pointer, so the "pushing away" is short lived on iOS.
## 1.6.0
### Minor Changes
- [#146341](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/146341)
[`ef9d1cdc6ea92`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/ef9d1cdc6ea92) -
The `pointerOutsideOfPreview()` utility will now correctly push the preview forward in right to
left layouts.
- Left to right (ltr): preview on right hand side of pointer
- Right to left (rtl): preview on left hand side of pointer (**new improvement**)
## 1.5.3
### Patch Changes
- [#145191](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/145191)
[`cd21ebedb9a08`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/cd21ebedb9a08) -
Internal change to move towards Compiled CSS-in-JS styling.
## 1.5.2
### Patch Changes
- [#128775](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/128775)
[`7a47573fb87cd`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/7a47573fb87cd) -
The optional `reorder` utility is helpful for returning a new reordered array, without modifying
the original array. Previously the _original_ array was returned unmodified if an invalid
`startIndex` or `finishIndex` was provided. `reorder` now always returns a new array, even when an
invalid `startIndex` or `finishIndex` is provided for consistency.
We consider this a bug fix as the `reorder` function claimed that it returned a new array. Now it
always does that.
Here is how things continue to work for `reorder` with valid arguments:
```ts
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
const original = ['A', 'B'];
const result = reorder({
list: original,
// Grab A
startIndex: 0,
// Move it to where B is
finishIndex: 1,
});
console.log(result); // ['B', 'A']
console.log(result === original); // false - we got a new array back
```
Things were a little different when an invalid `startIndex` or `finishIndex` was provided:
```ts
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
const original = ['A', 'B'];
const result = reorder({
list: original,
startIndex: -1, // invalid start index
finishIndex: 1,
});
console.log(result); // ['A', 'B'] (array not reordered)
// Original array was returned for this error case
console.log(result === original); // true
```
When an invalid `startIndex` or `finishIndex` is provided, `reorder` will now return a new array
```ts
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
const original = ['A', 'B'];
const result = reorder({
list: original,
startIndex: -1, // invalid start index
finishIndex: 1,
});
console.log(result); // ['A', 'B'] (array not reordered - unchanged)
// We now return a new array in this case
console.log(result === original); // false
```
In addition to this improvement, we have also improved the clarity of documentation and jsdoc for
`reorder`
## 1.5.1
### Patch Changes
- [#125185](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/pull-requests/125185)
[`423e7b65d4846`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/423e7b65d4846) -
Fixing an incorrectly exported `type` name from our external adapter.
```diff
- import type { ElementDropTargetEventBasePayload } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
+ import type { ExternalDropTargetEventBasePayload } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
```
## 1.5.0
### Minor Changes
- [#109060](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/109060)
[`4660ec858a305`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/4660ec858a305) -
Update `React` from v16 to v18
## 1.4.0
### Minor Changes
- [#145232](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/pull-requests/145232)
[`04641b5e6ed55`](https://stash.atlassian.com/projects/CONFCLOUD/repos/confluence-frontend/commits/04641b5e6ed55) -
Adding new optional utility for element dragging: `blockDraggingToIFrames` which disables the
ability for a user to drag into an `