@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
|
@@ -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
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// @vitest-environment happy-dom
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { useEffect } from "./use-effect.js";
|
|
5
|
+
import { createHost, render, unmount } from "./test-host.test-utils.js";
|
|
6
|
+
|
|
7
|
+
describe("useEffect", () => {
|
|
8
|
+
it("runs synchronously on the first render with the host as this", () => {
|
|
9
|
+
const host = createHost();
|
|
10
|
+
let self: unknown;
|
|
11
|
+
render(host, () => useEffect(function (this: unknown) { self = this; }, []));
|
|
12
|
+
expect(self).toBe(host);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("skips equal dependencies and reruns changed ones, cleaning up first", () => {
|
|
16
|
+
const host = createHost();
|
|
17
|
+
const calls: string[] = [];
|
|
18
|
+
const effect = (n: number) => () => {
|
|
19
|
+
calls.push(`setup ${n}`);
|
|
20
|
+
return () => calls.push(`cleanup ${n}`);
|
|
21
|
+
};
|
|
22
|
+
render(host, () => useEffect(effect(1), [1]));
|
|
23
|
+
render(host, () => useEffect(effect(1), [1]));
|
|
24
|
+
render(host, () => useEffect(effect(2), [2]));
|
|
25
|
+
expect(calls).toEqual(["setup 1", "cleanup 1", "setup 2"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("runs once with the default dependencies", () => {
|
|
29
|
+
const host = createHost();
|
|
30
|
+
const effect = vi.fn();
|
|
31
|
+
render(host, () => useEffect(effect));
|
|
32
|
+
render(host, () => useEffect(effect));
|
|
33
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("runs the new effect when the previous cleanup throws", () => {
|
|
37
|
+
const host = createHost();
|
|
38
|
+
const next = vi.fn();
|
|
39
|
+
render(host, () => useEffect(() => () => { throw new Error("cleanup failed"); }, [1]));
|
|
40
|
+
expect(() => render(host, () => useEffect(next, [2]))).toThrow("cleanup failed");
|
|
41
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("runs its cleanup on unmount", () => {
|
|
45
|
+
const host = createHost();
|
|
46
|
+
const cleanup = vi.fn();
|
|
47
|
+
render(host, () => useEffect(() => cleanup, []));
|
|
48
|
+
unmount(host);
|
|
49
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/hooks/use-effect.ts
CHANGED
|
@@ -13,7 +13,10 @@ export function useEffect<T extends Component>(callback: EffectCallback, depende
|
|
|
13
13
|
const oldHookState = component.hooks[hookIndex] as EffectHookState | undefined;
|
|
14
14
|
const rerunEffect = !oldHookState || !equalsShallow(dependencies, oldHookState.dependencies);
|
|
15
15
|
if (rerunEffect) {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
try {
|
|
17
|
+
oldHookState?.dispose?.();
|
|
18
|
+
} finally {
|
|
19
|
+
component.hooks[hookIndex] = { dispose: callback.call(component) ?? undefined, dependencies };
|
|
20
|
+
}
|
|
18
21
|
}
|
|
19
22
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { LitElement, html, nothing } from "lit";
|
|
4
|
+
import { attachDecorator } from "./attach-decorator.js";
|
|
5
|
+
import { withHooks } from "./with-hooks.js";
|
|
6
|
+
import { useElement } from "./use-element.js";
|
|
7
|
+
|
|
8
|
+
class SelectorProbe extends LitElement {
|
|
9
|
+
selector = "span";
|
|
10
|
+
shadowMatch = false;
|
|
11
|
+
found: Element | null = null;
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
super();
|
|
15
|
+
attachDecorator(this, "render", withHooks);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render() {
|
|
19
|
+
this.found = useElement(this.selector);
|
|
20
|
+
return html`${this.shadowMatch ? html`<span class="shadow"></span>` : nothing}<slot></slot>`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
customElements.define("use-element-probe", SelectorProbe);
|
|
24
|
+
|
|
25
|
+
const flushMutations = () => new Promise<void>(resolve => setTimeout(resolve));
|
|
26
|
+
|
|
27
|
+
async function mount(...children: Element[]): Promise<SelectorProbe> {
|
|
28
|
+
const element = document.createElement("use-element-probe") as SelectorProbe;
|
|
29
|
+
element.append(...children);
|
|
30
|
+
document.body.append(element);
|
|
31
|
+
await element.updateComplete;
|
|
32
|
+
return element;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
document.body.replaceChildren();
|
|
37
|
+
await flushMutations();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("useElement(selector)", () => {
|
|
41
|
+
it("returns an existing light-DOM match on the first render", async () => {
|
|
42
|
+
const span = document.createElement("span");
|
|
43
|
+
const element = await mount(span);
|
|
44
|
+
expect(element.found).toBe(span);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders when a match is inserted and when it is removed", async () => {
|
|
48
|
+
const element = await mount();
|
|
49
|
+
expect(element.found).toBeNull();
|
|
50
|
+
|
|
51
|
+
const span = document.createElement("span");
|
|
52
|
+
element.append(span);
|
|
53
|
+
await flushMutations();
|
|
54
|
+
await element.updateComplete;
|
|
55
|
+
expect(element.found).toBe(span);
|
|
56
|
+
|
|
57
|
+
span.remove();
|
|
58
|
+
await flushMutations();
|
|
59
|
+
await element.updateComplete;
|
|
60
|
+
expect(element.found).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("re-queries when the selector changes", async () => {
|
|
64
|
+
const element = await mount(document.createElement("span"));
|
|
65
|
+
element.selector = "button";
|
|
66
|
+
element.requestUpdate();
|
|
67
|
+
await element.updateComplete;
|
|
68
|
+
expect(element.found).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prefers a shadow-root match over a light-DOM match", async () => {
|
|
72
|
+
const light = document.createElement("span");
|
|
73
|
+
const element = document.createElement("use-element-probe") as SelectorProbe;
|
|
74
|
+
element.shadowMatch = true;
|
|
75
|
+
element.append(light);
|
|
76
|
+
document.body.append(element);
|
|
77
|
+
await element.updateComplete;
|
|
78
|
+
await flushMutations();
|
|
79
|
+
await element.updateComplete;
|
|
80
|
+
expect(element.found).toBe(element.shadowRoot?.querySelector(".shadow"));
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/hooks/use-element.ts
CHANGED
|
@@ -6,78 +6,35 @@ import { useState } from "./use-state.js";
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Hook that returns the currently active component.
|
|
9
|
-
* If a querySelector is provided, it
|
|
10
|
-
*
|
|
9
|
+
* If a querySelector is provided, it returns the first match in the component's
|
|
10
|
+
* shadow root, then its light DOM. The match is re-queried on every render and
|
|
11
|
+
* whenever either tree's children change; a changed match requests a render.
|
|
11
12
|
*/
|
|
12
13
|
export function useElement<K extends keyof HTMLElementTagNameMap>(querySelector: K): HTMLElementTagNameMap[K] | null
|
|
14
|
+
export function useElement<T extends Element = HTMLElement>(querySelector: string): T | null
|
|
13
15
|
export function useElement<T = HTMLElement>(): T
|
|
14
16
|
export function useElement<T = HTMLElement>(querySelector?: string): T | null {
|
|
15
17
|
const component = Component_stack.active();
|
|
16
|
-
|
|
17
|
-
// If no querySelector provided, return the active component
|
|
18
|
+
|
|
18
19
|
if (!querySelector) {
|
|
19
20
|
return component as unknown as T;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
23
|
+
const host = component as unknown as HTMLElement;
|
|
24
|
+
const find = () => (host.shadowRoot?.querySelector(querySelector) ?? host.querySelector(querySelector)) as T | null;
|
|
25
|
+
const [, setFoundElement] = useState<T | null>(null);
|
|
26
|
+
const foundElement = find();
|
|
27
|
+
setFoundElement(foundElement);
|
|
28
|
+
|
|
25
29
|
useEffect(() => {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (existingElement) {
|
|
31
|
-
setFoundElement(existingElement);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Set up MutationObserver to watch for new child elements
|
|
36
|
-
const observer = new MutationObserver((mutations) => {
|
|
37
|
-
for (const mutation of mutations) {
|
|
38
|
-
if (mutation.type === 'childList') {
|
|
39
|
-
// Check if any added nodes match the selector
|
|
40
|
-
for (const node of mutation.addedNodes) {
|
|
41
|
-
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
42
|
-
const element = node as Element;
|
|
43
|
-
// Check if the added node itself matches
|
|
44
|
-
if (element.matches(querySelector)) {
|
|
45
|
-
setFoundElement(element as T);
|
|
46
|
-
observer.disconnect();
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
// Check if any descendant matches
|
|
50
|
-
const descendant = element.querySelector(querySelector) as T | null;
|
|
51
|
-
if (descendant) {
|
|
52
|
-
setFoundElement(descendant);
|
|
53
|
-
observer.disconnect();
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// Start observing both element and shadowRoot
|
|
63
|
-
for (const observe of [element.shadowRoot, element]) {
|
|
64
|
-
if (observe) {
|
|
65
|
-
observer.observe(observe, {
|
|
66
|
-
childList: true,
|
|
67
|
-
subtree: true
|
|
68
|
-
});
|
|
30
|
+
const observer = new MutationObserver(() => setFoundElement(find()));
|
|
31
|
+
for (const root of [host.shadowRoot, host]) {
|
|
32
|
+
if (root) {
|
|
33
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
69
34
|
}
|
|
70
35
|
}
|
|
71
|
-
observer.
|
|
72
|
-
childList: true,
|
|
73
|
-
subtree: true
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// Cleanup function
|
|
77
|
-
return () => {
|
|
78
|
-
observer.disconnect();
|
|
79
|
-
};
|
|
36
|
+
return () => observer.disconnect();
|
|
80
37
|
}, [querySelector]);
|
|
81
|
-
|
|
38
|
+
|
|
82
39
|
return foundElement;
|
|
83
40
|
}
|