@adia-ai/web-components 0.2.3 → 0.2.5
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/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/fields/fields.a2ui.json +106 -0
- package/components/fields/fields.css +60 -0
- package/components/fields/fields.js +96 -0
- package/components/fields/fields.test.js +88 -0
- package/components/fields/fields.yaml +120 -0
- package/components/index.js +2 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +21 -0
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +12 -1
- package/package.json +10 -10
- package/styles/components.css +2 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +259 -4
- 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 +67 -63
- 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 +43 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +458 -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 +136 -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.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,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
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { dropTarget } from './drop-target.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* happy-dom's DragEvent constructor does NOT preserve the `dataTransfer`
|
|
7
|
+
* field passed via the init dict (verified at authoring time:
|
|
8
|
+
* `new DragEvent('dragover', { dataTransfer })` then `.dataTransfer`
|
|
9
|
+
* comes back as null). We synthesize the event by creating an Event,
|
|
10
|
+
* then defining `dataTransfer` as a non-enumerable read-only property —
|
|
11
|
+
* the trait reads it the same way the browser would.
|
|
12
|
+
*/
|
|
13
|
+
function fireDragEvent(host, type, dataTransfer = null) {
|
|
14
|
+
const ev = new Event(type, { bubbles: true, cancelable: true });
|
|
15
|
+
Object.defineProperty(ev, 'dataTransfer', {
|
|
16
|
+
value: dataTransfer,
|
|
17
|
+
writable: false,
|
|
18
|
+
configurable: true,
|
|
19
|
+
});
|
|
20
|
+
host.dispatchEvent(ev);
|
|
21
|
+
return ev;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fakeDataTransfer({ types = [], items = [] } = {}) {
|
|
25
|
+
return {
|
|
26
|
+
types,
|
|
27
|
+
items,
|
|
28
|
+
files: items.filter((i) => i.kind === 'file').map((i) => i.file).filter(Boolean),
|
|
29
|
+
getData: () => '',
|
|
30
|
+
setData: () => {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('drop-target', () => {
|
|
35
|
+
beforeEach(resetDOM);
|
|
36
|
+
|
|
37
|
+
it('sets data-drop-target-active on connect, removes on disconnect', () => {
|
|
38
|
+
const host = mountHost();
|
|
39
|
+
const inst = connectTrait(dropTarget, host);
|
|
40
|
+
expect(host.hasAttribute('data-drop-target-active')).toBe(true);
|
|
41
|
+
inst.disconnect(host);
|
|
42
|
+
expect(host.hasAttribute('data-drop-target-active')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('dragenter sets data-drop-target-over and dispatches drop-enter', () => {
|
|
46
|
+
const host = mountHost();
|
|
47
|
+
connectTrait(dropTarget, host);
|
|
48
|
+
const spy = spyEvent(host, 'drop-enter');
|
|
49
|
+
fireDragEvent(host, 'dragenter', fakeDataTransfer());
|
|
50
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(true);
|
|
51
|
+
expect(spy.count).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('dragleave clears data-drop-target-over and dispatches drop-leave', () => {
|
|
55
|
+
const host = mountHost();
|
|
56
|
+
connectTrait(dropTarget, host);
|
|
57
|
+
const enterSpy = spyEvent(host, 'drop-enter');
|
|
58
|
+
const leaveSpy = spyEvent(host, 'drop-leave');
|
|
59
|
+
fireDragEvent(host, 'dragenter', fakeDataTransfer());
|
|
60
|
+
fireDragEvent(host, 'dragleave', fakeDataTransfer());
|
|
61
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(false);
|
|
62
|
+
expect(enterSpy.count).toBe(1);
|
|
63
|
+
expect(leaveSpy.count).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('dragenter from descendant nesting only emits one drop-enter', () => {
|
|
67
|
+
const host = mountHost();
|
|
68
|
+
connectTrait(dropTarget, host);
|
|
69
|
+
const enterSpy = spyEvent(host, 'drop-enter');
|
|
70
|
+
const leaveSpy = spyEvent(host, 'drop-leave');
|
|
71
|
+
// Two enters (host then descendant) should produce ONE drop-enter
|
|
72
|
+
// and NOT clear the over-state when one matching leave fires.
|
|
73
|
+
fireDragEvent(host, 'dragenter', fakeDataTransfer());
|
|
74
|
+
fireDragEvent(host, 'dragenter', fakeDataTransfer());
|
|
75
|
+
fireDragEvent(host, 'dragleave', fakeDataTransfer());
|
|
76
|
+
expect(enterSpy.count).toBe(1);
|
|
77
|
+
expect(leaveSpy.count).toBe(0);
|
|
78
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(true);
|
|
79
|
+
// The matched leave finally clears.
|
|
80
|
+
fireDragEvent(host, 'dragleave', fakeDataTransfer());
|
|
81
|
+
expect(leaveSpy.count).toBe(1);
|
|
82
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('dragover calls preventDefault when no accept filter is set', () => {
|
|
86
|
+
const host = mountHost();
|
|
87
|
+
connectTrait(dropTarget, host);
|
|
88
|
+
const ev = fireDragEvent(host, 'dragover', fakeDataTransfer({ types: ['text/plain'] }));
|
|
89
|
+
expect(ev.defaultPrevented).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('dragover with empty accept attribute still accepts (preventDefault)', () => {
|
|
93
|
+
const host = mountHost('div', { 'data-drop-target-accepts': '' });
|
|
94
|
+
connectTrait(dropTarget, host);
|
|
95
|
+
const ev = fireDragEvent(host, 'dragover', fakeDataTransfer({ types: ['text/plain'] }));
|
|
96
|
+
expect(ev.defaultPrevented).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('accepts="files" — accepts file drags, rejects non-file payloads', () => {
|
|
100
|
+
const host = mountHost('div', { 'data-drop-target-accepts': 'files' });
|
|
101
|
+
connectTrait(dropTarget, host);
|
|
102
|
+
const rejectedSpy = spyEvent(host, 'drop-rejected');
|
|
103
|
+
|
|
104
|
+
// File drag — Files token present → accepted
|
|
105
|
+
const fileEv = fireDragEvent(host, 'dragover', fakeDataTransfer({ types: ['Files'] }));
|
|
106
|
+
expect(fileEv.defaultPrevented).toBe(true);
|
|
107
|
+
expect(host.hasAttribute('data-drop-target-rejected')).toBe(false);
|
|
108
|
+
|
|
109
|
+
// Plain text drag — no Files → rejected, no preventDefault
|
|
110
|
+
const textEv = fireDragEvent(host, 'dragover', fakeDataTransfer({ types: ['text/plain'] }));
|
|
111
|
+
expect(textEv.defaultPrevented).toBe(false);
|
|
112
|
+
expect(host.getAttribute('data-drop-target-rejected')).toBe('files-only');
|
|
113
|
+
expect(rejectedSpy.count).toBe(1);
|
|
114
|
+
expect(rejectedSpy.last.reason).toBe('files-only');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accepts="image/png,image/jpeg" — rejects mismatched MIME types', () => {
|
|
118
|
+
const host = mountHost('div', { 'data-drop-target-accepts': 'image/png,image/jpeg' });
|
|
119
|
+
connectTrait(dropTarget, host);
|
|
120
|
+
|
|
121
|
+
// Direct text type → rejected
|
|
122
|
+
const textEv = fireDragEvent(host, 'dragover', fakeDataTransfer({ types: ['text/plain'] }));
|
|
123
|
+
expect(textEv.defaultPrevented).toBe(false);
|
|
124
|
+
expect(host.getAttribute('data-drop-target-rejected')).toBe('type');
|
|
125
|
+
|
|
126
|
+
// PNG file drag → accepted
|
|
127
|
+
const pngDt = fakeDataTransfer({
|
|
128
|
+
types: ['Files'],
|
|
129
|
+
items: [{ kind: 'file', type: 'image/png' }],
|
|
130
|
+
});
|
|
131
|
+
const pngEv = fireDragEvent(host, 'dragover', pngDt);
|
|
132
|
+
expect(pngEv.defaultPrevented).toBe(true);
|
|
133
|
+
expect(host.hasAttribute('data-drop-target-rejected')).toBe(false);
|
|
134
|
+
|
|
135
|
+
// PDF file drag → rejected
|
|
136
|
+
const pdfDt = fakeDataTransfer({
|
|
137
|
+
types: ['Files'],
|
|
138
|
+
items: [{ kind: 'file', type: 'application/pdf' }],
|
|
139
|
+
});
|
|
140
|
+
const pdfEv = fireDragEvent(host, 'dragover', pdfDt);
|
|
141
|
+
expect(pdfEv.defaultPrevented).toBe(false);
|
|
142
|
+
expect(host.getAttribute('data-drop-target-rejected')).toBe('type');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('drop dispatches drop-receive with dataTransfer detail', () => {
|
|
146
|
+
const host = mountHost();
|
|
147
|
+
connectTrait(dropTarget, host);
|
|
148
|
+
const spy = spyEvent(host, 'drop-receive');
|
|
149
|
+
const dt = fakeDataTransfer({ types: ['text/plain'] });
|
|
150
|
+
|
|
151
|
+
fireDragEvent(host, 'dragenter', dt);
|
|
152
|
+
fireDragEvent(host, 'dragover', dt);
|
|
153
|
+
const dropEv = fireDragEvent(host, 'drop', dt);
|
|
154
|
+
expect(dropEv.defaultPrevented).toBe(true);
|
|
155
|
+
expect(spy.count).toBe(1);
|
|
156
|
+
expect(spy.last.dataTransfer).toBe(dt);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('drop clears data-drop-target-over after firing drop-receive', () => {
|
|
160
|
+
const host = mountHost();
|
|
161
|
+
connectTrait(dropTarget, host);
|
|
162
|
+
const dt = fakeDataTransfer({ types: ['text/plain'] });
|
|
163
|
+
fireDragEvent(host, 'dragenter', dt);
|
|
164
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(true);
|
|
165
|
+
fireDragEvent(host, 'drop', dt);
|
|
166
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('drop on rejected payload dispatches drop-rejected, not drop-receive', () => {
|
|
170
|
+
const host = mountHost('div', { 'data-drop-target-accepts': 'image/png' });
|
|
171
|
+
connectTrait(dropTarget, host);
|
|
172
|
+
const receiveSpy = spyEvent(host, 'drop-receive');
|
|
173
|
+
const rejectedSpy = spyEvent(host, 'drop-rejected');
|
|
174
|
+
const dt = fakeDataTransfer({ types: ['text/plain'] });
|
|
175
|
+
|
|
176
|
+
fireDragEvent(host, 'drop', dt);
|
|
177
|
+
expect(receiveSpy.count).toBe(0);
|
|
178
|
+
expect(rejectedSpy.count).toBe(1);
|
|
179
|
+
expect(rejectedSpy.last.reason).toBe('type');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('respects [disabled] — no event, no over-state', () => {
|
|
183
|
+
const host = mountHost('div', { disabled: '' });
|
|
184
|
+
connectTrait(dropTarget, host);
|
|
185
|
+
const enterSpy = spyEvent(host, 'drop-enter');
|
|
186
|
+
const receiveSpy = spyEvent(host, 'drop-receive');
|
|
187
|
+
|
|
188
|
+
fireDragEvent(host, 'dragenter', fakeDataTransfer());
|
|
189
|
+
fireDragEvent(host, 'dragover', fakeDataTransfer());
|
|
190
|
+
fireDragEvent(host, 'drop', fakeDataTransfer());
|
|
191
|
+
|
|
192
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(false);
|
|
193
|
+
expect(enterSpy.count).toBe(0);
|
|
194
|
+
expect(receiveSpy.count).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('disconnect removes listeners and clears managed attributes', () => {
|
|
198
|
+
const host = mountHost();
|
|
199
|
+
const inst = connectTrait(dropTarget, host);
|
|
200
|
+
fireDragEvent(host, 'dragenter', fakeDataTransfer());
|
|
201
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(true);
|
|
202
|
+
inst.disconnect(host);
|
|
203
|
+
expect(host.hasAttribute('data-drop-target-over')).toBe(false);
|
|
204
|
+
expect(host.hasAttribute('data-drop-target-active')).toBe(false);
|
|
205
|
+
expect(host.hasAttribute('data-drop-target-rejected')).toBe(false);
|
|
206
|
+
|
|
207
|
+
// Re-firing post-disconnect is a no-op.
|
|
208
|
+
const spy = spyEvent(host, 'drop-enter');
|
|
209
|
+
fireDragEvent(host, 'dragenter', fakeDataTransfer());
|
|
210
|
+
expect(spy.count).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('clears data-drop-target-rejected when dragover later sees an accepted payload', () => {
|
|
214
|
+
const host = mountHost('div', { 'data-drop-target-accepts': 'image/png' });
|
|
215
|
+
connectTrait(dropTarget, host);
|
|
216
|
+
|
|
217
|
+
fireDragEvent(host, 'dragover', fakeDataTransfer({ types: ['text/plain'] }));
|
|
218
|
+
expect(host.getAttribute('data-drop-target-rejected')).toBe('type');
|
|
219
|
+
|
|
220
|
+
const pngDt = fakeDataTransfer({
|
|
221
|
+
types: ['Files'],
|
|
222
|
+
items: [{ kind: 'file', type: 'image/png' }],
|
|
223
|
+
});
|
|
224
|
+
fireDragEvent(host, 'dragover', pngDt);
|
|
225
|
+
expect(host.hasAttribute('data-drop-target-rejected')).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('dispatches drop-rejected only once per rejection reason on consecutive dragover', () => {
|
|
229
|
+
const host = mountHost('div', { 'data-drop-target-accepts': 'image/png' });
|
|
230
|
+
connectTrait(dropTarget, host);
|
|
231
|
+
const rejectedSpy = spyEvent(host, 'drop-rejected');
|
|
232
|
+
const dt = fakeDataTransfer({ types: ['text/plain'] });
|
|
233
|
+
|
|
234
|
+
// Three consecutive dragover events with the same rejection reason
|
|
235
|
+
// should emit ONE drop-rejected, not three.
|
|
236
|
+
fireDragEvent(host, 'dragover', dt);
|
|
237
|
+
fireDragEvent(host, 'dragover', dt);
|
|
238
|
+
fireDragEvent(host, 'dragover', dt);
|
|
239
|
+
expect(rejectedSpy.count).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `droppable-collection` — coordinator over a tree of `droppable` children.
|
|
5
|
+
*
|
|
6
|
+
* Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
|
|
7
|
+
* Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'input-interaction').
|
|
8
|
+
*
|
|
9
|
+
* Pairs with `droppable` (per-target) and `draggable-list-item` (motion-
|
|
10
|
+
* positioning, the lifter). Represents a board / multi-column container
|
|
11
|
+
* whose immediate descendant droppables form a coordinated set:
|
|
12
|
+
*
|
|
13
|
+
* <task-board-ui [droppable-collection]>
|
|
14
|
+
* <task-column-ui [droppable] data-droppable-id="todo">…</task-column-ui>
|
|
15
|
+
* <task-column-ui [droppable] data-droppable-id="doing">…</task-column-ui>
|
|
16
|
+
* <task-column-ui [droppable] data-droppable-id="done">…</task-column-ui>
|
|
17
|
+
* </task-board-ui>
|
|
18
|
+
*
|
|
19
|
+
* Responsibilities (pure DOM coordination — no shared module state):
|
|
20
|
+
* 1. Listen for `dnd-drop` events bubbling up from any droppable child.
|
|
21
|
+
* 2. Re-emit a single `dnd-collection-drop` on the collection host that
|
|
22
|
+
* view code can subscribe to once instead of N column listeners.
|
|
23
|
+
* 3. Reflect the dragging state to the collection host so the board can
|
|
24
|
+
* dim non-target columns or render group affordances.
|
|
25
|
+
*
|
|
26
|
+
* Coordination is via DOM events only (light-DOM, bubbles: true) so it
|
|
27
|
+
* remains observable from authoring code, no shared registry needed.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const ATTR_ACTIVE = 'data-droppable-collection-active';
|
|
31
|
+
const ATTR_DRAGGING = 'data-droppable-collection-dragging';
|
|
32
|
+
|
|
33
|
+
export const droppableCollection = defineTrait({
|
|
34
|
+
name: 'droppable-collection',
|
|
35
|
+
category: 'input-interaction',
|
|
36
|
+
description: 'Coordinates droppable children; re-emits drops as dnd-collection-drop',
|
|
37
|
+
attributes: [ATTR_ACTIVE, ATTR_DRAGGING],
|
|
38
|
+
events: ['dnd-collection-drop'],
|
|
39
|
+
config: [],
|
|
40
|
+
setup({ host }) {
|
|
41
|
+
host.setAttribute(ATTR_ACTIVE, '');
|
|
42
|
+
|
|
43
|
+
function onLift() {
|
|
44
|
+
host.setAttribute(ATTR_DRAGGING, '');
|
|
45
|
+
}
|
|
46
|
+
function onDragEnd() {
|
|
47
|
+
host.removeAttribute(ATTR_DRAGGING);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {Event} e */
|
|
51
|
+
function onChildDrop(e) {
|
|
52
|
+
// Only react to drops that landed inside a child droppable of THIS
|
|
53
|
+
// collection — don't double-handle drops from a nested collection.
|
|
54
|
+
const target = /** @type {Element} */ (e.target);
|
|
55
|
+
if (!target || !host.contains(target)) return;
|
|
56
|
+
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
57
|
+
if (!detail) return;
|
|
58
|
+
|
|
59
|
+
host.removeAttribute(ATTR_DRAGGING);
|
|
60
|
+
host.dispatchEvent(new CustomEvent('dnd-collection-drop', {
|
|
61
|
+
bubbles: true,
|
|
62
|
+
composed: false,
|
|
63
|
+
detail: {
|
|
64
|
+
source_id: detail.source_id,
|
|
65
|
+
source_index: detail.source_index,
|
|
66
|
+
source_container_id: detail.source_container_id,
|
|
67
|
+
target_container_id: detail.target_container_id,
|
|
68
|
+
target_index: detail.target_index,
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
host.addEventListener('dnd-lift', onLift);
|
|
74
|
+
host.addEventListener('dnd-drop-cancel', onDragEnd);
|
|
75
|
+
// Listen for the TARGET-side event from the [droppable] trait, not the
|
|
76
|
+
// source-side `dnd-drop` from the lifter — those would arrive too late
|
|
77
|
+
// (the lifter dispatches on document, only the host re-emit reaches us)
|
|
78
|
+
// AND match this collection's own outbound `dnd-collection-drop` shape.
|
|
79
|
+
host.addEventListener('dnd-drop-receive', onChildDrop);
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
host.removeEventListener('dnd-lift', onLift);
|
|
83
|
+
host.removeEventListener('dnd-drop-cancel', onDragEnd);
|
|
84
|
+
host.removeEventListener('dnd-drop-receive', onChildDrop);
|
|
85
|
+
host.removeAttribute(ATTR_ACTIVE);
|
|
86
|
+
host.removeAttribute(ATTR_DRAGGING);
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { droppableCollection } from './droppable-collection.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
// Minimal smoke coverage. The droppable-collection trait coordinates
|
|
6
|
+
// child droppables of the docs/projects/tasks-playground/ DnD model;
|
|
7
|
+
// the cases below verify only the trait's own surface (schema, attribute
|
|
8
|
+
// lifecycle, child-drop re-emission) so the verify:traits gate stays
|
|
9
|
+
// green. End-to-end multi-column behavior belongs in the playground tests.
|
|
10
|
+
|
|
11
|
+
describe('droppable-collection', () => {
|
|
12
|
+
beforeEach(resetDOM);
|
|
13
|
+
|
|
14
|
+
it('schema shape is defined', () => {
|
|
15
|
+
expect(droppableCollection.schema.name).toBe('droppable-collection');
|
|
16
|
+
expect(droppableCollection.schema.category).toBe('input-interaction');
|
|
17
|
+
expect(droppableCollection.schema.description.length).toBeGreaterThanOrEqual(11);
|
|
18
|
+
expect(droppableCollection.schema.events).toContain('dnd-collection-drop');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('connect sets data-droppable-collection-active', () => {
|
|
22
|
+
const host = mountHost('div');
|
|
23
|
+
connectTrait(droppableCollection, host);
|
|
24
|
+
expect(host.hasAttribute('data-droppable-collection-active')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('disconnect clears its attributes', () => {
|
|
28
|
+
const host = mountHost('div');
|
|
29
|
+
const inst = droppableCollection();
|
|
30
|
+
inst.connect(host);
|
|
31
|
+
host.setAttribute('data-droppable-collection-dragging', '');
|
|
32
|
+
inst.disconnect(host);
|
|
33
|
+
expect(host.hasAttribute('data-droppable-collection-active')).toBe(false);
|
|
34
|
+
expect(host.hasAttribute('data-droppable-collection-dragging')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('re-emits a child dnd-drop-receive as dnd-collection-drop on the host', () => {
|
|
38
|
+
// The collection listens for the TARGET-side `dnd-drop-receive` (emitted
|
|
39
|
+
// by the [droppable] trait on its host), NOT the source-side `dnd-drop`
|
|
40
|
+
// (emitted by the lifter on document). The two events are deliberately
|
|
41
|
+
// distinct names so that the target re-emit doesn't bubble back into
|
|
42
|
+
// the lifter's document-level listener and recurse.
|
|
43
|
+
const host = mountHost('div');
|
|
44
|
+
const child = document.createElement('div');
|
|
45
|
+
host.appendChild(child);
|
|
46
|
+
connectTrait(droppableCollection, host);
|
|
47
|
+
|
|
48
|
+
const spy = spyEvent(host, 'dnd-collection-drop');
|
|
49
|
+
child.dispatchEvent(new CustomEvent('dnd-drop-receive', {
|
|
50
|
+
bubbles: true,
|
|
51
|
+
composed: false,
|
|
52
|
+
detail: {
|
|
53
|
+
source_id: 't-1',
|
|
54
|
+
source_index: 0,
|
|
55
|
+
source_container_id: 'col-a',
|
|
56
|
+
target_container_id: 'col-b',
|
|
57
|
+
target_index: 2,
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
expect(spy.count).toBe(1);
|
|
62
|
+
expect(spy.last).toMatchObject({
|
|
63
|
+
source_id: 't-1',
|
|
64
|
+
target_container_id: 'col-b',
|
|
65
|
+
target_index: 2,
|
|
66
|
+
});
|
|
67
|
+
spy.cleanup();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does NOT react to the source-side dnd-drop (would recurse)', () => {
|
|
71
|
+
// Regression guard: a source-side `dnd-drop` bubbling up from a child
|
|
72
|
+
// must NOT trigger `dnd-collection-drop`. Only the target-side
|
|
73
|
+
// `dnd-drop-receive` is the trigger.
|
|
74
|
+
const host = mountHost('div');
|
|
75
|
+
const child = document.createElement('div');
|
|
76
|
+
host.appendChild(child);
|
|
77
|
+
connectTrait(droppableCollection, host);
|
|
78
|
+
|
|
79
|
+
const spy = spyEvent(host, 'dnd-collection-drop');
|
|
80
|
+
child.dispatchEvent(new CustomEvent('dnd-drop', {
|
|
81
|
+
bubbles: true,
|
|
82
|
+
composed: false,
|
|
83
|
+
detail: { source_id: 't-1', target_container_id: 'col-b', target_index: 0 },
|
|
84
|
+
}));
|
|
85
|
+
expect(spy.count).toBe(0);
|
|
86
|
+
spy.cleanup();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reflects dragging state from dnd-lift / dnd-drop-cancel', () => {
|
|
90
|
+
const host = mountHost('div');
|
|
91
|
+
connectTrait(droppableCollection, host);
|
|
92
|
+
|
|
93
|
+
host.dispatchEvent(new CustomEvent('dnd-lift', { bubbles: true, detail: {} }));
|
|
94
|
+
expect(host.hasAttribute('data-droppable-collection-dragging')).toBe(true);
|
|
95
|
+
|
|
96
|
+
host.dispatchEvent(new CustomEvent('dnd-drop-cancel', { bubbles: true, detail: {} }));
|
|
97
|
+
expect(host.hasAttribute('data-droppable-collection-dragging')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|