@adia-ai/web-components 0.2.2 → 0.2.4
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/components/agent-trace/agent-trace.css +24 -3
- package/components/button/button.js +3 -0
- package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
- package/components/demo-toggle/demo-toggle.css +120 -0
- package/components/demo-toggle/demo-toggle.js +144 -0
- package/components/demo-toggle/demo-toggle.test.js +102 -0
- package/components/demo-toggle/demo-toggle.yaml +144 -0
- package/components/index.js +1 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +66 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +13 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +258 -5
- package/traits/active-state.test.js +1 -1
- package/traits/anchor-positioning.js +205 -52
- package/traits/anchor-positioning.test.js +77 -4
- package/traits/announcer-stage.js +157 -0
- package/traits/announcer.js +145 -0
- package/traits/announcer.test.js +268 -0
- package/traits/arrow-grid-nav.js +234 -0
- package/traits/arrow-grid-nav.test.js +375 -0
- package/traits/attention-pulse.js +1 -1
- package/traits/attention-pulse.test.js +1 -1
- package/traits/confetti-burst.js +90 -60
- package/traits/confetti-burst.test.js +16 -8
- package/traits/confetti-stage.js +143 -0
- package/traits/confetti.js +44 -47
- package/traits/confetti.test.js +24 -5
- package/traits/count-up.js +31 -6
- package/traits/count-up.test.js +1 -1
- package/traits/declarative.test.js +1 -1
- package/traits/dirty-state.test.js +1 -1
- package/traits/drag-ghost.js +55 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +279 -0
- package/traits/draggable-list-item.test.js +51 -0
- package/traits/draggable.js +14 -4
- package/traits/draggable.test.js +1 -1
- package/traits/drop-target.js +223 -0
- package/traits/drop-target.test.js +241 -0
- package/traits/droppable-collection.js +89 -0
- package/traits/droppable-collection.test.js +99 -0
- package/traits/droppable.js +125 -0
- package/traits/droppable.test.js +54 -0
- package/traits/error-shake.js +157 -0
- package/traits/error-shake.test.js +114 -0
- package/traits/fade-presence.test.js +1 -1
- package/traits/focus-restore.js +135 -0
- package/traits/focus-restore.test.js +202 -0
- package/traits/focus-trap.test.js +1 -1
- package/traits/focusable.test.js +1 -1
- package/traits/glow-focus.js +1 -1
- package/traits/glow-focus.test.js +1 -1
- package/traits/gradient-shift.js +1 -1
- package/traits/gradient-shift.test.js +1 -1
- package/traits/haptic-feedback.test.js +1 -1
- package/traits/hotkey.test.js +1 -1
- package/traits/hoverable.test.js +1 -1
- package/traits/index.js +15 -0
- package/traits/inertia-drag.js +9 -0
- package/traits/inertia-drag.test.js +1 -1
- package/traits/input-mask.js +328 -0
- package/traits/input-mask.test.js +151 -0
- package/traits/intersection-observer.test.js +1 -1
- package/traits/keyboard-nav.test.js +1 -1
- package/traits/keyboard-reorderable.js +254 -0
- package/traits/keyboard-reorderable.test.js +45 -0
- package/traits/layout-animation.js +229 -0
- package/traits/layout-animation.test.js +114 -0
- package/traits/long-press.js +212 -0
- package/traits/long-press.test.js +244 -0
- package/traits/magnetic-hover.js +1 -1
- package/traits/magnetic-hover.test.js +1 -1
- package/traits/noise-texture.js +7 -3
- package/traits/noise-texture.test.js +1 -1
- package/traits/parallax.js +1 -1
- package/traits/parallax.test.js +1 -1
- package/traits/portal.test.js +1 -1
- package/traits/pressable.test.js +1 -1
- package/traits/resettable.js +29 -3
- package/traits/resettable.test.js +34 -1
- package/traits/resizable.test.js +1 -1
- package/traits/resize-observer.test.js +1 -1
- package/traits/ripple.js +1 -1
- package/traits/ripple.test.js +1 -1
- package/traits/roving-tabindex.test.js +1 -1
- package/traits/scale-press.test.js +1 -1
- package/traits/scroll-lock.test.js +1 -1
- package/traits/scroll-progress.js +201 -0
- package/traits/scroll-progress.test.js +182 -0
- package/traits/shimmer-loading.js +1 -1
- package/traits/shimmer-loading.test.js +1 -1
- package/traits/{_smoke.test.js → smoke.test.js} +1 -1
- package/traits/snap-to-grid.test.js +1 -1
- package/traits/sound-feedback.test.js +1 -1
- package/traits/spring-animate.js +8 -3
- package/traits/spring-animate.test.js +1 -1
- package/traits/success-checkmark.js +222 -0
- package/traits/success-checkmark.test.js +120 -0
- package/traits/tilt-hover.js +1 -1
- package/traits/tilt-hover.test.js +1 -1
- package/traits/tossable.js +9 -0
- package/traits/tossable.test.js +1 -1
- package/traits/traits-host.test.js +1 -1
- package/traits/typeahead.test.js +1 -1
- package/traits/typewriter.js +1 -1
- package/traits/typewriter.test.js +1 -1
- package/traits/validation.test.js +1 -1
- package/traits/view-transition.js +140 -0
- package/traits/view-transition.test.js +268 -0
- /package/traits/{_motion.js → motion.js} +0 -0
- /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `draggable-list-item` — pointer-driven list-reorder lifter.
|
|
5
|
+
*
|
|
6
|
+
* Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
|
|
7
|
+
* Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'motion-positioning').
|
|
8
|
+
*
|
|
9
|
+
* Different mental model than the existing `draggable` trait:
|
|
10
|
+
*
|
|
11
|
+
* `draggable` (free-positioning): pointer drag → mutate style.translate;
|
|
12
|
+
* emits `drag-end {x, y}`.
|
|
13
|
+
* `draggable-list-item` (reorder): pointer drag → lift the item, signal
|
|
14
|
+
* drop targets to render affordances,
|
|
15
|
+
* then on release the parent rewrites
|
|
16
|
+
* the child order. The source DOES NOT
|
|
17
|
+
* physically move during the drag —
|
|
18
|
+
* only `data-draggable-list-item-lifting`
|
|
19
|
+
* is reflected, with optional `opacity:
|
|
20
|
+
* 0.4` styling left to component CSS
|
|
21
|
+
* (Atlassian Pragmatic DnD convention,
|
|
22
|
+
* SPEC §6.5).
|
|
23
|
+
*
|
|
24
|
+
* Coordinates with sibling traits via document-scoped CustomEvents:
|
|
25
|
+
* dispatch `dnd-lift` — on pointerdown + threshold cross
|
|
26
|
+
* dispatch `dnd-drop-target-change` — when hovered droppable changes
|
|
27
|
+
* dispatch `dnd-drop` — on pointerup over a valid target
|
|
28
|
+
* dispatch `dnd-drop-cancel` — on pointerup off-target / Esc
|
|
29
|
+
*
|
|
30
|
+
* `[droppable]` listens for `dnd-drop-target-change` and self-marks.
|
|
31
|
+
* `[droppable-collection]` listens for `dnd-drop` from any descendant
|
|
32
|
+
* droppable and re-emits `dnd-collection-drop`.
|
|
33
|
+
*
|
|
34
|
+
* Hit-testing is `document.elementFromPoint()` + closest `[data-droppable-id]`
|
|
35
|
+
* walk (no shared registry — DOM is the source of truth).
|
|
36
|
+
*
|
|
37
|
+
* Keyboard alternative is delivered by the sibling `keyboard-reorderable`
|
|
38
|
+
* trait (per WCAG 2.5.7) — both traits consume the shared `announcer-stage.js`
|
|
39
|
+
* aria-live regions so screen-reader narration is single-stream.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { announce } from './announcer-stage.js';
|
|
43
|
+
|
|
44
|
+
// Drop-target-change announcements are debounced at 200 ms (per
|
|
45
|
+
// a11y-live-region-drag-announcements.md research note); other phases
|
|
46
|
+
// (lift / drop / cancel) announce immediately and supersede the pending
|
|
47
|
+
// debounced message. The shared announcer-stage handles the actual
|
|
48
|
+
// aria-live write; we just gate the call here.
|
|
49
|
+
const DEBOUNCE_MS = 200;
|
|
50
|
+
let debounceHandle = null;
|
|
51
|
+
function announceDebounced(message) {
|
|
52
|
+
if (debounceHandle != null) clearTimeout(debounceHandle);
|
|
53
|
+
debounceHandle = setTimeout(() => {
|
|
54
|
+
debounceHandle = null;
|
|
55
|
+
announce(message, 'polite');
|
|
56
|
+
}, DEBOUNCE_MS);
|
|
57
|
+
}
|
|
58
|
+
function announceImmediate(message) {
|
|
59
|
+
if (debounceHandle != null) {
|
|
60
|
+
clearTimeout(debounceHandle);
|
|
61
|
+
debounceHandle = null;
|
|
62
|
+
}
|
|
63
|
+
announce(message, 'polite');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ATTR_LIFTING = 'data-draggable-list-item-lifting';
|
|
67
|
+
const ATTR_SOURCE_ID = 'data-draggable-list-item-id';
|
|
68
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
69
|
+
|
|
70
|
+
/** @type {{ container: Element; index: number } | null} */
|
|
71
|
+
function readSourceContext(host) {
|
|
72
|
+
const container = host.parentElement;
|
|
73
|
+
if (!container) return null;
|
|
74
|
+
const siblings = Array.from(container.children).filter((c) => c.matches?.('[data-draggable-list-item-id]'));
|
|
75
|
+
const index = siblings.indexOf(host);
|
|
76
|
+
return { container, index };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readContainerId(el) {
|
|
80
|
+
// Walk up looking for the nearest `[data-droppable-id]`.
|
|
81
|
+
/** @type {Element | null} */
|
|
82
|
+
let cur = el;
|
|
83
|
+
while (cur && cur !== document.body) {
|
|
84
|
+
if (cur.hasAttribute?.('data-droppable-id')) return cur.getAttribute('data-droppable-id');
|
|
85
|
+
cur = cur.parentElement;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dropTargetFromPoint(x, y) {
|
|
91
|
+
const el = document.elementFromPoint(x, y);
|
|
92
|
+
if (!el) return null;
|
|
93
|
+
/** @type {Element | null} */
|
|
94
|
+
let cur = el;
|
|
95
|
+
while (cur && cur !== document.body) {
|
|
96
|
+
if (cur.hasAttribute?.('data-droppable-id')) return cur;
|
|
97
|
+
cur = cur.parentElement;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function indexWithinTarget(target, x, y) {
|
|
103
|
+
// Compute the insertion index by counting how many sibling list-items
|
|
104
|
+
// start above the pointer's y. Cheap and good-enough for v1; a future
|
|
105
|
+
// pass can compute by midpoint of each child rect.
|
|
106
|
+
const items = Array.from(target.querySelectorAll('[data-draggable-list-item-id]'));
|
|
107
|
+
for (let i = 0; i < items.length; i++) {
|
|
108
|
+
const r = items[i].getBoundingClientRect();
|
|
109
|
+
if (y < r.top + r.height / 2) return i;
|
|
110
|
+
}
|
|
111
|
+
return items.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const draggableListItem = defineTrait({
|
|
115
|
+
name: 'draggable-list-item',
|
|
116
|
+
category: 'motion-positioning',
|
|
117
|
+
description: 'Pointer drag for list reordering; emits dnd-lift/drop-target-change/drop/drop-cancel',
|
|
118
|
+
attributes: [ATTR_LIFTING, ATTR_SOURCE_ID],
|
|
119
|
+
events: ['dnd-lift', 'dnd-drop-target-change', 'dnd-drop', 'dnd-drop-cancel'],
|
|
120
|
+
config: [],
|
|
121
|
+
setup({ host }) {
|
|
122
|
+
let id = host.getAttribute(ATTR_SOURCE_ID);
|
|
123
|
+
if (!id) {
|
|
124
|
+
id = `dlitem-${crypto.randomUUID().slice(0, 8)}`;
|
|
125
|
+
host.setAttribute(ATTR_SOURCE_ID, id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @type {{ x: number; y: number; pointerId: number; sourceCtx: ReturnType<typeof readSourceContext>; sourceContainerId: string | null } | null} */
|
|
129
|
+
let down = null;
|
|
130
|
+
let lifted = false;
|
|
131
|
+
/** @type {string | null} */
|
|
132
|
+
let lastTargetId = null;
|
|
133
|
+
let lastTargetIndex = -1;
|
|
134
|
+
|
|
135
|
+
function isDisabled() {
|
|
136
|
+
return host.hasAttribute('disabled') || host.getAttribute('aria-disabled') === 'true';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reset() {
|
|
140
|
+
if (down && host.hasPointerCapture?.(down.pointerId)) {
|
|
141
|
+
try { host.releasePointerCapture(down.pointerId); } catch { /* ignore */ }
|
|
142
|
+
}
|
|
143
|
+
down = null;
|
|
144
|
+
lifted = false;
|
|
145
|
+
lastTargetId = null;
|
|
146
|
+
lastTargetIndex = -1;
|
|
147
|
+
host.removeAttribute(ATTR_LIFTING);
|
|
148
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onPointerDown(e) {
|
|
152
|
+
if (isDisabled()) return;
|
|
153
|
+
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
154
|
+
|
|
155
|
+
const sourceCtx = readSourceContext(host);
|
|
156
|
+
if (!sourceCtx) return;
|
|
157
|
+
|
|
158
|
+
down = {
|
|
159
|
+
x: e.clientX,
|
|
160
|
+
y: e.clientY,
|
|
161
|
+
pointerId: e.pointerId,
|
|
162
|
+
sourceCtx,
|
|
163
|
+
sourceContainerId: readContainerId(sourceCtx.container),
|
|
164
|
+
};
|
|
165
|
+
// Hold the pointer so we keep getting move/up even outside the host.
|
|
166
|
+
try { host.setPointerCapture(e.pointerId); } catch { /* ignore */ }
|
|
167
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function onPointerMove(e) {
|
|
171
|
+
if (!down) return;
|
|
172
|
+
const dx = e.clientX - down.x;
|
|
173
|
+
const dy = e.clientY - down.y;
|
|
174
|
+
|
|
175
|
+
if (!lifted) {
|
|
176
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
177
|
+
lifted = true;
|
|
178
|
+
host.setAttribute(ATTR_LIFTING, '');
|
|
179
|
+
host.dispatchEvent(new CustomEvent('dnd-lift', {
|
|
180
|
+
bubbles: true,
|
|
181
|
+
composed: false,
|
|
182
|
+
detail: {
|
|
183
|
+
source_id: id,
|
|
184
|
+
source_index: down.sourceCtx.index,
|
|
185
|
+
source_container_id: down.sourceContainerId,
|
|
186
|
+
},
|
|
187
|
+
}));
|
|
188
|
+
announceImmediate(`Picked up item. Press arrow keys to navigate. Press Space to drop. Press Esc to cancel.`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
|
|
192
|
+
const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
|
|
193
|
+
const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY) : -1;
|
|
194
|
+
|
|
195
|
+
if (targetId !== lastTargetId || targetIdx !== lastTargetIndex) {
|
|
196
|
+
lastTargetId = targetId;
|
|
197
|
+
lastTargetIndex = targetIdx;
|
|
198
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-target-change', {
|
|
199
|
+
bubbles: true,
|
|
200
|
+
composed: false,
|
|
201
|
+
detail: {
|
|
202
|
+
source_id: id,
|
|
203
|
+
target_container_id: targetId,
|
|
204
|
+
target_index: targetIdx,
|
|
205
|
+
pointer: { x: e.clientX, y: e.clientY },
|
|
206
|
+
},
|
|
207
|
+
}));
|
|
208
|
+
if (targetId) announceDebounced(`Drop position ${targetIdx + 1} in ${targetId}.`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function onPointerUp(e) {
|
|
213
|
+
if (!down) return;
|
|
214
|
+
if (!lifted) { reset(); return; }
|
|
215
|
+
|
|
216
|
+
const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
|
|
217
|
+
const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
|
|
218
|
+
|
|
219
|
+
if (!targetId) {
|
|
220
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
221
|
+
bubbles: true,
|
|
222
|
+
composed: false,
|
|
223
|
+
detail: { source_id: id, reason: 'off-target' },
|
|
224
|
+
}));
|
|
225
|
+
announceImmediate(`Drag cancelled. Item returned to its original position.`);
|
|
226
|
+
} else {
|
|
227
|
+
const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY);
|
|
228
|
+
document.dispatchEvent(new CustomEvent('dnd-drop', {
|
|
229
|
+
bubbles: true,
|
|
230
|
+
composed: false,
|
|
231
|
+
detail: {
|
|
232
|
+
source_id: id,
|
|
233
|
+
source_index: down.sourceCtx.index,
|
|
234
|
+
source_container_id: down.sourceContainerId,
|
|
235
|
+
target_container_id: targetId,
|
|
236
|
+
target_index: targetIdx,
|
|
237
|
+
},
|
|
238
|
+
}));
|
|
239
|
+
announceImmediate(`Dropped item in ${targetId} at position ${targetIdx + 1}.`);
|
|
240
|
+
}
|
|
241
|
+
reset();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function onKeyDown(e) {
|
|
245
|
+
if (e.key === 'Escape' && lifted) {
|
|
246
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
247
|
+
bubbles: true,
|
|
248
|
+
composed: false,
|
|
249
|
+
detail: { source_id: id, reason: 'esc' },
|
|
250
|
+
}));
|
|
251
|
+
announceImmediate(`Drag cancelled. Item returned to its original position.`);
|
|
252
|
+
reset();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
host.addEventListener('pointerdown', onPointerDown);
|
|
257
|
+
host.addEventListener('pointermove', onPointerMove);
|
|
258
|
+
host.addEventListener('pointerup', onPointerUp);
|
|
259
|
+
host.addEventListener('pointercancel', () => {
|
|
260
|
+
if (lifted) {
|
|
261
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
262
|
+
bubbles: true,
|
|
263
|
+
composed: false,
|
|
264
|
+
detail: { source_id: id, reason: 'pointer-cancel' },
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
reset();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return () => {
|
|
271
|
+
host.removeEventListener('pointerdown', onPointerDown);
|
|
272
|
+
host.removeEventListener('pointermove', onPointerMove);
|
|
273
|
+
host.removeEventListener('pointerup', onPointerUp);
|
|
274
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
275
|
+
reset();
|
|
276
|
+
host.removeAttribute(ATTR_SOURCE_ID);
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { draggableListItem } from './draggable-list-item.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
// Minimal smoke coverage. The draggable-list-item trait is the pointer
|
|
6
|
+
// driver for the docs/projects/tasks-playground/ DnD model. The cases
|
|
7
|
+
// below exercise only its own surface (schema, attribute lifecycle,
|
|
8
|
+
// id assignment, idempotent disconnect). Full pointer-flow tests need a
|
|
9
|
+
// laid-out DOM with sibling droppables and live elementFromPoint —
|
|
10
|
+
// those belong with the playground integration suite, not the trait
|
|
11
|
+
// gate.
|
|
12
|
+
|
|
13
|
+
describe('draggable-list-item', () => {
|
|
14
|
+
beforeEach(resetDOM);
|
|
15
|
+
|
|
16
|
+
it('schema shape is defined', () => {
|
|
17
|
+
expect(draggableListItem.schema.name).toBe('draggable-list-item');
|
|
18
|
+
expect(draggableListItem.schema.category).toBe('motion-positioning');
|
|
19
|
+
expect(draggableListItem.schema.description.length).toBeGreaterThanOrEqual(11);
|
|
20
|
+
expect(draggableListItem.schema.events).toContain('dnd-lift');
|
|
21
|
+
expect(draggableListItem.schema.events).toContain('dnd-drop');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('connect assigns an id + sets the host attribute', () => {
|
|
25
|
+
const host = mountHost('div');
|
|
26
|
+
connectTrait(draggableListItem, host);
|
|
27
|
+
expect(host.getAttribute('data-draggable-list-item-id')).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('disconnect clears its attributes', () => {
|
|
31
|
+
const host = mountHost('div');
|
|
32
|
+
const inst = draggableListItem();
|
|
33
|
+
inst.connect(host);
|
|
34
|
+
host.setAttribute('data-draggable-list-item-lifting', '');
|
|
35
|
+
inst.disconnect(host);
|
|
36
|
+
expect(host.hasAttribute('data-draggable-list-item-lifting')).toBe(false);
|
|
37
|
+
expect(host.hasAttribute('data-draggable-list-item-id')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('reconnect with the same host does not throw', () => {
|
|
41
|
+
const host = mountHost('div');
|
|
42
|
+
const inst = draggableListItem();
|
|
43
|
+
inst.connect(host);
|
|
44
|
+
inst.disconnect(host);
|
|
45
|
+
expect(() => {
|
|
46
|
+
const inst2 = draggableListItem();
|
|
47
|
+
inst2.connect(host);
|
|
48
|
+
inst2.disconnect(host);
|
|
49
|
+
}).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
package/traits/draggable.js
CHANGED
|
@@ -24,11 +24,21 @@ export const draggable = defineTrait({
|
|
|
24
24
|
startX = e.clientX;
|
|
25
25
|
startY = e.clientY;
|
|
26
26
|
|
|
27
|
-
// Parse current translate values
|
|
27
|
+
// Parse current translate values. The trait writes to `style.translate`
|
|
28
|
+
// (independent of `transform` in the modern CSS model). Read translate
|
|
29
|
+
// first so subsequent drags pick up the current position, then fall back
|
|
30
|
+
// to the transform matrix for callers that nudged via `transform`.
|
|
28
31
|
const style = getComputedStyle(host);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const t = style.translate;
|
|
33
|
+
if (t && t !== 'none') {
|
|
34
|
+
const parts = t.split(/\s+/).map(parseFloat);
|
|
35
|
+
originX = parts[0] || 0;
|
|
36
|
+
originY = parts[1] || 0;
|
|
37
|
+
} else {
|
|
38
|
+
const matrix = new DOMMatrixReadOnly(style.transform);
|
|
39
|
+
originX = matrix.m41;
|
|
40
|
+
originY = matrix.m42;
|
|
41
|
+
}
|
|
32
42
|
|
|
33
43
|
host.setPointerCapture(e.pointerId);
|
|
34
44
|
host.setAttribute('data-draggable-dragging', '');
|
package/traits/draggable.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { draggable } from './draggable.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
function pointer(host, type, x, y) {
|
|
6
6
|
const ev = new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true });
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declarative drop zone — wires the host into the HTML5 drag-and-drop
|
|
5
|
+
* pipeline so a sibling `draggable` / `drag-ghost` source (or a native
|
|
6
|
+
* file drag from the OS) can land on it.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* dragenter → set data-drop-target-over, dispatch `drop-enter`
|
|
10
|
+
* dragover → conditionally call preventDefault() per the
|
|
11
|
+
* `data-drop-target-accepts` filter (calling preventDefault
|
|
12
|
+
* is the platform's own "this target accepts" signal —
|
|
13
|
+
* without it the browser shows a reject cursor + the
|
|
14
|
+
* subsequent `drop` event never fires)
|
|
15
|
+
* dragleave → clear data-drop-target-over, dispatch `drop-leave`
|
|
16
|
+
* drop → dispatch `drop-receive` with { dataTransfer, draggable };
|
|
17
|
+
* consumer is responsible for processing the payload
|
|
18
|
+
*
|
|
19
|
+
* Accept filter (`data-drop-target-accepts`):
|
|
20
|
+
* "" → accepts anything
|
|
21
|
+
* "files" → only file drops (e.dataTransfer.types.includes('Files'))
|
|
22
|
+
* "<csv>" → only the listed MIME types (image/png, application/json…)
|
|
23
|
+
*
|
|
24
|
+
* When a payload is rejected the trait does NOT call preventDefault, so
|
|
25
|
+
* the cursor shows reject. It also sets `data-drop-target-rejected`
|
|
26
|
+
* with a short reason ("type" | "files-only") so consumers can style
|
|
27
|
+
* the rejection feedback (red flash, alert toast, etc.) and dispatches
|
|
28
|
+
* a `drop-rejected` event so reactive code can respond.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const FILES_TYPE = 'Files';
|
|
32
|
+
|
|
33
|
+
function parseAcceptList(raw) {
|
|
34
|
+
if (raw == null) return null; // attribute absent → accept anything
|
|
35
|
+
const trimmed = String(raw).trim();
|
|
36
|
+
if (trimmed === '') return null; // empty string → accept anything
|
|
37
|
+
|
|
38
|
+
// Special token: only file drops.
|
|
39
|
+
if (trimmed.toLowerCase() === 'files') return { mode: 'files' };
|
|
40
|
+
|
|
41
|
+
// Otherwise: csv MIME list. Split, trim, lowercase for case-insensitive
|
|
42
|
+
// compare against dataTransfer.types (which are already lowercase per spec).
|
|
43
|
+
const list = trimmed
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((s) => s.trim().toLowerCase())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
|
|
48
|
+
if (list.length === 0) return null;
|
|
49
|
+
return { mode: 'mime', list };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function evaluateAccept(dataTransfer, parsed) {
|
|
53
|
+
// null / no constraint → accept anything.
|
|
54
|
+
if (!parsed) return { ok: true };
|
|
55
|
+
if (!dataTransfer) return { ok: false, reason: 'no-data' };
|
|
56
|
+
|
|
57
|
+
// dataTransfer.types is a DOMStringList (also indexable like an array).
|
|
58
|
+
// Coerce to a real array for set-membership checks.
|
|
59
|
+
const types = Array.from(dataTransfer.types || []);
|
|
60
|
+
|
|
61
|
+
if (parsed.mode === 'files') {
|
|
62
|
+
return types.includes(FILES_TYPE)
|
|
63
|
+
? { ok: true }
|
|
64
|
+
: { ok: false, reason: 'files-only' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (parsed.mode === 'mime') {
|
|
68
|
+
// dataTransfer carries 'Files' for OS-level file drags AND the file's
|
|
69
|
+
// MIME type once the file objects are inspected. We accept either:
|
|
70
|
+
// a literal type match, or a 'Files' drag whose first item matches.
|
|
71
|
+
const lowerTypes = types.map((t) => t.toLowerCase());
|
|
72
|
+
|
|
73
|
+
// Direct MIME type match (e.g., 'text/plain' from a text drag).
|
|
74
|
+
if (parsed.list.some((m) => lowerTypes.includes(m))) {
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// OS file drag: inspect the items list for file-kind entries.
|
|
79
|
+
if (lowerTypes.includes(FILES_TYPE.toLowerCase()) && dataTransfer.items) {
|
|
80
|
+
const items = Array.from(dataTransfer.items);
|
|
81
|
+
const fileItem = items.find((it) => it.kind === 'file');
|
|
82
|
+
if (fileItem && parsed.list.includes(fileItem.type.toLowerCase())) {
|
|
83
|
+
return { ok: true };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { ok: false, reason: 'type' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const dropTarget = defineTrait({
|
|
94
|
+
name: 'drop-target',
|
|
95
|
+
category: 'motion-positioning',
|
|
96
|
+
description: 'Declarative drop zone: hit-testing, accept-reject, drag-over feedback',
|
|
97
|
+
attributes: ['data-drop-target-active', 'data-drop-target-over', 'data-drop-target-rejected'],
|
|
98
|
+
events: ['drop-enter', 'drop-leave', 'drop-receive', 'drop-rejected'],
|
|
99
|
+
config: ['data-drop-target-accepts'],
|
|
100
|
+
setup({ host }) {
|
|
101
|
+
// dragenter/dragleave fire for descendants too — track depth so we
|
|
102
|
+
// only flip data-drop-target-over off when the pointer truly leaves
|
|
103
|
+
// the host's subtree, not when it crosses into a child element.
|
|
104
|
+
let enterDepth = 0;
|
|
105
|
+
|
|
106
|
+
function isDisabled() {
|
|
107
|
+
return host.hasAttribute('disabled');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readAccepts() {
|
|
111
|
+
return parseAcceptList(host.getAttribute('data-drop-target-accepts'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function clearOver() {
|
|
115
|
+
enterDepth = 0;
|
|
116
|
+
host.removeAttribute('data-drop-target-over');
|
|
117
|
+
host.removeAttribute('data-drop-target-rejected');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onDragEnter(e) {
|
|
121
|
+
if (isDisabled()) return;
|
|
122
|
+
enterDepth++;
|
|
123
|
+
if (enterDepth === 1) {
|
|
124
|
+
host.setAttribute('data-drop-target-over', '');
|
|
125
|
+
host.dispatchEvent(new CustomEvent('drop-enter', {
|
|
126
|
+
bubbles: true,
|
|
127
|
+
detail: { dataTransfer: e.dataTransfer },
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onDragOver(e) {
|
|
133
|
+
if (isDisabled()) return;
|
|
134
|
+
|
|
135
|
+
const parsed = readAccepts();
|
|
136
|
+
const verdict = evaluateAccept(e.dataTransfer, parsed);
|
|
137
|
+
|
|
138
|
+
if (verdict.ok) {
|
|
139
|
+
// preventDefault on dragover is the platform contract: "this target
|
|
140
|
+
// accepts this payload". Without it, the drop event never fires
|
|
141
|
+
// and the cursor shows the reject icon.
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
if (host.hasAttribute('data-drop-target-rejected')) {
|
|
144
|
+
host.removeAttribute('data-drop-target-rejected');
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// No preventDefault — cursor shows reject. Surface the reason so
|
|
148
|
+
// CSS can theme the rejection (red flash, etc.).
|
|
149
|
+
if (host.getAttribute('data-drop-target-rejected') !== verdict.reason) {
|
|
150
|
+
host.setAttribute('data-drop-target-rejected', verdict.reason);
|
|
151
|
+
host.dispatchEvent(new CustomEvent('drop-rejected', {
|
|
152
|
+
bubbles: true,
|
|
153
|
+
detail: { reason: verdict.reason, dataTransfer: e.dataTransfer },
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onDragLeave() {
|
|
160
|
+
if (isDisabled()) return;
|
|
161
|
+
enterDepth = Math.max(0, enterDepth - 1);
|
|
162
|
+
if (enterDepth === 0) {
|
|
163
|
+
host.removeAttribute('data-drop-target-over');
|
|
164
|
+
host.removeAttribute('data-drop-target-rejected');
|
|
165
|
+
host.dispatchEvent(new CustomEvent('drop-leave', { bubbles: true }));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onDrop(e) {
|
|
170
|
+
if (isDisabled()) return;
|
|
171
|
+
|
|
172
|
+
const parsed = readAccepts();
|
|
173
|
+
const verdict = evaluateAccept(e.dataTransfer, parsed);
|
|
174
|
+
|
|
175
|
+
if (!verdict.ok) {
|
|
176
|
+
// Defensive — drop should not have fired (no preventDefault on
|
|
177
|
+
// dragover would have suppressed it), but a programmatic dispatch
|
|
178
|
+
// can still land here. Treat as rejection.
|
|
179
|
+
host.setAttribute('data-drop-target-rejected', verdict.reason);
|
|
180
|
+
host.dispatchEvent(new CustomEvent('drop-rejected', {
|
|
181
|
+
bubbles: true,
|
|
182
|
+
detail: { reason: verdict.reason, dataTransfer: e.dataTransfer },
|
|
183
|
+
}));
|
|
184
|
+
clearOver();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// preventDefault on the drop itself stops the browser's default
|
|
189
|
+
// behavior (e.g., navigating to a dropped file's URL).
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
|
|
192
|
+
// The 'draggable' detail field is best-effort — only populated when
|
|
193
|
+
// the drag started from another element in the same document, since
|
|
194
|
+
// OS file drags carry no source element.
|
|
195
|
+
const draggable = e.dataTransfer?.types?.includes?.('text/x-source-id')
|
|
196
|
+
? document.getElementById(e.dataTransfer.getData('text/x-source-id'))
|
|
197
|
+
: null;
|
|
198
|
+
|
|
199
|
+
host.dispatchEvent(new CustomEvent('drop-receive', {
|
|
200
|
+
bubbles: true,
|
|
201
|
+
detail: { dataTransfer: e.dataTransfer, draggable },
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
clearOver();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
host.setAttribute('data-drop-target-active', '');
|
|
208
|
+
host.addEventListener('dragenter', onDragEnter);
|
|
209
|
+
host.addEventListener('dragover', onDragOver);
|
|
210
|
+
host.addEventListener('dragleave', onDragLeave);
|
|
211
|
+
host.addEventListener('drop', onDrop);
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
host.removeEventListener('dragenter', onDragEnter);
|
|
215
|
+
host.removeEventListener('dragover', onDragOver);
|
|
216
|
+
host.removeEventListener('dragleave', onDragLeave);
|
|
217
|
+
host.removeEventListener('drop', onDrop);
|
|
218
|
+
host.removeAttribute('data-drop-target-active');
|
|
219
|
+
host.removeAttribute('data-drop-target-over');
|
|
220
|
+
host.removeAttribute('data-drop-target-rejected');
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
});
|