@adobe/data-lit 0.10.30 → 0.10.32
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/README.md +22 -12
- package/dist/elements/application-host.d.ts +6 -2
- package/dist/elements/application-host.js +17 -1
- package/dist/elements/application-host.js.map +1 -1
- package/dist/functions/iterate-self-and-ancestors.d.ts +1 -0
- package/dist/functions/iterate-self-and-ancestors.js +2 -2
- package/dist/functions/iterate-self-and-ancestors.js.map +1 -1
- package/dist/hooks/component/hooks-controller.js +16 -5
- package/dist/hooks/component/hooks-controller.js.map +1 -1
- package/dist/hooks/use-drag-observe.js +2 -1
- package/dist/hooks/use-drag-observe.js.map +1 -1
- package/dist/hooks/use-drag-transaction.d.ts +5 -0
- package/dist/hooks/use-drag-transaction.js +42 -22
- package/dist/hooks/use-drag-transaction.js.map +1 -1
- package/dist/hooks/use-draggable.js +70 -48
- package/dist/hooks/use-draggable.js.map +1 -1
- package/dist/hooks/use-effect.js +6 -2
- package/dist/hooks/use-effect.js.map +1 -1
- package/dist/hooks/use-element.d.ts +4 -2
- package/dist/hooks/use-element.js +10 -51
- package/dist/hooks/use-element.js.map +1 -1
- package/dist/hooks/use-memo.js +11 -9
- package/dist/hooks/use-memo.js.map +1 -1
- package/dist/hooks/use-observable.d.ts +4 -0
- package/dist/hooks/use-observable.js +13 -8
- package/dist/hooks/use-observable.js.map +1 -1
- package/dist/hooks/use-ref.js +4 -3
- package/dist/hooks/use-ref.js.map +1 -1
- package/dist/hooks/use-resize-observer.js +5 -2
- package/dist/hooks/use-resize-observer.js.map +1 -1
- package/dist/hooks/use-resize-observer.test.d.ts +1 -0
- package/dist/hooks/use-resize-observer.test.js +102 -0
- package/dist/hooks/use-resize-observer.test.js.map +1 -0
- package/dist/hooks/use-state.js +9 -7
- package/dist/hooks/use-state.js.map +1 -1
- package/dist/hooks/use-state.test.d.ts +1 -0
- package/dist/hooks/use-state.test.js +83 -0
- package/dist/hooks/use-state.test.js.map +1 -0
- package/dist/hooks/use-window-event.js +1 -1
- package/dist/hooks/use-window-event.js.map +1 -1
- package/package.json +3 -3
- package/src/decorators/apply-decorator.test.ts +37 -0
- package/src/decorators/apply-service-decorators.test.ts +53 -0
- package/src/decorators/require-service.test.ts +31 -0
- package/src/elements/application-element.test.ts +80 -0
- package/src/elements/application-host.test.ts +57 -0
- package/src/elements/application-host.ts +16 -3
- package/src/elements/database-element.test.ts +111 -0
- package/src/functions/iterate-self-and-ancestors.test.ts +29 -0
- package/src/functions/iterate-self-and-ancestors.ts +2 -2
- package/src/hooks/attach-decorator.test.ts +45 -0
- package/src/hooks/component/hooks-controller.cleanup.test.ts +65 -0
- package/src/hooks/component/hooks-controller.ts +15 -5
- package/src/hooks/component/stack.test.ts +37 -0
- package/src/hooks/test-host.test-utils.ts +43 -0
- package/src/hooks/use-connected.test.ts +88 -0
- package/src/hooks/use-debounce.test.ts +69 -0
- package/src/hooks/use-drag-generator.test.ts +67 -0
- package/src/hooks/use-drag-observe.test.ts +74 -0
- package/src/hooks/use-drag-observe.ts +2 -1
- package/src/hooks/use-drag-transaction.test.ts +109 -0
- package/src/hooks/use-drag-transaction.ts +48 -27
- package/src/hooks/use-draggable.test.ts +148 -0
- package/src/hooks/use-draggable.ts +73 -48
- package/src/hooks/use-effect.test.ts +51 -0
- package/src/hooks/use-effect.ts +5 -2
- package/src/hooks/use-element.test.ts +82 -0
- package/src/hooks/use-element.ts +17 -60
- package/src/hooks/use-memo.test.ts +66 -0
- package/src/hooks/use-memo.ts +16 -11
- package/src/hooks/use-observable-values.test.ts +53 -0
- package/src/hooks/use-observable.test.ts +63 -0
- package/src/hooks/use-observable.ts +13 -8
- package/src/hooks/use-pointer-observe.test.ts +59 -0
- package/src/hooks/use-ref.test.ts +50 -0
- package/src/hooks/use-ref.ts +4 -3
- package/src/hooks/use-resize-observer.test.ts +114 -0
- package/src/hooks/use-resize-observer.ts +5 -2
- package/src/hooks/use-state.test.ts +108 -0
- package/src/hooks/use-state.ts +14 -9
- package/src/hooks/use-updated.test.ts +98 -0
- package/src/hooks/use-window-event.test.ts +61 -0
- package/src/hooks/use-window-event.ts +1 -1
- package/src/hooks/with-hooks.test.ts +45 -0
- package/src/index.test.ts +26 -0
- package/src/public-types.type-test.ts +17 -0
- package/tsconfig.json +1 -0
- package/tsconfig.test.json +9 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { useDragGenerator } from "./use-drag-generator.js";
|
|
5
|
+
import type { DragState } from "./use-drag-observe.js";
|
|
6
|
+
import { createHost, pointerCancel, pointerDown, pointerMove, pointerUp, render, unmount } from "./test-host.test-utils.js";
|
|
7
|
+
|
|
8
|
+
async function collectTypes(generator: AsyncGenerator<DragState>): Promise<DragState["type"][]> {
|
|
9
|
+
const types: DragState["type"][] = [];
|
|
10
|
+
for await (const value of generator) types.push(value.type);
|
|
11
|
+
return types;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("useDragGenerator", () => {
|
|
15
|
+
it("calls back once per drag with the start event and a generator that ends with the drag", async () => {
|
|
16
|
+
const host = createHost();
|
|
17
|
+
const callback = vi.fn();
|
|
18
|
+
render(host, () => useDragGenerator({}, [], callback));
|
|
19
|
+
pointerDown(host);
|
|
20
|
+
pointerMove(20);
|
|
21
|
+
pointerUp(30);
|
|
22
|
+
|
|
23
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
24
|
+
const [generator, startEvent] = callback.mock.calls[0];
|
|
25
|
+
expect(startEvent).toBeInstanceOf(PointerEvent);
|
|
26
|
+
expect((startEvent as PointerEvent).clientX).toBe(20);
|
|
27
|
+
expect(await collectTypes(generator)).toEqual(expect.arrayContaining(["move", "end"]));
|
|
28
|
+
unmount(host);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("ends the generator when the drag is cancelled", async () => {
|
|
32
|
+
const host = createHost();
|
|
33
|
+
const callback = vi.fn();
|
|
34
|
+
render(host, () => useDragGenerator({}, [], callback));
|
|
35
|
+
pointerDown(host);
|
|
36
|
+
pointerMove(20);
|
|
37
|
+
pointerCancel();
|
|
38
|
+
|
|
39
|
+
const types = await collectTypes(callback.mock.calls[0][0]);
|
|
40
|
+
expect(types.at(-1)).toBe("cancel");
|
|
41
|
+
unmount(host);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("starts a new generator for each drag", () => {
|
|
45
|
+
const host = createHost();
|
|
46
|
+
const callback = vi.fn();
|
|
47
|
+
render(host, () => useDragGenerator({}, [], callback));
|
|
48
|
+
for (const x of [20, 40]) {
|
|
49
|
+
pointerDown(host);
|
|
50
|
+
pointerMove(x);
|
|
51
|
+
pointerUp(x);
|
|
52
|
+
}
|
|
53
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
54
|
+
expect(callback.mock.calls[0][0]).not.toBe(callback.mock.calls[1][0]);
|
|
55
|
+
unmount(host);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("stops calling back after unmount", () => {
|
|
59
|
+
const host = createHost();
|
|
60
|
+
const callback = vi.fn();
|
|
61
|
+
render(host, () => useDragGenerator({}, [], callback));
|
|
62
|
+
unmount(host);
|
|
63
|
+
pointerDown(host);
|
|
64
|
+
pointerMove(20);
|
|
65
|
+
expect(callback).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { useDragObserve, type DragState } from "./use-drag-observe.js";
|
|
5
|
+
import { createHost, pointerCancel, pointerDown, pointerMove, pointerUp, render, unmount } from "./test-host.test-utils.js";
|
|
6
|
+
|
|
7
|
+
describe("useDragObserve", () => {
|
|
8
|
+
it("emits start, move, and end with positions and deltas", () => {
|
|
9
|
+
const host = createHost();
|
|
10
|
+
const events: DragState[] = [];
|
|
11
|
+
render(host, () => useDragObserve({}, []))(event => events.push(event));
|
|
12
|
+
pointerDown(host);
|
|
13
|
+
pointerMove(20);
|
|
14
|
+
pointerUp(30);
|
|
15
|
+
|
|
16
|
+
expect(events.map(event => event.type)).toEqual(["start", "move", "end"]);
|
|
17
|
+
expect(events[0].type === "start" && events[0].event).toBeInstanceOf(PointerEvent);
|
|
18
|
+
expect(events[1]).toEqual({ type: "move", position: [20, 0], delta: [20, 0] });
|
|
19
|
+
expect(events[2]).toEqual({ type: "end", position: [30, 0], delta: [30, 0] });
|
|
20
|
+
unmount(host);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("emits cancel when the pointer is cancelled", () => {
|
|
24
|
+
const host = createHost();
|
|
25
|
+
const types: DragState["type"][] = [];
|
|
26
|
+
render(host, () => useDragObserve({}, []))(event => types.push(event.type));
|
|
27
|
+
pointerDown(host);
|
|
28
|
+
pointerMove(20);
|
|
29
|
+
pointerCancel();
|
|
30
|
+
expect(types).toEqual(["start", "move", "cancel"]);
|
|
31
|
+
unmount(host);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns the same stream across renders and delivers drag events to it", () => {
|
|
35
|
+
const host = createHost();
|
|
36
|
+
const first = render(host, () => useDragObserve({}, []));
|
|
37
|
+
const second = render(host, () => useDragObserve({}, []));
|
|
38
|
+
expect(second).toBe(first);
|
|
39
|
+
|
|
40
|
+
const types: DragState["type"][] = [];
|
|
41
|
+
second(event => types.push(event.type));
|
|
42
|
+
pointerDown(host);
|
|
43
|
+
pointerMove(20);
|
|
44
|
+
pointerUp(20);
|
|
45
|
+
expect(types).toEqual(["start", "move", "end"]);
|
|
46
|
+
unmount(host);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns a new stream when dependencies change", () => {
|
|
50
|
+
const host = createHost();
|
|
51
|
+
const first = render(host, () => useDragObserve({}, [1]));
|
|
52
|
+
const second = render(host, () => useDragObserve({}, [2]));
|
|
53
|
+
expect(second).not.toBe(first);
|
|
54
|
+
|
|
55
|
+
const types: DragState["type"][] = [];
|
|
56
|
+
second(event => types.push(event.type));
|
|
57
|
+
pointerDown(host);
|
|
58
|
+
pointerMove(20);
|
|
59
|
+
pointerUp(20);
|
|
60
|
+
expect(types).toEqual(["start", "move", "end"]);
|
|
61
|
+
unmount(host);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("applies minDragDistance", () => {
|
|
65
|
+
const host = createHost();
|
|
66
|
+
const types: DragState["type"][] = [];
|
|
67
|
+
render(host, () => useDragObserve({ minDragDistance: 50 }, []))(event => types.push(event.type));
|
|
68
|
+
pointerDown(host);
|
|
69
|
+
pointerMove(20);
|
|
70
|
+
pointerUp(20);
|
|
71
|
+
expect(types).toEqual([]);
|
|
72
|
+
unmount(host);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { DraggableProps, useDraggable } from './use-draggable.js';
|
|
4
4
|
import { useElement } from './use-element.js';
|
|
5
|
+
import { useMemo } from './use-memo.js';
|
|
5
6
|
import { Observe } from '@adobe/data/observe';
|
|
6
7
|
import { Vec2 } from '@adobe/data/math';
|
|
7
8
|
|
|
@@ -37,7 +38,7 @@ export type DragState = DragStart | DragMove | DragEnd;
|
|
|
37
38
|
* @returns
|
|
38
39
|
*/
|
|
39
40
|
export function useDragObserve(props: DragObserveProps, dependencies: unknown[]) {
|
|
40
|
-
const [dragState, setDragState] = Observe.createEvent<DragState>();
|
|
41
|
+
const [dragState, setDragState] = useMemo(() => Observe.createEvent<DragState>(), dependencies);
|
|
41
42
|
const element = useElement();
|
|
42
43
|
useDraggable(element,
|
|
43
44
|
{
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { AsyncArgsProvider } from "@adobe/data/ecs";
|
|
5
|
+
import { useDragTransaction } from "./use-drag-transaction.js";
|
|
6
|
+
import type { DragEnd, DragMove } from "./use-drag-observe.js";
|
|
7
|
+
import { createHost, pointerCancel, pointerDown, pointerMove, pointerUp, render, unmount } from "./test-host.test-utils.js";
|
|
8
|
+
|
|
9
|
+
type Args = { x: number };
|
|
10
|
+
|
|
11
|
+
function mountTransaction(update: (drag: DragMove | DragEnd) => Args | void = drag => ({ x: drag.position[0] })) {
|
|
12
|
+
const host = createHost();
|
|
13
|
+
const providers: AsyncArgsProvider<Args>[] = [];
|
|
14
|
+
render(host, () => useDragTransaction<Args>({ transaction: provider => { providers.push(provider); }, update }, []));
|
|
15
|
+
const args = (index = 0) => providers[index]() as AsyncGenerator<Args>;
|
|
16
|
+
return { host, providers, args };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function collect(generator: AsyncGenerator<Args>): Promise<Args[]> {
|
|
20
|
+
const values: Args[] = [];
|
|
21
|
+
for await (const value of generator) values.push(value);
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("useDragTransaction", () => {
|
|
26
|
+
it("yields move and end arguments and completes at the end", async () => {
|
|
27
|
+
const { host, args } = mountTransaction();
|
|
28
|
+
pointerDown(host);
|
|
29
|
+
pointerMove(20);
|
|
30
|
+
pointerUp(50);
|
|
31
|
+
expect(await collect(args())).toEqual([{ x: 20 }, { x: 50 }]);
|
|
32
|
+
unmount(host);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("skips drags for which update returns nothing and still completes", async () => {
|
|
36
|
+
const { host, args } = mountTransaction(drag => (drag.type === "move" ? { x: drag.position[0] } : undefined));
|
|
37
|
+
pointerDown(host);
|
|
38
|
+
pointerMove(20);
|
|
39
|
+
pointerUp(50);
|
|
40
|
+
expect(await collect(args())).toEqual([{ x: 20 }]);
|
|
41
|
+
unmount(host);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects a pending read when the drag is cancelled", async () => {
|
|
45
|
+
const { host, args } = mountTransaction();
|
|
46
|
+
pointerDown(host);
|
|
47
|
+
pointerMove(20);
|
|
48
|
+
const generator = args();
|
|
49
|
+
expect(await generator.next()).toEqual({ done: false, value: { x: 20 } });
|
|
50
|
+
|
|
51
|
+
const pending = generator.next();
|
|
52
|
+
pointerCancel();
|
|
53
|
+
await expect(pending).rejects.toThrow("Drag cancelled");
|
|
54
|
+
unmount(host);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects a pending read when unmounted mid-drag", async () => {
|
|
58
|
+
const { host, args } = mountTransaction();
|
|
59
|
+
pointerDown(host);
|
|
60
|
+
pointerMove(20);
|
|
61
|
+
const generator = args();
|
|
62
|
+
await generator.next();
|
|
63
|
+
|
|
64
|
+
const pending = generator.next();
|
|
65
|
+
unmount(host);
|
|
66
|
+
await expect(pending).rejects.toThrow("Drag cancelled");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("starts one transaction per drag", async () => {
|
|
70
|
+
const { host, providers, args } = mountTransaction();
|
|
71
|
+
pointerDown(host);
|
|
72
|
+
pointerMove(20);
|
|
73
|
+
pointerUp(20);
|
|
74
|
+
pointerDown(host);
|
|
75
|
+
pointerMove(40);
|
|
76
|
+
pointerUp(40);
|
|
77
|
+
expect(providers).toHaveLength(2);
|
|
78
|
+
expect(await collect(args(1))).toEqual([{ x: 40 }, { x: 40 }]);
|
|
79
|
+
unmount(host);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not start a transaction for a press below the drag threshold", () => {
|
|
83
|
+
const { host, providers } = mountTransaction();
|
|
84
|
+
pointerDown(host);
|
|
85
|
+
pointerUp(2);
|
|
86
|
+
expect(providers).toHaveLength(0);
|
|
87
|
+
unmount(host);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("lets an async transaction settle after cancellation without an unhandled rejection", async () => {
|
|
91
|
+
const host = createHost();
|
|
92
|
+
let settled: Promise<void> | undefined;
|
|
93
|
+
render(host, () => useDragTransaction<Args>({
|
|
94
|
+
transaction: provider => {
|
|
95
|
+
settled = (async () => {
|
|
96
|
+
for await (const _ of provider() as AsyncGenerator<Args>) { /* consume */ }
|
|
97
|
+
})();
|
|
98
|
+
return settled;
|
|
99
|
+
},
|
|
100
|
+
update: drag => ({ x: drag.position[0] }),
|
|
101
|
+
}, []));
|
|
102
|
+
pointerDown(host);
|
|
103
|
+
pointerMove(20);
|
|
104
|
+
pointerCancel();
|
|
105
|
+
await expect(settled).rejects.toThrow("Drag cancelled");
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve));
|
|
107
|
+
unmount(host);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
2
|
|
|
3
|
-
import { DragEnd, DragMove, DragObserveProps, useDragObserve } from './use-drag-observe.js';
|
|
3
|
+
import { DragEnd, DragMove, DragObserveProps, DragState, useDragObserve } from './use-drag-observe.js';
|
|
4
4
|
import { useEffect } from "./use-effect.js";
|
|
5
5
|
import type { AsyncArgsProvider } from '@adobe/data/ecs';
|
|
6
6
|
import { Observe } from '@adobe/data/observe';
|
|
@@ -10,37 +10,58 @@ export type DragTransactionProps<T extends {} | null> = DragObserveProps & {
|
|
|
10
10
|
update: (drag: DragMove | DragEnd) => T | void;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
// Thrown into a drag's argument generator so the transaction discards its transient state.
|
|
14
|
+
const dragCancelled = new Error('Drag cancelled');
|
|
15
|
+
|
|
16
|
+
async function* toTransactionArgs<T extends {} | null>(
|
|
17
|
+
drags: AsyncGenerator<DragState>,
|
|
18
|
+
update: DragTransactionProps<T>['update'],
|
|
19
|
+
): AsyncGenerator<T> {
|
|
20
|
+
for await (const drag of drags) {
|
|
21
|
+
if (drag.type === 'move' || drag.type === 'end') {
|
|
22
|
+
const args = update(drag);
|
|
23
|
+
if (args !== undefined) {
|
|
24
|
+
yield args;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Drives `transaction` with one argument generator per drag: each move or end
|
|
32
|
+
* yields `update(drag)` as intermediate arguments, the end commits the last
|
|
33
|
+
* arguments, and a cancellation (including unmount mid-drag) discards them.
|
|
34
|
+
*/
|
|
13
35
|
export function useDragTransaction<T extends {} | null>(props: DragTransactionProps<T>, dependencies: unknown[]) {
|
|
14
|
-
const { transaction, update } = props;
|
|
15
36
|
const dragObserve = useDragObserve(props, dependencies);
|
|
16
|
-
const startDragTransaction = () => {
|
|
17
|
-
let done = false;
|
|
18
|
-
// we start the transaction by calling it with an async generator that will yield the args asynchronously
|
|
19
|
-
transaction(
|
|
20
|
-
// we create the async generator by mapping the dragObserve state changes to transaction args
|
|
21
|
-
() =>
|
|
22
|
-
Observe.toAsyncGenerator(
|
|
23
|
-
// this uses the withMap function and the provided update and finish functions
|
|
24
|
-
// any type 'start' will just be ignored and filtered out
|
|
25
|
-
Observe.withFilter(dragObserve, value => {
|
|
26
|
-
if (value.type === 'end') {
|
|
27
|
-
done = true;
|
|
28
|
-
return update(value);
|
|
29
|
-
}
|
|
30
|
-
if (value.type === 'move') {
|
|
31
|
-
return update(value);
|
|
32
|
-
}
|
|
33
|
-
}),
|
|
34
|
-
_value => done
|
|
35
|
-
)
|
|
36
|
-
);
|
|
37
|
-
};
|
|
38
|
-
// now we will observe the drag state and start a new transaction whenever a drag starts
|
|
39
37
|
useEffect(() => {
|
|
40
|
-
|
|
38
|
+
const { transaction, update } = props;
|
|
39
|
+
let active: AsyncGenerator<DragState> | undefined;
|
|
40
|
+
const cancel = () => {
|
|
41
|
+
const drags = active;
|
|
42
|
+
active = undefined;
|
|
43
|
+
drags?.throw(dragCancelled).catch(() => undefined);
|
|
44
|
+
};
|
|
45
|
+
const unobserve = dragObserve(value => {
|
|
41
46
|
if (value.type === 'start') {
|
|
42
|
-
|
|
47
|
+
cancel();
|
|
48
|
+
const drags = active = Observe.toAsyncGenerator(
|
|
49
|
+
dragObserve,
|
|
50
|
+
v => v.type === 'end' || v.type === 'cancel',
|
|
51
|
+
);
|
|
52
|
+
const result: unknown = transaction(() => toTransactionArgs(drags, update));
|
|
53
|
+
Promise.resolve(result).catch(error => {
|
|
54
|
+
if (error !== dragCancelled) throw error;
|
|
55
|
+
});
|
|
56
|
+
} else if (value.type === 'end') {
|
|
57
|
+
active = undefined;
|
|
58
|
+
} else if (value.type === 'cancel') {
|
|
59
|
+
cancel();
|
|
43
60
|
}
|
|
44
61
|
});
|
|
62
|
+
return () => {
|
|
63
|
+
unobserve();
|
|
64
|
+
cancel();
|
|
65
|
+
};
|
|
45
66
|
}, dependencies);
|
|
46
67
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { Vec2 } from "@adobe/data/math";
|
|
5
|
+
import { useDraggable, type DraggableProps } from "./use-draggable.js";
|
|
6
|
+
import { createHost, pointerCancel, pointerDown, pointerMove, pointerUp, render, unmount } from "./test-host.test-utils.js";
|
|
7
|
+
|
|
8
|
+
function mountDraggable(options: Partial<DraggableProps> = {}, onDragStart: () => Vec2 | void = () => {}) {
|
|
9
|
+
const host = createHost();
|
|
10
|
+
const parent = document.createElement("section");
|
|
11
|
+
parent.append(host);
|
|
12
|
+
const props = {
|
|
13
|
+
onDragStart: vi.fn(onDragStart),
|
|
14
|
+
onDrag: vi.fn(),
|
|
15
|
+
onDragEnd: vi.fn(),
|
|
16
|
+
onDragCancel: vi.fn(),
|
|
17
|
+
...options,
|
|
18
|
+
};
|
|
19
|
+
render(host, () => useDraggable(host, props, []));
|
|
20
|
+
return { host, parent, props };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("useDraggable", () => {
|
|
24
|
+
it("offsets the start position from onDragStart and ends at the pointerup coordinates", () => {
|
|
25
|
+
const { host, props } = mountDraggable({}, () => [100, 200]);
|
|
26
|
+
pointerDown(host);
|
|
27
|
+
pointerMove(20);
|
|
28
|
+
pointerUp(50);
|
|
29
|
+
|
|
30
|
+
expect(props.onDrag).toHaveBeenCalledWith(expect.any(PointerEvent), [120, 200], [20, 0]);
|
|
31
|
+
expect(props.onDragEnd).toHaveBeenCalledWith(expect.any(PointerEvent), [150, 200], [50, 0]);
|
|
32
|
+
unmount(host);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("starts from the element offset when onDragStart returns nothing", () => {
|
|
36
|
+
const { host, props } = mountDraggable();
|
|
37
|
+
Object.defineProperty(host, "offsetLeft", { value: 7 });
|
|
38
|
+
Object.defineProperty(host, "offsetTop", { value: 3 });
|
|
39
|
+
pointerDown(host);
|
|
40
|
+
pointerMove(20);
|
|
41
|
+
expect(props.onDrag).toHaveBeenCalledWith(expect.any(PointerEvent), [27, 3], [20, 0]);
|
|
42
|
+
pointerUp(20);
|
|
43
|
+
unmount(host);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("starts once the pointer has moved at least minDragDistance", () => {
|
|
47
|
+
const { host, props } = mountDraggable({ minDragDistance: 5 });
|
|
48
|
+
pointerDown(host);
|
|
49
|
+
pointerMove(4);
|
|
50
|
+
expect(props.onDragStart).not.toHaveBeenCalled();
|
|
51
|
+
pointerMove(5);
|
|
52
|
+
expect(props.onDragStart).toHaveBeenCalledTimes(1);
|
|
53
|
+
pointerMove(6);
|
|
54
|
+
expect(props.onDragStart).toHaveBeenCalledTimes(1);
|
|
55
|
+
pointerUp(6);
|
|
56
|
+
unmount(host);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not end or cancel a press released before the threshold", () => {
|
|
60
|
+
const { host, props } = mountDraggable();
|
|
61
|
+
pointerDown(host);
|
|
62
|
+
pointerMove(3);
|
|
63
|
+
pointerUp(3);
|
|
64
|
+
expect(props.onDragEnd).not.toHaveBeenCalled();
|
|
65
|
+
expect(props.onDragCancel).not.toHaveBeenCalled();
|
|
66
|
+
unmount(host);
|
|
67
|
+
expect(props.onDragCancel).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("sets the drag cursor and restores the original cursor at the end", () => {
|
|
71
|
+
const { host } = mountDraggable({ dragCursor: "move" });
|
|
72
|
+
host.style.cursor = "pointer";
|
|
73
|
+
pointerDown(host);
|
|
74
|
+
pointerMove(20);
|
|
75
|
+
expect(host.style.cursor).toBe("move");
|
|
76
|
+
pointerUp(20);
|
|
77
|
+
expect(host.style.cursor).toBe("pointer");
|
|
78
|
+
unmount(host);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("adds a placeholder to the parent while dragging when requested", () => {
|
|
82
|
+
const { host, parent } = mountDraggable({ addPlaceholder: true });
|
|
83
|
+
pointerDown(host);
|
|
84
|
+
pointerMove(20);
|
|
85
|
+
expect(parent.children).toHaveLength(2);
|
|
86
|
+
pointerUp(20);
|
|
87
|
+
expect(parent.children).toHaveLength(1);
|
|
88
|
+
unmount(host);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("stops propagation of its pointer events when requested", () => {
|
|
92
|
+
const { host } = mountDraggable({ stopPropagation: true });
|
|
93
|
+
const parentListener = vi.fn();
|
|
94
|
+
host.parentElement!.addEventListener("pointerdown", parentListener);
|
|
95
|
+
pointerDown(host);
|
|
96
|
+
expect(parentListener).not.toHaveBeenCalled();
|
|
97
|
+
pointerUp(0);
|
|
98
|
+
unmount(host);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("cancels on pointercancel, restores the cursor, and stops delivering moves", () => {
|
|
102
|
+
const { host, props } = mountDraggable();
|
|
103
|
+
pointerDown(host);
|
|
104
|
+
pointerMove(20);
|
|
105
|
+
expect(host.style.cursor).toBe("grab");
|
|
106
|
+
|
|
107
|
+
pointerCancel();
|
|
108
|
+
pointerMove(30);
|
|
109
|
+
pointerUp(30);
|
|
110
|
+
|
|
111
|
+
expect(props.onDragCancel).toHaveBeenCalledTimes(1);
|
|
112
|
+
expect(props.onDrag).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(props.onDragEnd).not.toHaveBeenCalled();
|
|
114
|
+
expect(host.style.cursor).toBe("");
|
|
115
|
+
unmount(host);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("ignores secondary buttons and other pointers", () => {
|
|
119
|
+
const { host, props } = mountDraggable();
|
|
120
|
+
pointerDown(host, 0, 1, 2);
|
|
121
|
+
pointerMove(20);
|
|
122
|
+
expect(props.onDragStart).not.toHaveBeenCalled();
|
|
123
|
+
|
|
124
|
+
pointerDown(host);
|
|
125
|
+
pointerDown(host, 0, 2);
|
|
126
|
+
pointerMove(20, 2);
|
|
127
|
+
pointerUp(20, 2);
|
|
128
|
+
expect(props.onDragStart).not.toHaveBeenCalled();
|
|
129
|
+
pointerMove(20);
|
|
130
|
+
pointerUp(20);
|
|
131
|
+
expect(props.onDragEnd).toHaveBeenCalledTimes(1);
|
|
132
|
+
unmount(host);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("cancels an active drag on unmount and stops listening", () => {
|
|
136
|
+
const { host, props } = mountDraggable();
|
|
137
|
+
pointerDown(host);
|
|
138
|
+
pointerMove(20);
|
|
139
|
+
unmount(host);
|
|
140
|
+
|
|
141
|
+
expect(props.onDragCancel).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(host.style.cursor).toBe("");
|
|
143
|
+
pointerMove(30);
|
|
144
|
+
pointerDown(host);
|
|
145
|
+
pointerMove(60);
|
|
146
|
+
expect(props.onDrag).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -25,86 +25,111 @@ export interface DraggableProps {
|
|
|
25
25
|
export function useDraggable(element: HTMLElement, props: DraggableProps, dependencies: unknown[]) {
|
|
26
26
|
const { minDragDistance = 10, dragCursor = 'grab', addPlaceholder = false, stopPropagation = false } = props;
|
|
27
27
|
useEffect(() => {
|
|
28
|
-
|
|
29
|
-
//
|
|
28
|
+
const minDragDistanceSquared = minDragDistance * minDragDistance;
|
|
29
|
+
// The pointer that started the session; other pointers are ignored until it ends.
|
|
30
|
+
let pointerId: number | null = null;
|
|
31
|
+
let downPosition: Vec2 = [0, 0];
|
|
32
|
+
// The element position when the drag started, or null before the threshold is crossed.
|
|
30
33
|
let dragStartOffset: Vec2 | null = null;
|
|
31
34
|
let originalCursor = '';
|
|
32
35
|
let placeholder: HTMLElement | null = null;
|
|
33
|
-
|
|
34
|
-
function notify(e: PointerEvent, dragListener: DraggableProps["onDrag"]) {
|
|
35
|
-
const delta =
|
|
36
|
-
dragListener(e, Vec2.add(
|
|
36
|
+
|
|
37
|
+
function notify(e: PointerEvent, start: Vec2, dragListener: DraggableProps["onDrag"]) {
|
|
38
|
+
const delta: Vec2 = [e.clientX - downPosition[0], e.clientY - downPosition[1]];
|
|
39
|
+
dragListener(e, Vec2.add(start, delta), delta);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function startDrag(e: PointerEvent): Vec2 {
|
|
43
|
+
const offset: Vec2 = [element.offsetLeft, element.offsetTop];
|
|
44
|
+
const start = (props.onDragStart(e) as Vec2 | undefined) ?? offset;
|
|
45
|
+
dragStartOffset = start;
|
|
46
|
+
if (dragCursor) {
|
|
47
|
+
originalCursor = element.style.cursor;
|
|
48
|
+
element.style.cursor = dragCursor;
|
|
49
|
+
}
|
|
50
|
+
// add a placeholder so the parent element doesn't change size when the element is dragged.
|
|
51
|
+
if (addPlaceholder) {
|
|
52
|
+
placeholder = document.createElement('div');
|
|
53
|
+
Object.assign(placeholder.style, {
|
|
54
|
+
position: 'absolute',
|
|
55
|
+
backgroundColor: 'pink',
|
|
56
|
+
left: toCssUnitString(element.offsetLeft),
|
|
57
|
+
top: toCssUnitString(element.offsetTop),
|
|
58
|
+
width: toCssUnitString(element.offsetWidth),
|
|
59
|
+
height: toCssUnitString(element.offsetHeight),
|
|
60
|
+
visibility: 'hidden',
|
|
61
|
+
});
|
|
62
|
+
element.parentElement?.appendChild(placeholder);
|
|
63
|
+
}
|
|
64
|
+
return start;
|
|
37
65
|
}
|
|
38
66
|
|
|
39
67
|
function onPointerMove(e: PointerEvent) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
element.style.cursor = dragCursor;
|
|
48
|
-
}
|
|
49
|
-
// add a placeholder so the parent element doesn't change size when the element is dragged.
|
|
50
|
-
if (addPlaceholder) {
|
|
51
|
-
placeholder = document.createElement('div');
|
|
52
|
-
Object.assign(placeholder.style, {
|
|
53
|
-
position: 'absolute',
|
|
54
|
-
backgroundColor: 'pink',
|
|
55
|
-
left: toCssUnitString(element.offsetLeft),
|
|
56
|
-
top: toCssUnitString(element.offsetTop),
|
|
57
|
-
width: toCssUnitString(element.offsetWidth),
|
|
58
|
-
height: toCssUnitString(element.offsetHeight),
|
|
59
|
-
visibility: 'hidden',
|
|
60
|
-
});
|
|
61
|
-
element.parentElement?.appendChild(placeholder);
|
|
62
|
-
}
|
|
68
|
+
if (e.pointerId !== pointerId) return;
|
|
69
|
+
let start = dragStartOffset;
|
|
70
|
+
if (!start) {
|
|
71
|
+
const dx = e.clientX - downPosition[0];
|
|
72
|
+
const dy = e.clientY - downPosition[1];
|
|
73
|
+
if (dx * dx + dy * dy >= minDragDistanceSquared) {
|
|
74
|
+
start = startDrag(e);
|
|
63
75
|
}
|
|
64
76
|
}
|
|
65
|
-
if (
|
|
66
|
-
notify(e, props.onDrag);
|
|
77
|
+
if (start) {
|
|
78
|
+
notify(e, start, props.onDrag);
|
|
67
79
|
}
|
|
68
80
|
if (stopPropagation) e.stopPropagation();
|
|
69
81
|
}
|
|
70
|
-
|
|
82
|
+
|
|
83
|
+
/** Ends the session and restores the element; returns the drag start if a drag had started. */
|
|
84
|
+
function release(): Vec2 | null {
|
|
71
85
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
72
86
|
window.removeEventListener('pointerup', onPointerUp);
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
88
|
+
window.removeEventListener('pointercancel', onPointerCancel);
|
|
73
89
|
window.removeEventListener('pointermove', onPointerMove);
|
|
74
|
-
|
|
90
|
+
const start = dragStartOffset;
|
|
91
|
+
if (start && dragCursor) {
|
|
75
92
|
element.style.cursor = originalCursor;
|
|
76
93
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
94
|
+
placeholder?.remove();
|
|
95
|
+
placeholder = null;
|
|
96
|
+
pointerId = null;
|
|
97
|
+
dragStartOffset = null;
|
|
98
|
+
return start;
|
|
81
99
|
}
|
|
100
|
+
|
|
82
101
|
function onPointerUp(e: PointerEvent) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
if (e.pointerId !== pointerId) return;
|
|
103
|
+
const start = release();
|
|
104
|
+
if (start && props.onDragEnd) {
|
|
105
|
+
notify(e, start, props.onDragEnd);
|
|
86
106
|
}
|
|
87
107
|
if (stopPropagation) e.stopPropagation();
|
|
88
|
-
downPosition = null;
|
|
89
|
-
dragStartOffset = null;
|
|
90
108
|
}
|
|
109
|
+
|
|
110
|
+
function onPointerCancel(e: PointerEvent) {
|
|
111
|
+
if (e.pointerId !== pointerId) return;
|
|
112
|
+
if (release()) {
|
|
113
|
+
props.onDragCancel?.();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
function onPointerDown(e: PointerEvent) {
|
|
92
118
|
// Only start drag transaction for left mouse button clicks
|
|
93
|
-
if (e.button !== 0) return;
|
|
119
|
+
if (e.button !== 0 || pointerId !== null) return;
|
|
94
120
|
|
|
121
|
+
pointerId = e.pointerId;
|
|
122
|
+
downPosition = [e.clientX, e.clientY];
|
|
95
123
|
window.addEventListener('pointermove', onPointerMove);
|
|
96
124
|
window.addEventListener('pointerup', onPointerUp);
|
|
97
|
-
|
|
125
|
+
window.addEventListener('pointercancel', onPointerCancel);
|
|
98
126
|
if (stopPropagation) e.stopPropagation();
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
element?.addEventListener('pointerdown', onPointerDown);
|
|
102
130
|
return () => {
|
|
103
131
|
element?.removeEventListener('pointerdown', onPointerDown);
|
|
104
|
-
if (
|
|
105
|
-
cleanup();
|
|
106
|
-
}
|
|
107
|
-
if (dragStartOffset) {
|
|
132
|
+
if (pointerId !== null && release()) {
|
|
108
133
|
props.onDragCancel?.();
|
|
109
134
|
}
|
|
110
135
|
};
|