├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── LICENSE
├── README.md
├── babel.config.js
├── cypress.json
├── cypress
├── fixtures
│ └── example.json
├── integration
│ ├── dispatcher.spec.js
│ ├── intersection.spec.js
│ ├── listUtil.spec.js
│ └── util.spec.js
├── plugins
│ └── index.js
└── support
│ ├── commands.js
│ └── index.js
├── package.json
├── release-notes.md
├── rollup.config.js
├── src
├── action.js
├── constants.js
├── featureFlags.js
├── helpers
│ ├── aria.js
│ ├── dispatcher.js
│ ├── intersection.js
│ ├── listUtil.js
│ ├── multiScroller.js
│ ├── observer.js
│ ├── scroller.js
│ ├── styler.js
│ ├── svelteNodeClone.js
│ └── util.js
├── index.js
├── keyboardAction.js
├── pointerAction.js
└── wrappers
│ ├── simpleStore.js
│ └── withDragHandles.js
├── typings
└── index.d.ts
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | # Ignore everything in the root
2 | /*
3 | # Un-ignore all of subtree
4 | !/src/
5 | !.gitignore
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true
5 | },
6 | extends: "eslint:recommended",
7 | parserOptions: {
8 | ecmaVersion: 12,
9 | sourceType: "module"
10 | },
11 | rules: {}
12 | };
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea
3 | .vscode
4 | *.iml
5 | node_modules
6 | /dist/
7 | cypress/videos
8 | cypress/screenshots
9 |
10 | /.eslintcache
11 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea
3 | .vscode
4 | *.iml
5 | node_modules
6 | /dist/
7 | cypress/videos
8 | cypress/screenshots
9 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 150,
3 | "tabWidth": 4,
4 | "trailingComma": "none",
5 | "bracketSpacing": false,
6 | "arrowParens": "avoid"
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Isaac Hagoel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SVELTE DND ACTION [](https://snyk.io/test/github/isaacHagoel/svelte-dnd-action?targetFile=package.json)
2 |
3 | This is a feature-complete implementation of drag and drop for Svelte using a custom action. It supports almost every imaginable drag and drop use-case, any input device and is fully accessible.
4 | It requires very minimal configuration, while offering a rich set of primitives that allow overriding basically any of its default behaviours (using the handler functions).
5 | See full features list below.
6 |
7 | 
8 |
9 | [Play with this example in the REPL](https://svelte.dev/playground/e2ef044af75c4b16b424b8219fb31fd9?version=3).
10 |
11 | ### Current Status
12 |
13 | The library is **production ready**, and I am in the process of integrating it into several production systems that will be used at scale.
14 | It is being actively maintained.
15 | **I am doing my best to avoid breaking-changes and keep the API stable**.
16 |
17 | ### Features
18 |
19 | - Awesome drag and drop with minimal fuss
20 | - Supports horizontal, vertical or any other type of container (it doesn't care much about the shape)
21 | - Supports nested dnd-zones (draggable containers with other draggable elements inside, think Trello)
22 | - Rich animations (can be opted out of)
23 | - Touch support
24 | - Define what can be dropped where (dnd-zones optionally have a "type")
25 | - Scroll dnd-zones (of the relevant "type"), parent containers that contains them and/or the window horizontally or vertically by placing the dragged element next to the edge
26 | - Supports advanced use-cases such as various flavours of copy-on-drag and custom drag handles (see examples below)
27 | - Performant and small footprint (no external dependencies, no fluff code)
28 | - Fully accessible (beta) - keyboard support, aria attributes and assistive instructions for screen readers
29 |
30 | ### Why a svelte action rather than a higher order component?
31 |
32 | A custom action allows for a much more elegant API (no slot props thanks god) as well as more control.
33 | If you prefer a generic dnd list component that accepts different child components as your abstraction, you can very easily wrap this library with one (see [here](https://svelte.dev/playground/028674733f67409c94bd52995d5906f1?version=3)).
34 |
35 | ### Installation
36 |
37 | **Pre-requisites**: svelte-3 (>=3.23.0)
38 |
39 | ```bash
40 | yarn add -D svelte-dnd-action
41 | ```
42 |
43 | or
44 |
45 | ```bash
46 | npm install --save-dev svelte-dnd-action
47 | ```
48 |
49 | ### Usage
50 |
51 | ```html
52 |
53 | {#each myItems as item(item.id)}
54 |
this is now a draggable div that can be dropped in other dnd zones
55 | {/each}
56 |
57 | ```
58 |
59 | ##### Basic Example:
60 |
61 | ```html
62 |
79 |
80 |
96 |
97 | {#each items as item(item.id)}
98 | {item.name}
99 | {/each}
100 |
101 | ```
102 |
103 | ##### Input:
104 |
105 | An options-object with the following attributes:
106 | | Name | Type | Required? | Default Value | Description |
107 | | ------------------------- | -------------- | ------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------ |
108 | | `items` | Array<Object> | Yes. Each object in the array **has to have** an `id` property (key name can be overridden globally) with a unique value (within all dnd-zones of the same type) | N/A | The data array that is used to produce the list with the draggable items (the same thing you run your #each block on). The dndzone should not have children that don't originate in `items` |
109 | | `flipDurationMs` | Number | No | `0` | The same value you give the flip animation on the items (to make them animated as they "make space" for the dragged item). Set to zero if you dont want animations, if unset it defaults to 100ms |
110 | | `type` | String | No | Internal | dnd-zones that share the same type can have elements from one dragged into another. By default, all dnd-zones have the same type |
111 | | `dragDisabled` | Boolean | No | `false` | Setting it to true will make it impossible to drag elements out of the dnd-zone. You can change it at any time, and the zone will adjust on the fly |
112 | | `morphDisabled` | Boolean | No | `false` | By default, when dragging over a zone, the dragged element is morphed to look like it would if dropped. You can prevent it by setting this option. |
113 | | `dropFromOthersDisabled` | Boolean | No | `false` | Setting it to true will make it impossible to drop elements from other dnd-zones of the same type. Can be useful if you want to limit the max number of items for example. You can change it at any time, and the zone will adjust on the fly |
114 | | `zoneTabIndex` | Number | No | `0` | Allow user to set custom tabindex to the list container when not dragging. Can be useful if you want to make the screen reader to skip the list container. You can change it at any time. |
115 | | `zoneItemTabIndex` | Number | No | `0` | Allow user to set custom tabindex to the list container items when not dragging. Can be useful if you use [Drag handles](https://github.com/isaacHagoel/svelte-dnd-action#examples-and-recipes). You can change it at any time. |
116 | | `dropTargetStyle` | Object<String> | No | `{outline: 'rgba(255, 255, 102, 0.7) solid 2px'}` | An object of styles to apply to the dnd-zone when items can be dragged into it. Note: the styles override any inline styles applied to the dnd-zone. When the styles are removed, any original inline styles will be lost |
117 | | `dropTargetClasses`| Array<String> | No | `[]` | A list of classes to apply to the dnd-zone when items can be dragged into it. Note: make sure the classes you use are global. |
118 | | `transformDraggedElement` | Function | No | `() => {}` | A function that is invoked when the draggable element enters the dnd-zone or hover overs a new index in the current dnd-zone. Signature: function(element, data, index) {} **element**: The dragged element. **data**: The data of the item from the items array. **index**: The index the dragged element will become in the new dnd-zone. This allows you to override properties on the dragged element, such as innerHTML to change how it displays. If what you are after is altering styles, do it to the children, not to the dragged element itself |
119 | | `autoAriaDisabled` | Boolean | No | `false` | Setting it to true will disable all the automatically added aria attributes and aria alerts (for example when the user starts/ stops dragging using the keyboard). **Use it only if you intend to implement your own custom instructions, roles and alerts.** In such a case, you might find the exported function `alertToScreenReader(string)` useful. |
120 | | `centreDraggedOnCursor` | Boolean | No | `false` | Setting it to true will cause elements from this dnd-zone to position their center on the cursor on drag start, effectively turning the cursor to the focal point that triggers all the dnd events (ex: entering another zone). Useful for dnd-zones with large items that can be dragged over small items. |
121 | | `dropAnimationDisabled` | Boolean | No | `false` | Setting it to true will disable the animation of the dropped element to its final place. |
122 |
123 | ##### Output:
124 |
125 | The action dispatches two custom events:
126 |
127 | - `consider` - dispatched whenever the dragged element needs to make room for itself in a new position in the items list and when it leaves. The host (your component) is expected to update the items list (you can keep a copy of the original list if you need to)
128 | - `finalize` - dispatched on the target and origin dnd-zones when the dragged element is dropped into position. This is the event you want to use to [save the items to the server](https://svelte.dev/playground/964fdac31cb9496da9ded35002300abb?version=3) for example.
129 |
130 | The expectation is the same for both event handlers - update the list of items.
131 | In both cases the payload (within e.detail) is the same: an object with two attributes: `items` and `info`.
132 |
133 | - `items`: contains the updated items list.
134 | - `info`: This one can be used to achieve very advanced custom behaviours (ex: copy on drag). In most cases, don't worry about it. It is an object with the following properties:
135 | - `trigger`: will be one of the exported list of TRIGGERS (Please import if you plan to use): [DRAG_STARTED, DRAGGED_ENTERED, DRAGGED_ENTERED_ANOTHER, DRAGGED_OVER_INDEX, DRAGGED_LEFT, DRAGGED_LEFT_ALL, DROPPED_INTO_ZONE, DROPPED_INTO_ANOTHER, DROPPED_OUTSIDE_OF_ANY, DRAG_STOPPED]. Most triggers apply to both pointer and keyboard, but some are only relevant for pointer (dragged_entered, dragged_over_index and dragged_left), and some only for keyboard (drag_stopped).
136 | - `id`: the item id of the dragged element
137 | - `source`: will be one of the exported list of SOURCES (Please import if you plan to use): [POINTER, KEYBOARD]
138 |
139 | You have to listen for both events and update the list of items in order for this library to work correctly.
140 |
141 | For advanced use-cases (ex: [custom styling for the placeholder element](https://svelte.dev/playground/9c8db8b91b2142d19dcf9bc963a27838?version=3)) you might also need to import `SHADOW_ITEM_MARKER_PROPERTY_NAME`, which marks the placeholder element that is temporarily added to the list the dragged element hovers over.
142 | For use cases that have recursively nested zones (ex: [crazy nesting](https://svelte.dev/playground/fe8c9eca04f9417a94a8b6041df77139?version=3)), you might want to import `SHADOW_PLACEHOLDER_ITEM_ID` in order to filter the placeholder out when passing the items in to the nested component.
143 | If you need to manipulate the dragged element either dynamically (and don't want to use the `transformDraggedElement` option), or statically targeting it or its children with CSS, you can import and use `DRAGGED_ELEMENT_ID`;
144 |
145 | ### Accessibility (beta)
146 |
147 | If you want screen-readers to tell the user which item is being dragged and which container it interacts with, **please add `aria-label` on the container and on every draggable item**. The library will take care of the rest.
148 | For example:
149 |
150 | ```html
151 | {listName}
152 |
153 | {#each items as item(item.id)}
154 | {item.name}
155 | {/each}
156 |
157 | ```
158 |
159 | If you don't provide the aria-labels everything will still work, but the messages to the user will be less informative.
160 | _Note_: in general you probably want to use semantic-html (ex: `ol` and `li` elements rather than `section` and `div`) but the library is screen readers friendly regardless (or at least that's the goal :)).
161 | If you want to implement your own custom screen-reader alerts, roles and instructions, you can use the `autoAriaDisabled` options and wire everything up yourself using markup and the `consider` and `finalize` handlers (for example: [unsortable list](https://svelte.dev/playground/e020ea1051dc4ae3ac2b697064f234bc?version=3)).
162 |
163 | ##### Keyboard support
164 |
165 | - Tab into a dnd container to get a description and instructions
166 | - Tab into an item and press the _Space_/_Enter_ key to enter dragging-mode. The reader will tell the user a drag has started.
167 | - Use the _arrow keys_ while in dragging-mode to change the item's position in the list (down and right are the same, up and left are the same). The reader will tell the user about position changes.
168 | - Tab to another dnd container while in dragging-mode in order to move the item to it (the item will be moved to it when it gets focus). The reader will tell the user that item was added to the new list.
169 | - Press _Space_/_Enter_ key while focused on an item, or the _Escape_ key anywhere to exit dragging mode. The reader will tell the user that they are no longer dragging.
170 | - Clicking on another item while in drag mode will make it the new drag target. Clicking outside of any draggable will exit dragging-mode (and tell the user)
171 | - Mouse drag and drop can be preformed independently of keyboard dragging (as in an item can be dragged with the mouse while in or out of keyboard initiated dragging-mode)
172 | - Keyboard drag uses the same `consider` (only on drag start) and `finalize` (every time the item is moved) events but share only some of the `TRIGGERS`. The same handlers should work fine for both.
173 |
174 | ### Drag Handles Support
175 |
176 | Due to popular demand, starting in version 0.9.46 the library exports a wrapper action that greatly improves the ergonomics around using drag handles.
177 | Notes:
178 |
179 | - A draggable item within a `dragHandleZone` would not be draggable unless it has an element that uses the `dragHandle` action inside (doesn't have to be a direct child but has to be inside the bounding rect of the item).
180 | - Don't forget an aria-label on the handle
181 | Usage:
182 |
183 | ```html
184 |
208 |
209 |
227 |
228 | Drag Handles
229 | Items can be dragged using the grey handles via mouse, touch or keyboard. The text on the items can be selected without starting a drag
230 |
231 |
232 | {#each items as item (item.id)}
233 |
234 |
235 |
{item.text}
236 |
237 | {/each}
238 |
239 | ```
240 |
241 | ### Examples and Recipes
242 |
243 | - [Super basic, single list, no animation](https://svelte.dev/playground/bbd709b1a00b453e94658392c97a018a?version=3)
244 | - [Super basic, single list, with animation](https://svelte.dev/playground/3d544791e5c24fd4aa1eb983d749f776?version=3)
245 | - [Multiple dndzones, multiple types](https://svelte.dev/playground/4d23eb3b9e184b90b58f0867010ad258?version=3)
246 | - [Board (nested zones and multiple types), scrolling containers, scrolling page](https://svelte.dev/playground/e2ef044af75c4b16b424b8219fb31fd9?version=3)
247 | - [Selectively enable/disable drag/drop](https://svelte.dev/playground/44c9229556f3456e9883c10fc0aa0ee9?version=3)
248 | - [Custom active dropzone styling](https://svelte.dev/playground/4ceecc5bae54490b811bd62d4d613e59?version=3)
249 | - [Customizing the dragged element](https://svelte.dev/playground/438fca28bb1f4eb1b34eff9dc6a728dc?version=3)
250 | - [Styling the dragged element](https://svelte.dev/playground/3d8be94b2bbd407c8a706d5054c8df6a?version=3)
251 | - [Customizing the placeholder(shadow) element](https://svelte.dev/playground/9c8db8b91b2142d19dcf9bc963a27838?version=3)
252 |
253 | - [Copy on drag, simple and Dragula like](https://svelte.dev/playground/924b4cc920524065a637fa910fe10193?version=3)
254 | - [Copy on drop and a drop area with a single slot](https://svelte.dev/playground/b4e120c45c3e48e49a0d637f0cf097d9?version=3)
255 | - [Drag handles using wrapper actions](https://svelte.dev/playground/cc1bc63be7a74830b4c97d428f62054d?version=4.2.17), for nested scenarios (same usage, it "just works"), see [here](https://svelte.dev/playground/4f7cbeb7b11b470b948e9af03b82a073?version=4.2.17) and [here](https://svelte.dev/playground/47c5f52f4c774cad8c367516395c7f99?version=4.2.17)
256 | - [Drag handles - legacy](https://svelte.dev/playground/4949485c5a8f46e7bdbeb73ed565a9c7?version=3), use before version 0.9.46, courtesy of @gleuch
257 | - [Interaction (save/get items) with an asynchronous server](https://svelte.dev/playground/964fdac31cb9496da9ded35002300abb?version=3)
258 | - [Unsortable lists with custom aria instructions](https://svelte.dev/playground/e020ea1051dc4ae3ac2b697064f234bc?version=3)
259 | - [Crazy nesting](https://svelte.dev/playground/fe8c9eca04f9417a94a8b6041df77139?version=3), courtesy of @zahachtah
260 | - [Generic List Component (Alternative to Slots)](https://svelte.dev/playground/028674733f67409c94bd52995d5906f1?version=3)
261 | - [Maitaining internal scroll poisition on scrollable dragabble](https://svelte.dev/playground/eb2f5988bd2f46488810606c1fb13392?version=3)
262 | - [Scrabble like board using over a 100 single slot dnd-zones](https://svelte.dev/playground/ed2e138417094281be6db1aef23d7859?version=3)
263 | - [Select multiple elements to drag (multi-drag) with mouse or keyboard](https://svelte.dev/playground/c4eb917bb8df42c4b17402a7dda54856?version=3)
264 |
265 | - [Fade in/out but without using Svelte transitions](https://svelte.dev/playground/3f1e68203ef140969a8240eba3475a8d?version=3)
266 | - [Nested fade in/out without using Svelte transitions](https://svelte.dev/playground/49b09aedfe0543b4bc8f575c8dbf9a53?version=3)
267 |
268 | ### Rules/ assumptions to keep in mind
269 |
270 | - Only one element can be dragged in any given time
271 | - The data that represents items within dnd-zones **of the same type** is expected to have the same shape (as in a data object that represents an item in one container can be added to another without conversion).
272 | - Item ids (#each keys) are unique in all dnd containers of the same type. EVERY DRAGGABLE ITEM (passed in through `items`) MUST HAVE AN ID PROPERTY CALLED `id`. You can override it globally if you'd like to use a different key (see below)
273 | - Item ids are provided as the key for the #each block (no keyless each blocks please)
274 | - If you need to make a copy an item, you allocate a new id for the copy upon creation.
275 | - The items in the list that is passed-in are in the same order as the children of the container (i.e the items are rendered in an #each block), and the container has no extra (and no fewer) children.
276 | - Any data that should "survive" when the items are dragged around and dropped should be included in the `items` array that is passed in.
277 | - The host component must refresh the items that are passed in to the custom-action when receiving consider and finalize events (do not omit any handler).
278 | - FYI, the library assumes it is okay to add a temporary item to the items list in any of the dnd-zones while an element is dragged around.
279 | - Svelte's built-in transitions might not play nice with this library. Luckily, it is an easy issue to work around. There are examples above.
280 |
281 | ### Overriding the item id key name
282 |
283 | Sometimes it is useful to use a different key for your items instead of `id`, for example when working with PouchDB which expects `_id`. It can save some annoying conversions back and forth.
284 | In such cases you can import and call `overrideItemIdKeyNameBeforeInitialisingDndZones`. This function accepts one parameter of type `string` which is the new id key name.
285 | For example:
286 |
287 | ```javascript
288 | import {overrideItemIdKeyNameBeforeInitialisingDndZones} from "svelte-dnd-action";
289 | overrideItemIdKeyNameBeforeInitialisingDndZones("_id");
290 | ```
291 |
292 | It applies globally (as in, all of your items everywhere are expected to have a unique identifier with this name). It can only be called when there are no rendered dndzones (I recommend calling it within the top-level
403 |
404 | {#each items as item(item.id)}
405 | {item.title}
406 | {/each}
407 |
408 | ```
409 |
410 | #### Custom types with `DndEvent`
411 |
412 | You can use generics to set the type of `items` you are expecting in `DndEvent`. Simply add a type to it like so: `DndEvent`. For example:
413 |
414 | ```html
415 |
436 | ```
437 |
438 | ##### Svelte 5:
439 |
440 | Svelte 5 prefers `onconsider` and `onfinalize` (over `on:consider` and `on:finalize`) but works both ways as long as it's consistent within a file.
441 |
442 | ### Nested Zones Optional Optimization (experimental)
443 |
444 | This is an experimental feature added in version 0.9.29. If you have multiple levels of nesting, the lib might do unnecessary work when dragging an element that has nested zones inside.
445 | Specifically, it allows nested zones within the shadow element (the placeholder under the dragged element) to register and destroy.
446 | This is because Svelte calls nested actions before the parent action (opposite to the rendering order).
447 | You can use a data attribute **on the items** to help the lib prevent this: `data-is-dnd-shadow-item-hint={item[SHADOW_ITEM_MARKER_PROPERTY_NAME]} `
448 | Starting with version 0.9.42. if you use the hint make sure to include it in the key you provide in your each block e.g:
449 |
450 | ```sveltehtml
451 | {#each columnItems as column (`${column._id}${column[SHADOW_ITEM_MARKER_PROPERTY_NAME] ? "_" + column[SHADOW_ITEM_MARKER_PROPERTY_NAME] : ""}`)}
452 | ...
453 | {/each}
454 | ```
455 |
456 | #### Simplified Example (just shows where to place the attribute):
457 |
458 | ```html
459 |
463 |
464 | items = e.detail.items} on:finalize={e => items = e.detail.items}>
465 | {#each items as item (item.id)}
466 |
467 |
{item.title}
468 |
469 |
470 | {/each}
471 |
472 |
473 | ```
474 |
475 | ### Contributing [](https://github.com/isaacHagoel/svelte-dnd-action/issues)
476 |
477 | There is still quite a lot to do. If you'd like to contribute please get in touch (raise an issue or comment on an existing one).
478 | Ideally, be specific about which area you'd like to help with.
479 | Thank you for reading :)
480 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | // babel is only used in order to make the tests work
2 | module.exports = {
3 | presets: [
4 | [
5 | "@babel/preset-env",
6 | {
7 | targets: {
8 | node: "current"
9 | }
10 | }
11 | ]
12 | ]
13 | };
14 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
6 |
--------------------------------------------------------------------------------
/cypress/integration/dispatcher.spec.js:
--------------------------------------------------------------------------------
1 | import {SOURCES, TRIGGERS} from "../../src";
2 | import {dispatchFinalizeEvent, dispatchConsiderEvent} from "../../src/helpers/dispatcher";
3 |
4 | describe("dispatcher", () => {
5 | let divEl = document.createElement("div");
6 | let items;
7 | let info;
8 | beforeEach(() => {
9 | items = [];
10 | info = {};
11 | });
12 | it("honors contract - finalize", () => {
13 | divEl.addEventListener("finalize", e => {
14 | items = e.detail.items;
15 | info = e.detail.info;
16 | });
17 | const myItems = [1, 2];
18 | const myInfo = {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: "someId", source: SOURCES.POINTER};
19 | dispatchFinalizeEvent(divEl, myItems, myInfo);
20 | expect(items).to.deep.equal(myItems);
21 | expect(info).to.deep.equal(myInfo);
22 | });
23 | it("honors contract - consider", () => {
24 | divEl.addEventListener("consider", e => {
25 | items = e.detail.items;
26 | info = e.detail.info;
27 | });
28 | const myItems = [3, 4];
29 | const myInfo = {trigger: TRIGGERS.DRAGGED_ENTERED, id: "someId", source: SOURCES.KEYBOARD};
30 | dispatchConsiderEvent(divEl, myItems, myInfo);
31 | expect(items).to.deep.equal(myItems);
32 | expect(info).to.deep.equal(myInfo);
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/cypress/integration/intersection.spec.js:
--------------------------------------------------------------------------------
1 | import {isCenterOfAInsideB, calcDistanceBetweenCenters, isElementOffDocument} from "../../src/helpers/intersection";
2 |
3 | function makeDiv(widthPx = 50, heightPx = 50) {
4 | const el = document.createElement("div");
5 | el.style.width = `${widthPx}px`;
6 | el.style.height = `${heightPx}px`;
7 | return el;
8 | }
9 |
10 | describe("intersection", () => {
11 | describe("isCenterOfAInsideB", () => {
12 | it("center is inside", () => {
13 | const el = makeDiv(50, 50);
14 | document.body.style.width = "1000px";
15 | document.body.style.height = "1000px";
16 | document.body.appendChild(el);
17 | expect(isCenterOfAInsideB(el, document.body)).to.equal(true);
18 | });
19 | it("center is outside", () => {
20 | const elA = makeDiv();
21 | const elB = makeDiv();
22 | document.body.appendChild(elA);
23 | document.body.appendChild(elB);
24 | expect(isCenterOfAInsideB(elA, elB)).to.equal(false);
25 | });
26 | });
27 |
28 | describe("calcDistanceBetweenCenters", () => {
29 | it("distance from self is zero", () => {
30 | const el = makeDiv();
31 | document.body.appendChild(el);
32 | expect(calcDistanceBetweenCenters(el, el)).to.equal(0);
33 | });
34 | it("calculates distance correctly", () => {
35 | const elA = makeDiv(80, 60);
36 | const elB = makeDiv(40, 30);
37 | elA.style.position = "relative";
38 | elB.style.position = "absolute";
39 | elB.top = 0;
40 | elB.left = 0;
41 | document.body.appendChild(elA);
42 | elA.appendChild(elB);
43 | expect(calcDistanceBetweenCenters(elA, elB)).to.equal(25);
44 | expect(calcDistanceBetweenCenters(elB, elA)).to.equal(25);
45 | });
46 | });
47 |
48 | describe("isElementOffDocument", () => {
49 | before(() => {
50 | document.body.style.width = "100vw";
51 | document.body.style.height = "100vh";
52 | });
53 | it("returns false when element is inside", () => {
54 | const el = makeDiv(50, 50);
55 | document.body.appendChild(el);
56 | expect(isElementOffDocument(el)).to.equal(false);
57 | });
58 | it("returns false when partially outside to the left", () => {
59 | const el = makeDiv(50, 50);
60 | el.style.position = "fixed";
61 | el.style.top = "-30px";
62 | el.style.left = "-45px";
63 | document.body.appendChild(el);
64 | expect(isElementOffDocument(el)).to.equal(false);
65 | });
66 | it("returns true when fully outside to the right", () => {
67 | const el = makeDiv(50, 50);
68 | el.style.position = "fixed";
69 | el.style.top = "0";
70 | el.style.right = "51px";
71 | document.body.appendChild(el);
72 | expect(isElementOffDocument(el)).to.equal(true);
73 | });
74 | it("returns true when fully outside to the top", () => {
75 | const el = makeDiv(50, 50);
76 | el.style.position = "fixed";
77 | el.style.top = "-51px";
78 | el.style.right = "0";
79 | document.body.appendChild(el);
80 | expect(isElementOffDocument(el)).to.equal(true);
81 | });
82 | it("returns true when fully outside to the bottom", () => {
83 | const el = makeDiv(50, 50);
84 | el.style.position = "fixed";
85 | el.style.bottom = "51px";
86 | el.style.left = "80px";
87 | document.body.appendChild(el);
88 | expect(isElementOffDocument(el)).to.equal(true);
89 | });
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/cypress/integration/listUtil.spec.js:
--------------------------------------------------------------------------------
1 | import {findWouldBeIndex} from "../../src/helpers/listUtil";
2 |
3 | describe("listUtil", () => {
4 | describe("findWouldBeIndex", () => {
5 | let containerEl, draggedEl;
6 | before(() => {
7 | document.body.style.height = "2000px";
8 | document.body.style.width = "2000px";
9 |
10 | containerEl = document.createElement("section");
11 | containerEl.style.height = "500px";
12 | containerEl.style.width = "100px";
13 | containerEl.style.position = "fixed";
14 | containerEl.style.top = "0";
15 | containerEl.style.left = "0";
16 | document.body.appendChild(containerEl);
17 | function addListItem() {
18 | const divEl = document.createElement("div");
19 | divEl.style.height = "100px";
20 | divEl.style.width = "50px";
21 | containerEl.appendChild(divEl);
22 | }
23 | addListItem();
24 | addListItem();
25 | addListItem();
26 |
27 | draggedEl = document.createElement("div");
28 | draggedEl.style.height = "100px";
29 | draggedEl.style.width = "50px";
30 | draggedEl.style.position = "fixed";
31 | document.body.appendChild(draggedEl);
32 | });
33 | beforeEach(() => {
34 | draggedEl.style.top = "0";
35 | draggedEl.style.left = "0";
36 | });
37 | it("returns null when element is outside of containers", () => {
38 | draggedEl.style.top = "600px";
39 | draggedEl.style.left = "0";
40 | expect(findWouldBeIndex(draggedEl, containerEl)).to.equal(null);
41 | });
42 | it("works correctly, not proximity based", () => {
43 | draggedEl.style.top = "150px";
44 | draggedEl.style.left = "5px";
45 | expect(findWouldBeIndex(draggedEl, containerEl)).to.deep.equal({index: 1, isProximityBased: false});
46 | });
47 | it("works correctly, proximity based", () => {
48 | draggedEl.style.top = "450px";
49 | draggedEl.style.left = "5px";
50 | expect(findWouldBeIndex(draggedEl, containerEl)).to.deep.equal({index: 2, isProximityBased: true});
51 | });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/cypress/integration/util.spec.js:
--------------------------------------------------------------------------------
1 | import {areArraysShallowEqualSameOrder, areObjectsShallowEqual, getDepth} from "../../src/helpers/util";
2 | import {printDebug, setDebugMode} from "../../src/constants";
3 |
4 | describe("util", () => {
5 | describe("getDepth", () => {
6 | it("get correct depth", () => {
7 | const div = document.createElement("div");
8 | const p = document.createElement("p");
9 | const section = document.createElement("section");
10 | const div2 = document.createElement("div");
11 | document.body.appendChild(div);
12 | div.appendChild(p);
13 | div.appendChild(section);
14 | section.appendChild(div2);
15 | expect(getDepth(div)).to.equal(1);
16 | expect(getDepth(p)).to.equal(2);
17 | expect(getDepth(section)).to.equal(2);
18 | expect(getDepth(div2)).to.equal(3);
19 | });
20 | });
21 | describe("areObjectsShallowEqual", () => {
22 | it("equal when both empty", () => {
23 | expect(areObjectsShallowEqual({}, {})).to.equal(true);
24 | });
25 | it("simple objects equal ", () => {
26 | expect(areObjectsShallowEqual({a: 1, b: 2, c: "lala"}, {b: 2, c: "lala", a: 1})).to.equal(true);
27 | });
28 | it("not equal with additional entries", () => {
29 | expect(areObjectsShallowEqual({a: 1, b: 2, c: 3}, {b: 2, a: 1})).to.equal(false);
30 | expect(areObjectsShallowEqual({a: 1, b: 2}, {b: 2, a: 1, c: 3})).to.equal(false);
31 | });
32 | it("not equal same key different value", () => {
33 | expect(areObjectsShallowEqual({a: 1}, {a: 9})).to.equal(false);
34 | });
35 | it("not equal at all", () => {
36 | expect(areObjectsShallowEqual({a: 1, z: "lala"}, {b: 9, h: 7})).to.equal(false);
37 | });
38 | });
39 | describe("debug output can be configured", () => {
40 | let consoleStub = null;
41 | const logStub = message => (consoleStub = message);
42 | const logMessage = () => "some debug message";
43 |
44 | it("does not log anything by default", () => {
45 | printDebug(logMessage, logStub);
46 | expect(consoleStub).to.equal(null);
47 | });
48 | it("does log if debugMode is set, stops logging when turned off again", () => {
49 | setDebugMode(true);
50 | printDebug(logMessage, logStub);
51 | expect(consoleStub).to.equal(logMessage());
52 | consoleStub = null;
53 | setDebugMode(false);
54 | printDebug(logMessage, logStub);
55 | expect(consoleStub).to.equal(null);
56 | });
57 | });
58 | describe("areArraysShallowEqual", () => {
59 | it("return true when equal same order", () => {
60 | expect(areArraysShallowEqualSameOrder([1, "hello", null], [1, "hello", null]));
61 | expect(areArraysShallowEqualSameOrder([], []));
62 | });
63 | it("return false when equal but different order", () => {
64 | expect(areArraysShallowEqualSameOrder([1, "hello", null], ["hello", 1, null]));
65 | });
66 | it("return false when different size", () => {
67 | expect(areArraysShallowEqualSameOrder(["hello"], ["hello", 1, null]));
68 | expect(areArraysShallowEqualSameOrder(["hello", 1, null], ["hello"]));
69 | });
70 | it("return false when different", () => {
71 | expect(areArraysShallowEqualSameOrder([1, "hello", null], ["1", "hello", null]));
72 | expect(areArraysShallowEqualSameOrder([1, 2], []));
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************************
3 | // This example plugins/index.js can be used to load plugins
4 | //
5 | // You can change the location of this file or turn off loading
6 | // the plugins file with the 'pluginsFile' configuration option.
7 | //
8 | // You can read more here:
9 | // https://on.cypress.io/plugins-guide
10 | // ***********************************************************
11 |
12 | // This function is called when a project is opened or re-opened (e.g. due to
13 | // the project's config changing)
14 |
15 | /**
16 | * @type {Cypress.PluginConfig}
17 | */
18 | module.exports = (on, config) => {
19 | // `on` is used to hook into various events Cypress emits
20 | // `config` is the resolved Cypress config
21 | };
22 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add("login", (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
26 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import "./commands";
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "svelte-dnd-action",
3 | "description": "*An awesome drag and drop library for Svelte 3 and 4 (not using the browser's built-in dnd, thanks god): Rich animations, nested containers, touch support and more *",
4 | "version": "0.9.61",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/isaacHagoel/svelte-dnd-action.git"
8 | },
9 | "author": "Isaac Hagoel",
10 | "license": "MIT",
11 | "bugs": {
12 | "url": "https://github.com/isaacHagoel/svelte-dnd-action/issues"
13 | },
14 | "homepage": "https://github.com/isaacHagoel/svelte-dnd-action#readme",
15 | "module": "dist/index.mjs",
16 | "main": "dist/index.js",
17 | "types": "dist/index.d.ts",
18 | "svelte": "src/index.js",
19 | "exports": {
20 | ".": {
21 | "import": {
22 | "types": "./dist/index.d.ts",
23 | "default": "./dist/index.mjs"
24 | },
25 | "require": {
26 | "types": "./dist/index.d.ts",
27 | "default": "./dist/index.js"
28 | },
29 | "svelte": "./src/index.js"
30 | }
31 | },
32 | "scripts": {
33 | "test": "cypress run",
34 | "lint": "eslint .",
35 | "format": "prettier --write .",
36 | "build": "yarn lint && rollup -c",
37 | "prepublishOnly": "yarn build"
38 | },
39 | "dependencies": {},
40 | "devDependencies": {
41 | "@babel/core": "^7.9.6",
42 | "@babel/preset-env": "^7.9.6",
43 | "@rollup/plugin-node-resolve": "^6.0.0",
44 | "babel-jest": "^26.0.1",
45 | "cypress": "^4.5.0",
46 | "eslint": "^7.11.0",
47 | "husky": "^4.3.0",
48 | "lint-staged": "^10.5.1",
49 | "prettier": "^2.1.2",
50 | "rollup": "^2.79.2",
51 | "rollup-plugin-babel": "^4.3.2",
52 | "rollup-plugin-copy": "^3.3.0"
53 | },
54 | "peerDependencies": {
55 | "svelte": ">=3.23.0 || ^5.0.0-next.0"
56 | },
57 | "keywords": [
58 | "svelte",
59 | "drag and drop",
60 | "sortable",
61 | "dnd",
62 | "draggable",
63 | "accessible",
64 | "touch"
65 | ],
66 | "files": [
67 | "src",
68 | "dist"
69 | ],
70 | "husky": {
71 | "hooks": {
72 | "pre-commit": "lint-staged"
73 | }
74 | },
75 | "lint-staged": {
76 | "*.js": "eslint --cache --fix",
77 | "*.{js,css,md}": "prettier --write"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/release-notes.md:
--------------------------------------------------------------------------------
1 | ## Svelte Dnd Action - Release Notes
2 |
3 | ### [0.9.61](https://github.com/isaacHagoel/svelte-dnd-action/pull/645)
4 |
5 | Bugfix: Fixed an issue in `dragHandle` where clicking and releasing without dragging left `isItemsDragDisabled` as `false`, making the entire element draggable.
6 |
7 | ### [0.9.60](https://github.com/isaacHagoel/svelte-dnd-action/pull/639)
8 |
9 | Bugfix: the touchend listener was emulating a click using the click() function and that broke when the user clicked on an SVG
10 |
11 | ### [0.9.59](https://github.com/isaacHagoel/svelte-dnd-action/pull/638)
12 |
13 | Bugfix: fixed an issue that affected $state (for items) that was introduced by version 0.9.58
14 |
15 | ### [0.9.58](https://github.com/isaacHagoel/svelte-dnd-action/pull/636)
16 |
17 | Svelte 5 $state users (if you store items as $state) - please skip this version!
18 | Bugfix: when the items in the origin zone shrink in height right on drag start, the pointer would find itself outside the dragged element
19 |
20 | ### [0.9.57](https://github.com/isaacHagoel/svelte-dnd-action/pull/629)
21 |
22 | Readme update (`on` vs `on:` handlers in Svelte 5)
23 |
24 | ### [0.9.56](https://github.com/isaacHagoel/svelte-dnd-action/pull/628)
25 |
26 | Fixed dndzones inside an element with the 'popover' attribute
27 |
28 | ### [0.9.55](https://github.com/isaacHagoel/svelte-dnd-action/pull/626)
29 |
30 | Fixed logic that could leave the shadow element invisible after drop if the dom wasn't yet updated to reflect the data list (rare)
31 |
32 | ### [0.9.54](https://github.com/isaacHagoel/svelte-dnd-action/pull/621)
33 |
34 | Readme REPL links updated repl -> playground
35 |
36 | ### [0.9.53](https://github.com/isaacHagoel/svelte-dnd-action/pull/618)
37 |
38 | Added a check to address edge cases where multiScroller is undefined when accessed on destroy
39 |
40 | ### [0.9.52](https://github.com/isaacHagoel/svelte-dnd-action/pull/610)
41 |
42 | Fixed a bug that affected dndzone inside scrollable parents - calculated the target index incorrectly when the element was above a hidden part of the zone
43 |
44 | ### [0.9.51](https://github.com/isaacHagoel/svelte-dnd-action/pull/608)
45 |
46 | Added the option to disable the final drop animation
47 |
48 | ### [0.9.50](https://github.com/isaacHagoel/svelte-dnd-action/pull/598)
49 |
50 | Fixed a bug where library would crash when a store updated "considers" in local storage from another tab
51 |
52 | ### [0.9.49](https://github.com/isaacHagoel/svelte-dnd-action/pull/588)
53 |
54 | Fixed a bug where library would crash if one of the canvas had an empty size
55 |
56 | ### [0.9.48](https://github.com/isaacHagoel/svelte-dnd-action/pull/582)
57 |
58 | Fixed a bug where the scroller would stay active and scroll the page after drop
59 |
60 | ### [0.9.47](https://github.com/isaacHagoel/svelte-dnd-action/pull/578)
61 |
62 | Added examples to the README (for the drag handles wrapper actions).
63 |
64 | ### [0.9.46](https://github.com/isaacHagoel/svelte-dnd-action/pull/576)
65 |
66 | Added two wrapper actions (`dragHandleZone` and `dragHandle`) to make using drag handle easy.
67 |
68 | ### [0.9.45](https://github.com/isaacHagoel/svelte-dnd-action/pull/573)
69 |
70 | Bug fix - calling transformDraggedElement after the element was morphed so that changes transform makes aren't overridden by the morphing.
71 |
72 | ### [0.9.44](https://github.com/isaacHagoel/svelte-dnd-action/pull/567)
73 |
74 | Allows Svelte 5.0.0-next as peer dependency.
75 |
76 | ### [0.9.43](https://github.com/isaacHagoel/svelte-dnd-action/pull/556)
77 |
78 | Fixes an issue on some touch devices, where attempting to drag an item causes the page to scroll.
79 |
80 | ### [0.9.42](https://github.com/isaacHagoel/svelte-dnd-action/pull/553)
81 |
82 | Fixes that won't affect most use cases (but do affect recursive nesting).
83 | Fixed updating the items config prior to configure being called.
84 | Restored using the real id throughout the drag operation after the initial frame to prevent issues from implementations relying on it.
85 | This affects the each loop key should be set up when using `data-is-dnd-shadow-item-hint` (see README).
86 |
87 | ### [0.9.41](https://github.com/isaacHagoel/svelte-dnd-action/pull/549)
88 |
89 | The library can now scroll dropzones and any scrollable element that contains dropzones inside, including the window.
90 | This happens when the mouse pointer is near one of the edges of a scrollable container during drag.
91 |
92 | ### [0.9.40](https://github.com/isaacHagoel/svelte-dnd-action/pull/542)
93 |
94 | Added custom events typings with generics to support TypeScript out of the box.
95 |
96 | ### [0.9.39](https://github.com/isaacHagoel/svelte-dnd-action/pull/538)
97 |
98 | Updated README to help set up sveltekit + typescript
99 |
100 | ### [0.9.38](https://github.com/isaacHagoel/svelte-dnd-action/pull/533)
101 |
102 | Added fault tolerance for use cases in which the user removes the shadow item from the list (e.g zones with limited slots)
103 |
104 | ### [0.9.37](https://github.com/isaacHagoel/svelte-dnd-action/pull/532)
105 |
106 | Added support for class instances as list items
107 |
108 | ### [0.9.36](https://github.com/isaacHagoel/svelte-dnd-action/pull/528)
109 |
110 | Added `import` and `require` to the export block in package.json so that types are properly resolved.
111 |
112 | ### [0.9.35](https://github.com/isaacHagoel/svelte-dnd-action/pull/527)
113 |
114 | Added an export block to package.json to remove a Svelte 5 (actually vite) warning about the deprecated "svelte" entry
115 |
116 | ### [0.9.34](https://github.com/isaacHagoel/svelte-dnd-action/pull/524)
117 |
118 | Don't use this version please. It has a silly mistake that cause an error with Sveltekit 2
119 |
120 | ### [0.9.33](https://github.com/isaacHagoel/svelte-dnd-action/pull/499)
121 |
122 | bugfix - now works properly inside a `` element
123 |
124 | ### [0.9.32](https://github.com/isaacHagoel/svelte-dnd-action/pull/517)
125 |
126 | Fixed canvas content not getting cloned on dragged node.
127 |
128 | ### [0.9.31](https://github.com/isaacHagoel/svelte-dnd-action/pull/496)
129 |
130 | Introduce zoneItemTabindex - It allows the user to set custom tabindex to the list container items when not dragging. Can be useful if you use [Drag handles](https://github.com/isaacHagoel/svelte-dnd-action#examples-and-recipes)
131 |
132 | ### [0.9.30](https://github.com/isaacHagoel/svelte-dnd-action/pull/493)
133 |
134 | This version introduces a way for the user to set the flipdurationms to 0, and have it actually have 0 animation (20ms or 1 frame animation). Useful for gaming
135 | as before the 100ms minimum made it so that it was too slow.
136 |
137 | ### [0.9.29](https://github.com/isaacHagoel/svelte-dnd-action/pull/488)
138 |
139 | This version addresses some issues around nested zones. By-default the shadow element now has a temporary id until it's dropped.
140 | This version also adds the option to provide a hint data attribute (`data-is-dnd-shadow-item-hint`) that helps the lib optimise when there is a lot of nesting (see readme).
141 |
142 | ### [0.9.28](https://github.com/isaacHagoel/svelte-dnd-action/pull/484)
143 |
144 | A revert of the problematic part in 0.9.27. This version is functionally equal to 0.9.26
145 |
146 | ### [0.9.27](https://github.com/isaacHagoel/svelte-dnd-action/pull/481)
147 |
148 | PLEASE DON'T USE THIS VERSION
149 | An unsuccessful attempt to support for dropzone being added mid-drag. It breaks in nested scenarios.
150 |
151 | ### [0.9.26](https://github.com/isaacHagoel/svelte-dnd-action/pull/476)
152 |
153 | Readme typo fix in an example: setFeatueFlag -> setFeatureFlag
154 |
155 | ### [0.9.25](https://github.com/isaacHagoel/svelte-dnd-action/pull/473)
156 |
157 | Made the fix that was introduced in version 0.9.23 available via feature flag but inactive by default
158 |
159 | ### [0.9.24](https://github.com/isaacHagoel/svelte-dnd-action/pull/459)
160 |
161 | Updated readme with Svelte 4 types configuration
162 |
163 | ### [0.9.23](https://github.com/isaacHagoel/svelte-dnd-action/pull/457)
164 |
165 | Fix morphing when within css grid
166 |
167 | ### [0.9.22](https://github.com/isaacHagoel/svelte-dnd-action/pull/410)
168 |
169 | Fix repl examples in Readme. Add svelte >=3.23.0 as peerDependency
170 |
171 | ### [0.9.21](https://github.com/isaacHagoel/svelte-dnd-action/pull/405)
172 |
173 | transformDraggedElement is called even if morphing is disabled and a bug that has to do with morphing is now fixed (it was moving the element before styling it)
174 |
175 | ### [0.9.20](https://github.com/isaacHagoel/svelte-dnd-action/pull/401)
176 |
177 | update README to fix global.d.ts example
178 |
179 | ### [0.9.19](https://github.com/isaacHagoel/svelte-dnd-action/pull/382)
180 |
181 | enhancement: DndEvent now allows the use of generics.
182 |
183 | ### [0.9.18](https://github.com/isaacHagoel/svelte-dnd-action/pull/365)
184 |
185 | fix: if a drop zone is removed mid-drag it was causing the lib to throw errors
186 |
187 | ### [0.9.17](https://github.com/isaacHagoel/svelte-dnd-action/pull/320)
188 |
189 | fix: dropdowns (select elements) will now maintain their value during drag
190 |
191 | ### [0.9.16](https://github.com/isaacHagoel/svelte-dnd-action/pull/356)
192 |
193 | fixed a bug that made dropTargetClasses and dropTarget styles work incorrectly when applied to nested zones
194 |
195 | ### [0.9.15](https://github.com/isaacHagoel/svelte-dnd-action/pull/350)
196 |
197 | made the aria support more friendly for multi-page apps (ex: SvelteKit) by having the lib lazy init and clean up the aria divs when the last instance is removed
198 |
199 | ### [0.9.14](https://github.com/isaacHagoel/svelte-dnd-action/pull/340/)
200 |
201 | fixed an issue with items sometimes not making way for the dragged element after autoscroll
202 |
203 | ### [0.9.13](https://github.com/isaacHagoel/svelte-dnd-action/pull/331/)
204 |
205 | fixed the typescript type for dropTargetClasses
206 |
207 | ### [0.9.12](https://github.com/isaacHagoel/svelte-dnd-action/pull/328/)
208 |
209 | added a link example for a basic implementation of multi-drag in the README
210 |
211 | ### [0.9.11](https://github.com/isaacHagoel/svelte-dnd-action/pull/315/)
212 |
213 | added a new option, `zoneTabIndex`, that allows to set custom tabindex in the list container.
214 |
215 | ### 0.9.10
216 |
217 | Please do not use. It was deployed with unintended changes
218 |
219 | ### [0.9.9](https://github.com/isaacHagoel/svelte-dnd-action/pull/301)
220 |
221 | bugfix - works properly when under shadow dom
222 |
223 | ### [0.9.7](https://github.com/isaacHagoel/svelte-dnd-action/pull/290)
224 |
225 | bugfix - works properly now when dropFromOtherDisabled is set to true while the shadow element is in the zone
226 |
227 | ### [0.9.5](https://github.com/isaacHagoel/svelte-dnd-action/pull/271)
228 |
229 | added a new option, `morphDisabled`, that allows to disable morphing of dragged item.
230 |
231 | ### [0.9.4](https://github.com/isaacHagoel/svelte-dnd-action/pull/274)
232 |
233 | bug fix - not crashing when a new dnd zone is created mid drag
234 |
235 | ### [0.9.3](https://github.com/isaacHagoel/svelte-dnd-action/pull/273)
236 |
237 | exporting `DRAGGED_ELEMENT_ID` to allow targeting the dragged element and its subtree using CSS or to fetch it with `document.getElementById`.
238 |
239 | ### [0.9.2](https://github.com/isaacHagoel/svelte-dnd-action/pull/264)
240 |
241 | fixed a race condition that could happen under extremely rapid drag-start -> drop while spam-clicking feverishly
242 |
243 | ### [0.9.1](https://github.com/isaacHagoel/svelte-dnd-action/pull/256)
244 |
245 | exporting `SHADOW_PLACEHOLDER_ITEM_ID` for easier filtering in recursive zones use-cases
246 |
247 | ### [0.9.0](https://github.com/isaacHagoel/svelte-dnd-action/pull/250)
248 |
249 | added the `centreDraggedOnCursor` option to deal with zones that have large items (wide, tall or both) in them that can be dragged over much smaller items.
250 | in these cases, having the center of the items (which is the focal point that triggers all dnd events), and the cursor be the same point makes it more intuitive to drag the large items around.
251 |
252 | ### [0.8.6](https://github.com/isaacHagoel/svelte-dnd-action/pull/231)
253 |
254 | fixed an issue when dragging an item on top of a droppedFromItemsDisabled zone (it is treated as outside of any now, as it should)
255 |
256 | ### [0.8.4](https://github.com/isaacHagoel/svelte-dnd-action/pull/226)
257 |
258 | fixed a keyboard related bug - it is now possible to tab back to the dragged item after tabbing to external elements mid drag
259 |
260 | ### [0.8.2](https://github.com/isaacHagoel/svelte-dnd-action/pull/221)
261 |
262 | accessibility features now work when the library is dynamically imported (in other words, keyboard navigation now works in the REPL again).
263 |
264 | ### [0.8.1](https://github.com/isaacHagoel/svelte-dnd-action/pull/220)
265 |
266 | Made `dropTargetClasses` when initiating drag via keyboard.
267 |
268 | ### [v0.8.0](https://github.com/isaacHagoel/svelte-dnd-action/pull/218)
269 |
270 | Added a new option, `dropTargetClasses`, that allows adding global classes to a dnd-zone when it is a potential drop target (during drag).
271 |
272 | ### [v0.7.4](https://github.com/isaacHagoel/svelte-dnd-action/pull/213)
273 |
274 | This release introduces a subtle change to the dragStarted event.
275 | If you are using [Dragula Copy on Drag](https://svelte.dev/playground/924b4cc920524065a637fa910fe10193?version=3.31.2), you will need to update your consider handler (add 1 line of code to remove the newly added shadow placeholder, see linked REPL).
276 | Same goes for the [crazy nesting](https://svelte.dev/playground/fe8c9eca04f9417a94a8b6041df77139?version=3.31.2) example
277 | Starting with this version, the initial consider event (dragStarted) places a placeholder item with a new id instead of the dragged item in the items list (old behaviour: removing the dragged item from the list altogether). The placeholder is replaced with the real shadow element (the one that has the same id as the original item) in the next event (basically instantly).
278 | This change makes the initial behaviour of large items (relative to their peers) much smoother.
279 |
280 | ### [v0.7.0](https://github.com/isaacHagoel/svelte-dnd-action/pull/202)
281 |
282 | All the changes in this release only affect pointer (mouse/ touch) based drag and drop operations.
283 | It changes some default behaviours (for the better).
284 |
285 | - When an element is being dragged outside of any dnd zone, the placeholder element now appears in the original dnd zone in the original index and indicates where the element would land if dropped. This was added for better UX and to address single sortable list use cases.
286 | - This change includes the introduction of two new triggers, that can be intercepted by the `consider` handler: `DRAGGED_LEFT_ALL` which fires when the placeholder is added to the origin dndzone, and `DRAGGED_ENTERED_ANOTHER` which fires when the placeholder is removed from the origin dnd zone.
287 | - When drag starts - the library now locks the minimum width and height of the origin dropzone for the duration of the drag operation. This is done in order to prevent the container from shrinking and growing jarringly as the element is dragged around. This is especially helpful when the user drags the last element, which in previous versions could make the dndzone shrink underneath such that the dragged element wasn't over it anymore.
288 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from "@rollup/plugin-node-resolve";
2 | import babel from "rollup-plugin-babel";
3 | import copy from "rollup-plugin-copy";
4 | import pkg from "./package.json";
5 |
6 | const name = pkg.name
7 | .replace(/^(@\S+\/)?(svelte-)?(\S+)/, "$3")
8 | .replace(/^\w/, m => m.toUpperCase())
9 | .replace(/-\w/g, m => m[1].toUpperCase());
10 |
11 | export default {
12 | input: "src/index.js",
13 | output: [
14 | {file: pkg.module, format: "es"},
15 | {file: pkg.main, format: "umd", name}
16 | ],
17 | plugins: [
18 | babel({
19 | presets: [
20 | [
21 | "@babel/preset-env",
22 | {
23 | modules: false
24 | }
25 | ]
26 | ]
27 | }),
28 | resolve(),
29 | copy({
30 | targets: [{src: "typings/index.d.ts", dest: "dist/"}]
31 | })
32 | ]
33 | };
34 |
--------------------------------------------------------------------------------
/src/action.js:
--------------------------------------------------------------------------------
1 | import {dndzone as pointerDndZone} from "./pointerAction";
2 | import {dndzone as keyboardDndZone} from "./keyboardAction";
3 | import {ITEM_ID_KEY, SHADOW_ELEMENT_HINT_ATTRIBUTE_NAME} from "./constants";
4 | import {toString} from "./helpers/util";
5 |
6 | /**
7 | * A custom action to turn any container to a dnd zone and all of its direct children to draggables
8 | * Supports mouse, touch and keyboard interactions.
9 | * Dispatches two events that the container is expected to react to by modifying its list of items,
10 | * which will then feed back in to this action via the update function
11 | *
12 | * @typedef {object} Options
13 | * @property {array} items - the list of items that was used to generate the children of the given node (the list used in the #each block
14 | * @property {string} [type] - the type of the dnd zone. children dragged from here can only be dropped in other zones of the same type, default to a base type
15 | * @property {number} [flipDurationMs] - if the list animated using flip (recommended), specifies the flip duration such that everything syncs with it without conflict, defaults to zero
16 | * @property {boolean} [dragDisabled]
17 | * @property {boolean} [morphDisabled] - whether dragged element should morph to zone dimensions
18 | * @property {boolean} [dropFromOthersDisabled]
19 | * @property {number} [zoneTabIndex] - set the tabindex of the list container when not dragging
20 | * @property {number} [zoneItemTabIndex] - set the tabindex of the list container items when not dragging
21 | * @property {object} [dropTargetStyle]
22 | * @property {string[]} [dropTargetClasses]
23 | * @property {boolean} [dropAnimationDisabled] - cancels the drop animation to place
24 | * @property {function} [transformDraggedElement]
25 | * @param {HTMLElement} node - the element to enhance
26 | * @param {Options} options
27 | * @return {{update: function, destroy: function}}
28 | */
29 | export function dndzone(node, options) {
30 | if (shouldIgnoreZone(node)) {
31 | return {
32 | update: () => {},
33 | destroy: () => {}
34 | };
35 | }
36 | validateOptions(options);
37 | const pointerZone = pointerDndZone(node, options);
38 | const keyboardZone = keyboardDndZone(node, options);
39 | return {
40 | update: newOptions => {
41 | validateOptions(newOptions);
42 | pointerZone.update(newOptions);
43 | keyboardZone.update(newOptions);
44 | },
45 | destroy: () => {
46 | pointerZone.destroy();
47 | keyboardZone.destroy();
48 | }
49 | };
50 | }
51 |
52 | /**
53 | * If the user marked something in the ancestry of our node as shadow element, we can ignore it
54 | * We need the user to mark it for us because svelte updates the action from deep to shallow (but renders top down)
55 | * @param {HTMLElement} node
56 | * @return {boolean}
57 | */
58 | function shouldIgnoreZone(node) {
59 | return !!node.closest(`[${SHADOW_ELEMENT_HINT_ATTRIBUTE_NAME}="true"]`);
60 | }
61 |
62 | function validateOptions(options) {
63 | /*eslint-disable*/
64 | const {
65 | items,
66 | flipDurationMs,
67 | type,
68 | dragDisabled,
69 | morphDisabled,
70 | dropFromOthersDisabled,
71 | zoneTabIndex,
72 | zoneItemTabIndex,
73 | dropTargetStyle,
74 | dropTargetClasses,
75 | transformDraggedElement,
76 | autoAriaDisabled,
77 | centreDraggedOnCursor,
78 | dropAnimationDisabled,
79 | ...rest
80 | } = options;
81 | /*eslint-enable*/
82 | if (Object.keys(rest).length > 0) {
83 | console.warn(`dndzone will ignore unknown options`, rest);
84 | }
85 | if (!items) {
86 | throw new Error("no 'items' key provided to dndzone");
87 | }
88 | const itemWithMissingId = items.find(item => !{}.hasOwnProperty.call(item, ITEM_ID_KEY));
89 | if (itemWithMissingId) {
90 | throw new Error(`missing '${ITEM_ID_KEY}' property for item ${toString(itemWithMissingId)}`);
91 | }
92 | if (dropTargetClasses && !Array.isArray(dropTargetClasses)) {
93 | throw new Error(`dropTargetClasses should be an array but instead it is a ${typeof dropTargetClasses}, ${toString(dropTargetClasses)}`);
94 | }
95 | if (zoneTabIndex && !isInt(zoneTabIndex)) {
96 | throw new Error(`zoneTabIndex should be a number but instead it is a ${typeof zoneTabIndex}, ${toString(zoneTabIndex)}`);
97 | }
98 | if (zoneItemTabIndex && !isInt(zoneItemTabIndex)) {
99 | throw new Error(`zoneItemTabIndex should be a number but instead it is a ${typeof zoneItemTabIndex}, ${toString(zoneItemTabIndex)}`);
100 | }
101 | }
102 |
103 | function isInt(value) {
104 | return (
105 | !isNaN(value) &&
106 | (function (x) {
107 | return (x | 0) === x;
108 | })(parseFloat(value))
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | import {DRAGGED_ENTERED_EVENT_NAME, DRAGGED_LEFT_EVENT_NAME, DRAGGED_OVER_INDEX_EVENT_NAME} from "./helpers/dispatcher";
2 |
3 | export const TRIGGERS = {
4 | DRAG_STARTED: "dragStarted",
5 | DRAGGED_ENTERED: DRAGGED_ENTERED_EVENT_NAME,
6 | DRAGGED_ENTERED_ANOTHER: "dragEnteredAnother",
7 | DRAGGED_OVER_INDEX: DRAGGED_OVER_INDEX_EVENT_NAME,
8 | DRAGGED_LEFT: DRAGGED_LEFT_EVENT_NAME,
9 | DRAGGED_LEFT_ALL: "draggedLeftAll",
10 | DROPPED_INTO_ZONE: "droppedIntoZone",
11 | DROPPED_INTO_ANOTHER: "droppedIntoAnother",
12 | DROPPED_OUTSIDE_OF_ANY: "droppedOutsideOfAny",
13 | DRAG_STOPPED: "dragStopped"
14 | };
15 |
16 | export const SOURCES = {
17 | POINTER: "pointer",
18 | KEYBOARD: "keyboard"
19 | };
20 |
21 | export const SHADOW_ITEM_MARKER_PROPERTY_NAME = "isDndShadowItem";
22 | export const SHADOW_ELEMENT_ATTRIBUTE_NAME = "data-is-dnd-shadow-item-internal";
23 | export const SHADOW_ELEMENT_HINT_ATTRIBUTE_NAME = "data-is-dnd-shadow-item-hint";
24 | export const SHADOW_PLACEHOLDER_ITEM_ID = "id:dnd-shadow-placeholder-0000";
25 | export const DRAGGED_ELEMENT_ID = "dnd-action-dragged-el";
26 |
27 | export let ITEM_ID_KEY = "id";
28 | let activeDndZoneCount = 0;
29 | export function incrementActiveDropZoneCount() {
30 | activeDndZoneCount++;
31 | }
32 | export function decrementActiveDropZoneCount() {
33 | if (activeDndZoneCount === 0) {
34 | throw new Error("Bug! trying to decrement when there are no dropzones");
35 | }
36 | activeDndZoneCount--;
37 | }
38 |
39 | /**
40 | * Allows using another key instead of "id" in the items data. This is global and applies to all dndzones.
41 | * Has to be called when there are no rendered dndzones whatsoever.
42 | * @param {String} newKeyName
43 | * @throws {Error} if it was called when there are rendered dndzones or if it is given the wrong type (not a string)
44 | */
45 | export function overrideItemIdKeyNameBeforeInitialisingDndZones(newKeyName) {
46 | if (activeDndZoneCount > 0) {
47 | throw new Error("can only override the id key before initialising any dndzone");
48 | }
49 | if (typeof newKeyName !== "string") {
50 | throw new Error("item id key has to be a string");
51 | }
52 | printDebug(() => ["overriding item id key name", newKeyName]);
53 | ITEM_ID_KEY = newKeyName;
54 | }
55 |
56 | export const isOnServer = typeof window === "undefined";
57 |
58 | export let printDebug = () => {};
59 |
60 | /**
61 | * Allows the user to show/hide console debug output
62 | * * @param {boolean} isDebug
63 | */
64 | export function setDebugMode(isDebug) {
65 | if (isDebug) {
66 | printDebug = (generateMessage, logFunction = console.debug) => {
67 | const message = generateMessage();
68 | if (Array.isArray(message)) {
69 | logFunction(...message);
70 | } else {
71 | logFunction(message);
72 | }
73 | };
74 | } else {
75 | printDebug = () => {};
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/featureFlags.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {{USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT: string}}
3 | */
4 | export const FEATURE_FLAG_NAMES = Object.freeze({
5 | // This flag exists as a workaround for issue 454 (basically a browser bug) - seems like these rect values take time to update when in grid layout. Setting it to true can cause strange behaviour in the REPL for non-grid zones, see issue 470
6 | USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT: "USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT"
7 | });
8 |
9 | const featureFlagsMap = {
10 | [FEATURE_FLAG_NAMES.USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT]: false
11 | };
12 |
13 | /**
14 | * @param {FEATURE_FLAG_NAMES} flagName
15 | * @param {boolean} flagValue
16 | */
17 | export function setFeatureFlag(flagName, flagValue) {
18 | if (!FEATURE_FLAG_NAMES[flagName])
19 | throw new Error(`Can't set non existing feature flag ${flagName}! Supported flags: ${Object.keys(FEATURE_FLAG_NAMES)}`);
20 | featureFlagsMap[flagName] = !!flagValue;
21 | }
22 |
23 | /**
24 | *
25 | * @param {FEATURE_FLAG_NAMES} flagName
26 | * @return {boolean}
27 | */
28 | export function getFeatureFlag(flagName) {
29 | if (!FEATURE_FLAG_NAMES[flagName])
30 | throw new Error(`Can't get non existing feature flag ${flagName}! Supported flags: ${Object.keys(FEATURE_FLAG_NAMES)}`);
31 | return featureFlagsMap[flagName];
32 | }
33 |
--------------------------------------------------------------------------------
/src/helpers/aria.js:
--------------------------------------------------------------------------------
1 | import {isOnServer} from "../constants";
2 |
3 | const INSTRUCTION_IDs = {
4 | DND_ZONE_ACTIVE: "dnd-zone-active",
5 | DND_ZONE_DRAG_DISABLED: "dnd-zone-drag-disabled"
6 | };
7 | const ID_TO_INSTRUCTION = {
8 | [INSTRUCTION_IDs.DND_ZONE_ACTIVE]: "Tab to one the items and press space-bar or enter to start dragging it",
9 | [INSTRUCTION_IDs.DND_ZONE_DRAG_DISABLED]: "This is a disabled drag and drop list"
10 | };
11 |
12 | const ALERT_DIV_ID = "dnd-action-aria-alert";
13 | let alertsDiv;
14 |
15 | function initAriaOnBrowser() {
16 | if (alertsDiv) {
17 | // it is already initialized
18 | return;
19 | }
20 | // setting the dynamic alerts
21 | alertsDiv = document.createElement("div");
22 | (function initAlertsDiv() {
23 | alertsDiv.id = ALERT_DIV_ID;
24 | // tab index -1 makes the alert be read twice on chrome for some reason
25 | //alertsDiv.tabIndex = -1;
26 | alertsDiv.style.position = "fixed";
27 | alertsDiv.style.bottom = "0";
28 | alertsDiv.style.left = "0";
29 | alertsDiv.style.zIndex = "-5";
30 | alertsDiv.style.opacity = "0";
31 | alertsDiv.style.height = "0";
32 | alertsDiv.style.width = "0";
33 | alertsDiv.setAttribute("role", "alert");
34 | })();
35 | document.body.prepend(alertsDiv);
36 |
37 | // setting the instructions
38 | Object.entries(ID_TO_INSTRUCTION).forEach(([id, txt]) => document.body.prepend(instructionToHiddenDiv(id, txt)));
39 | }
40 |
41 | /**
42 | * Initializes the static aria instructions so they can be attached to zones
43 | * @return {{DND_ZONE_ACTIVE: string, DND_ZONE_DRAG_DISABLED: string} | null} - the IDs for static aria instruction (to be used via aria-describedby) or null on the server
44 | */
45 | export function initAria() {
46 | if (isOnServer) return null;
47 | if (document.readyState === "complete") {
48 | initAriaOnBrowser();
49 | } else {
50 | window.addEventListener("DOMContentLoaded", initAriaOnBrowser);
51 | }
52 | return {...INSTRUCTION_IDs};
53 | }
54 |
55 | /**
56 | * Removes all the artifacts (dom elements) added by this module
57 | */
58 | export function destroyAria() {
59 | if (isOnServer || !alertsDiv) return;
60 | Object.keys(ID_TO_INSTRUCTION).forEach(id => document.getElementById(id)?.remove());
61 | alertsDiv.remove();
62 | alertsDiv = undefined;
63 | }
64 |
65 | function instructionToHiddenDiv(id, txt) {
66 | const div = document.createElement("div");
67 | div.id = id;
68 | div.innerHTML = `${txt}
`;
69 | div.style.display = "none";
70 | div.style.position = "fixed";
71 | div.style.zIndex = "-5";
72 | return div;
73 | }
74 |
75 | /**
76 | * Will make the screen reader alert the provided text to the user
77 | * @param {string} txt
78 | */
79 | export function alertToScreenReader(txt) {
80 | if (isOnServer) return;
81 | if (!alertsDiv) {
82 | initAriaOnBrowser();
83 | }
84 | alertsDiv.innerHTML = "";
85 | const alertText = document.createTextNode(txt);
86 | alertsDiv.appendChild(alertText);
87 | // this is needed for Safari
88 | alertsDiv.style.display = "none";
89 | alertsDiv.style.display = "inline";
90 | }
91 |
--------------------------------------------------------------------------------
/src/helpers/dispatcher.js:
--------------------------------------------------------------------------------
1 | // external events
2 | const FINALIZE_EVENT_NAME = "finalize";
3 | const CONSIDER_EVENT_NAME = "consider";
4 |
5 | /**
6 | * @typedef {Object} Info
7 | * @property {string} trigger
8 | * @property {string} id
9 | * @property {string} source
10 | * @param {Node} el
11 | * @param {Array} items
12 | * @param {Info} info
13 | */
14 | export function dispatchFinalizeEvent(el, items, info) {
15 | el.dispatchEvent(
16 | new CustomEvent(FINALIZE_EVENT_NAME, {
17 | detail: {items, info}
18 | })
19 | );
20 | }
21 |
22 | /**
23 | * Dispatches a consider event
24 | * @param {Node} el
25 | * @param {Array} items
26 | * @param {Info} info
27 | */
28 | export function dispatchConsiderEvent(el, items, info) {
29 | el.dispatchEvent(
30 | new CustomEvent(CONSIDER_EVENT_NAME, {
31 | detail: {items, info}
32 | })
33 | );
34 | }
35 |
36 | // internal events
37 | export const DRAGGED_ENTERED_EVENT_NAME = "draggedEntered";
38 | export const DRAGGED_LEFT_EVENT_NAME = "draggedLeft";
39 | export const DRAGGED_OVER_INDEX_EVENT_NAME = "draggedOverIndex";
40 | export const DRAGGED_LEFT_DOCUMENT_EVENT_NAME = "draggedLeftDocument";
41 |
42 | export const DRAGGED_LEFT_TYPES = {
43 | LEFT_FOR_ANOTHER: "leftForAnother",
44 | OUTSIDE_OF_ANY: "outsideOfAny"
45 | };
46 |
47 | export function dispatchDraggedElementEnteredContainer(containerEl, indexObj, draggedEl) {
48 | containerEl.dispatchEvent(
49 | new CustomEvent(DRAGGED_ENTERED_EVENT_NAME, {
50 | detail: {indexObj, draggedEl}
51 | })
52 | );
53 | }
54 |
55 | /**
56 | * @param containerEl - the dropzone the element left
57 | * @param draggedEl - the dragged element
58 | * @param theOtherDz - the new dropzone the element entered
59 | */
60 | export function dispatchDraggedElementLeftContainerForAnother(containerEl, draggedEl, theOtherDz) {
61 | containerEl.dispatchEvent(
62 | new CustomEvent(DRAGGED_LEFT_EVENT_NAME, {
63 | detail: {draggedEl, type: DRAGGED_LEFT_TYPES.LEFT_FOR_ANOTHER, theOtherDz}
64 | })
65 | );
66 | }
67 |
68 | export function dispatchDraggedElementLeftContainerForNone(containerEl, draggedEl) {
69 | containerEl.dispatchEvent(
70 | new CustomEvent(DRAGGED_LEFT_EVENT_NAME, {
71 | detail: {draggedEl, type: DRAGGED_LEFT_TYPES.OUTSIDE_OF_ANY}
72 | })
73 | );
74 | }
75 | export function dispatchDraggedElementIsOverIndex(containerEl, indexObj, draggedEl) {
76 | containerEl.dispatchEvent(
77 | new CustomEvent(DRAGGED_OVER_INDEX_EVENT_NAME, {
78 | detail: {indexObj, draggedEl}
79 | })
80 | );
81 | }
82 | export function dispatchDraggedLeftDocument(draggedEl) {
83 | window.dispatchEvent(
84 | new CustomEvent(DRAGGED_LEFT_DOCUMENT_EVENT_NAME, {
85 | detail: {draggedEl}
86 | })
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/helpers/intersection.js:
--------------------------------------------------------------------------------
1 | // This is based off https://stackoverflow.com/questions/27745438/how-to-compute-getboundingclientrect-without-considering-transforms/57876601#57876601
2 | // It removes the transforms that are potentially applied by the flip animations
3 | /**
4 | * Gets the bounding rect but removes transforms (ex: flip animation)
5 | * @param {HTMLElement} el
6 | * @param {boolean} [onlyVisible] - use the visible rect defaults to true
7 | * @return {{top: number, left: number, bottom: number, right: number}}
8 | */
9 | export function getBoundingRectNoTransforms(el, onlyVisible = true) {
10 | let ta;
11 | const rect = onlyVisible ? getVisibleRectRecursive(el) : el.getBoundingClientRect();
12 | const style = getComputedStyle(el);
13 | const tx = style.transform;
14 |
15 | if (tx) {
16 | let sx, sy, dx, dy;
17 | if (tx.startsWith("matrix3d(")) {
18 | ta = tx.slice(9, -1).split(/, /);
19 | sx = +ta[0];
20 | sy = +ta[5];
21 | dx = +ta[12];
22 | dy = +ta[13];
23 | } else if (tx.startsWith("matrix(")) {
24 | ta = tx.slice(7, -1).split(/, /);
25 | sx = +ta[0];
26 | sy = +ta[3];
27 | dx = +ta[4];
28 | dy = +ta[5];
29 | } else {
30 | return rect;
31 | }
32 |
33 | const to = style.transformOrigin;
34 | const x = rect.x - dx - (1 - sx) * parseFloat(to);
35 | const y = rect.y - dy - (1 - sy) * parseFloat(to.slice(to.indexOf(" ") + 1));
36 | const w = sx ? rect.width / sx : el.offsetWidth;
37 | const h = sy ? rect.height / sy : el.offsetHeight;
38 | return {
39 | x: x,
40 | y: y,
41 | width: w,
42 | height: h,
43 | top: y,
44 | right: x + w,
45 | bottom: y + h,
46 | left: x
47 | };
48 | } else {
49 | return rect;
50 | }
51 | }
52 |
53 | /**
54 | * Gets the absolute bounding rect (accounts for the window's scroll position and removes transforms)
55 | * @param {HTMLElement} el
56 | * @return {{top: number, left: number, bottom: number, right: number}}
57 | */
58 | export function getAbsoluteRectNoTransforms(el) {
59 | const rect = getBoundingRectNoTransforms(el);
60 | return {
61 | top: rect.top + window.scrollY,
62 | bottom: rect.bottom + window.scrollY,
63 | left: rect.left + window.scrollX,
64 | right: rect.right + window.scrollX
65 | };
66 | }
67 |
68 | /**
69 | * Gets the absolute bounding rect (accounts for the window's scroll position)
70 | * @param {HTMLElement} el
71 | * @return {{top: number, left: number, bottom: number, right: number}}
72 | */
73 | export function getAbsoluteRect(el) {
74 | const rect = el.getBoundingClientRect();
75 | return {
76 | top: rect.top + window.scrollY,
77 | bottom: rect.bottom + window.scrollY,
78 | left: rect.left + window.scrollX,
79 | right: rect.right + window.scrollX
80 | };
81 | }
82 |
83 | /**
84 | * finds the center :)
85 | * @typedef {Object} Rect
86 | * @property {number} top
87 | * @property {number} bottom
88 | * @property {number} left
89 | * @property {number} right
90 | * @param {Rect} rect
91 | * @return {{x: number, y: number}}
92 | */
93 | export function findCenter(rect) {
94 | return {
95 | x: (rect.left + rect.right) / 2,
96 | y: (rect.top + rect.bottom) / 2
97 | };
98 | }
99 |
100 | /**
101 | * @typedef {Object} Point
102 | * @property {number} x
103 | * @property {number} y
104 | * @param {Point} pointA
105 | * @param {Point} pointB
106 | * @return {number}
107 | */
108 | function calcDistance(pointA, pointB) {
109 | return Math.sqrt(Math.pow(pointA.x - pointB.x, 2) + Math.pow(pointA.y - pointB.y, 2));
110 | }
111 |
112 | /**
113 | * @param {Point} point
114 | * @param {Rect} rect
115 | * @return {boolean|boolean}
116 | */
117 | export function isPointInsideRect(point, rect) {
118 | return point.y <= rect.bottom && point.y >= rect.top && point.x >= rect.left && point.x <= rect.right;
119 | }
120 |
121 | /**
122 | * find the absolute coordinates of the center of a dom element
123 | * @param el {HTMLElement}
124 | * @returns {{x: number, y: number}}
125 | */
126 | export function findCenterOfElement(el) {
127 | return findCenter(getAbsoluteRect(el));
128 | }
129 |
130 | /**
131 | * @param {HTMLElement} elA
132 | * @param {HTMLElement} elB
133 | * @return {boolean}
134 | */
135 | export function isCenterOfAInsideB(elA, elB) {
136 | const centerOfA = findCenterOfElement(elA);
137 | const rectOfB = getAbsoluteRectNoTransforms(elB);
138 | return isPointInsideRect(centerOfA, rectOfB);
139 | }
140 |
141 | /**
142 | * @param {HTMLElement|ChildNode} elA
143 | * @param {HTMLElement|ChildNode} elB
144 | * @return {number}
145 | */
146 | export function calcDistanceBetweenCenters(elA, elB) {
147 | const centerOfA = findCenterOfElement(elA);
148 | const centerOfB = findCenterOfElement(elB);
149 | return calcDistance(centerOfA, centerOfB);
150 | }
151 |
152 | /**
153 | * @param {HTMLElement} el - the element to check
154 | * @returns {boolean} - true if the element in its entirety is off-screen including the scrollable area (the normal dom events look at the mouse rather than the element)
155 | */
156 | export function isElementOffDocument(el) {
157 | const rect = getAbsoluteRect(el);
158 | return rect.right < 0 || rect.left > document.documentElement.scrollWidth || rect.bottom < 0 || rect.top > document.documentElement.scrollHeight;
159 | }
160 |
161 | function getVisibleRectRecursive(element) {
162 | let rect = element.getBoundingClientRect();
163 | let visibleRect = {
164 | top: rect.top,
165 | bottom: rect.bottom,
166 | left: rect.left,
167 | right: rect.right
168 | };
169 |
170 | // Traverse up the DOM hierarchy, checking for scrollable ancestors
171 | let parent = element.parentElement;
172 | while (parent && parent !== document.body) {
173 | let parentRect = parent.getBoundingClientRect();
174 |
175 | // Check if the parent has a scrollable overflow
176 | const overflowY = window.getComputedStyle(parent).overflowY;
177 | const overflowX = window.getComputedStyle(parent).overflowX;
178 | const isScrollableY = overflowY === "scroll" || overflowY === "auto";
179 | const isScrollableX = overflowX === "scroll" || overflowX === "auto";
180 |
181 | // Constrain the visible area to the parent's visible area
182 | if (isScrollableY) {
183 | visibleRect.top = Math.max(visibleRect.top, parentRect.top);
184 | visibleRect.bottom = Math.min(visibleRect.bottom, parentRect.bottom);
185 | }
186 | if (isScrollableX) {
187 | visibleRect.left = Math.max(visibleRect.left, parentRect.left);
188 | visibleRect.right = Math.min(visibleRect.right, parentRect.right);
189 | }
190 |
191 | parent = parent.parentElement;
192 | }
193 |
194 | // Finally, constrain the visible rect to the viewport
195 | visibleRect.top = Math.max(visibleRect.top, 0);
196 | visibleRect.bottom = Math.min(visibleRect.bottom, window.innerHeight);
197 | visibleRect.left = Math.max(visibleRect.left, 0);
198 | visibleRect.right = Math.min(visibleRect.right, window.innerWidth);
199 |
200 | // Return the visible rectangle, ensuring that all values are valid
201 | return {
202 | top: visibleRect.top,
203 | bottom: visibleRect.bottom,
204 | left: visibleRect.left,
205 | right: visibleRect.right,
206 | width: Math.max(0, visibleRect.right - visibleRect.left),
207 | height: Math.max(0, visibleRect.bottom - visibleRect.top)
208 | };
209 | }
210 |
--------------------------------------------------------------------------------
/src/helpers/listUtil.js:
--------------------------------------------------------------------------------
1 | import {isCenterOfAInsideB, calcDistanceBetweenCenters, getAbsoluteRectNoTransforms, isPointInsideRect, findCenterOfElement} from "./intersection";
2 | import {printDebug, SHADOW_ELEMENT_ATTRIBUTE_NAME} from "../constants";
3 |
4 | let dzToShadowIndexToRect;
5 |
6 | /**
7 | * Resets the cache that allows for smarter "would be index" resolution. Should be called after every drag operation
8 | */
9 | export function resetIndexesCache() {
10 | printDebug(() => "resetting indexes cache");
11 | dzToShadowIndexToRect = new Map();
12 | }
13 | resetIndexesCache();
14 |
15 | /**
16 | * Caches the coordinates of the shadow element when it's in a certain index in a certain dropzone.
17 | * Helpful in order to determine "would be index" more effectively
18 | * @param {HTMLElement} dz
19 | * @return {number} - the shadow element index
20 | */
21 | function cacheShadowRect(dz) {
22 | const shadowElIndex = Array.from(dz.children).findIndex(child => child.getAttribute(SHADOW_ELEMENT_ATTRIBUTE_NAME));
23 | if (shadowElIndex >= 0) {
24 | if (!dzToShadowIndexToRect.has(dz)) {
25 | dzToShadowIndexToRect.set(dz, new Map());
26 | }
27 | dzToShadowIndexToRect.get(dz).set(shadowElIndex, getAbsoluteRectNoTransforms(dz.children[shadowElIndex]));
28 | return shadowElIndex;
29 | }
30 | return undefined;
31 | }
32 |
33 | /**
34 | * @typedef {Object} Index
35 | * @property {number} index - the would be index
36 | * @property {boolean} isProximityBased - false if the element is actually over the index, true if it is not over it but this index is the closest
37 | */
38 | /**
39 | * Find the index for the dragged element in the list it is dragged over
40 | * @param {HTMLElement} floatingAboveEl
41 | * @param {HTMLElement} collectionBelowEl
42 | * @returns {Index|null} - if the element is over the container the Index object otherwise null
43 | */
44 | export function findWouldBeIndex(floatingAboveEl, collectionBelowEl) {
45 | if (!isCenterOfAInsideB(floatingAboveEl, collectionBelowEl)) {
46 | return null;
47 | }
48 | const children = collectionBelowEl.children;
49 | // the container is empty, floating element should be the first
50 | if (children.length === 0) {
51 | return {index: 0, isProximityBased: true};
52 | }
53 | const shadowElIndex = cacheShadowRect(collectionBelowEl);
54 |
55 | // the search could be more efficient but keeping it simple for now
56 | // a possible improvement: pass in the lastIndex it was found in and check there first, then expand from there
57 | for (let i = 0; i < children.length; i++) {
58 | if (isCenterOfAInsideB(floatingAboveEl, children[i])) {
59 | const cachedShadowRect = dzToShadowIndexToRect.has(collectionBelowEl) && dzToShadowIndexToRect.get(collectionBelowEl).get(i);
60 | if (cachedShadowRect) {
61 | if (!isPointInsideRect(findCenterOfElement(floatingAboveEl), cachedShadowRect)) {
62 | return {index: shadowElIndex, isProximityBased: false};
63 | }
64 | }
65 | return {index: i, isProximityBased: false};
66 | }
67 | }
68 | // this can happen if there is space around the children so the floating element has
69 | //entered the container but not any of the children, in this case we will find the nearest child
70 | let minDistanceSoFar = Number.MAX_VALUE;
71 | let indexOfMin = undefined;
72 | // we are checking all of them because we don't know whether we are dealing with a horizontal or vertical container and where the floating element entered from
73 | for (let i = 0; i < children.length; i++) {
74 | const distance = calcDistanceBetweenCenters(floatingAboveEl, children[i]);
75 | if (distance < minDistanceSoFar) {
76 | minDistanceSoFar = distance;
77 | indexOfMin = i;
78 | }
79 | }
80 | return {index: indexOfMin, isProximityBased: true};
81 | }
82 |
--------------------------------------------------------------------------------
/src/helpers/multiScroller.js:
--------------------------------------------------------------------------------
1 | import {makeScroller} from "./scroller";
2 | import {printDebug} from "../constants";
3 | import {getDepth} from "./util";
4 | import {isPointInsideRect} from "./intersection";
5 |
6 | /**
7 | @typedef {Object} MultiScroller
8 | @property {function():boolean} multiScrollIfNeeded - call this on every "tick" to scroll containers if needed, returns true if anything was scrolled
9 | /**
10 | * Creates a scroller than can scroll any of the provided containers or any of their scrollable parents (including the document's scrolling element)
11 | * @param {HTMLElement[]} baseElementsForScrolling
12 | * @param {function():Point} getPointerPosition
13 | * @return {MultiScroller}
14 | */
15 | export function createMultiScroller(baseElementsForScrolling = [], getPointerPosition) {
16 | printDebug(() => "creating multi-scroller");
17 | const scrollingContainersSet = findRelevantScrollContainers(baseElementsForScrolling);
18 | const scrollingContainersDeepToShallow = Array.from(scrollingContainersSet).sort((dz1, dz2) => getDepth(dz2) - getDepth(dz1));
19 | const {scrollIfNeeded, resetScrolling} = makeScroller();
20 |
21 | /**
22 | * @return {boolean} - was any container scrolled
23 | */
24 | function tick() {
25 | const mousePosition = getPointerPosition();
26 | if (!mousePosition || !scrollingContainersDeepToShallow) {
27 | return false;
28 | }
29 | const scrollContainersUnderCursor = scrollingContainersDeepToShallow.filter(
30 | el => isPointInsideRect(mousePosition, el.getBoundingClientRect()) || el === document.scrollingElement
31 | );
32 | for (let i = 0; i < scrollContainersUnderCursor.length; i++) {
33 | const scrolled = scrollIfNeeded(mousePosition, scrollContainersUnderCursor[i]);
34 | if (scrolled) {
35 | return true;
36 | }
37 | }
38 | return false;
39 | }
40 | return {
41 | multiScrollIfNeeded: scrollingContainersSet.size > 0 ? tick : () => false,
42 | destroy: () => resetScrolling()
43 | };
44 | }
45 |
46 | // internal utils
47 | function findScrollableParents(element) {
48 | if (!element) {
49 | return [];
50 | }
51 | const scrollableContainers = [];
52 | let parent = element;
53 | while (parent) {
54 | const {overflow} = window.getComputedStyle(parent);
55 | if (overflow.split(" ").some(o => o.includes("auto") || o.includes("scroll"))) {
56 | scrollableContainers.push(parent);
57 | }
58 | parent = parent.parentElement;
59 | }
60 | return scrollableContainers;
61 | }
62 | function findRelevantScrollContainers(dropZones) {
63 | const scrollingContainers = new Set();
64 | for (let dz of dropZones) {
65 | findScrollableParents(dz).forEach(container => scrollingContainers.add(container));
66 | }
67 | // The scrolling element might have overflow visible and still be scrollable
68 | if (
69 | document.scrollingElement.scrollHeight > document.scrollingElement.clientHeight ||
70 | document.scrollingElement.scrollWidth > document.scrollingElement.clientHeight
71 | ) {
72 | scrollingContainers.add(document.scrollingElement);
73 | }
74 | return scrollingContainers;
75 | }
76 |
--------------------------------------------------------------------------------
/src/helpers/observer.js:
--------------------------------------------------------------------------------
1 | import {findWouldBeIndex, resetIndexesCache} from "./listUtil";
2 | import {findCenterOfElement, isElementOffDocument} from "./intersection";
3 | import {
4 | dispatchDraggedElementEnteredContainer,
5 | dispatchDraggedElementLeftContainerForAnother,
6 | dispatchDraggedElementLeftContainerForNone,
7 | dispatchDraggedLeftDocument,
8 | dispatchDraggedElementIsOverIndex
9 | } from "./dispatcher";
10 | import {getDepth} from "./util";
11 | import {printDebug} from "../constants";
12 |
13 | const INTERVAL_MS = 200;
14 | const TOLERANCE_PX = 10;
15 | let next;
16 |
17 | /**
18 | * Tracks the dragged elements and performs the side effects when it is dragged over a drop zone (basically dispatching custom-events scrolling)
19 | * @param {Set} dropZones
20 | * @param {HTMLElement} draggedEl
21 | * @param {number} [intervalMs = INTERVAL_MS]
22 | * @param {MultiScroller} multiScroller
23 | */
24 | export function observe(draggedEl, dropZones, intervalMs = INTERVAL_MS, multiScroller) {
25 | // initialization
26 | let lastDropZoneFound;
27 | let lastIndexFound;
28 | let lastIsDraggedInADropZone = false;
29 | let lastCentrePositionOfDragged;
30 | // We are sorting to make sure that in case of nested zones of the same type the one "on top" is considered first
31 | const dropZonesFromDeepToShallow = Array.from(dropZones).sort((dz1, dz2) => getDepth(dz2) - getDepth(dz1));
32 |
33 | /**
34 | * The main function in this module. Tracks where everything is/ should be a take the actions
35 | */
36 | function andNow() {
37 | const currentCenterOfDragged = findCenterOfElement(draggedEl);
38 | const scrolled = multiScroller.multiScrollIfNeeded();
39 | // we only want to make a new decision after the element was moved a bit to prevent flickering
40 | if (
41 | !scrolled &&
42 | lastCentrePositionOfDragged &&
43 | Math.abs(lastCentrePositionOfDragged.x - currentCenterOfDragged.x) < TOLERANCE_PX &&
44 | Math.abs(lastCentrePositionOfDragged.y - currentCenterOfDragged.y) < TOLERANCE_PX
45 | ) {
46 | next = window.setTimeout(andNow, intervalMs);
47 | return;
48 | }
49 | if (isElementOffDocument(draggedEl)) {
50 | printDebug(() => "off document");
51 | dispatchDraggedLeftDocument(draggedEl);
52 | return;
53 | }
54 |
55 | lastCentrePositionOfDragged = currentCenterOfDragged;
56 | // this is a simple algorithm, potential improvement: first look at lastDropZoneFound
57 | let isDraggedInADropZone = false;
58 | for (const dz of dropZonesFromDeepToShallow) {
59 | if (scrolled) resetIndexesCache();
60 | const indexObj = findWouldBeIndex(draggedEl, dz);
61 | if (indexObj === null) {
62 | // it is not inside
63 | continue;
64 | }
65 | const {index} = indexObj;
66 | isDraggedInADropZone = true;
67 | // the element is over a container
68 | if (dz !== lastDropZoneFound) {
69 | lastDropZoneFound && dispatchDraggedElementLeftContainerForAnother(lastDropZoneFound, draggedEl, dz);
70 | dispatchDraggedElementEnteredContainer(dz, indexObj, draggedEl);
71 | lastDropZoneFound = dz;
72 | } else if (index !== lastIndexFound) {
73 | dispatchDraggedElementIsOverIndex(dz, indexObj, draggedEl);
74 | lastIndexFound = index;
75 | }
76 | // we handle looping with the 'continue' statement above
77 | break;
78 | }
79 | // the first time the dragged element is not in any dropzone we need to notify the last dropzone it was in
80 | if (!isDraggedInADropZone && lastIsDraggedInADropZone && lastDropZoneFound) {
81 | dispatchDraggedElementLeftContainerForNone(lastDropZoneFound, draggedEl);
82 | lastDropZoneFound = undefined;
83 | lastIndexFound = undefined;
84 | lastIsDraggedInADropZone = false;
85 | } else {
86 | lastIsDraggedInADropZone = true;
87 | }
88 | next = window.setTimeout(andNow, intervalMs);
89 | }
90 | andNow();
91 | }
92 |
93 | // assumption - we can only observe one dragged element at a time, this could be changed in the future
94 | export function unobserve() {
95 | printDebug(() => "unobserving");
96 | clearTimeout(next);
97 | resetIndexesCache();
98 | }
99 |
--------------------------------------------------------------------------------
/src/helpers/scroller.js:
--------------------------------------------------------------------------------
1 | import {isPointInsideRect} from "./intersection";
2 | const SCROLL_ZONE_PX = 30;
3 |
4 | /**
5 | * Will make a scroller that can scroll any element given to it in any direction
6 | * @returns {{scrollIfNeeded: function(Point, HTMLElement): boolean, resetScrolling: function(void):void}}
7 | */
8 | export function makeScroller() {
9 | let scrollingInfo;
10 | function resetScrolling() {
11 | scrollingInfo = {directionObj: undefined, stepPx: 0};
12 | }
13 | resetScrolling();
14 | // directionObj {x: 0|1|-1, y:0|1|-1} - 1 means down in y and right in x
15 | function scrollContainer(containerEl) {
16 | const {directionObj, stepPx} = scrollingInfo;
17 | if (directionObj) {
18 | containerEl.scrollBy(directionObj.x * stepPx, directionObj.y * stepPx);
19 | window.requestAnimationFrame(() => scrollContainer(containerEl));
20 | }
21 | }
22 | function calcScrollStepPx(distancePx) {
23 | return SCROLL_ZONE_PX - distancePx;
24 | }
25 |
26 | /**
27 | * @param {Point} pointer - the pointer will be used to decide in which direction to scroll
28 | * @param {HTMLElement} elementToScroll - the scroll container
29 | * If the pointer is next to the sides of the element to scroll, will trigger scrolling
30 | * Can be called repeatedly with updated pointer and elementToScroll values without issues
31 | * @return {boolean} - true if scrolling was needed
32 | */
33 | function scrollIfNeeded(pointer, elementToScroll) {
34 | if (!elementToScroll) {
35 | return false;
36 | }
37 | const distances = calcInnerDistancesBetweenPointAndSidesOfElement(pointer, elementToScroll);
38 | const isAlreadyScrolling = !!scrollingInfo.directionObj;
39 | if (distances === null) {
40 | if (isAlreadyScrolling) resetScrolling();
41 | return false;
42 | }
43 | let [scrollingVertically, scrollingHorizontally] = [false, false];
44 | // vertical
45 | if (elementToScroll.scrollHeight > elementToScroll.clientHeight) {
46 | if (distances.bottom < SCROLL_ZONE_PX) {
47 | scrollingVertically = true;
48 | scrollingInfo.directionObj = {x: 0, y: 1};
49 | scrollingInfo.stepPx = calcScrollStepPx(distances.bottom);
50 | } else if (distances.top < SCROLL_ZONE_PX) {
51 | scrollingVertically = true;
52 | scrollingInfo.directionObj = {x: 0, y: -1};
53 | scrollingInfo.stepPx = calcScrollStepPx(distances.top);
54 | }
55 | if (!isAlreadyScrolling && scrollingVertically) {
56 | scrollContainer(elementToScroll);
57 | return true;
58 | }
59 | }
60 | // horizontal
61 | if (elementToScroll.scrollWidth > elementToScroll.clientWidth) {
62 | if (distances.right < SCROLL_ZONE_PX) {
63 | scrollingHorizontally = true;
64 | scrollingInfo.directionObj = {x: 1, y: 0};
65 | scrollingInfo.stepPx = calcScrollStepPx(distances.right);
66 | } else if (distances.left < SCROLL_ZONE_PX) {
67 | scrollingHorizontally = true;
68 | scrollingInfo.directionObj = {x: -1, y: 0};
69 | scrollingInfo.stepPx = calcScrollStepPx(distances.left);
70 | }
71 | if (!isAlreadyScrolling && scrollingHorizontally) {
72 | scrollContainer(elementToScroll);
73 | return true;
74 | }
75 | }
76 | resetScrolling();
77 | return false;
78 | }
79 |
80 | return {
81 | scrollIfNeeded,
82 | resetScrolling
83 | };
84 | }
85 |
86 | /**
87 | * If the point is inside the element returns its distances from the sides, otherwise returns null
88 | * @param {Point} point
89 | * @param {HTMLElement} el
90 | * @return {null|{top: number, left: number, bottom: number, right: number}}
91 | */
92 | function calcInnerDistancesBetweenPointAndSidesOfElement(point, el) {
93 | // Even if the scrolling element is small it acts as a scroller for the viewport
94 | const rect =
95 | el === document.scrollingElement
96 | ? {
97 | top: 0,
98 | bottom: window.innerHeight,
99 | left: 0,
100 | right: window.innerWidth
101 | }
102 | : el.getBoundingClientRect();
103 | if (!isPointInsideRect(point, rect)) {
104 | return null;
105 | }
106 | return {
107 | top: point.y - rect.top,
108 | bottom: rect.bottom - point.y,
109 | left: point.x - rect.left,
110 | right: rect.right - point.x
111 | };
112 | }
113 |
--------------------------------------------------------------------------------
/src/helpers/styler.js:
--------------------------------------------------------------------------------
1 | import {SHADOW_ELEMENT_ATTRIBUTE_NAME, DRAGGED_ELEMENT_ID} from "../constants";
2 | import {findCenter} from "./intersection";
3 | import {svelteNodeClone} from "./svelteNodeClone";
4 | import {getFeatureFlag, FEATURE_FLAG_NAMES} from "../featureFlags";
5 |
6 | const TRANSITION_DURATION_SECONDS = 0.2;
7 |
8 | /**
9 | * private helper function - creates a transition string for a property
10 | * @param {string} property
11 | * @return {string} - the transition string
12 | */
13 | function trs(property) {
14 | return `${property} ${TRANSITION_DURATION_SECONDS}s ease`;
15 | }
16 | /**
17 | * clones the given element and applies proper styles and transitions to the dragged element
18 | * @param {HTMLElement} originalElement
19 | * @param {Point} [positionCenterOnXY]
20 | * @return {Node} - the cloned, styled element
21 | */
22 | export function createDraggedElementFrom(originalElement, positionCenterOnXY) {
23 | const rect = originalElement.getBoundingClientRect();
24 | const draggedEl = svelteNodeClone(originalElement);
25 | copyStylesFromTo(originalElement, draggedEl);
26 | draggedEl.id = DRAGGED_ELEMENT_ID;
27 | draggedEl.style.position = "fixed";
28 | let elTopPx = rect.top;
29 | let elLeftPx = rect.left;
30 | draggedEl.style.top = `${elTopPx}px`;
31 | draggedEl.style.left = `${elLeftPx}px`;
32 | if (positionCenterOnXY) {
33 | const center = findCenter(rect);
34 | elTopPx -= center.y - positionCenterOnXY.y;
35 | elLeftPx -= center.x - positionCenterOnXY.x;
36 | window.setTimeout(() => {
37 | draggedEl.style.top = `${elTopPx}px`;
38 | draggedEl.style.left = `${elLeftPx}px`;
39 | }, 0);
40 | }
41 | draggedEl.style.margin = "0";
42 | // we can't have relative or automatic height and width or it will break the illusion
43 | draggedEl.style.boxSizing = "border-box";
44 | draggedEl.style.height = `${rect.height}px`;
45 | draggedEl.style.width = `${rect.width}px`;
46 | draggedEl.style.transition = `${trs("top")}, ${trs("left")}, ${trs("background-color")}, ${trs("opacity")}, ${trs("color")} `;
47 | // this is a workaround for a strange browser bug that causes the right border to disappear when all the transitions are added at the same time
48 | window.setTimeout(() => (draggedEl.style.transition += `, ${trs("width")}, ${trs("height")}`), 0);
49 | draggedEl.style.zIndex = "9999";
50 | draggedEl.style.cursor = "grabbing";
51 |
52 | return draggedEl;
53 | }
54 |
55 | /**
56 | * styles the dragged element to a 'dropped' state
57 | * @param {HTMLElement} draggedEl
58 | */
59 | export function moveDraggedElementToWasDroppedState(draggedEl) {
60 | draggedEl.style.cursor = "grab";
61 | }
62 |
63 | /**
64 | * Morphs the dragged element style, maintains the mouse pointer within the element
65 | * @param {HTMLElement} draggedEl
66 | * @param {HTMLElement} copyFromEl - the element the dragged element should look like, typically the shadow element
67 | * @param {number} currentMouseX
68 | * @param {number} currentMouseY
69 | */
70 | export function morphDraggedElementToBeLike(draggedEl, copyFromEl, currentMouseX, currentMouseY) {
71 | copyStylesFromTo(copyFromEl, draggedEl);
72 | const newRect = copyFromEl.getBoundingClientRect();
73 | const draggedElRect = draggedEl.getBoundingClientRect();
74 | const widthChange = newRect.width - draggedElRect.width;
75 | const heightChange = newRect.height - draggedElRect.height;
76 | if (widthChange || heightChange) {
77 | const relativeDistanceOfMousePointerFromDraggedSides = {
78 | left: (currentMouseX - draggedElRect.left) / draggedElRect.width,
79 | top: (currentMouseY - draggedElRect.top) / draggedElRect.height
80 | };
81 | if (!getFeatureFlag(FEATURE_FLAG_NAMES.USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT)) {
82 | draggedEl.style.height = `${newRect.height}px`;
83 | draggedEl.style.width = `${newRect.width}px`;
84 | }
85 | draggedEl.style.left = `${parseFloat(draggedEl.style.left) - relativeDistanceOfMousePointerFromDraggedSides.left * widthChange}px`;
86 | draggedEl.style.top = `${parseFloat(draggedEl.style.top) - relativeDistanceOfMousePointerFromDraggedSides.top * heightChange}px`;
87 | }
88 | }
89 |
90 | /**
91 | * @param {HTMLElement} copyFromEl
92 | * @param {HTMLElement} copyToEl
93 | */
94 | function copyStylesFromTo(copyFromEl, copyToEl) {
95 | const computedStyle = window.getComputedStyle(copyFromEl);
96 | Array.from(computedStyle)
97 | .filter(
98 | s =>
99 | s.startsWith("background") ||
100 | s.startsWith("padding") ||
101 | s.startsWith("font") ||
102 | s.startsWith("text") ||
103 | s.startsWith("align") ||
104 | s.startsWith("justify") ||
105 | s.startsWith("display") ||
106 | s.startsWith("flex") ||
107 | s.startsWith("border") ||
108 | s === "opacity" ||
109 | s === "color" ||
110 | s === "list-style-type" ||
111 | // copying with and height to make up for rect update timing issues in some browsers
112 | (getFeatureFlag(FEATURE_FLAG_NAMES.USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT) && (s === "width" || s === "height"))
113 | )
114 | .forEach(s => copyToEl.style.setProperty(s, computedStyle.getPropertyValue(s), computedStyle.getPropertyPriority(s)));
115 | }
116 |
117 | /**
118 | * makes the element compatible with being draggable
119 | * @param {HTMLElement} draggableEl
120 | * @param {boolean} dragDisabled
121 | */
122 | export function styleDraggable(draggableEl, dragDisabled) {
123 | draggableEl.draggable = false;
124 | draggableEl.ondragstart = () => false;
125 | if (!dragDisabled) {
126 | draggableEl.style.userSelect = "none";
127 | draggableEl.style.WebkitUserSelect = "none";
128 | draggableEl.style.cursor = "grab";
129 | } else {
130 | draggableEl.style.userSelect = "";
131 | draggableEl.style.WebkitUserSelect = "";
132 | draggableEl.style.cursor = "";
133 | }
134 | }
135 |
136 | /**
137 | * Hides the provided element so that it can stay in the dom without interrupting
138 | * @param {HTMLElement} dragTarget
139 | */
140 | export function hideElement(dragTarget) {
141 | dragTarget.style.display = "none";
142 | dragTarget.style.position = "fixed";
143 | dragTarget.style.zIndex = "-5";
144 | }
145 |
146 | /**
147 | * styles the shadow element
148 | * @param {HTMLElement} shadowEl
149 | */
150 | export function decorateShadowEl(shadowEl) {
151 | shadowEl.style.visibility = "hidden";
152 | shadowEl.setAttribute(SHADOW_ELEMENT_ATTRIBUTE_NAME, "true");
153 | }
154 |
155 | /**
156 | * undo the styles the shadow element
157 | * @param {HTMLElement} shadowEl
158 | */
159 | export function unDecorateShadowElement(shadowEl) {
160 | shadowEl.style.visibility = "";
161 | shadowEl.removeAttribute(SHADOW_ELEMENT_ATTRIBUTE_NAME);
162 | }
163 |
164 | /**
165 | * will mark the given dropzones as visually active
166 | * @param {Array} dropZones
167 | * @param {Function} getStyles - maps a dropzone to a styles object (so the styles can be removed)
168 | * @param {Function} getClasses - maps a dropzone to a classList
169 | */
170 | export function styleActiveDropZones(dropZones, getStyles = () => {}, getClasses = () => []) {
171 | dropZones.forEach(dz => {
172 | const styles = getStyles(dz);
173 | Object.keys(styles).forEach(style => {
174 | dz.style[style] = styles[style];
175 | });
176 | getClasses(dz).forEach(c => dz.classList.add(c));
177 | });
178 | }
179 |
180 | /**
181 | * will remove the 'active' styling from given dropzones
182 | * @param {Array} dropZones
183 | * @param {Function} getStyles - maps a dropzone to a styles object
184 | * @param {Function} getClasses - maps a dropzone to a classList
185 | */
186 | export function styleInactiveDropZones(dropZones, getStyles = () => {}, getClasses = () => []) {
187 | dropZones.forEach(dz => {
188 | const styles = getStyles(dz);
189 | Object.keys(styles).forEach(style => {
190 | dz.style[style] = "";
191 | });
192 | getClasses(dz).forEach(c => dz.classList.contains(c) && dz.classList.remove(c));
193 | });
194 | }
195 |
196 | /**
197 | * will prevent the provided element from shrinking by setting its minWidth and minHeight to the current width and height values
198 | * @param {HTMLElement} el
199 | * @return {function(): void} - run this function to undo the operation and restore the original values
200 | */
201 | export function preventShrinking(el) {
202 | const originalMinHeight = el.style.minHeight;
203 | el.style.minHeight = window.getComputedStyle(el).getPropertyValue("height");
204 | const originalMinWidth = el.style.minWidth;
205 | el.style.minWidth = window.getComputedStyle(el).getPropertyValue("width");
206 | return function undo() {
207 | el.style.minHeight = originalMinHeight;
208 | el.style.minWidth = originalMinWidth;
209 | };
210 | }
211 |
--------------------------------------------------------------------------------
/src/helpers/svelteNodeClone.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Fixes svelte issue when cloning node containing (or being) which will loose it's value.
3 | * Since svelte manages select value internally.
4 | * @see https://github.com/sveltejs/svelte/issues/6717
5 | * @see https://github.com/isaacHagoel/svelte-dnd-action/issues/306
6 | *
7 | * @param {HTMLElement} el
8 | * @returns
9 | */
10 | export function svelteNodeClone(el) {
11 | const cloned = el.cloneNode(true);
12 |
13 | const values = [];
14 | const elIsSelect = el.tagName === "SELECT";
15 | const selects = elIsSelect ? [el] : [...el.querySelectorAll("select")];
16 | for (const select of selects) {
17 | values.push(select.value);
18 | }
19 |
20 | if (selects.length > 0) {
21 | const clonedSelects = elIsSelect ? [cloned] : [...cloned.querySelectorAll("select")];
22 | for (let i = 0; i < clonedSelects.length; i++) {
23 | const select = clonedSelects[i];
24 | const value = values[i];
25 | const optionEl = select.querySelector(`option[value="${value}"`);
26 | if (optionEl) {
27 | optionEl.setAttribute("selected", true);
28 | }
29 | }
30 | }
31 |
32 | const elIsCanvas = el.tagName === "CANVAS";
33 | const canvases = elIsCanvas ? [el] : [...el.querySelectorAll("canvas")];
34 | if (canvases.length > 0) {
35 | const clonedCanvases = elIsCanvas ? [cloned] : [...cloned.querySelectorAll("canvas")];
36 | for (let i = 0; i < clonedCanvases.length; i++) {
37 | const canvas = canvases[i];
38 | const clonedCanvas = clonedCanvases[i];
39 | clonedCanvas.width = canvas.width;
40 | clonedCanvas.height = canvas.height;
41 | if (canvas.width > 0 && canvas.height > 0) {
42 | clonedCanvas.getContext("2d").drawImage(canvas, 0, 0);
43 | }
44 | }
45 | }
46 |
47 | return cloned;
48 | }
49 |
--------------------------------------------------------------------------------
/src/helpers/util.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {Object} object
3 | * @return {string}
4 | */
5 | export function toString(object) {
6 | return JSON.stringify(object, null, 2);
7 | }
8 |
9 | /**
10 | * Finds the depth of the given node in the DOM tree
11 | * @param {HTMLElement} node
12 | * @return {number} - the depth of the node
13 | */
14 | export function getDepth(node) {
15 | if (!node) {
16 | throw new Error("cannot get depth of a falsy node");
17 | }
18 | return _getDepth(node, 0);
19 | }
20 | function _getDepth(node, countSoFar = 0) {
21 | if (!node.parentElement) {
22 | return countSoFar - 1;
23 | }
24 | return _getDepth(node.parentElement, countSoFar + 1);
25 | }
26 |
27 | /**
28 | * A simple util to shallow compare objects quickly, it doesn't validate the arguments so pass objects in
29 | * @param {Object} objA
30 | * @param {Object} objB
31 | * @return {boolean} - true if objA and objB are shallow equal
32 | */
33 | export function areObjectsShallowEqual(objA, objB) {
34 | if (Object.keys(objA).length !== Object.keys(objB).length) {
35 | return false;
36 | }
37 | for (const keyA in objA) {
38 | if (!{}.hasOwnProperty.call(objB, keyA) || objB[keyA] !== objA[keyA]) {
39 | return false;
40 | }
41 | }
42 | return true;
43 | }
44 |
45 | /**
46 | * Shallow compares two arrays
47 | * @param arrA
48 | * @param arrB
49 | * @return {boolean} - whether the arrays are shallow equal
50 | */
51 | export function areArraysShallowEqualSameOrder(arrA, arrB) {
52 | if (arrA.length !== arrB.length) {
53 | return false;
54 | }
55 | for (let i = 0; i < arrA.length; i++) {
56 | if (arrA[i] !== arrB[i]) {
57 | return false;
58 | }
59 | }
60 | return true;
61 | }
62 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export {dndzone} from "./action.js";
2 | export {dragHandleZone, dragHandle} from "./wrappers/withDragHandles";
3 | export {alertToScreenReader} from "./helpers/aria";
4 | export {
5 | TRIGGERS,
6 | SOURCES,
7 | SHADOW_ITEM_MARKER_PROPERTY_NAME,
8 | SHADOW_PLACEHOLDER_ITEM_ID,
9 | DRAGGED_ELEMENT_ID,
10 | overrideItemIdKeyNameBeforeInitialisingDndZones,
11 | setDebugMode
12 | } from "./constants";
13 |
14 | export {setFeatureFlag, FEATURE_FLAG_NAMES} from "./featureFlags";
15 |
--------------------------------------------------------------------------------
/src/keyboardAction.js:
--------------------------------------------------------------------------------
1 | import {decrementActiveDropZoneCount, incrementActiveDropZoneCount, ITEM_ID_KEY, SOURCES, TRIGGERS} from "./constants";
2 | import {styleActiveDropZones, styleInactiveDropZones} from "./helpers/styler";
3 | import {dispatchConsiderEvent, dispatchFinalizeEvent} from "./helpers/dispatcher";
4 | import {initAria, alertToScreenReader, destroyAria} from "./helpers/aria";
5 | import {toString} from "./helpers/util";
6 | import {printDebug} from "./constants";
7 |
8 | const DEFAULT_DROP_ZONE_TYPE = "--any--";
9 | const DEFAULT_DROP_TARGET_STYLE = {
10 | outline: "rgba(255, 255, 102, 0.7) solid 2px"
11 | };
12 |
13 | let isDragging = false;
14 | let draggedItemType;
15 | let focusedDz;
16 | let focusedDzLabel = "";
17 | let focusedItem;
18 | let focusedItemId;
19 | let focusedItemLabel = "";
20 | const allDragTargets = new WeakSet();
21 | const elToKeyDownListeners = new WeakMap();
22 | const elToFocusListeners = new WeakMap();
23 | const dzToHandles = new Map();
24 | const dzToConfig = new Map();
25 | const typeToDropZones = new Map();
26 |
27 | /* TODO (potentially)
28 | * what's the deal with the black border of voice-reader not following focus?
29 | * maybe keep focus on the last dragged item upon drop?
30 | */
31 |
32 | let INSTRUCTION_IDs;
33 |
34 | /* drop-zones registration management */
35 | function registerDropZone(dropZoneEl, type) {
36 | printDebug(() => "registering drop-zone if absent");
37 | if (typeToDropZones.size === 0) {
38 | printDebug(() => "adding global keydown and click handlers");
39 | INSTRUCTION_IDs = initAria();
40 | window.addEventListener("keydown", globalKeyDownHandler);
41 | window.addEventListener("click", globalClickHandler);
42 | }
43 | if (!typeToDropZones.has(type)) {
44 | typeToDropZones.set(type, new Set());
45 | }
46 | if (!typeToDropZones.get(type).has(dropZoneEl)) {
47 | typeToDropZones.get(type).add(dropZoneEl);
48 | incrementActiveDropZoneCount();
49 | }
50 | }
51 | function unregisterDropZone(dropZoneEl, type) {
52 | printDebug(() => "unregistering drop-zone");
53 | if (focusedDz === dropZoneEl) {
54 | handleDrop();
55 | }
56 | typeToDropZones.get(type).delete(dropZoneEl);
57 | decrementActiveDropZoneCount();
58 | if (typeToDropZones.get(type).size === 0) {
59 | typeToDropZones.delete(type);
60 | }
61 | if (typeToDropZones.size === 0) {
62 | printDebug(() => "removing global keydown and click handlers");
63 | window.removeEventListener("keydown", globalKeyDownHandler);
64 | window.removeEventListener("click", globalClickHandler);
65 | INSTRUCTION_IDs = undefined;
66 | destroyAria();
67 | }
68 | }
69 |
70 | function globalKeyDownHandler(e) {
71 | if (!isDragging) return;
72 | switch (e.key) {
73 | case "Escape": {
74 | handleDrop();
75 | break;
76 | }
77 | }
78 | }
79 |
80 | function globalClickHandler() {
81 | if (!isDragging) return;
82 | if (!allDragTargets.has(document.activeElement)) {
83 | printDebug(() => "clicked outside of any draggable");
84 | handleDrop();
85 | }
86 | }
87 |
88 | function handleZoneFocus(e) {
89 | printDebug(() => "zone focus");
90 | if (!isDragging) return;
91 | const newlyFocusedDz = e.currentTarget;
92 | if (newlyFocusedDz === focusedDz) return;
93 |
94 | focusedDzLabel = newlyFocusedDz.getAttribute("aria-label") || "";
95 | const {items: originItems} = dzToConfig.get(focusedDz);
96 | const originItem = originItems.find(item => item[ITEM_ID_KEY] === focusedItemId);
97 | const originIdx = originItems.indexOf(originItem);
98 | const itemToMove = originItems.splice(originIdx, 1)[0];
99 | const {items: targetItems, autoAriaDisabled} = dzToConfig.get(newlyFocusedDz);
100 | if (
101 | newlyFocusedDz.getBoundingClientRect().top < focusedDz.getBoundingClientRect().top ||
102 | newlyFocusedDz.getBoundingClientRect().left < focusedDz.getBoundingClientRect().left
103 | ) {
104 | targetItems.push(itemToMove);
105 | if (!autoAriaDisabled) {
106 | alertToScreenReader(`Moved item ${focusedItemLabel} to the end of the list ${focusedDzLabel}`);
107 | }
108 | } else {
109 | targetItems.unshift(itemToMove);
110 | if (!autoAriaDisabled) {
111 | alertToScreenReader(`Moved item ${focusedItemLabel} to the beginning of the list ${focusedDzLabel}`);
112 | }
113 | }
114 | const dzFrom = focusedDz;
115 | dispatchFinalizeEvent(dzFrom, originItems, {trigger: TRIGGERS.DROPPED_INTO_ANOTHER, id: focusedItemId, source: SOURCES.KEYBOARD});
116 | dispatchFinalizeEvent(newlyFocusedDz, targetItems, {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: focusedItemId, source: SOURCES.KEYBOARD});
117 | focusedDz = newlyFocusedDz;
118 | }
119 |
120 | function triggerAllDzsUpdate() {
121 | dzToHandles.forEach(({update}, dz) => update(dzToConfig.get(dz)));
122 | }
123 |
124 | function handleDrop(dispatchConsider = true) {
125 | printDebug(() => "drop");
126 | if (!dzToConfig.get(focusedDz).autoAriaDisabled) {
127 | alertToScreenReader(`Stopped dragging item ${focusedItemLabel}`);
128 | }
129 | if (allDragTargets.has(document.activeElement)) {
130 | document.activeElement.blur();
131 | }
132 | if (dispatchConsider) {
133 | dispatchConsiderEvent(focusedDz, dzToConfig.get(focusedDz).items, {
134 | trigger: TRIGGERS.DRAG_STOPPED,
135 | id: focusedItemId,
136 | source: SOURCES.KEYBOARD
137 | });
138 | }
139 | styleInactiveDropZones(
140 | typeToDropZones.get(draggedItemType),
141 | dz => dzToConfig.get(dz).dropTargetStyle,
142 | dz => dzToConfig.get(dz).dropTargetClasses
143 | );
144 | focusedItem = null;
145 | focusedItemId = null;
146 | focusedItemLabel = "";
147 | draggedItemType = null;
148 | focusedDz = null;
149 | focusedDzLabel = "";
150 | isDragging = false;
151 | triggerAllDzsUpdate();
152 | }
153 | //////
154 | export function dndzone(node, options) {
155 | const config = {
156 | items: undefined,
157 | type: undefined,
158 | dragDisabled: false,
159 | zoneTabIndex: 0,
160 | zoneItemTabIndex: 0,
161 | dropFromOthersDisabled: false,
162 | dropTargetStyle: DEFAULT_DROP_TARGET_STYLE,
163 | dropTargetClasses: [],
164 | autoAriaDisabled: false
165 | };
166 |
167 | function swap(arr, i, j) {
168 | if (arr.length <= 1) return;
169 | arr.splice(j, 1, arr.splice(i, 1, arr[j])[0]);
170 | }
171 |
172 | function handleKeyDown(e) {
173 | printDebug(() => ["handling key down", e.key]);
174 | switch (e.key) {
175 | case "Enter":
176 | case " ": {
177 | // we don't want to affect nested input elements or clickable elements
178 | if ((e.target.disabled !== undefined || e.target.href || e.target.isContentEditable) && !allDragTargets.has(e.target)) {
179 | return;
180 | }
181 | e.preventDefault(); // preventing scrolling on spacebar
182 | e.stopPropagation();
183 | if (isDragging) {
184 | // TODO - should this trigger a drop? only here or in general (as in when hitting space or enter outside of any zone)?
185 | handleDrop();
186 | } else {
187 | // drag start
188 | handleDragStart(e);
189 | }
190 | break;
191 | }
192 | case "ArrowDown":
193 | case "ArrowRight": {
194 | if (!isDragging) return;
195 | e.preventDefault(); // prevent scrolling
196 | e.stopPropagation();
197 | const {items} = dzToConfig.get(node);
198 | const children = Array.from(node.children);
199 | const idx = children.indexOf(e.currentTarget);
200 | printDebug(() => ["arrow down", idx]);
201 | if (idx < children.length - 1) {
202 | if (!config.autoAriaDisabled) {
203 | alertToScreenReader(`Moved item ${focusedItemLabel} to position ${idx + 2} in the list ${focusedDzLabel}`);
204 | }
205 | swap(items, idx, idx + 1);
206 | dispatchFinalizeEvent(node, items, {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: focusedItemId, source: SOURCES.KEYBOARD});
207 | }
208 | break;
209 | }
210 | case "ArrowUp":
211 | case "ArrowLeft": {
212 | if (!isDragging) return;
213 | e.preventDefault(); // prevent scrolling
214 | e.stopPropagation();
215 | const {items} = dzToConfig.get(node);
216 | const children = Array.from(node.children);
217 | const idx = children.indexOf(e.currentTarget);
218 | printDebug(() => ["arrow up", idx]);
219 | if (idx > 0) {
220 | if (!config.autoAriaDisabled) {
221 | alertToScreenReader(`Moved item ${focusedItemLabel} to position ${idx} in the list ${focusedDzLabel}`);
222 | }
223 | swap(items, idx, idx - 1);
224 | dispatchFinalizeEvent(node, items, {trigger: TRIGGERS.DROPPED_INTO_ZONE, id: focusedItemId, source: SOURCES.KEYBOARD});
225 | }
226 | break;
227 | }
228 | }
229 | }
230 | function handleDragStart(e) {
231 | printDebug(() => "drag start");
232 | setCurrentFocusedItem(e.currentTarget);
233 | focusedDz = node;
234 | draggedItemType = config.type;
235 | isDragging = true;
236 | const dropTargets = Array.from(typeToDropZones.get(config.type)).filter(dz => dz === focusedDz || !dzToConfig.get(dz).dropFromOthersDisabled);
237 | styleActiveDropZones(
238 | dropTargets,
239 | dz => dzToConfig.get(dz).dropTargetStyle,
240 | dz => dzToConfig.get(dz).dropTargetClasses
241 | );
242 | if (!config.autoAriaDisabled) {
243 | let msg = `Started dragging item ${focusedItemLabel}. Use the arrow keys to move it within its list ${focusedDzLabel}`;
244 | if (dropTargets.length > 1) {
245 | msg += `, or tab to another list in order to move the item into it`;
246 | }
247 | alertToScreenReader(msg);
248 | }
249 | dispatchConsiderEvent(node, dzToConfig.get(node).items, {trigger: TRIGGERS.DRAG_STARTED, id: focusedItemId, source: SOURCES.KEYBOARD});
250 | triggerAllDzsUpdate();
251 | }
252 |
253 | function handleClick(e) {
254 | if (!isDragging) return;
255 | if (e.currentTarget === focusedItem) return;
256 | e.stopPropagation();
257 | handleDrop(false);
258 | handleDragStart(e);
259 | }
260 | function setCurrentFocusedItem(draggableEl) {
261 | const {items} = dzToConfig.get(node);
262 | const children = Array.from(node.children);
263 | const focusedItemIdx = children.indexOf(draggableEl);
264 | focusedItem = draggableEl;
265 | focusedItem.tabIndex = config.zoneItemTabIndex;
266 | focusedItemId = items[focusedItemIdx][ITEM_ID_KEY];
267 | focusedItemLabel = children[focusedItemIdx].getAttribute("aria-label") || "";
268 | }
269 |
270 | function configure({
271 | items = [],
272 | type: newType = DEFAULT_DROP_ZONE_TYPE,
273 | dragDisabled = false,
274 | zoneTabIndex = 0,
275 | zoneItemTabIndex = 0,
276 | dropFromOthersDisabled = false,
277 | dropTargetStyle = DEFAULT_DROP_TARGET_STYLE,
278 | dropTargetClasses = [],
279 | autoAriaDisabled = false
280 | }) {
281 | config.items = [...items];
282 | config.dragDisabled = dragDisabled;
283 | config.dropFromOthersDisabled = dropFromOthersDisabled;
284 | config.zoneTabIndex = zoneTabIndex;
285 | config.zoneItemTabIndex = zoneItemTabIndex;
286 | config.dropTargetStyle = dropTargetStyle;
287 | config.dropTargetClasses = dropTargetClasses;
288 | config.autoAriaDisabled = autoAriaDisabled;
289 | if (config.type && newType !== config.type) {
290 | unregisterDropZone(node, config.type);
291 | }
292 | config.type = newType;
293 | registerDropZone(node, newType);
294 | if (!autoAriaDisabled) {
295 | node.setAttribute("aria-disabled", dragDisabled);
296 | node.setAttribute("role", "list");
297 | node.setAttribute("aria-describedby", dragDisabled ? INSTRUCTION_IDs.DND_ZONE_DRAG_DISABLED : INSTRUCTION_IDs.DND_ZONE_ACTIVE);
298 | }
299 | dzToConfig.set(node, config);
300 |
301 | if (isDragging) {
302 | node.tabIndex =
303 | node === focusedDz ||
304 | focusedItem.contains(node) ||
305 | config.dropFromOthersDisabled ||
306 | (focusedDz && config.type !== dzToConfig.get(focusedDz).type)
307 | ? -1
308 | : 0;
309 | } else {
310 | node.tabIndex = config.zoneTabIndex;
311 | }
312 |
313 | node.addEventListener("focus", handleZoneFocus);
314 |
315 | for (let i = 0; i < node.children.length; i++) {
316 | const draggableEl = node.children[i];
317 | allDragTargets.add(draggableEl);
318 | draggableEl.tabIndex = isDragging ? -1 : config.zoneItemTabIndex;
319 | if (!autoAriaDisabled) {
320 | draggableEl.setAttribute("role", "listitem");
321 | }
322 | draggableEl.removeEventListener("keydown", elToKeyDownListeners.get(draggableEl));
323 | draggableEl.removeEventListener("click", elToFocusListeners.get(draggableEl));
324 | if (!dragDisabled) {
325 | draggableEl.addEventListener("keydown", handleKeyDown);
326 | elToKeyDownListeners.set(draggableEl, handleKeyDown);
327 | draggableEl.addEventListener("click", handleClick);
328 | elToFocusListeners.set(draggableEl, handleClick);
329 | }
330 | if (isDragging && config.items[i][ITEM_ID_KEY] === focusedItemId) {
331 | printDebug(() => ["focusing on", {i, focusedItemId}]);
332 | // if it is a nested dropzone, it was re-rendered and we need to refresh our pointer
333 | focusedItem = draggableEl;
334 | focusedItem.tabIndex = config.zoneItemTabIndex;
335 | // without this the element loses focus if it moves backwards in the list
336 | draggableEl.focus();
337 | }
338 | }
339 | }
340 | configure(options);
341 |
342 | const handles = {
343 | update: newOptions => {
344 | printDebug(() => `keyboard dndzone will update newOptions: ${toString(newOptions)}`);
345 | configure(newOptions);
346 | },
347 | destroy: () => {
348 | printDebug(() => "keyboard dndzone will destroy");
349 | unregisterDropZone(node, config.type);
350 | dzToConfig.delete(node);
351 | dzToHandles.delete(node);
352 | }
353 | };
354 | dzToHandles.set(node, handles);
355 | return handles;
356 | }
357 |
--------------------------------------------------------------------------------
/src/pointerAction.js:
--------------------------------------------------------------------------------
1 | import {
2 | decrementActiveDropZoneCount,
3 | incrementActiveDropZoneCount,
4 | ITEM_ID_KEY,
5 | printDebug,
6 | SHADOW_ELEMENT_ATTRIBUTE_NAME,
7 | SHADOW_ITEM_MARKER_PROPERTY_NAME,
8 | SHADOW_PLACEHOLDER_ITEM_ID,
9 | SOURCES,
10 | TRIGGERS
11 | } from "./constants";
12 | import {observe, unobserve} from "./helpers/observer";
13 | import {createMultiScroller} from "./helpers/multiScroller";
14 | import {
15 | createDraggedElementFrom,
16 | decorateShadowEl,
17 | hideElement,
18 | morphDraggedElementToBeLike,
19 | moveDraggedElementToWasDroppedState,
20 | preventShrinking,
21 | styleActiveDropZones,
22 | styleDraggable,
23 | styleInactiveDropZones,
24 | unDecorateShadowElement
25 | } from "./helpers/styler";
26 | import {
27 | dispatchConsiderEvent,
28 | dispatchFinalizeEvent,
29 | DRAGGED_ENTERED_EVENT_NAME,
30 | DRAGGED_LEFT_DOCUMENT_EVENT_NAME,
31 | DRAGGED_LEFT_EVENT_NAME,
32 | DRAGGED_LEFT_TYPES,
33 | DRAGGED_OVER_INDEX_EVENT_NAME
34 | } from "./helpers/dispatcher";
35 | import {areArraysShallowEqualSameOrder, areObjectsShallowEqual, toString} from "./helpers/util";
36 | import {getBoundingRectNoTransforms} from "./helpers/intersection";
37 |
38 | const DEFAULT_DROP_ZONE_TYPE = "--any--";
39 | const MIN_OBSERVATION_INTERVAL_MS = 100;
40 | const DISABLED_OBSERVATION_INTERVAL_MS = 20;
41 | const MIN_MOVEMENT_BEFORE_DRAG_START_PX = 3;
42 | const DEFAULT_DROP_TARGET_STYLE = {
43 | outline: "rgba(255, 255, 102, 0.7) solid 2px"
44 | };
45 | const ORIGINAL_DRAGGED_ITEM_MARKER_ATTRIBUTE = "data-is-dnd-original-dragged-item";
46 |
47 | let originalDragTarget;
48 | let draggedEl;
49 | let draggedElData;
50 | let draggedElType;
51 | let originDropZone;
52 | let originIndex;
53 | let shadowElData;
54 | let shadowElDropZone;
55 | let dragStartMousePosition;
56 | let currentMousePosition;
57 | let isWorkingOnPreviousDrag = false;
58 | let finalizingPreviousDrag = false;
59 | let unlockOriginDzMinDimensions;
60 | let isDraggedOutsideOfAnyDz = false;
61 | let scheduledForRemovalAfterDrop = [];
62 | let multiScroller;
63 |
64 | // a map from type to a set of drop-zones
65 | const typeToDropZones = new Map();
66 | // important - this is needed because otherwise the config that would be used for everyone is the config of the element that created the event listeners
67 | const dzToConfig = new Map();
68 | // this is needed in order to be able to cleanup old listeners and avoid stale closures issues (as the listener is defined within each zone)
69 | const elToMouseDownListener = new WeakMap();
70 |
71 | /* drop-zones registration management */
72 | function registerDropZone(dropZoneEl, type) {
73 | printDebug(() => "registering drop-zone if absent");
74 | if (!typeToDropZones.has(type)) {
75 | typeToDropZones.set(type, new Set());
76 | }
77 | if (!typeToDropZones.get(type).has(dropZoneEl)) {
78 | typeToDropZones.get(type).add(dropZoneEl);
79 | incrementActiveDropZoneCount();
80 | }
81 | }
82 | function unregisterDropZone(dropZoneEl, type) {
83 | typeToDropZones.get(type).delete(dropZoneEl);
84 | decrementActiveDropZoneCount();
85 | if (typeToDropZones.get(type).size === 0) {
86 | typeToDropZones.delete(type);
87 | }
88 | }
89 |
90 | /* functions to manage observing the dragged element and trigger custom drag-events */
91 | function watchDraggedElement() {
92 | printDebug(() => "watching dragged element");
93 | const dropZones = typeToDropZones.get(draggedElType);
94 |
95 | for (const dz of dropZones) {
96 | dz.addEventListener(DRAGGED_ENTERED_EVENT_NAME, handleDraggedEntered);
97 | dz.addEventListener(DRAGGED_LEFT_EVENT_NAME, handleDraggedLeft);
98 | dz.addEventListener(DRAGGED_OVER_INDEX_EVENT_NAME, handleDraggedIsOverIndex);
99 | }
100 | window.addEventListener(DRAGGED_LEFT_DOCUMENT_EVENT_NAME, handleDrop);
101 |
102 | // it is important that we don't have an interval that is faster than the flip duration because it can cause elements to jump bach and forth
103 | const setIntervalMs = Math.max(...Array.from(dropZones.keys()).map(dz => dzToConfig.get(dz).dropAnimationDurationMs));
104 | const observationIntervalMs = setIntervalMs === 0 ? DISABLED_OBSERVATION_INTERVAL_MS : Math.max(setIntervalMs, MIN_OBSERVATION_INTERVAL_MS); // if setIntervalMs is 0 it goes to 20, otherwise it is max between it and min observation.
105 | multiScroller = createMultiScroller(dropZones, () => currentMousePosition);
106 | observe(draggedEl, dropZones, observationIntervalMs * 1.07, multiScroller);
107 | }
108 | function unWatchDraggedElement() {
109 | printDebug(() => "unwatching dragged element");
110 | const dropZones = typeToDropZones.get(draggedElType);
111 | for (const dz of dropZones) {
112 | dz.removeEventListener(DRAGGED_ENTERED_EVENT_NAME, handleDraggedEntered);
113 | dz.removeEventListener(DRAGGED_LEFT_EVENT_NAME, handleDraggedLeft);
114 | dz.removeEventListener(DRAGGED_OVER_INDEX_EVENT_NAME, handleDraggedIsOverIndex);
115 | }
116 | window.removeEventListener(DRAGGED_LEFT_DOCUMENT_EVENT_NAME, handleDrop);
117 | // ensuring multiScroller is not already destroyed before destroying
118 | if (multiScroller) {
119 | multiScroller.destroy();
120 | multiScroller = undefined;
121 | }
122 | unobserve();
123 | }
124 |
125 | function findShadowElementIdx(items) {
126 | return items.findIndex(item => !!item[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
127 | }
128 | function createShadowElData(draggedElData) {
129 | return {...draggedElData, [SHADOW_ITEM_MARKER_PROPERTY_NAME]: true, [ITEM_ID_KEY]: SHADOW_PLACEHOLDER_ITEM_ID};
130 | }
131 |
132 | /* custom drag-events handlers */
133 | function handleDraggedEntered(e) {
134 | printDebug(() => ["dragged entered", e.currentTarget, e.detail]);
135 | let {items, dropFromOthersDisabled} = dzToConfig.get(e.currentTarget);
136 | if (dropFromOthersDisabled && e.currentTarget !== originDropZone) {
137 | printDebug(() => "ignoring dragged entered because drop is currently disabled");
138 | return;
139 | }
140 | isDraggedOutsideOfAnyDz = false;
141 | // this deals with another race condition. in rare occasions (super rapid operations) the list hasn't updated yet
142 | items = items.filter(item => item[ITEM_ID_KEY] !== shadowElData[ITEM_ID_KEY]);
143 | printDebug(() => `dragged entered items ${toString(items)}`);
144 |
145 | if (originDropZone !== e.currentTarget) {
146 | const originZoneItems = dzToConfig.get(originDropZone).items;
147 | const newOriginZoneItems = originZoneItems.filter(item => !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
148 | dispatchConsiderEvent(originDropZone, newOriginZoneItems, {
149 | trigger: TRIGGERS.DRAGGED_ENTERED_ANOTHER,
150 | id: draggedElData[ITEM_ID_KEY],
151 | source: SOURCES.POINTER
152 | });
153 | }
154 | const {index, isProximityBased} = e.detail.indexObj;
155 | const shadowElIdx = isProximityBased && index === e.currentTarget.children.length - 1 ? index + 1 : index;
156 | shadowElDropZone = e.currentTarget;
157 | items.splice(shadowElIdx, 0, shadowElData);
158 | dispatchConsiderEvent(e.currentTarget, items, {trigger: TRIGGERS.DRAGGED_ENTERED, id: draggedElData[ITEM_ID_KEY], source: SOURCES.POINTER});
159 | }
160 |
161 | function handleDraggedLeft(e) {
162 | // dealing with a rare race condition on extremely rapid clicking and dropping
163 | if (!isWorkingOnPreviousDrag) return;
164 | printDebug(() => ["dragged left", e.currentTarget, e.detail]);
165 | const {items: originalItems, dropFromOthersDisabled} = dzToConfig.get(e.currentTarget);
166 | if (dropFromOthersDisabled && e.currentTarget !== originDropZone && e.currentTarget !== shadowElDropZone) {
167 | printDebug(() => "drop is currently disabled");
168 | return;
169 | }
170 | const items = [...originalItems];
171 | const shadowElIdx = findShadowElementIdx(items);
172 | if (shadowElIdx !== -1) {
173 | items.splice(shadowElIdx, 1);
174 | }
175 | const origShadowDz = shadowElDropZone;
176 | shadowElDropZone = undefined;
177 | const {type, theOtherDz} = e.detail;
178 | if (
179 | type === DRAGGED_LEFT_TYPES.OUTSIDE_OF_ANY ||
180 | (type === DRAGGED_LEFT_TYPES.LEFT_FOR_ANOTHER && theOtherDz !== originDropZone && dzToConfig.get(theOtherDz).dropFromOthersDisabled)
181 | ) {
182 | printDebug(() => "dragged left all, putting shadow element back in the origin dz");
183 | isDraggedOutsideOfAnyDz = true;
184 | shadowElDropZone = originDropZone;
185 | // if the last zone it left is the origin dz, we will put it back into items (which we just removed it from)
186 | const originZoneItems = origShadowDz === originDropZone ? items : [...dzToConfig.get(originDropZone).items];
187 | originZoneItems.splice(originIndex, 0, shadowElData);
188 | dispatchConsiderEvent(originDropZone, originZoneItems, {
189 | trigger: TRIGGERS.DRAGGED_LEFT_ALL,
190 | id: draggedElData[ITEM_ID_KEY],
191 | source: SOURCES.POINTER
192 | });
193 | }
194 | // for the origin dz, when the dragged is outside of any, this will be fired in addition to the previous. this is for simplicity
195 | dispatchConsiderEvent(e.currentTarget, items, {
196 | trigger: TRIGGERS.DRAGGED_LEFT,
197 | id: draggedElData[ITEM_ID_KEY],
198 | source: SOURCES.POINTER
199 | });
200 | }
201 | function handleDraggedIsOverIndex(e) {
202 | printDebug(() => ["dragged is over index", e.currentTarget, e.detail]);
203 | const {items: originalItems, dropFromOthersDisabled} = dzToConfig.get(e.currentTarget);
204 | if (dropFromOthersDisabled && e.currentTarget !== originDropZone) {
205 | printDebug(() => "drop is currently disabled");
206 | return;
207 | }
208 | const items = [...originalItems];
209 | isDraggedOutsideOfAnyDz = false;
210 | const {index} = e.detail.indexObj;
211 | const shadowElIdx = findShadowElementIdx(items);
212 | if (shadowElIdx !== -1) {
213 | items.splice(shadowElIdx, 1);
214 | }
215 | items.splice(index, 0, shadowElData);
216 | dispatchConsiderEvent(e.currentTarget, items, {trigger: TRIGGERS.DRAGGED_OVER_INDEX, id: draggedElData[ITEM_ID_KEY], source: SOURCES.POINTER});
217 | }
218 |
219 | // Global mouse/touch-events handlers
220 | function handleMouseMove(e) {
221 | e.preventDefault();
222 | const c = e.touches ? e.touches[0] : e;
223 | currentMousePosition = {x: c.clientX, y: c.clientY};
224 | draggedEl.style.transform = `translate3d(${currentMousePosition.x - dragStartMousePosition.x}px, ${
225 | currentMousePosition.y - dragStartMousePosition.y
226 | }px, 0)`;
227 | }
228 |
229 | function handleDrop() {
230 | printDebug(() => "dropped");
231 | finalizingPreviousDrag = true;
232 | // cleanup
233 | window.removeEventListener("mousemove", handleMouseMove);
234 | window.removeEventListener("touchmove", handleMouseMove);
235 | window.removeEventListener("mouseup", handleDrop);
236 | window.removeEventListener("touchend", handleDrop);
237 | unWatchDraggedElement();
238 | moveDraggedElementToWasDroppedState(draggedEl);
239 |
240 | if (!shadowElDropZone) {
241 | printDebug(() => "element was dropped right after it left origin but before entering somewhere else");
242 | shadowElDropZone = originDropZone;
243 | }
244 | printDebug(() => ["dropped in dz", shadowElDropZone]);
245 | let {items, type} = dzToConfig.get(shadowElDropZone);
246 | styleInactiveDropZones(
247 | typeToDropZones.get(type),
248 | dz => dzToConfig.get(dz).dropTargetStyle,
249 | dz => dzToConfig.get(dz).dropTargetClasses
250 | );
251 | let shadowElIdx = findShadowElementIdx(items);
252 | // the handler might remove the shadow element, ex: dragula like copy on drag
253 | if (shadowElIdx === -1) {
254 | if (shadowElDropZone === originDropZone) {
255 | shadowElIdx = originIndex;
256 | }
257 | }
258 |
259 | items = items.map(item => (item[SHADOW_ITEM_MARKER_PROPERTY_NAME] ? draggedElData : item));
260 | function finalizeWithinZone() {
261 | unlockOriginDzMinDimensions();
262 | dispatchFinalizeEvent(shadowElDropZone, items, {
263 | trigger: isDraggedOutsideOfAnyDz ? TRIGGERS.DROPPED_OUTSIDE_OF_ANY : TRIGGERS.DROPPED_INTO_ZONE,
264 | id: draggedElData[ITEM_ID_KEY],
265 | source: SOURCES.POINTER
266 | });
267 | if (shadowElDropZone !== originDropZone) {
268 | // letting the origin drop zone know the element was permanently taken away
269 | dispatchFinalizeEvent(originDropZone, dzToConfig.get(originDropZone).items, {
270 | trigger: TRIGGERS.DROPPED_INTO_ANOTHER,
271 | id: draggedElData[ITEM_ID_KEY],
272 | source: SOURCES.POINTER
273 | });
274 | }
275 | // In edge cases the dom might have not been updated yet so we can't rely on data list index
276 | const domShadowEl = Array.from(shadowElDropZone.children).find(c => c.getAttribute(SHADOW_ELEMENT_ATTRIBUTE_NAME));
277 | if (domShadowEl) unDecorateShadowElement(domShadowEl);
278 | cleanupPostDrop();
279 | }
280 | if (dzToConfig.get(shadowElDropZone).dropAnimationDisabled) {
281 | finalizeWithinZone();
282 | } else {
283 | animateDraggedToFinalPosition(shadowElIdx, finalizeWithinZone);
284 | }
285 | }
286 |
287 | // helper function for handleDrop
288 | function animateDraggedToFinalPosition(shadowElIdx, callback) {
289 | const shadowElRect =
290 | shadowElIdx > -1
291 | ? getBoundingRectNoTransforms(shadowElDropZone.children[shadowElIdx], false)
292 | : getBoundingRectNoTransforms(shadowElDropZone, false);
293 | const newTransform = {
294 | x: shadowElRect.left - parseFloat(draggedEl.style.left),
295 | y: shadowElRect.top - parseFloat(draggedEl.style.top)
296 | };
297 | const {dropAnimationDurationMs} = dzToConfig.get(shadowElDropZone);
298 | const transition = `transform ${dropAnimationDurationMs}ms ease`;
299 | draggedEl.style.transition = draggedEl.style.transition ? draggedEl.style.transition + "," + transition : transition;
300 | draggedEl.style.transform = `translate3d(${newTransform.x}px, ${newTransform.y}px, 0)`;
301 | window.setTimeout(callback, dropAnimationDurationMs);
302 | }
303 |
304 | function scheduleDZForRemovalAfterDrop(dz, destroy) {
305 | scheduledForRemovalAfterDrop.push({dz, destroy});
306 | window.requestAnimationFrame(() => {
307 | hideElement(dz);
308 | document.body.appendChild(dz);
309 | });
310 | }
311 | /* cleanup */
312 | function cleanupPostDrop() {
313 | draggedEl.remove();
314 | originalDragTarget.remove();
315 | if (scheduledForRemovalAfterDrop.length) {
316 | printDebug(() => ["will destroy zones that were removed during drag", scheduledForRemovalAfterDrop]);
317 | scheduledForRemovalAfterDrop.forEach(({dz, destroy}) => {
318 | destroy();
319 | dz.remove();
320 | });
321 | scheduledForRemovalAfterDrop = [];
322 | }
323 | draggedEl = undefined;
324 | originalDragTarget = undefined;
325 | draggedElData = undefined;
326 | draggedElType = undefined;
327 | originDropZone = undefined;
328 | originIndex = undefined;
329 | shadowElData = undefined;
330 | shadowElDropZone = undefined;
331 | dragStartMousePosition = undefined;
332 | currentMousePosition = undefined;
333 | isWorkingOnPreviousDrag = false;
334 | finalizingPreviousDrag = false;
335 | unlockOriginDzMinDimensions = undefined;
336 | isDraggedOutsideOfAnyDz = false;
337 | }
338 |
339 | export function dndzone(node, options) {
340 | let initialized = false;
341 | const config = {
342 | items: undefined,
343 | type: undefined,
344 | flipDurationMs: 0,
345 | dragDisabled: false,
346 | morphDisabled: false,
347 | dropFromOthersDisabled: false,
348 | dropTargetStyle: DEFAULT_DROP_TARGET_STYLE,
349 | dropTargetClasses: [],
350 | transformDraggedElement: () => {},
351 | centreDraggedOnCursor: false,
352 | dropAnimationDisabled: false
353 | };
354 | printDebug(() => [`dndzone good to go options: ${toString(options)}, config: ${toString(config)}`, {node}]);
355 | let elToIdx = new Map();
356 |
357 | function addMaybeListeners() {
358 | window.addEventListener("mousemove", handleMouseMoveMaybeDragStart, {passive: false});
359 | window.addEventListener("touchmove", handleMouseMoveMaybeDragStart, {passive: false, capture: false});
360 | window.addEventListener("mouseup", handleFalseAlarm, {passive: false});
361 | window.addEventListener("touchend", handleFalseAlarm, {passive: false});
362 | }
363 | function removeMaybeListeners() {
364 | window.removeEventListener("mousemove", handleMouseMoveMaybeDragStart);
365 | window.removeEventListener("touchmove", handleMouseMoveMaybeDragStart);
366 | window.removeEventListener("mouseup", handleFalseAlarm);
367 | window.removeEventListener("touchend", handleFalseAlarm);
368 | }
369 | function handleFalseAlarm(e) {
370 | removeMaybeListeners();
371 | originalDragTarget = undefined;
372 | dragStartMousePosition = undefined;
373 | currentMousePosition = undefined;
374 |
375 | // dragging initiated by touch events prevents onclick from initially firing
376 | if (e.type === "touchend") {
377 | const clickEvent = new Event("click", {
378 | bubbles: true,
379 | cancelable: true
380 | });
381 | // doing it this way instead of calling .click() because that doesn't work for SVG elements
382 | e.target.dispatchEvent(clickEvent);
383 | }
384 | }
385 |
386 | function handleMouseMoveMaybeDragStart(e) {
387 | e.preventDefault();
388 | const c = e.touches ? e.touches[0] : e;
389 | currentMousePosition = {x: c.clientX, y: c.clientY};
390 | if (
391 | Math.abs(currentMousePosition.x - dragStartMousePosition.x) >= MIN_MOVEMENT_BEFORE_DRAG_START_PX ||
392 | Math.abs(currentMousePosition.y - dragStartMousePosition.y) >= MIN_MOVEMENT_BEFORE_DRAG_START_PX
393 | ) {
394 | removeMaybeListeners();
395 | handleDragStart();
396 | }
397 | }
398 | function handleMouseDown(e) {
399 | // on safari clicking on a select element doesn't fire mouseup at the end of the click and in general this makes more sense
400 | if (e.target !== e.currentTarget && (e.target.value !== undefined || e.target.isContentEditable)) {
401 | printDebug(() => "won't initiate drag on a nested input element");
402 | return;
403 | }
404 | // prevents responding to any button but left click which equals 0 (which is falsy)
405 | if (e.button) {
406 | printDebug(() => `ignoring none left click button: ${e.button}`);
407 | return;
408 | }
409 | if (isWorkingOnPreviousDrag) {
410 | printDebug(() => "cannot start a new drag before finalizing previous one");
411 | return;
412 | }
413 | e.preventDefault();
414 | e.stopPropagation();
415 | const c = e.touches ? e.touches[0] : e;
416 | dragStartMousePosition = {x: c.clientX, y: c.clientY};
417 | currentMousePosition = {...dragStartMousePosition};
418 | originalDragTarget = e.currentTarget;
419 | addMaybeListeners();
420 | }
421 |
422 | function handleDragStart() {
423 | printDebug(() => [`drag start config: ${toString(config)}`, originalDragTarget]);
424 | isWorkingOnPreviousDrag = true;
425 |
426 | // initialising globals
427 | const currentIdx = elToIdx.get(originalDragTarget);
428 | originIndex = currentIdx;
429 | originDropZone = originalDragTarget.parentElement;
430 | /** @type {ShadowRoot | HTMLDocument | Element } */
431 | const rootNode = originDropZone.closest("dialog") || originDropZone.closest("[popover]") || originDropZone.getRootNode();
432 | const originDropZoneRoot = rootNode.body || rootNode;
433 | const {items: originalItems, type, centreDraggedOnCursor} = config;
434 | const items = [...originalItems];
435 | draggedElData = items[currentIdx];
436 | draggedElType = type;
437 | shadowElData = createShadowElData(draggedElData);
438 |
439 | // creating the draggable element
440 | draggedEl = createDraggedElementFrom(originalDragTarget, centreDraggedOnCursor && currentMousePosition);
441 | originDropZoneRoot.appendChild(draggedEl);
442 | // We will keep the original dom node in the dom because touch events keep firing on it, we want to re-add it after the framework removes it
443 | function keepOriginalElementInDom() {
444 | if (!originalDragTarget.parentElement) {
445 | originalDragTarget.setAttribute(ORIGINAL_DRAGGED_ITEM_MARKER_ATTRIBUTE, true);
446 | originDropZoneRoot.appendChild(originalDragTarget);
447 | // have to watch before we hide, otherwise Svelte 5 $state gets confused
448 | watchDraggedElement();
449 | hideElement(originalDragTarget);
450 | // after the removal of the original element we can give the shadow element the original item id so that the host zone can find it and render it correctly if it does lookups by id
451 | shadowElData[ITEM_ID_KEY] = draggedElData[ITEM_ID_KEY];
452 | // to prevent the outline from disappearing
453 | draggedEl.focus();
454 | } else {
455 | window.requestAnimationFrame(keepOriginalElementInDom);
456 | }
457 | }
458 | window.requestAnimationFrame(keepOriginalElementInDom);
459 |
460 | styleActiveDropZones(
461 | Array.from(typeToDropZones.get(config.type)).filter(dz => dz === originDropZone || !dzToConfig.get(dz).dropFromOthersDisabled),
462 | dz => dzToConfig.get(dz).dropTargetStyle,
463 | dz => dzToConfig.get(dz).dropTargetClasses
464 | );
465 |
466 | // removing the original element by removing its data entry
467 | items.splice(currentIdx, 1, shadowElData);
468 | unlockOriginDzMinDimensions = preventShrinking(originDropZone);
469 |
470 | dispatchConsiderEvent(originDropZone, items, {trigger: TRIGGERS.DRAG_STARTED, id: draggedElData[ITEM_ID_KEY], source: SOURCES.POINTER});
471 |
472 | // handing over to global handlers - starting to watch the element
473 | window.addEventListener("mousemove", handleMouseMove, {passive: false});
474 | window.addEventListener("touchmove", handleMouseMove, {passive: false, capture: false});
475 | window.addEventListener("mouseup", handleDrop, {passive: false});
476 | window.addEventListener("touchend", handleDrop, {passive: false});
477 | }
478 |
479 | function configure({
480 | items = undefined,
481 | flipDurationMs: dropAnimationDurationMs = 0,
482 | type: newType = DEFAULT_DROP_ZONE_TYPE,
483 | dragDisabled = false,
484 | morphDisabled = false,
485 | dropFromOthersDisabled = false,
486 | dropTargetStyle = DEFAULT_DROP_TARGET_STYLE,
487 | dropTargetClasses = [],
488 | transformDraggedElement = () => {},
489 | centreDraggedOnCursor = false,
490 | dropAnimationDisabled = false
491 | }) {
492 | config.dropAnimationDurationMs = dropAnimationDurationMs;
493 | if (config.type && newType !== config.type) {
494 | unregisterDropZone(node, config.type);
495 | }
496 | config.type = newType;
497 | config.items = [...items];
498 | config.dragDisabled = dragDisabled;
499 | config.morphDisabled = morphDisabled;
500 | config.transformDraggedElement = transformDraggedElement;
501 | config.centreDraggedOnCursor = centreDraggedOnCursor;
502 | config.dropAnimationDisabled = dropAnimationDisabled;
503 |
504 | // realtime update for dropTargetStyle
505 | if (
506 | initialized &&
507 | isWorkingOnPreviousDrag &&
508 | !finalizingPreviousDrag &&
509 | (!areObjectsShallowEqual(dropTargetStyle, config.dropTargetStyle) ||
510 | !areArraysShallowEqualSameOrder(dropTargetClasses, config.dropTargetClasses))
511 | ) {
512 | styleInactiveDropZones(
513 | [node],
514 | () => config.dropTargetStyle,
515 | () => dropTargetClasses
516 | );
517 | styleActiveDropZones(
518 | [node],
519 | () => dropTargetStyle,
520 | () => dropTargetClasses
521 | );
522 | }
523 | config.dropTargetStyle = dropTargetStyle;
524 | config.dropTargetClasses = [...dropTargetClasses];
525 |
526 | // realtime update for dropFromOthersDisabled
527 | function getConfigProp(dz, propName) {
528 | return dzToConfig.get(dz) ? dzToConfig.get(dz)[propName] : config[propName];
529 | }
530 | if (initialized && isWorkingOnPreviousDrag && config.dropFromOthersDisabled !== dropFromOthersDisabled) {
531 | if (dropFromOthersDisabled) {
532 | styleInactiveDropZones(
533 | [node],
534 | dz => getConfigProp(dz, "dropTargetStyle"),
535 | dz => getConfigProp(dz, "dropTargetClasses")
536 | );
537 | } else {
538 | styleActiveDropZones(
539 | [node],
540 | dz => getConfigProp(dz, "dropTargetStyle"),
541 | dz => getConfigProp(dz, "dropTargetClasses")
542 | );
543 | }
544 | }
545 | config.dropFromOthersDisabled = dropFromOthersDisabled;
546 |
547 | dzToConfig.set(node, config);
548 | registerDropZone(node, newType);
549 | const shadowElIdx = isWorkingOnPreviousDrag ? findShadowElementIdx(config.items) : -1;
550 | for (let idx = 0; idx < node.children.length; idx++) {
551 | const draggableEl = node.children[idx];
552 | styleDraggable(draggableEl, dragDisabled);
553 | if (idx === shadowElIdx) {
554 | if (!morphDisabled) {
555 | morphDraggedElementToBeLike(draggedEl, draggableEl, currentMousePosition.x, currentMousePosition.y);
556 | }
557 | config.transformDraggedElement(draggedEl, draggedElData, idx);
558 | decorateShadowEl(draggableEl);
559 | continue;
560 | }
561 | draggableEl.removeEventListener("mousedown", elToMouseDownListener.get(draggableEl));
562 | draggableEl.removeEventListener("touchstart", elToMouseDownListener.get(draggableEl));
563 | if (!dragDisabled) {
564 | draggableEl.addEventListener("mousedown", handleMouseDown);
565 | draggableEl.addEventListener("touchstart", handleMouseDown);
566 | elToMouseDownListener.set(draggableEl, handleMouseDown);
567 | }
568 | // updating the idx
569 | elToIdx.set(draggableEl, idx);
570 |
571 | if (!initialized) {
572 | initialized = true;
573 | }
574 | }
575 | }
576 | configure(options);
577 |
578 | return {
579 | update: newOptions => {
580 | printDebug(() => `pointer dndzone will update newOptions: ${toString(newOptions)}`);
581 | configure(newOptions);
582 | },
583 | destroy: () => {
584 | function destroyDz() {
585 | printDebug(() => "pointer dndzone will destroy");
586 | unregisterDropZone(node, dzToConfig.get(node).type);
587 | dzToConfig.delete(node);
588 | }
589 | if (isWorkingOnPreviousDrag && !node.closest(`[${ORIGINAL_DRAGGED_ITEM_MARKER_ATTRIBUTE}]`)) {
590 | printDebug(() => "pointer dndzone will be scheduled for destruction");
591 | scheduleDZForRemovalAfterDrop(node, destroyDz);
592 | } else {
593 | destroyDz();
594 | }
595 | }
596 | };
597 | }
598 |
--------------------------------------------------------------------------------
/src/wrappers/simpleStore.js:
--------------------------------------------------------------------------------
1 | export function createStore(initialValue) {
2 | let _val = initialValue;
3 | const subs = new Set();
4 | return {
5 | get: () => _val,
6 | set: newVal => {
7 | _val = newVal;
8 | Array.from(subs).forEach(cb => cb(_val));
9 | },
10 | subscribe: cb => {
11 | subs.add(cb);
12 | cb(_val);
13 | },
14 | unsubscribe: cb => {
15 | subs.delete(cb);
16 | }
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/src/wrappers/withDragHandles.js:
--------------------------------------------------------------------------------
1 | import {SOURCES, TRIGGERS} from "../constants";
2 | import {dndzone} from "../action";
3 | import {createStore} from "./simpleStore";
4 |
5 | const isItemsDragDisabled = createStore(true);
6 |
7 | function getAddedOptions(isItemsDragDisabled = true) {
8 | return {
9 | dragDisabled: isItemsDragDisabled,
10 | zoneItemTabIndex: -1
11 | };
12 | }
13 |
14 | /**
15 | * This is an action that wraps around the dndzone action to make it easy to work with drag handles
16 | * When using this you must also use the 'dragHandle' action (see below) on an element inside each item within the zone
17 | * Credit for the idea and initial implementation goes to @gleuch (Greg Leuch) and @geovie (Georg Vienna)
18 | *
19 | * @param {HTMLElement} node
20 | * @param options - will be passed down to the dndzone
21 | * @return {{update: (newOptions: Object) => {}, destroy: () => {}}}
22 | */
23 | export function dragHandleZone(node, options) {
24 | let currentOptions = options;
25 | const zone = dndzone(node, {
26 | ...currentOptions,
27 | ...getAddedOptions()
28 | });
29 | function isItemDisabledCB(isItemsDragDisabled) {
30 | zone.update({
31 | ...currentOptions,
32 | ...getAddedOptions(isItemsDragDisabled)
33 | });
34 | }
35 | isItemsDragDisabled.subscribe(isItemDisabledCB);
36 | function consider(e) {
37 | const {
38 | info: {source, trigger}
39 | } = e.detail;
40 | // Ensure dragging is stopped on drag finish via keyboard
41 | if (source === SOURCES.KEYBOARD && trigger === TRIGGERS.DRAG_STOPPED) {
42 | isItemsDragDisabled.set(true);
43 | }
44 | }
45 |
46 | function finalize(e) {
47 | const {
48 | info: {source}
49 | } = e.detail;
50 | // Ensure dragging is stopped on drag finish via pointer (mouse, touch)
51 | if (source === SOURCES.POINTER) {
52 | isItemsDragDisabled.set(true);
53 | }
54 | }
55 |
56 | node.addEventListener("consider", consider);
57 | node.addEventListener("finalize", finalize);
58 |
59 | return {
60 | update: newOptions => {
61 | currentOptions = newOptions;
62 | zone.update({
63 | ...currentOptions,
64 | ...getAddedOptions(isItemsDragDisabled.get())
65 | });
66 | },
67 | destroy: () => {
68 | node.removeEventListener("consider", consider);
69 | node.removeEventListener("finalize", finalize);
70 | isItemsDragDisabled.unsubscribe(isItemDisabledCB);
71 | }
72 | };
73 | }
74 |
75 | /**
76 | * This should be used to mark drag handles inside items that belong to a 'dragHandleZone' (see above)
77 | * @param {HTMLElement} handle
78 | * @return {{update: *, destroy: *}}
79 | */
80 | export function dragHandle(handle) {
81 | handle.setAttribute("role", "button");
82 |
83 | function startDrag(e) {
84 | // preventing default to prevent lag on touch devices (because of the browser checking for screen scrolling)
85 | e.preventDefault();
86 | isItemsDragDisabled.set(false);
87 |
88 | // Reset the startDrag/isItemsDragDisabled if the user releases the mouse/touch without initiating a drag
89 | window.addEventListener("mouseup", resetStartDrag);
90 | window.addEventListener("touchend", resetStartDrag);
91 | }
92 |
93 | function handleKeyDown(e) {
94 | if (e.key === "Enter" || e.key === " ") isItemsDragDisabled.set(false);
95 | }
96 |
97 | function resetStartDrag() {
98 | isItemsDragDisabled.set(true);
99 | window.removeEventListener("mouseup", resetStartDrag);
100 | window.removeEventListener("touchend", resetStartDrag);
101 | }
102 |
103 | isItemsDragDisabled.subscribe(disabled => {
104 | handle.tabIndex = disabled ? 0 : -1;
105 | handle.style.cursor = disabled ? "grab" : "grabbing";
106 | });
107 |
108 | handle.addEventListener("mousedown", startDrag);
109 | handle.addEventListener("touchstart", startDrag);
110 | handle.addEventListener("keydown", handleKeyDown);
111 | return {
112 | update: () => {},
113 | destroy: () => {
114 | handle.removeEventListener("mousedown", startDrag);
115 | handle.removeEventListener("touchstart", startDrag);
116 | handle.removeEventListener("keydown", handleKeyDown);
117 | }
118 | };
119 | }
120 |
--------------------------------------------------------------------------------
/typings/index.d.ts:
--------------------------------------------------------------------------------
1 | import type {ActionReturn} from "svelte/action";
2 |
3 | /**
4 | * A custom action to turn any container to a dnd zone and all of its direct children to draggables
5 | * Supports mouse, touch and keyboard interactions.
6 | * Dispatches two events that the container is expected to react to by modifying its list of items,
7 | * which will then feed back in to this action via the update function
8 | */
9 | export declare function dndzone(node: HTMLElement, options: Options): ActionReturn, DndZoneAttributes>;
10 |
11 | export declare function dndzone(
12 | node: HTMLElement,
13 | options: Options
14 | ): {
15 | update: (newOptions: Options) => void;
16 | destroy: () => void;
17 | };
18 |
19 | /**
20 | * A wrapper action to make it easy to work with drag handles.
21 | * When using this you must also use the 'dragHandle' action on an element inside each item within the zone.
22 | */
23 | export declare function dragHandleZone(node: HTMLElement, options: Options): ActionReturn, DndZoneAttributes>;
24 | export declare function dragHandleZone(
25 | node: HTMLElement,
26 | options: Options
27 | ): {
28 | update: (newOptions: Options) => void;
29 | destroy: () => void;
30 | };
31 |
32 | /**
33 | * This should be used to mark drag handles inside items that belong to a 'dragHandleZone'
34 | */
35 | export declare function dragHandle(node: HTMLElement): {
36 | update: () => void;
37 | destroy: () => void;
38 | };
39 |
40 | export type TransformDraggedElementFunction = (
41 | element?: HTMLElement, // the dragged element.
42 | draggedElementData?: Item, // the data of the item from the items array
43 | index?: number // the index the dragged element would get if dropped into the new dnd-zone
44 | ) => void;
45 |
46 | export declare type Item = Record;
47 | export interface Options {
48 | items: T[]; // the list of items that was used to generate the children of the given node
49 | type?: string; // the type of the dnd zone. children dragged from here can only be dropped in other zones of the same type, defaults to a base type
50 | flipDurationMs?: number; // if the list animated using flip (recommended), specifies the flip duration such that everything syncs with it without conflict
51 | dragDisabled?: boolean;
52 | morphDisabled?: boolean;
53 | dropFromOthersDisabled?: boolean;
54 | zoneTabIndex?: number; // set the tabindex of the list container when not dragging
55 | zoneItemTabIndex?: number; // set the tabindex of the list container items when not dragging
56 | dropTargetClasses?: string[];
57 | dropTargetStyle?: Record;
58 | transformDraggedElement?: TransformDraggedElementFunction;
59 | autoAriaDisabled?: boolean;
60 | centreDraggedOnCursor?: boolean;
61 | dropAnimationDisabled?: boolean;
62 | }
63 |
64 | export interface DndZoneAttributes {
65 | "on:consider"?: (e: CustomEvent>) => void;
66 | "on:finalize"?: (e: CustomEvent>) => void;
67 | onconsider?: (e: CustomEvent>) => void;
68 | onfinalize?: (e: CustomEvent>) => void;
69 | }
70 |
71 | /**
72 | * Will make the screen reader alert the provided text to the user
73 | */
74 | export declare function alertToScreenReader(txt: string): void;
75 |
76 | /**
77 | * Allows using another key instead of "id" in the items data. This is global and applies to all dndzones.
78 | * Has to be called when there are no rendered dndzones whatsoever.
79 | * @throws {Error} if it was called when there are rendered dndzones or if it is given the wrong type (not a string)
80 | */
81 | export declare function overrideItemIdKeyNameBeforeInitialisingDndZones(newKeyName: string): void;
82 |
83 | export enum TRIGGERS {
84 | DRAG_STARTED = "dragStarted",
85 | DRAGGED_ENTERED = "draggedEntered", //only relevant for pointer interactions
86 | DRAGGED_ENTERED_ANOTHER = "dragEnteredAnother", //only relevant for pointer interactions
87 | DRAGGED_OVER_INDEX = "draggedOverIndex", //only relevant for pointer interactions
88 | DRAGGED_LEFT = "draggedLeft", //only relevant for pointer interactions
89 | DRAGGED_LEFT_ALL = "draggedLeftAll", //only relevant for pointer interactions
90 | DROPPED_INTO_ZONE = "droppedIntoZone",
91 | DROPPED_INTO_ANOTHER = "droppedIntoAnother",
92 | DROPPED_OUTSIDE_OF_ANY = "droppedOutsideOfAny",
93 | DRAG_STOPPED = "dragStopped" //only relevant for keyboard interactions - when the use exists dragging mode
94 | }
95 |
96 | export enum SOURCES {
97 | POINTER = "pointer", // mouse or touch
98 | KEYBOARD = "keyboard"
99 | }
100 |
101 | export interface DndEventInfo {
102 | trigger: TRIGGERS; // the type of dnd event that took place
103 | id: string;
104 | source: SOURCES; // the type of interaction that the user used to perform the dnd operation
105 | }
106 |
107 | export type DndEvent = {
108 | items: T[];
109 | info: DndEventInfo;
110 | };
111 |
112 | export declare const SHADOW_ITEM_MARKER_PROPERTY_NAME: "isDndShadowItem";
113 | export declare const SHADOW_PLACEHOLDER_ITEM_ID: "id:dnd-shadow-placeholder-0000";
114 | export declare const DRAGGED_ELEMENT_ID: "dnd-action-dragged-el";
115 | export declare const SHADOW_ELEMENT_HINT_ATTRIBUTE_NAME = "data-is-dnd-shadow-item-hint";
116 |
117 | /**
118 | * Allows the user to show/hide console debug output
119 | */
120 | export declare function setDebugMode(isDebug: boolean): void;
121 |
122 | export enum FEATURE_FLAG_NAMES {
123 | // Default value: false, This flag exists as a workaround for issue 454 (basically a browser bug) - seems like these rect values take time to update when in grid layout. Setting it to true can cause strange behaviour in the REPL for non-grid zones, see issue 470
124 | USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT = "FEATURE_FLAG_NAMES.USE_COMPUTED_STYLE_INSTEAD_OF_BOUNDING_RECT"
125 | }
126 | export declare function setFeatureFlag(flagName: FEATURE_FLAG_NAMES, flagValue: boolean);
127 |
--------------------------------------------------------------------------------