@urbanstudio/ua-sortable 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@urbanstudio/ua-sortable` will be documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
6
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ---
9
+
10
+ ## [1.0.0] — 2026-06-05
11
+
12
+ ### Added
13
+ - Initial release
14
+ - `UA_Sortable` class with full API (options, callbacks, instance + static methods)
15
+ - Pointer Events based drag (mouse, touch, stylus — no separate handling)
16
+ - `direction: "auto"` — detects `flex-direction` via `getComputedStyle`
17
+ - Cross-list drag via `group` option
18
+ - `confirm` callback for cross-list drops with async support
19
+ - `onSort`, `onMove`, `onDragStart`, `onDragEnd` callbacks
20
+ - `handle`, `filter`, `delay`, `delayOnTouchOnly` options
21
+ - `UA_Sortable.get()`, `getGroup()`, `snapshot()` static methods
22
+ - `UA_Sortable.initAll()` and `UA_Sortable.observe()` for declarative HTML API
23
+ - `data-ua-sortable` attribute + `data-sortable-*` attributes for HTML init
24
+ - `uaMakeSortable()` shorthand function
25
+ - Auto-injected minimal CSS (`ua-sortable-ghost`, `ua-drag-handle`, etc.)
26
+ - MutationObserver on container for auto-refresh on child changes
27
+ - ESM build (`src/Sortable.js`) + IIFE/CJS build (`dist/ua-sortable.js`)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marian Feiler, urbanstudio GmbH (https://urbanstudio.de)
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.
package/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # ua-sortable
2
+
3
+ **Pointer-Events-based drag-and-drop sorting for lists and grids.**
4
+ No dependencies. No jQuery. No CDN required. Pure browser APIs.
5
+
6
+ [![npm](https://img.shields.io/npm/v/@urbanstudio/ua-sortable)](https://www.npmjs.com/package/@urbanstudio/ua-sortable)
7
+ [![license](https://img.shields.io/npm/l/@urbanstudio/ua-sortable)](LICENSE)
8
+ [![CDN](https://img.shields.io/badge/CDN-jsDelivr-orange)](https://cdn.jsdelivr.net/npm/@urbanstudio/ua-sortable/src/Sortable.js)
9
+
10
+ ---
11
+
12
+ ## What it does
13
+
14
+ `UA_Sortable` makes any list or grid container sortable by drag-and-drop using native [Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events). Works with mouse, touch, and stylus — no special handling needed.
15
+
16
+ ```
17
+ ua-sortable run myList
18
+ ```
19
+
20
+ That is: one line to get a sortable list, one callback to persist the order, zero dependencies.
21
+
22
+ ---
23
+
24
+ ## Install
25
+
26
+ **npm:**
27
+ ```sh
28
+ npm install @urbanstudio/ua-sortable
29
+ ```
30
+
31
+ **CDN (jsDelivr — ESM):**
32
+ ```html
33
+ <script type="module">
34
+ import { UA_Sortable } from "https://cdn.jsdelivr.net/npm/@urbanstudio/ua-sortable/src/Sortable.js";
35
+ </script>
36
+ ```
37
+
38
+ **CDN (jsDelivr — browser global `<script>`):**
39
+ ```html
40
+ <script src="https://cdn.jsdelivr.net/npm/@urbanstudio/ua-sortable/dist/ua-sortable.js"></script>
41
+ ```
42
+
43
+ **CDN (unpkg):**
44
+ ```html
45
+ <script src="https://unpkg.com/@urbanstudio/ua-sortable/dist/ua-sortable.js"></script>
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Quick start
51
+
52
+ ```js
53
+ import { UA_Sortable } from "@urbanstudio/ua-sortable";
54
+
55
+ new UA_Sortable(document.querySelector("ul"), {
56
+ handle: ".drag-handle",
57
+ animation: 150,
58
+ onSort: (ids) => console.log("new order:", ids),
59
+ });
60
+ ```
61
+
62
+ Or with the shorthand:
63
+ ```js
64
+ import { uaMakeSortable } from "@urbanstudio/ua-sortable";
65
+
66
+ const sortable = uaMakeSortable(document.querySelector("ul"), {
67
+ handle: ".drag-handle",
68
+ });
69
+ ```
70
+
71
+ Or declarative HTML — auto-initialized by `UA_Sortable.observe()`:
72
+ ```html
73
+ <ul data-ua-sortable data-sortable-handle=".drag-handle" data-sortable-group="tasks">
74
+ <li data-id="1">Item 1 <span class="drag-handle">⠿</span></li>
75
+ <li data-id="2">Item 2 <span class="drag-handle">⠿</span></li>
76
+ </ul>
77
+
78
+ <script type="module">
79
+ import { UA_Sortable } from "@urbanstudio/ua-sortable";
80
+ UA_Sortable.observe(document.body);
81
+ </script>
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Options
87
+
88
+ | Option | Type | Default | Description |
89
+ |--------|------|---------|-------------|
90
+ | `handle` | `string\|null` | `null` | CSS selector for drag handle. `null` = whole item is draggable. |
91
+ | `filter` | `string\|null` | `null` | CSS selector for items excluded from drag. |
92
+ | `group` | `string\|null` | `null` | Group name for cross-list drag. |
93
+ | `direction` | `string` | `"auto"` | `"vertical"`, `"horizontal"`, or `"auto"` (reads `flex-direction`). |
94
+ | `animation` | `number` | `150` | Ghost transition duration in ms. `0` = disabled. |
95
+ | `dataIdAttr` | `string` | `"data-id"` | Attribute used to identify items in callbacks. |
96
+ | `delay` | `number` | `0` | ms before drag starts after pointerdown. |
97
+ | `delayOnTouchOnly` | `boolean` | `false` | Apply `delay` only for touch input. |
98
+ | `disabled` | `boolean` | `false` | Disable drag on this instance. |
99
+ | `ghostClass` | `string` | `"ua-sortable-ghost"` | Class added to the ghost clone. |
100
+ | `dragClass` | `string` | `"ua-sortable-drag"` | Class added to the dragged item. |
101
+ | `confirm` | `function\|null` | `null` | `(movedId, from, to) => Promise<bool>` — called before cross-list drop. Return `false` to cancel. |
102
+ | `onDragStart` | `function\|null` | `null` | `(el)` — drag begins. |
103
+ | `onDragEnd` | `function\|null` | `null` | `(el, didMove: bool)` — drag ends. |
104
+ | `onSort` | `function\|null` | `null` | `(ids[], container)` — order changed within same list. |
105
+ | `onMove` | `function\|null` | `null` | `(movedId, from, to, fromIds[], toIds[]) => bool\|Promise<bool>` — item moved to other list. |
106
+
107
+ ---
108
+
109
+ ## Instance methods
110
+
111
+ | Method | Description |
112
+ |--------|-------------|
113
+ | `toArray()` | IDs of all draggable children in current DOM order. |
114
+ | `enable()` | Enable drag. |
115
+ | `disable()` | Disable drag. |
116
+ | `refresh()` | Re-scan draggable children (after manual DOM changes). |
117
+ | `destroy()` | Remove all listeners, disconnect observers, unregister. |
118
+ | `option(name)` | Get option value. |
119
+ | `option(name, value)` | Set option value at runtime. |
120
+
121
+ ---
122
+
123
+ ## Static methods
124
+
125
+ | Method | Description |
126
+ |--------|-------------|
127
+ | `UA_Sortable.get(el)` | Instance for a container element. |
128
+ | `UA_Sortable.getGroup(name)` | All instances with this group name. |
129
+ | `UA_Sortable.snapshot(group?)` | `[{container, ids, group}]` — current order of all grouped instances. |
130
+ | `UA_Sortable.initAll(root?)` | Initialize all `[data-ua-sortable]` in `root`. |
131
+ | `UA_Sortable.observe(root)` | MutationObserver — auto-init new `[data-ua-sortable]` containers. |
132
+
133
+ ---
134
+
135
+ ## Cross-list drag
136
+
137
+ Containers sharing the same `group` name accept each other's items:
138
+
139
+ ```js
140
+ const opts = {
141
+ group: "tasks",
142
+ onMove: (id, from, to, fromIds, toIds) => {
143
+ api.moveTask(id, to.dataset.listId);
144
+ },
145
+ };
146
+ new UA_Sortable(document.querySelector("#todo"), opts);
147
+ new UA_Sortable(document.querySelector("#doing"), opts);
148
+ new UA_Sortable(document.querySelector("#done"), opts);
149
+ ```
150
+
151
+ With a confirmation dialog before cross-list drop:
152
+ ```js
153
+ new UA_Sortable(el, {
154
+ group: "rooms",
155
+ confirm: async (id, from, to) => confirm(`Move item to "${to.dataset.label}"?`),
156
+ onMove: (id, from, to) => api.moveToSection(id, to.dataset.sectionId),
157
+ });
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Snapshot
163
+
164
+ ```js
165
+ // Read current order of all containers in a group
166
+ const state = UA_Sortable.snapshot("tasks");
167
+ // [{ container: HTMLElement, ids: ["id1", "id2"], group: "tasks" }, ...]
168
+
169
+ state.forEach(({ container, ids }) => {
170
+ api.saveOrder(container.dataset.listId, ids);
171
+ });
172
+ ```
173
+
174
+ ---
175
+
176
+ ## CSS
177
+
178
+ Minimal styles are **injected automatically** on first use — no stylesheet to include.
179
+
180
+ ```css
181
+ .ua-sortable-ghost { opacity: .4; }
182
+ .ua-sortable-drag { opacity: .4; }
183
+ .ua-drag-handle { cursor: grab; touch-action: none; }
184
+ .ua-drag-handle:active { cursor: grabbing; }
185
+ ```
186
+
187
+ The drop indicator uses `--accent` (CSS custom property) with fallback `#2563eb`.
188
+ Override any of these in your own stylesheet.
189
+
190
+ ---
191
+
192
+ ## HTML attributes
193
+
194
+ When using `UA_Sortable.observe()` or `UA_Sortable.initAll()`:
195
+
196
+ | Attribute | Option |
197
+ |-----------|--------|
198
+ | `data-ua-sortable` | triggers init |
199
+ | `data-sortable-handle` | `handle` |
200
+ | `data-sortable-filter` | `filter` |
201
+ | `data-sortable-group` | `group` |
202
+ | `data-sortable-direction` | `direction` |
203
+ | `data-sortable-animation` | `animation` |
204
+ | `data-sortable-delay` | `delay` |
205
+
206
+ ---
207
+
208
+ ## Browser support
209
+
210
+ Pointer Events: Chrome 55+, Firefox 59+, Safari 13+, Edge 79+.
211
+ No polyfills required.
212
+
213
+ ---
214
+
215
+ ## Design principles
216
+
217
+ `ua-sortable` tries to stay small and predictable.
218
+
219
+ It does not try to replace SortableJS.
220
+ It does not try to support IE11.
221
+ It does not ship a bundler, a build step, or a runtime dependency.
222
+
223
+ It simply:
224
+ 1. listens for pointer events
225
+ 2. moves a placeholder element as you drag
226
+ 3. calls your callback with the new order
227
+
228
+ For everything else, use the `onMove` / `onSort` callbacks.
229
+
230
+ ---
231
+
232
+ ## License
233
+
234
+ MIT License
235
+
236
+ Copyright (c) 2026 Marian Feiler, [urbanstudio GmbH](https://urbanstudio.de)
237
+
238
+ Permission is hereby granted, free of charge, to any person obtaining a copy
239
+ of this software and associated documentation files (the "Software"), to deal
240
+ in the Software without restriction, including without limitation the rights
241
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
242
+ copies of the Software, and to permit persons to whom the Software is
243
+ furnished to do so, subject to the following conditions:
244
+
245
+ The above copyright notice and this permission notice shall be included in all
246
+ copies or substantial portions of the Software.
247
+
248
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
249
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
250
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
251
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
252
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
253
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
254
+ SOFTWARE.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * UA_Sortable v1.0.0 — IIFE build (browser global)
3
+ * Pointer-Events-based drag-and-drop sorting. No dependencies.
4
+ *
5
+ * @author Marian Feiler <mf@urbanstudio.de>
6
+ * @company urbanstudio GmbH — https://urbanstudio.de
7
+ * @license MIT
8
+ * @see https://github.com/urbanstudioGmbH/ua-sortable
9
+ *
10
+ * Usage:
11
+ * <script src="ua-sortable.js"></script>
12
+ * <script>
13
+ * new UA_Sortable(document.querySelector("ul"), { handle: ".drag-handle" });
14
+ * // or shorthand:
15
+ * uaMakeSortable(document.querySelector("ul"), { handle: ".drag-handle" });
16
+ * </script>
17
+ */
18
+ (function (global, factory) {
19
+ "use strict";
20
+ if (typeof module !== "undefined" && module.exports) {
21
+ // CommonJS / Node (no DOM, only class export)
22
+ module.exports = factory();
23
+ } else {
24
+ // Browser global
25
+ const exports = factory();
26
+ global.UA_Sortable = exports.UA_Sortable;
27
+ global.uaMakeSortable = exports.uaMakeSortable;
28
+ }
29
+ })(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this, function () {
30
+ "use strict";
31
+
32
+ class UA_Sortable {
33
+
34
+ static #instanceRegistry = new WeakMap();
35
+ static #groupRegistry = new Map();
36
+ static #globalObserver = null;
37
+
38
+ #containerElement = null;
39
+ #options = {};
40
+ #draggableElements = [];
41
+ #ghostElement = null;
42
+ #draggedElement = null;
43
+ #placeholderElement = null;
44
+ #sourceContainerElement = null;
45
+ #currentContainerElement = null;
46
+ #pointerStartX = 0;
47
+ #pointerStartY = 0;
48
+ #delayTimer = null;
49
+ #isDragging = false;
50
+ #childObserver = null;
51
+ #boundHandlePointerDown = null;
52
+ #boundHandlePointerMove = null;
53
+ #boundHandlePointerUp = null;
54
+ #boundHandlePointerCancel = null;
55
+
56
+ constructor(containerElement, options = {}) {
57
+ if (!(containerElement instanceof HTMLElement)) {
58
+ throw new Error("UA_Sortable: first argument must be an HTMLElement");
59
+ }
60
+ if (UA_Sortable.#instanceRegistry.has(containerElement)) {
61
+ UA_Sortable.#instanceRegistry.get(containerElement).destroy();
62
+ }
63
+ this.#containerElement = containerElement;
64
+ this.#options = {
65
+ handle: null, filter: null, group: null, direction: "auto",
66
+ animation: 150, dataIdAttr: "data-id", delay: 0,
67
+ delayOnTouchOnly: false, disabled: false,
68
+ ghostClass: "ua-sortable-ghost", dragClass: "ua-sortable-drag",
69
+ confirm: null, onDragStart: null, onDragEnd: null,
70
+ onSort: null, onMove: null,
71
+ ...options,
72
+ };
73
+ this.#boundHandlePointerDown = this.#handlePointerDown.bind(this);
74
+ this.#boundHandlePointerMove = this.#handlePointerMove.bind(this);
75
+ this.#boundHandlePointerUp = this.#handlePointerUp.bind(this);
76
+ this.#boundHandlePointerCancel = this.#handlePointerCancel.bind(this);
77
+ this.#registerInGlobals();
78
+ this.#attachContainerListeners();
79
+ this.#observeChildChanges();
80
+ this.#refreshDraggableList();
81
+ }
82
+
83
+ toArray() {
84
+ return this.#getDraggableChildren().map(el => el.getAttribute(this.#options.dataIdAttr) ?? "");
85
+ }
86
+ enable() { this.option("disabled", false); }
87
+ disable() { this.option("disabled", true); }
88
+ refresh() { this.#refreshDraggableList(); }
89
+ destroy() {
90
+ this.#detachContainerListeners();
91
+ this.#childObserver?.disconnect();
92
+ this.#childObserver = null;
93
+ this.#cleanupDragState();
94
+ this.#unregisterFromGlobals();
95
+ }
96
+ option(name, value = undefined) {
97
+ if (value === undefined) return this.#options[name];
98
+ this.#options[name] = value;
99
+ if (name === "filter" || name === "handle") this.#refreshDraggableList();
100
+ }
101
+
102
+ static get(containerElement) {
103
+ return UA_Sortable.#instanceRegistry.get(containerElement) ?? null;
104
+ }
105
+ static getGroup(groupName) {
106
+ return [...(UA_Sortable.#groupRegistry.get(groupName) ?? [])];
107
+ }
108
+ static snapshot(groupName = null) {
109
+ let instances;
110
+ if (groupName !== null) {
111
+ instances = UA_Sortable.getGroup(groupName);
112
+ } else {
113
+ instances = [];
114
+ UA_Sortable.#groupRegistry.forEach(g => instances.push(...g));
115
+ }
116
+ return instances.map(i => ({ container: i.#containerElement, ids: i.toArray(), group: i.#options.group }));
117
+ }
118
+ static initAll(root = document) {
119
+ root.querySelectorAll("[data-ua-sortable]").forEach(el => {
120
+ if (!UA_Sortable.#instanceRegistry.has(el)) UA_Sortable.#initFromDataAttributes(el);
121
+ });
122
+ }
123
+ static observe(root) {
124
+ if (UA_Sortable.#globalObserver) UA_Sortable.#globalObserver.disconnect();
125
+ UA_Sortable.#globalObserver = new MutationObserver(mutations => {
126
+ for (const m of mutations) {
127
+ for (const node of m.addedNodes) {
128
+ if (!(node instanceof HTMLElement)) continue;
129
+ if (node.hasAttribute("data-ua-sortable") && !UA_Sortable.#instanceRegistry.has(node)) {
130
+ UA_Sortable.#initFromDataAttributes(node);
131
+ }
132
+ node.querySelectorAll("[data-ua-sortable]").forEach(el => {
133
+ if (!UA_Sortable.#instanceRegistry.has(el)) UA_Sortable.#initFromDataAttributes(el);
134
+ });
135
+ }
136
+ }
137
+ });
138
+ UA_Sortable.#globalObserver.observe(root, { childList: true, subtree: true });
139
+ }
140
+
141
+ static #initFromDataAttributes(el) {
142
+ const d = el.dataset, o = {};
143
+ if (d.sortableHandle !== undefined) o.handle = d.sortableHandle;
144
+ if (d.sortableFilter !== undefined) o.filter = d.sortableFilter;
145
+ if (d.sortableGroup !== undefined) o.group = d.sortableGroup;
146
+ if (d.sortableDirection !== undefined) o.direction = d.sortableDirection;
147
+ if (d.sortableAnimation !== undefined) o.animation = parseInt(d.sortableAnimation, 10);
148
+ if (d.sortableDataIdAttr!== undefined) o.dataIdAttr = d.sortableDataIdAttr;
149
+ if (d.sortableDelay !== undefined) o.delay = parseInt(d.sortableDelay, 10);
150
+ new UA_Sortable(el, o);
151
+ }
152
+ #registerInGlobals() {
153
+ UA_Sortable.#instanceRegistry.set(this.#containerElement, this);
154
+ const g = this.#options.group;
155
+ if (g) {
156
+ if (!UA_Sortable.#groupRegistry.has(g)) UA_Sortable.#groupRegistry.set(g, new Set());
157
+ UA_Sortable.#groupRegistry.get(g).add(this);
158
+ }
159
+ }
160
+ #unregisterFromGlobals() {
161
+ UA_Sortable.#instanceRegistry.delete(this.#containerElement);
162
+ const g = this.#options.group;
163
+ if (g && UA_Sortable.#groupRegistry.has(g)) UA_Sortable.#groupRegistry.get(g).delete(this);
164
+ }
165
+ #attachContainerListeners() {
166
+ this.#containerElement.addEventListener("pointerdown", this.#boundHandlePointerDown);
167
+ }
168
+ #detachContainerListeners() {
169
+ this.#containerElement.removeEventListener("pointerdown", this.#boundHandlePointerDown);
170
+ document.removeEventListener("pointermove", this.#boundHandlePointerMove);
171
+ document.removeEventListener("pointerup", this.#boundHandlePointerUp);
172
+ document.removeEventListener("pointercancel", this.#boundHandlePointerCancel);
173
+ }
174
+ #observeChildChanges() {
175
+ this.#childObserver = new MutationObserver(() => this.#refreshDraggableList());
176
+ this.#childObserver.observe(this.#containerElement, { childList: true });
177
+ }
178
+ #refreshDraggableList() { this.#draggableElements = this.#getDraggableChildren(); }
179
+ #getDraggableChildren() {
180
+ const c = [...this.#containerElement.children];
181
+ return this.#options.filter ? c.filter(el => !el.matches(this.#options.filter)) : c;
182
+ }
183
+ #handlePointerDown(e) {
184
+ if (this.#options.disabled) return;
185
+ if (e.button !== 0 && e.pointerType === "mouse") return;
186
+ const dragged = this.#findDraggableParent(e.target);
187
+ if (!dragged) return;
188
+ if (this.#options.filter && dragged.matches(this.#options.filter)) return;
189
+ if (this.#options.handle) {
190
+ const h = e.target.closest(this.#options.handle);
191
+ if (!h || !dragged.contains(h)) return;
192
+ }
193
+ this.#pointerStartX = e.clientX;
194
+ this.#pointerStartY = e.clientY;
195
+ const delay = this.#options.delay > 0 && (!this.#options.delayOnTouchOnly || e.pointerType === "touch");
196
+ if (delay) {
197
+ this.#delayTimer = setTimeout(() => this.#startDrag(e, dragged), this.#options.delay);
198
+ const cancel = mv => {
199
+ if (Math.hypot(mv.clientX - this.#pointerStartX, mv.clientY - this.#pointerStartY) > 5) {
200
+ clearTimeout(this.#delayTimer);
201
+ document.removeEventListener("pointermove", cancel);
202
+ }
203
+ };
204
+ document.addEventListener("pointermove", cancel);
205
+ } else {
206
+ this.#startDrag(e, dragged);
207
+ }
208
+ }
209
+ #startDrag(e, dragged) {
210
+ this.#isDragging = true;
211
+ this.#draggedElement = dragged;
212
+ this.#sourceContainerElement = this.#containerElement;
213
+ this.#currentContainerElement = this.#containerElement;
214
+ const r = dragged.getBoundingClientRect();
215
+ this.#ghostElement = dragged.cloneNode(true);
216
+ this.#ghostElement.classList.add(this.#options.ghostClass);
217
+ this.#ghostElement.style.cssText = `position:fixed;left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;margin:0;pointer-events:none;z-index:9999;`;
218
+ document.body.appendChild(this.#ghostElement);
219
+ this.#placeholderElement = document.createElement(dragged.tagName);
220
+ this.#placeholderElement.style.cssText = `width:${r.width}px;height:${r.height}px;opacity:0;pointer-events:none;`;
221
+ dragged.parentNode.insertBefore(this.#placeholderElement, dragged);
222
+ dragged.classList.add(this.#options.dragClass);
223
+ dragged.style.opacity = "0.001";
224
+ this.#containerElement.classList.add("ua-sortable-active");
225
+ try { dragged.setPointerCapture(e.pointerId); } catch (_) {}
226
+ document.addEventListener("pointermove", this.#boundHandlePointerMove);
227
+ document.addEventListener("pointerup", this.#boundHandlePointerUp);
228
+ document.addEventListener("pointercancel", this.#boundHandlePointerCancel);
229
+ this.#options.onDragStart?.(dragged);
230
+ }
231
+ #handlePointerMove(e) {
232
+ if (!this.#isDragging) return;
233
+ const dx = e.clientX - this.#pointerStartX, dy = e.clientY - this.#pointerStartY;
234
+ const or = this.#draggedElement.getBoundingClientRect();
235
+ this.#ghostElement.style.left = `${or.left + dx}px`;
236
+ this.#ghostElement.style.top = `${or.top + dy}px`;
237
+ const tc = this.#findTargetContainer(e.clientX, e.clientY);
238
+ if (tc && tc !== this.#currentContainerElement) {
239
+ this.#currentContainerElement.classList.remove("ua-sortable-active");
240
+ tc.classList.add("ua-sortable-active");
241
+ this.#currentContainerElement = tc;
242
+ }
243
+ this.#updatePlaceholderPosition(e.clientX, e.clientY);
244
+ }
245
+ #handlePointerUp() { if (this.#isDragging) this.#finalizeDrop(); }
246
+ #handlePointerCancel() { if (this.#isDragging) this.#revertDrag(); }
247
+ async #finalizeDrop() {
248
+ const dragged = this.#draggedElement;
249
+ const src = this.#sourceContainerElement;
250
+ const tgt = this.#currentContainerElement;
251
+ const moved = src !== tgt;
252
+ if (moved && this.#options.confirm) {
253
+ const ok = await Promise.resolve(this.#options.confirm(dragged.getAttribute(this.#options.dataIdAttr), src, tgt));
254
+ if (!ok) { this.#revertDrag(); return; }
255
+ }
256
+ tgt.insertBefore(dragged, this.#placeholderElement);
257
+ this.#cleanupDragState();
258
+ const id = dragged.getAttribute(this.#options.dataIdAttr);
259
+ const ids = UA_Sortable.get(tgt)?.toArray() ?? [...tgt.children]
260
+ .filter(el => el !== this.#placeholderElement && (!this.#options.filter || !el.matches(this.#options.filter)))
261
+ .map(el => el.getAttribute(this.#options.dataIdAttr) ?? "");
262
+ if (moved) {
263
+ const srcIds = UA_Sortable.get(src)?.toArray() ?? [];
264
+ const ok = await this.#invokeOnMove(id, src, tgt, srcIds, ids);
265
+ if (ok === false) { src.appendChild(dragged); this.#options.onDragEnd?.(dragged, false); return; }
266
+ } else {
267
+ this.#options.onSort?.(ids, tgt);
268
+ }
269
+ this.#options.onDragEnd?.(dragged, true);
270
+ }
271
+ #revertDrag() {
272
+ if (this.#placeholderElement?.parentNode) {
273
+ this.#placeholderElement.parentNode.insertBefore(this.#draggedElement, this.#placeholderElement);
274
+ }
275
+ const d = this.#draggedElement;
276
+ this.#cleanupDragState();
277
+ this.#options.onDragEnd?.(d, false);
278
+ }
279
+ async #invokeOnMove(id, from, to, fromIds, toIds) {
280
+ if (!this.#options.onMove) return true;
281
+ return await Promise.resolve(this.#options.onMove(id, from, to, fromIds, toIds)) !== false;
282
+ }
283
+ #cleanupDragState() {
284
+ this.#ghostElement?.remove();
285
+ this.#placeholderElement?.remove();
286
+ if (this.#draggedElement) {
287
+ this.#draggedElement.classList.remove(this.#options.dragClass);
288
+ this.#draggedElement.style.opacity = "";
289
+ }
290
+ this.#containerElement.classList.remove("ua-sortable-active");
291
+ if (this.#currentContainerElement && this.#currentContainerElement !== this.#containerElement) {
292
+ this.#currentContainerElement.classList.remove("ua-sortable-active");
293
+ }
294
+ clearTimeout(this.#delayTimer);
295
+ document.removeEventListener("pointermove", this.#boundHandlePointerMove);
296
+ document.removeEventListener("pointerup", this.#boundHandlePointerUp);
297
+ document.removeEventListener("pointercancel", this.#boundHandlePointerCancel);
298
+ this.#ghostElement = this.#placeholderElement = this.#draggedElement =
299
+ this.#sourceContainerElement = this.#currentContainerElement = this.#delayTimer = null;
300
+ this.#isDragging = false;
301
+ }
302
+ #updatePlaceholderPosition(px, py) {
303
+ const tc = this.#currentContainerElement;
304
+ const children = [...tc.children].filter(c =>
305
+ c !== this.#draggedElement && c !== this.#ghostElement &&
306
+ c !== this.#placeholderElement &&
307
+ (!this.#options.filter || !c.matches(this.#options.filter))
308
+ );
309
+ const dir = this.#resolveDirection(tc);
310
+ let before = null;
311
+ for (const s of children) {
312
+ const sr = s.getBoundingClientRect();
313
+ const mid = dir === "horizontal" ? sr.left + sr.width / 2 : sr.top + sr.height / 2;
314
+ if ((dir === "horizontal" ? px : py) < mid) { before = s; break; }
315
+ }
316
+ before ? tc.insertBefore(this.#placeholderElement, before) : tc.appendChild(this.#placeholderElement);
317
+ }
318
+ #resolveDirection(el) {
319
+ if (this.#options.direction !== "auto") return this.#options.direction;
320
+ const fd = getComputedStyle(el).flexDirection;
321
+ return (fd === "row" || fd === "row-reverse") ? "horizontal" : "vertical";
322
+ }
323
+ #findDraggableParent(el) {
324
+ let c = el;
325
+ while (c && c !== this.#containerElement) {
326
+ if (c.parentElement === this.#containerElement) return c;
327
+ c = c.parentElement;
328
+ }
329
+ return null;
330
+ }
331
+ #findTargetContainer(px, py) {
332
+ const g = this.#options.group;
333
+ if (!g) return this.#containerElement;
334
+ for (const inst of UA_Sortable.getGroup(g)) {
335
+ if (inst === this || inst.#options.disabled) continue;
336
+ const r = inst.#containerElement.getBoundingClientRect();
337
+ if (px >= r.left && px <= r.right && py >= r.top && py <= r.bottom) return inst.#containerElement;
338
+ }
339
+ return this.#containerElement;
340
+ }
341
+ }
342
+
343
+ // Inject minimal CSS
344
+ if (typeof document !== "undefined" && !document.getElementById("ua-sortable-styles")) {
345
+ const s = document.createElement("style");
346
+ s.id = "ua-sortable-styles";
347
+ s.textContent = ".ua-sortable-ghost{opacity:.4;}.ua-sortable-drag{opacity:.4;}.ua-sortable-active>.ua-sortable-over{border-top:2px solid var(--accent,#2563eb);}.ua-drag-handle{cursor:grab;touch-action:none;}.ua-drag-handle:active{cursor:grabbing;}";
348
+ document.head.appendChild(s);
349
+ }
350
+
351
+ function uaMakeSortable(el, options = {}) {
352
+ return new UA_Sortable(el, options);
353
+ }
354
+
355
+ return { UA_Sortable, uaMakeSortable };
356
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@urbanstudio/ua-sortable",
3
+ "version": "1.0.0",
4
+ "description": "Pointer-Events-based drag-and-drop sorting for lists and grids. No dependencies.",
5
+ "author": "Marian Feiler <mf@urbanstudio.de> (https://urbanstudio.de)",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/urbanstudioGmbH/ua-sortable#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/urbanstudioGmbH/ua-sortable.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/urbanstudioGmbH/ua-sortable/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/ua-sortable.js",
17
+ "module": "./src/Sortable.js",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./src/Sortable.js",
21
+ "require": "./dist/ua-sortable.js"
22
+ },
23
+ "./dist": "./dist/ua-sortable.js"
24
+ },
25
+ "files": [
26
+ "src/",
27
+ "dist/",
28
+ "README.md",
29
+ "LICENSE",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "keywords": [
33
+ "drag-and-drop",
34
+ "sortable",
35
+ "sort",
36
+ "reorder",
37
+ "drag",
38
+ "pointer-events",
39
+ "touch",
40
+ "es6-module",
41
+ "zero-dependency",
42
+ "vanilla-js",
43
+ "list",
44
+ "grid"
45
+ ],
46
+ "sideEffects": false,
47
+ "engines": {
48
+ "node": ">=14"
49
+ }
50
+ }
@@ -0,0 +1,583 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * UA_Sortable — Pointer-Events-based drag-and-drop sorting for lists and grids.
5
+ * No dependencies — no jQuery, no CDN, no external frameworks.
6
+ * Supports: simple sorting, cross-list groups, auto-direction, filter, delay.
7
+ *
8
+ * @author Marian Feiler <mf@urbanstudio.de>
9
+ * @company urbanstudio GmbH — https://urbanstudio.de
10
+ * @license MIT
11
+ * @see https://github.com/urbanstudioGmbH/ua-sortable
12
+ */
13
+ export class UA_Sortable {
14
+
15
+ // --- Global registries ---
16
+ static #instanceRegistry = new WeakMap(); // containerElement → UA_Sortable
17
+ static #groupRegistry = new Map(); // groupName → Set<UA_Sortable>
18
+ static #globalObserver = null;
19
+
20
+ // --- Internal drag state ---
21
+ #containerElement = null;
22
+ #options = {};
23
+ #draggableElements = [];
24
+ #ghostElement = null;
25
+ #draggedElement = null;
26
+ #placeholderElement = null;
27
+ #sourceContainerElement = null;
28
+ #currentContainerElement = null;
29
+ #pointerStartX = 0;
30
+ #pointerStartY = 0;
31
+ #delayTimer = null;
32
+ #isDragging = false;
33
+ #childObserver = null;
34
+
35
+ // Bound handler references for clean removeEventListener
36
+ #boundHandlePointerDown = null;
37
+ #boundHandlePointerMove = null;
38
+ #boundHandlePointerUp = null;
39
+ #boundHandlePointerCancel = null;
40
+
41
+ /**
42
+ * @param {HTMLElement} containerElement
43
+ * @param {object} options
44
+ */
45
+ constructor(containerElement, options = {}) {
46
+ if (!(containerElement instanceof HTMLElement)) {
47
+ throw new Error("UA_Sortable: first argument must be an HTMLElement");
48
+ }
49
+ if (UA_Sortable.#instanceRegistry.has(containerElement)) {
50
+ UA_Sortable.#instanceRegistry.get(containerElement).destroy();
51
+ }
52
+
53
+ this.#containerElement = containerElement;
54
+ this.#options = {
55
+ handle: null,
56
+ filter: null,
57
+ group: null,
58
+ direction: "auto",
59
+ animation: 150,
60
+ dataIdAttr: "data-id",
61
+ delay: 0,
62
+ delayOnTouchOnly: false,
63
+ disabled: false,
64
+ ghostClass: "ua-sortable-ghost",
65
+ dragClass: "ua-sortable-drag",
66
+ confirm: null,
67
+ onDragStart: null,
68
+ onDragEnd: null,
69
+ onSort: null,
70
+ onMove: null,
71
+ ...options,
72
+ };
73
+
74
+ this.#boundHandlePointerDown = this.#handlePointerDown.bind(this);
75
+ this.#boundHandlePointerMove = this.#handlePointerMove.bind(this);
76
+ this.#boundHandlePointerUp = this.#handlePointerUp.bind(this);
77
+ this.#boundHandlePointerCancel = this.#handlePointerCancel.bind(this);
78
+
79
+ this.#registerInGlobals();
80
+ this.#attachContainerListeners();
81
+ this.#observeChildChanges();
82
+ this.#refreshDraggableList();
83
+ }
84
+
85
+ // =========================================================================
86
+ // PUBLIC API — Instance methods
87
+ // =========================================================================
88
+
89
+ /** Returns IDs of all draggable children in current DOM order. */
90
+ toArray() {
91
+ return this.#getDraggableChildren().map(el => el.getAttribute(this.#options.dataIdAttr) ?? "");
92
+ }
93
+
94
+ /** Enable dragging. */
95
+ enable() {
96
+ this.option("disabled", false);
97
+ }
98
+
99
+ /** Disable dragging. */
100
+ disable() {
101
+ this.option("disabled", true);
102
+ }
103
+
104
+ /** Re-scan draggable children and filtered elements. */
105
+ refresh() {
106
+ this.#refreshDraggableList();
107
+ }
108
+
109
+ /** Remove all listeners and observers, delete from global registry. */
110
+ destroy() {
111
+ this.#detachContainerListeners();
112
+ this.#childObserver?.disconnect();
113
+ this.#childObserver = null;
114
+ this.#cleanupDragState();
115
+ this.#unregisterFromGlobals();
116
+ }
117
+
118
+ /**
119
+ * Get or set an option at runtime.
120
+ * @param {string} name
121
+ * @param {*} [value]
122
+ */
123
+ option(name, value = undefined) {
124
+ if (value === undefined) return this.#options[name];
125
+ this.#options[name] = value;
126
+ if (name === "filter" || name === "handle") this.#refreshDraggableList();
127
+ }
128
+
129
+ // =========================================================================
130
+ // PUBLIC API — Static methods
131
+ // =========================================================================
132
+
133
+ /** Returns the UA_Sortable instance for a container element. */
134
+ static get(containerElement) {
135
+ return UA_Sortable.#instanceRegistry.get(containerElement) ?? null;
136
+ }
137
+
138
+ /** Returns all instances registered under a group name. */
139
+ static getGroup(groupName) {
140
+ return [...(UA_Sortable.#groupRegistry.get(groupName) ?? [])];
141
+ }
142
+
143
+ /**
144
+ * Returns a snapshot of current order for all grouped instances.
145
+ * @param {string} [groupName] — if omitted, all grouped instances
146
+ * @returns {{ container: HTMLElement, ids: string[], group: string|null }[]}
147
+ */
148
+ static snapshot(groupName = null) {
149
+ let instances;
150
+ if (groupName !== null) {
151
+ instances = UA_Sortable.getGroup(groupName);
152
+ } else {
153
+ instances = [];
154
+ UA_Sortable.#groupRegistry.forEach(groupSet => instances.push(...groupSet));
155
+ }
156
+ return instances.map(instance => ({
157
+ container: instance.#containerElement,
158
+ ids: instance.toArray(),
159
+ group: instance.#options.group,
160
+ }));
161
+ }
162
+
163
+ /**
164
+ * Initialize all [data-ua-sortable] containers within root.
165
+ * @param {HTMLElement|Document} [root=document]
166
+ */
167
+ static initAll(root = document) {
168
+ root.querySelectorAll("[data-ua-sortable]").forEach(containerElement => {
169
+ if (!UA_Sortable.#instanceRegistry.has(containerElement)) {
170
+ UA_Sortable.#initFromDataAttributes(containerElement);
171
+ }
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Start a MutationObserver that auto-initializes new [data-ua-sortable] containers.
177
+ * @param {HTMLElement|Document} root
178
+ */
179
+ static observe(root) {
180
+ if (UA_Sortable.#globalObserver) UA_Sortable.#globalObserver.disconnect();
181
+ UA_Sortable.#globalObserver = new MutationObserver(mutationList => {
182
+ for (const mutation of mutationList) {
183
+ for (const addedNode of mutation.addedNodes) {
184
+ if (!(addedNode instanceof HTMLElement)) continue;
185
+ if (addedNode.hasAttribute("data-ua-sortable")) {
186
+ UA_Sortable.#initFromDataAttributes(addedNode);
187
+ }
188
+ addedNode.querySelectorAll("[data-ua-sortable]").forEach(el => {
189
+ if (!UA_Sortable.#instanceRegistry.has(el)) {
190
+ UA_Sortable.#initFromDataAttributes(el);
191
+ }
192
+ });
193
+ }
194
+ }
195
+ });
196
+ UA_Sortable.#globalObserver.observe(root, { childList: true, subtree: true });
197
+ }
198
+
199
+ // =========================================================================
200
+ // PRIVATE — Initialization
201
+ // =========================================================================
202
+
203
+ static #initFromDataAttributes(containerElement) {
204
+ const dataset = containerElement.dataset;
205
+ const options = {};
206
+ if (dataset.sortableHandle !== undefined) options.handle = dataset.sortableHandle;
207
+ if (dataset.sortableFilter !== undefined) options.filter = dataset.sortableFilter;
208
+ if (dataset.sortableGroup !== undefined) options.group = dataset.sortableGroup;
209
+ if (dataset.sortableDirection !== undefined) options.direction = dataset.sortableDirection;
210
+ if (dataset.sortableAnimation !== undefined) options.animation = parseInt(dataset.sortableAnimation, 10);
211
+ if (dataset.sortableDataIdAttr!== undefined) options.dataIdAttr = dataset.sortableDataIdAttr;
212
+ if (dataset.sortableDelay !== undefined) options.delay = parseInt(dataset.sortableDelay, 10);
213
+ new UA_Sortable(containerElement, options);
214
+ }
215
+
216
+ #registerInGlobals() {
217
+ UA_Sortable.#instanceRegistry.set(this.#containerElement, this);
218
+ const groupName = this.#options.group;
219
+ if (groupName) {
220
+ if (!UA_Sortable.#groupRegistry.has(groupName)) {
221
+ UA_Sortable.#groupRegistry.set(groupName, new Set());
222
+ }
223
+ UA_Sortable.#groupRegistry.get(groupName).add(this);
224
+ }
225
+ }
226
+
227
+ #unregisterFromGlobals() {
228
+ UA_Sortable.#instanceRegistry.delete(this.#containerElement);
229
+ const groupName = this.#options.group;
230
+ if (groupName && UA_Sortable.#groupRegistry.has(groupName)) {
231
+ UA_Sortable.#groupRegistry.get(groupName).delete(this);
232
+ }
233
+ }
234
+
235
+ #attachContainerListeners() {
236
+ this.#containerElement.addEventListener("pointerdown", this.#boundHandlePointerDown);
237
+ }
238
+
239
+ #detachContainerListeners() {
240
+ this.#containerElement.removeEventListener("pointerdown", this.#boundHandlePointerDown);
241
+ document.removeEventListener("pointermove", this.#boundHandlePointerMove);
242
+ document.removeEventListener("pointerup", this.#boundHandlePointerUp);
243
+ document.removeEventListener("pointercancel", this.#boundHandlePointerCancel);
244
+ }
245
+
246
+ #observeChildChanges() {
247
+ this.#childObserver = new MutationObserver(() => this.#refreshDraggableList());
248
+ this.#childObserver.observe(this.#containerElement, { childList: true });
249
+ }
250
+
251
+ #refreshDraggableList() {
252
+ this.#draggableElements = this.#getDraggableChildren();
253
+ }
254
+
255
+ #getDraggableChildren() {
256
+ const children = [...this.#containerElement.children];
257
+ if (!this.#options.filter) return children;
258
+ return children.filter(child => !child.matches(this.#options.filter));
259
+ }
260
+
261
+ // =========================================================================
262
+ // PRIVATE — Pointer event handlers
263
+ // =========================================================================
264
+
265
+ #handlePointerDown(pointerEvent) {
266
+ if (this.#options.disabled) return;
267
+ if (pointerEvent.button !== 0 && pointerEvent.pointerType === "mouse") return;
268
+
269
+ const draggedElement = this.#findDraggableParent(pointerEvent.target);
270
+ if (!draggedElement) return;
271
+
272
+ if (this.#options.filter && draggedElement.matches(this.#options.filter)) return;
273
+
274
+ if (this.#options.handle) {
275
+ const handleElement = pointerEvent.target.closest(this.#options.handle);
276
+ if (!handleElement || !draggedElement.contains(handleElement)) return;
277
+ }
278
+
279
+ this.#pointerStartX = pointerEvent.clientX;
280
+ this.#pointerStartY = pointerEvent.clientY;
281
+
282
+ const shouldDelay = this.#options.delay > 0 &&
283
+ (!this.#options.delayOnTouchOnly || pointerEvent.pointerType === "touch");
284
+
285
+ if (shouldDelay) {
286
+ this.#delayTimer = setTimeout(() => {
287
+ this.#startDrag(pointerEvent, draggedElement);
288
+ }, this.#options.delay);
289
+ const cancelDelay = (moveEvent) => {
290
+ const distance = Math.hypot(
291
+ moveEvent.clientX - this.#pointerStartX,
292
+ moveEvent.clientY - this.#pointerStartY
293
+ );
294
+ if (distance > 5) {
295
+ clearTimeout(this.#delayTimer);
296
+ document.removeEventListener("pointermove", cancelDelay);
297
+ }
298
+ };
299
+ document.addEventListener("pointermove", cancelDelay, { once: false });
300
+ } else {
301
+ this.#startDrag(pointerEvent, draggedElement);
302
+ }
303
+ }
304
+
305
+ #startDrag(pointerEvent, draggedElement) {
306
+ this.#isDragging = true;
307
+ this.#draggedElement = draggedElement;
308
+ this.#sourceContainerElement = this.#containerElement;
309
+ this.#currentContainerElement = this.#containerElement;
310
+
311
+ const boundingRect = draggedElement.getBoundingClientRect();
312
+ this.#ghostElement = draggedElement.cloneNode(true);
313
+ this.#ghostElement.classList.add(this.#options.ghostClass);
314
+ this.#ghostElement.style.cssText = [
315
+ "position:fixed",
316
+ `left:${boundingRect.left}px`,
317
+ `top:${boundingRect.top}px`,
318
+ `width:${boundingRect.width}px`,
319
+ `height:${boundingRect.height}px`,
320
+ "margin:0",
321
+ "pointer-events:none",
322
+ "z-index:9999",
323
+ ].join(";");
324
+ document.body.appendChild(this.#ghostElement);
325
+
326
+ this.#placeholderElement = document.createElement(draggedElement.tagName);
327
+ this.#placeholderElement.style.cssText = [
328
+ `width:${boundingRect.width}px`,
329
+ `height:${boundingRect.height}px`,
330
+ "opacity:0",
331
+ "pointer-events:none",
332
+ ].join(";");
333
+ draggedElement.parentNode.insertBefore(this.#placeholderElement, draggedElement);
334
+
335
+ draggedElement.classList.add(this.#options.dragClass);
336
+ draggedElement.style.opacity = "0.001";
337
+
338
+ this.#containerElement.classList.add("ua-sortable-active");
339
+
340
+ try { draggedElement.setPointerCapture(pointerEvent.pointerId); } catch (_) {}
341
+
342
+ document.addEventListener("pointermove", this.#boundHandlePointerMove);
343
+ document.addEventListener("pointerup", this.#boundHandlePointerUp);
344
+ document.addEventListener("pointercancel", this.#boundHandlePointerCancel);
345
+
346
+ this.#options.onDragStart?.(draggedElement);
347
+ }
348
+
349
+ #handlePointerMove(pointerEvent) {
350
+ if (!this.#isDragging) return;
351
+
352
+ const deltaX = pointerEvent.clientX - this.#pointerStartX;
353
+ const deltaY = pointerEvent.clientY - this.#pointerStartY;
354
+
355
+ const originalRect = this.#draggedElement.getBoundingClientRect();
356
+ this.#ghostElement.style.left = `${originalRect.left + deltaX}px`;
357
+ this.#ghostElement.style.top = `${originalRect.top + deltaY}px`;
358
+
359
+ const targetContainer = this.#findTargetContainer(pointerEvent.clientX, pointerEvent.clientY);
360
+ if (targetContainer && targetContainer !== this.#currentContainerElement) {
361
+ this.#currentContainerElement.classList.remove("ua-sortable-active");
362
+ targetContainer.classList.add("ua-sortable-active");
363
+ this.#currentContainerElement = targetContainer;
364
+ }
365
+
366
+ this.#updatePlaceholderPosition(pointerEvent.clientX, pointerEvent.clientY);
367
+ }
368
+
369
+ #handlePointerUp() {
370
+ if (!this.#isDragging) return;
371
+ this.#finalizeDrop();
372
+ }
373
+
374
+ #handlePointerCancel() {
375
+ if (!this.#isDragging) return;
376
+ this.#revertDrag();
377
+ }
378
+
379
+ // =========================================================================
380
+ // PRIVATE — Drop logic
381
+ // =========================================================================
382
+
383
+ async #finalizeDrop() {
384
+ const draggedElement = this.#draggedElement;
385
+ const sourceContainerElement = this.#sourceContainerElement;
386
+ const targetContainerElement = this.#currentContainerElement;
387
+ const didChangeContainer = sourceContainerElement !== targetContainerElement;
388
+
389
+ if (didChangeContainer && this.#options.confirm !== null) {
390
+ const confirmFunction = this.#options.confirm;
391
+ const confirmed = await Promise.resolve(
392
+ confirmFunction(
393
+ draggedElement.getAttribute(this.#options.dataIdAttr),
394
+ sourceContainerElement,
395
+ targetContainerElement
396
+ )
397
+ );
398
+ if (!confirmed) {
399
+ this.#revertDrag();
400
+ return;
401
+ }
402
+ }
403
+
404
+ targetContainerElement.insertBefore(draggedElement, this.#placeholderElement);
405
+ this.#cleanupDragState();
406
+
407
+ const movedId = draggedElement.getAttribute(this.#options.dataIdAttr);
408
+ const orderedTargetIds = UA_Sortable.get(targetContainerElement)?.toArray()
409
+ ?? [...targetContainerElement.children]
410
+ .filter(el => el !== this.#placeholderElement && (!this.#options.filter || !el.matches(this.#options.filter)))
411
+ .map(el => el.getAttribute(this.#options.dataIdAttr) ?? "");
412
+
413
+ if (didChangeContainer) {
414
+ const sourceInstance = UA_Sortable.get(sourceContainerElement);
415
+ const orderedSourceIds = sourceInstance?.toArray() ?? [];
416
+
417
+ const shouldProceed = await this.#invokeOnMove(
418
+ movedId,
419
+ sourceContainerElement,
420
+ targetContainerElement,
421
+ orderedSourceIds,
422
+ orderedTargetIds
423
+ );
424
+ if (shouldProceed === false) {
425
+ sourceContainerElement.appendChild(draggedElement);
426
+ this.#options.onDragEnd?.(draggedElement, false);
427
+ return;
428
+ }
429
+ } else {
430
+ this.#options.onSort?.(orderedTargetIds, targetContainerElement);
431
+ }
432
+
433
+ this.#options.onDragEnd?.(draggedElement, true);
434
+ }
435
+
436
+ #revertDrag() {
437
+ if (this.#placeholderElement?.parentNode) {
438
+ this.#placeholderElement.parentNode.insertBefore(
439
+ this.#draggedElement,
440
+ this.#placeholderElement
441
+ );
442
+ }
443
+ const draggedElement = this.#draggedElement;
444
+ this.#cleanupDragState();
445
+ this.#options.onDragEnd?.(draggedElement, false);
446
+ }
447
+
448
+ async #invokeOnMove(movedId, fromContainer, toContainer, orderedFromIds, orderedToIds) {
449
+ if (!this.#options.onMove) return true;
450
+ const result = await Promise.resolve(
451
+ this.#options.onMove(movedId, fromContainer, toContainer, orderedFromIds, orderedToIds)
452
+ );
453
+ return result !== false;
454
+ }
455
+
456
+ #cleanupDragState() {
457
+ this.#ghostElement?.remove();
458
+ this.#placeholderElement?.remove();
459
+
460
+ if (this.#draggedElement) {
461
+ this.#draggedElement.classList.remove(this.#options.dragClass);
462
+ this.#draggedElement.style.opacity = "";
463
+ }
464
+
465
+ this.#containerElement.classList.remove("ua-sortable-active");
466
+ if (this.#currentContainerElement && this.#currentContainerElement !== this.#containerElement) {
467
+ this.#currentContainerElement.classList.remove("ua-sortable-active");
468
+ }
469
+
470
+ clearTimeout(this.#delayTimer);
471
+ document.removeEventListener("pointermove", this.#boundHandlePointerMove);
472
+ document.removeEventListener("pointerup", this.#boundHandlePointerUp);
473
+ document.removeEventListener("pointercancel", this.#boundHandlePointerCancel);
474
+
475
+ this.#ghostElement = null;
476
+ this.#placeholderElement = null;
477
+ this.#draggedElement = null;
478
+ this.#sourceContainerElement = null;
479
+ this.#currentContainerElement = null;
480
+ this.#isDragging = false;
481
+ this.#delayTimer = null;
482
+ }
483
+
484
+ // =========================================================================
485
+ // PRIVATE — Position & direction calculation
486
+ // =========================================================================
487
+
488
+ #updatePlaceholderPosition(pointerX, pointerY) {
489
+ const targetContainer = this.#currentContainerElement;
490
+ const children = [...targetContainer.children].filter(child =>
491
+ child !== this.#draggedElement &&
492
+ child !== this.#ghostElement &&
493
+ child !== this.#placeholderElement &&
494
+ (!this.#options.filter || !child.matches(this.#options.filter))
495
+ );
496
+
497
+ const direction = this.#resolveDirection(targetContainer);
498
+ let insertBeforeElement = null;
499
+
500
+ for (const sibling of children) {
501
+ const siblingRect = sibling.getBoundingClientRect();
502
+ const siblingMidpoint = direction === "horizontal"
503
+ ? siblingRect.left + siblingRect.width / 2
504
+ : siblingRect.top + siblingRect.height / 2;
505
+ const pointerPosition = direction === "horizontal" ? pointerX : pointerY;
506
+
507
+ if (pointerPosition < siblingMidpoint) {
508
+ insertBeforeElement = sibling;
509
+ break;
510
+ }
511
+ }
512
+
513
+ if (insertBeforeElement) {
514
+ targetContainer.insertBefore(this.#placeholderElement, insertBeforeElement);
515
+ } else {
516
+ targetContainer.appendChild(this.#placeholderElement);
517
+ }
518
+ }
519
+
520
+ #resolveDirection(containerElement) {
521
+ if (this.#options.direction !== "auto") return this.#options.direction;
522
+ const flexDirection = getComputedStyle(containerElement).flexDirection;
523
+ return (flexDirection === "row" || flexDirection === "row-reverse")
524
+ ? "horizontal"
525
+ : "vertical";
526
+ }
527
+
528
+ #findDraggableParent(targetElement) {
529
+ let current = targetElement;
530
+ while (current && current !== this.#containerElement) {
531
+ if (current.parentElement === this.#containerElement) return current;
532
+ current = current.parentElement;
533
+ }
534
+ return null;
535
+ }
536
+
537
+ #findTargetContainer(pointerX, pointerY) {
538
+ const groupName = this.#options.group;
539
+ if (!groupName) return this.#containerElement;
540
+
541
+ const groupInstances = UA_Sortable.getGroup(groupName);
542
+ for (const instance of groupInstances) {
543
+ if (instance === this) continue;
544
+ if (instance.#options.disabled) continue;
545
+ const containerRect = instance.#containerElement.getBoundingClientRect();
546
+ if (
547
+ pointerX >= containerRect.left &&
548
+ pointerX <= containerRect.right &&
549
+ pointerY >= containerRect.top &&
550
+ pointerY <= containerRect.bottom
551
+ ) {
552
+ return instance.#containerElement;
553
+ }
554
+ }
555
+ return this.#containerElement;
556
+ }
557
+ }
558
+
559
+ // --- Inject minimal CSS once ---
560
+ (function injectUASortableCSS() {
561
+ if (typeof document === "undefined") return;
562
+ if (document.getElementById("ua-sortable-styles")) return;
563
+ const style = document.createElement("style");
564
+ style.id = "ua-sortable-styles";
565
+ style.textContent = [
566
+ ".ua-sortable-ghost{opacity:.4;}",
567
+ ".ua-sortable-drag{opacity:.4;}",
568
+ ".ua-sortable-active>.ua-sortable-over{border-top:2px solid var(--accent,#2563eb);}",
569
+ ".ua-drag-handle{cursor:grab;touch-action:none;}",
570
+ ".ua-drag-handle:active{cursor:grabbing;}",
571
+ ].join("");
572
+ document.head.appendChild(style);
573
+ })();
574
+
575
+ /**
576
+ * Shorthand: make a container element sortable.
577
+ * @param {HTMLElement} el
578
+ * @param {object} [options]
579
+ * @returns {UA_Sortable}
580
+ */
581
+ export function uaMakeSortable(el, options = {}) {
582
+ return new UA_Sortable(el, options);
583
+ }