@adobe/data-lit 0.10.31 → 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 +12 -14
- 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-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-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,65 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ReactiveController } from "lit";
|
|
4
|
+
import type { Component } from "./component.js";
|
|
5
|
+
import { installHooksController } from "./hooks-controller.js";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** Disconnects a host and returns its finalization, captured instead of queued. */
|
|
12
|
+
function disconnect(hooks: { dispose?: () => void }[]) {
|
|
13
|
+
const controllers: ReactiveController[] = [];
|
|
14
|
+
const host = Object.assign(new EventTarget(), {
|
|
15
|
+
isConnected: false,
|
|
16
|
+
hooks,
|
|
17
|
+
hookIndex: hooks.length,
|
|
18
|
+
updatedListeners: new Set<() => void>(),
|
|
19
|
+
requestUpdate: vi.fn(),
|
|
20
|
+
addController: (controller: ReactiveController) => controllers.push(controller),
|
|
21
|
+
}) as Component & EventTarget;
|
|
22
|
+
let finalize!: () => void;
|
|
23
|
+
vi.stubGlobal("queueMicrotask", (callback: () => void) => { finalize = callback; });
|
|
24
|
+
installHooksController(host);
|
|
25
|
+
controllers[0].hostDisconnected?.();
|
|
26
|
+
return { host, finalize };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("installHooksController cleanup failures", () => {
|
|
30
|
+
it("disposes every hook and resets slots when a disposer throws", () => {
|
|
31
|
+
const later = vi.fn();
|
|
32
|
+
const { host, finalize } = disconnect([
|
|
33
|
+
{ dispose: () => { throw new Error("first failed"); } },
|
|
34
|
+
{ dispose: later },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
expect(finalize).toThrow("first failed");
|
|
38
|
+
expect(later).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(host.hooks).toEqual([]);
|
|
40
|
+
expect(host.hookIndex).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports multiple disposer failures together", () => {
|
|
44
|
+
const { finalize } = disconnect([
|
|
45
|
+
{ dispose: () => { throw new Error("a"); } },
|
|
46
|
+
{ dispose: () => { throw new Error("b"); } },
|
|
47
|
+
]);
|
|
48
|
+
let caught: unknown;
|
|
49
|
+
try {
|
|
50
|
+
finalize();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
caught = error;
|
|
53
|
+
}
|
|
54
|
+
expect(caught).toBeInstanceOf(AggregateError);
|
|
55
|
+
expect((caught as AggregateError).errors.map(error => (error as Error).message)).toEqual(["a", "b"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not dispose a hook twice after a failed finalization", () => {
|
|
59
|
+
const dispose = vi.fn(() => { throw new Error("failed"); });
|
|
60
|
+
const { finalize } = disconnect([{ dispose }]);
|
|
61
|
+
expect(finalize).toThrow();
|
|
62
|
+
expect(finalize).not.toThrow();
|
|
63
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -20,17 +20,27 @@ function isReactiveHost(host: Component): host is ReactiveHost {
|
|
|
20
20
|
* Finalize a host: dispose every hook slot's cleanup and clear the slots so the
|
|
21
21
|
* next render re-initializes from scratch. Only slots that expose a `dispose`
|
|
22
22
|
* function (effects, subscriptions) are torn down; value slots (state, memo,
|
|
23
|
-
* ref) are simply dropped.
|
|
23
|
+
* ref) are simply dropped. Every disposer runs even if an earlier one throws;
|
|
24
|
+
* the failures are rethrown afterwards.
|
|
24
25
|
*/
|
|
25
26
|
function finalizeHooks(host: Component): void {
|
|
26
27
|
const { hooks } = host;
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
host.hooks = [];
|
|
29
|
+
host.hookIndex = 0;
|
|
30
|
+
const errors: unknown[] = [];
|
|
31
|
+
for (const hook of hooks ?? []) {
|
|
32
|
+
try {
|
|
29
33
|
(hook as { dispose?: () => void } | undefined)?.dispose?.();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
errors.push(error);
|
|
30
36
|
}
|
|
31
37
|
}
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
if (errors.length === 1) {
|
|
39
|
+
throw errors[0];
|
|
40
|
+
}
|
|
41
|
+
if (errors.length > 1) {
|
|
42
|
+
throw new AggregateError(errors, "Hook cleanup failed");
|
|
43
|
+
}
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { Component } from "./component.js";
|
|
4
|
+
import { Component_stack } from "./stack.js";
|
|
5
|
+
|
|
6
|
+
const createComponent = () => ({ hookIndex: 5 }) as unknown as Component;
|
|
7
|
+
|
|
8
|
+
describe("Component_stack", () => {
|
|
9
|
+
it("makes the most recently pushed component active and restores the previous one on pop", () => {
|
|
10
|
+
const outer = createComponent();
|
|
11
|
+
const inner = createComponent();
|
|
12
|
+
Component_stack.push(outer);
|
|
13
|
+
Component_stack.push(inner);
|
|
14
|
+
expect(Component_stack.active()).toBe(inner);
|
|
15
|
+
Component_stack.pop();
|
|
16
|
+
expect(Component_stack.active()).toBe(outer);
|
|
17
|
+
Component_stack.pop();
|
|
18
|
+
expect(Component_stack.active()).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("resets the hook cursor and creates the slot array on push", () => {
|
|
22
|
+
const component = createComponent();
|
|
23
|
+
Component_stack.push(component);
|
|
24
|
+
Component_stack.pop();
|
|
25
|
+
expect(component.hookIndex).toBe(0);
|
|
26
|
+
expect(component.hooks).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps existing hook slots on push", () => {
|
|
30
|
+
const component = createComponent();
|
|
31
|
+
const hooks = [1, 2];
|
|
32
|
+
component.hooks = hooks;
|
|
33
|
+
Component_stack.push(component);
|
|
34
|
+
Component_stack.pop();
|
|
35
|
+
expect(component.hooks).toBe(hooks);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { vi } from "vitest";
|
|
3
|
+
import type { Component } from "./component/component.js";
|
|
4
|
+
import { Component_stack } from "./component/stack.js";
|
|
5
|
+
|
|
6
|
+
export type TestHost = HTMLElement & Component;
|
|
7
|
+
|
|
8
|
+
/** A detached element that hooks can render into; `requestUpdate` is a mock. */
|
|
9
|
+
export function createHost(): TestHost {
|
|
10
|
+
return Object.assign(document.createElement("div"), {
|
|
11
|
+
hookIndex: 0,
|
|
12
|
+
hooks: [] as unknown[],
|
|
13
|
+
updatedListeners: new Set<() => void>(),
|
|
14
|
+
requestUpdate: vi.fn(),
|
|
15
|
+
}) as unknown as TestHost;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function render<T>(host: Component, hooks: () => T): T {
|
|
19
|
+
Component_stack.push(host);
|
|
20
|
+
try {
|
|
21
|
+
return hooks();
|
|
22
|
+
} finally {
|
|
23
|
+
Component_stack.pop();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Disposes and clears every hook slot, as a genuine unmount does. */
|
|
28
|
+
export function unmount(host: Component): void {
|
|
29
|
+
for (const hook of host.hooks) {
|
|
30
|
+
(hook as { dispose?: () => void } | undefined)?.dispose?.();
|
|
31
|
+
}
|
|
32
|
+
host.hooks = [];
|
|
33
|
+
host.hookIndex = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const pointer = (type: string, clientX: number, pointerId: number, button = 0) =>
|
|
37
|
+
new PointerEvent(type, { clientX, clientY: 0, pointerId, button, bubbles: true });
|
|
38
|
+
|
|
39
|
+
export const pointerDown = (target: EventTarget, x = 0, pointerId = 1, button = 0) =>
|
|
40
|
+
target.dispatchEvent(pointer("pointerdown", x, pointerId, button));
|
|
41
|
+
export const pointerMove = (x: number, pointerId = 1) => window.dispatchEvent(pointer("pointermove", x, pointerId));
|
|
42
|
+
export const pointerUp = (x: number, pointerId = 1) => window.dispatchEvent(pointer("pointerup", x, pointerId));
|
|
43
|
+
export const pointerCancel = (pointerId = 1) => window.dispatchEvent(pointer("pointercancel", 0, pointerId));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { useConnected } from "./use-connected.js";
|
|
5
|
+
import { createHost, render, unmount, type TestHost } from "./test-host.test-utils.js";
|
|
6
|
+
|
|
7
|
+
const connected = (host: TestHost) => host.dispatchEvent(new Event("connected"));
|
|
8
|
+
const disconnected = (host: TestHost) => host.dispatchEvent(new Event("disconnected"));
|
|
9
|
+
|
|
10
|
+
function mountConnected(host: TestHost) {
|
|
11
|
+
document.body.append(host);
|
|
12
|
+
return host;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("useConnected", () => {
|
|
16
|
+
it("runs setup immediately when the host is already connected", () => {
|
|
17
|
+
const host = mountConnected(createHost());
|
|
18
|
+
const setup = vi.fn();
|
|
19
|
+
render(host, () => useConnected(setup));
|
|
20
|
+
expect(setup).toHaveBeenCalledTimes(1);
|
|
21
|
+
unmount(host);
|
|
22
|
+
host.remove();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("waits for the connected edge when the host is detached", () => {
|
|
26
|
+
const host = createHost();
|
|
27
|
+
const setup = vi.fn();
|
|
28
|
+
render(host, () => useConnected(setup));
|
|
29
|
+
expect(setup).not.toHaveBeenCalled();
|
|
30
|
+
connected(host);
|
|
31
|
+
expect(setup).toHaveBeenCalledTimes(1);
|
|
32
|
+
unmount(host);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("tears down on each disconnect and sets up again on each connect", () => {
|
|
36
|
+
const host = createHost();
|
|
37
|
+
const events: string[] = [];
|
|
38
|
+
render(host, () => useConnected(() => {
|
|
39
|
+
events.push("setup");
|
|
40
|
+
return () => events.push("teardown");
|
|
41
|
+
}));
|
|
42
|
+
connected(host);
|
|
43
|
+
connected(host);
|
|
44
|
+
disconnected(host);
|
|
45
|
+
disconnected(host);
|
|
46
|
+
connected(host);
|
|
47
|
+
expect(events).toEqual(["setup", "teardown", "setup"]);
|
|
48
|
+
unmount(host);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("uses the latest callback on the next connect without re-subscribing", () => {
|
|
52
|
+
const host = createHost();
|
|
53
|
+
const first = vi.fn();
|
|
54
|
+
const second = vi.fn();
|
|
55
|
+
render(host, () => useConnected(first));
|
|
56
|
+
render(host, () => useConnected(second));
|
|
57
|
+
connected(host);
|
|
58
|
+
expect(first).not.toHaveBeenCalled();
|
|
59
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
60
|
+
unmount(host);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("re-runs setup when dependencies change while connected", () => {
|
|
64
|
+
const host = mountConnected(createHost());
|
|
65
|
+
const events: string[] = [];
|
|
66
|
+
const callback = (n: number) => () => {
|
|
67
|
+
events.push(`setup ${n}`);
|
|
68
|
+
return () => events.push(`teardown ${n}`);
|
|
69
|
+
};
|
|
70
|
+
render(host, () => useConnected(callback(1), [1]));
|
|
71
|
+
render(host, () => useConnected(callback(2), [2]));
|
|
72
|
+
expect(events).toEqual(["setup 1", "teardown 1", "setup 2"]);
|
|
73
|
+
unmount(host);
|
|
74
|
+
host.remove();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("tears down and stops listening on unmount", () => {
|
|
78
|
+
const host = createHost();
|
|
79
|
+
const teardown = vi.fn();
|
|
80
|
+
const setup = vi.fn(() => teardown);
|
|
81
|
+
render(host, () => useConnected(setup));
|
|
82
|
+
connected(host);
|
|
83
|
+
unmount(host);
|
|
84
|
+
connected(host);
|
|
85
|
+
expect(teardown).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(setup).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { vi } from "vitest";
|
|
5
|
+
import { useDebounce } from "./use-debounce.js";
|
|
6
|
+
import { createHost, render, unmount } from "./test-host.test-utils.js";
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("useDebounce", () => {
|
|
17
|
+
it("returns the initial value immediately", () => {
|
|
18
|
+
const host = createHost();
|
|
19
|
+
expect(render(host, () => useDebounce("a", 100))).toBe("a");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns a new value only after the delay", () => {
|
|
23
|
+
const host = createHost();
|
|
24
|
+
render(host, () => useDebounce("a", 100));
|
|
25
|
+
render(host, () => useDebounce("b", 100));
|
|
26
|
+
|
|
27
|
+
vi.advanceTimersByTime(99);
|
|
28
|
+
expect(render(host, () => useDebounce("b", 100))).toBe("a");
|
|
29
|
+
vi.advanceTimersByTime(1);
|
|
30
|
+
expect(render(host, () => useDebounce("b", 100))).toBe("b");
|
|
31
|
+
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
|
|
32
|
+
unmount(host);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("restarts the delay on each change and keeps only the last value", () => {
|
|
36
|
+
const host = createHost();
|
|
37
|
+
render(host, () => useDebounce("a", 100));
|
|
38
|
+
render(host, () => useDebounce("b", 100));
|
|
39
|
+
vi.advanceTimersByTime(60);
|
|
40
|
+
render(host, () => useDebounce("c", 100));
|
|
41
|
+
vi.advanceTimersByTime(60);
|
|
42
|
+
expect(render(host, () => useDebounce("c", 100))).toBe("a");
|
|
43
|
+
vi.advanceTimersByTime(40);
|
|
44
|
+
expect(render(host, () => useDebounce("c", 100))).toBe("c");
|
|
45
|
+
unmount(host);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("restarts the delay when the delay changes", () => {
|
|
49
|
+
const host = createHost();
|
|
50
|
+
render(host, () => useDebounce("a", 100));
|
|
51
|
+
render(host, () => useDebounce("b", 100));
|
|
52
|
+
vi.advanceTimersByTime(50);
|
|
53
|
+
render(host, () => useDebounce("b", 200));
|
|
54
|
+
vi.advanceTimersByTime(150);
|
|
55
|
+
expect(render(host, () => useDebounce("b", 200))).toBe("a");
|
|
56
|
+
vi.advanceTimersByTime(50);
|
|
57
|
+
expect(render(host, () => useDebounce("b", 200))).toBe("b");
|
|
58
|
+
unmount(host);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("cancels the pending update on unmount", () => {
|
|
62
|
+
const host = createHost();
|
|
63
|
+
render(host, () => useDebounce("a", 100));
|
|
64
|
+
render(host, () => useDebounce("b", 100));
|
|
65
|
+
unmount(host);
|
|
66
|
+
vi.advanceTimersByTime(100);
|
|
67
|
+
expect(host.requestUpdate).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -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
|
+
});
|