@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.
Files changed (121) hide show
  1. package/components/agent-trace/agent-trace.css +24 -3
  2. package/components/button/button.js +3 -0
  3. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  4. package/components/demo-toggle/demo-toggle.css +120 -0
  5. package/components/demo-toggle/demo-toggle.js +144 -0
  6. package/components/demo-toggle/demo-toggle.test.js +102 -0
  7. package/components/demo-toggle/demo-toggle.yaml +144 -0
  8. package/components/index.js +1 -0
  9. package/components/input/input.js +11 -0
  10. package/components/list/list.css +66 -3
  11. package/components/nav-group/nav-group.a2ui.json +1 -1
  12. package/components/nav-group/nav-group.css +5 -5
  13. package/components/nav-group/nav-group.yaml +1 -1
  14. package/components/nav-item/nav-item.a2ui.json +1 -1
  15. package/components/nav-item/nav-item.css +3 -4
  16. package/components/nav-item/nav-item.yaml +1 -1
  17. package/components/textarea/textarea.js +10 -0
  18. package/core/icons.js +13 -1
  19. package/package.json +1 -1
  20. package/styles/components.css +1 -0
  21. package/styles/typography.css +1 -1
  22. package/traits/_catalog.json +258 -5
  23. package/traits/active-state.test.js +1 -1
  24. package/traits/anchor-positioning.js +205 -52
  25. package/traits/anchor-positioning.test.js +77 -4
  26. package/traits/announcer-stage.js +157 -0
  27. package/traits/announcer.js +145 -0
  28. package/traits/announcer.test.js +268 -0
  29. package/traits/arrow-grid-nav.js +234 -0
  30. package/traits/arrow-grid-nav.test.js +375 -0
  31. package/traits/attention-pulse.js +1 -1
  32. package/traits/attention-pulse.test.js +1 -1
  33. package/traits/confetti-burst.js +90 -60
  34. package/traits/confetti-burst.test.js +16 -8
  35. package/traits/confetti-stage.js +143 -0
  36. package/traits/confetti.js +44 -47
  37. package/traits/confetti.test.js +24 -5
  38. package/traits/count-up.js +31 -6
  39. package/traits/count-up.test.js +1 -1
  40. package/traits/declarative.test.js +1 -1
  41. package/traits/dirty-state.test.js +1 -1
  42. package/traits/drag-ghost.js +55 -3
  43. package/traits/drag-ghost.test.js +1 -1
  44. package/traits/draggable-list-item.js +279 -0
  45. package/traits/draggable-list-item.test.js +51 -0
  46. package/traits/draggable.js +14 -4
  47. package/traits/draggable.test.js +1 -1
  48. package/traits/drop-target.js +223 -0
  49. package/traits/drop-target.test.js +241 -0
  50. package/traits/droppable-collection.js +89 -0
  51. package/traits/droppable-collection.test.js +99 -0
  52. package/traits/droppable.js +125 -0
  53. package/traits/droppable.test.js +54 -0
  54. package/traits/error-shake.js +157 -0
  55. package/traits/error-shake.test.js +114 -0
  56. package/traits/fade-presence.test.js +1 -1
  57. package/traits/focus-restore.js +135 -0
  58. package/traits/focus-restore.test.js +202 -0
  59. package/traits/focus-trap.test.js +1 -1
  60. package/traits/focusable.test.js +1 -1
  61. package/traits/glow-focus.js +1 -1
  62. package/traits/glow-focus.test.js +1 -1
  63. package/traits/gradient-shift.js +1 -1
  64. package/traits/gradient-shift.test.js +1 -1
  65. package/traits/haptic-feedback.test.js +1 -1
  66. package/traits/hotkey.test.js +1 -1
  67. package/traits/hoverable.test.js +1 -1
  68. package/traits/index.js +15 -0
  69. package/traits/inertia-drag.js +9 -0
  70. package/traits/inertia-drag.test.js +1 -1
  71. package/traits/input-mask.js +328 -0
  72. package/traits/input-mask.test.js +151 -0
  73. package/traits/intersection-observer.test.js +1 -1
  74. package/traits/keyboard-nav.test.js +1 -1
  75. package/traits/keyboard-reorderable.js +254 -0
  76. package/traits/keyboard-reorderable.test.js +45 -0
  77. package/traits/layout-animation.js +229 -0
  78. package/traits/layout-animation.test.js +114 -0
  79. package/traits/long-press.js +212 -0
  80. package/traits/long-press.test.js +244 -0
  81. package/traits/magnetic-hover.js +1 -1
  82. package/traits/magnetic-hover.test.js +1 -1
  83. package/traits/noise-texture.js +7 -3
  84. package/traits/noise-texture.test.js +1 -1
  85. package/traits/parallax.js +1 -1
  86. package/traits/parallax.test.js +1 -1
  87. package/traits/portal.test.js +1 -1
  88. package/traits/pressable.test.js +1 -1
  89. package/traits/resettable.js +29 -3
  90. package/traits/resettable.test.js +34 -1
  91. package/traits/resizable.test.js +1 -1
  92. package/traits/resize-observer.test.js +1 -1
  93. package/traits/ripple.js +1 -1
  94. package/traits/ripple.test.js +1 -1
  95. package/traits/roving-tabindex.test.js +1 -1
  96. package/traits/scale-press.test.js +1 -1
  97. package/traits/scroll-lock.test.js +1 -1
  98. package/traits/scroll-progress.js +201 -0
  99. package/traits/scroll-progress.test.js +182 -0
  100. package/traits/shimmer-loading.js +1 -1
  101. package/traits/shimmer-loading.test.js +1 -1
  102. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  103. package/traits/snap-to-grid.test.js +1 -1
  104. package/traits/sound-feedback.test.js +1 -1
  105. package/traits/spring-animate.js +8 -3
  106. package/traits/spring-animate.test.js +1 -1
  107. package/traits/success-checkmark.js +222 -0
  108. package/traits/success-checkmark.test.js +120 -0
  109. package/traits/tilt-hover.js +1 -1
  110. package/traits/tilt-hover.test.js +1 -1
  111. package/traits/tossable.js +9 -0
  112. package/traits/tossable.test.js +1 -1
  113. package/traits/traits-host.test.js +1 -1
  114. package/traits/typeahead.test.js +1 -1
  115. package/traits/typewriter.js +1 -1
  116. package/traits/typewriter.test.js +1 -1
  117. package/traits/validation.test.js +1 -1
  118. package/traits/view-transition.js +140 -0
  119. package/traits/view-transition.test.js +268 -0
  120. /package/traits/{_motion.js → motion.js} +0 -0
  121. /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
@@ -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
+ });
@@ -0,0 +1,125 @@
1
+ import { defineTrait } from './define.js';
2
+
3
+ /**
4
+ * `droppable` — marks an element as a drop target for `draggable-list-item`.
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 `draggable-list-item` (motion-positioning) which lifts a list
10
+ * item and emits `dnd-lift` / `dnd-drop-target-change` / `dnd-drop` /
11
+ * `dnd-drop-cancel` ON THE DOCUMENT. This trait observes the drag in
12
+ * flight and emits target-side events ON THE HOST:
13
+ *
14
+ * `dnd-drop-enter` pointer entered this droppable
15
+ * `dnd-drop-leave` pointer left this droppable
16
+ * `dnd-drop-receive` the drop landed here (source-side `dnd-drop`
17
+ * on the document is the lifter's "release"
18
+ * signal; the target-side event MUST be named
19
+ * differently or it bubbles back to the
20
+ * document and re-triggers this same listener
21
+ * in an infinite loop)
22
+ *
23
+ * Coordination: pure DOM events (light-DOM, bubbles: true). No shared
24
+ * runtime state. The dragging item dispatches `dnd-drop-target-change`
25
+ * with the candidate target id; the targeted droppable receives a
26
+ * synthetic `dnd-drop-enter` from a tiny internal coordinator.
27
+ *
28
+ * For v1 the coordinator is a single shared module-level Map keyed by
29
+ * `data-droppable-id`. Future versions may promote to a registry trait.
30
+ */
31
+
32
+ const ATTR_ID = 'data-droppable-id';
33
+ const ATTR_OVER = 'data-droppable-over';
34
+ const ATTR_VALID = 'data-droppable-valid';
35
+
36
+ /** @type {Map<string, HTMLElement>} */
37
+ const REGISTRY = new Map();
38
+ /** @type {string | null} */
39
+ let CURRENT_TARGET_ID = null;
40
+
41
+ export const droppable = defineTrait({
42
+ name: 'droppable',
43
+ category: 'input-interaction',
44
+ description: 'Marks an element as a drop target for draggable-list-item; emits dnd-drop-enter / leave / receive',
45
+ attributes: [ATTR_ID, ATTR_OVER, ATTR_VALID],
46
+ events: ['dnd-drop-enter', 'dnd-drop-leave', 'dnd-drop-receive'],
47
+ config: [],
48
+ setup({ host }) {
49
+ // Assign a stable id for the registry. Authoring code may set it
50
+ // explicitly via the attribute; otherwise generate.
51
+ let id = host.getAttribute(ATTR_ID);
52
+ if (!id) {
53
+ id = `drop-${crypto.randomUUID().slice(0, 8)}`;
54
+ host.setAttribute(ATTR_ID, id);
55
+ }
56
+ REGISTRY.set(id, host);
57
+
58
+ // Listen for drag-in-flight signals on the document so this droppable
59
+ // knows when it should self-mark as the target.
60
+ function onTargetChange(/** @type {CustomEvent} */ e) {
61
+ const detail = e.detail;
62
+ const isMe = detail && detail.target_container_id === id;
63
+ if (isMe && CURRENT_TARGET_ID !== id) {
64
+ CURRENT_TARGET_ID = id;
65
+ host.setAttribute(ATTR_OVER, '');
66
+ host.dispatchEvent(new CustomEvent('dnd-drop-enter', {
67
+ bubbles: true,
68
+ composed: false,
69
+ detail: { source_id: detail.source_id, pointer_position: detail.pointer ?? { x: 0, y: 0 } },
70
+ }));
71
+ } else if (!isMe && CURRENT_TARGET_ID === id) {
72
+ CURRENT_TARGET_ID = null;
73
+ host.removeAttribute(ATTR_OVER);
74
+ host.dispatchEvent(new CustomEvent('dnd-drop-leave', {
75
+ bubbles: true,
76
+ composed: false,
77
+ detail: { source_id: detail?.source_id ?? null },
78
+ }));
79
+ }
80
+ }
81
+
82
+ function onDrop(/** @type {CustomEvent} */ e) {
83
+ const detail = e.detail;
84
+ if (!detail || detail.target_container_id !== id) return;
85
+ host.removeAttribute(ATTR_OVER);
86
+ CURRENT_TARGET_ID = null;
87
+ host.dispatchEvent(new CustomEvent('dnd-drop-receive', {
88
+ bubbles: true,
89
+ composed: false,
90
+ detail: {
91
+ source_id: detail.source_id,
92
+ source_index: detail.source_index,
93
+ source_container_id: detail.source_container_id,
94
+ target_container_id: id,
95
+ target_index: detail.target_index,
96
+ },
97
+ }));
98
+ }
99
+
100
+ function onDragEnd() {
101
+ // Whatever happened, reset the over state.
102
+ host.removeAttribute(ATTR_OVER);
103
+ if (CURRENT_TARGET_ID === id) CURRENT_TARGET_ID = null;
104
+ }
105
+
106
+ document.addEventListener('dnd-drop-target-change', onTargetChange);
107
+ document.addEventListener('dnd-drop', onDrop);
108
+ document.addEventListener('dnd-drop-cancel', onDragEnd);
109
+
110
+ return () => {
111
+ document.removeEventListener('dnd-drop-target-change', onTargetChange);
112
+ document.removeEventListener('dnd-drop', onDrop);
113
+ document.removeEventListener('dnd-drop-cancel', onDragEnd);
114
+ REGISTRY.delete(id);
115
+ host.removeAttribute(ATTR_ID);
116
+ host.removeAttribute(ATTR_OVER);
117
+ host.removeAttribute(ATTR_VALID);
118
+ };
119
+ },
120
+ });
121
+
122
+ /** Internal: lookup a droppable host by id. Used by `draggable-list-item`. */
123
+ export function getDroppable(id) {
124
+ return REGISTRY.get(id) ?? null;
125
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { droppable } from './droppable.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
+
5
+ // Minimal smoke coverage. The droppable trait is authored against the
6
+ // docs/projects/tasks-playground/ DnD coordinator and assumes a sibling
7
+ // `draggable-list-item` trait dispatches `dnd:drop-target-change` /
8
+ // `dnd:drop` on the document. The cases below only exercise the trait's
9
+ // own surface (schema, attribute lifecycle, registry entry, cleanup) so
10
+ // the verify:traits gate stays green; the higher-fidelity DnD-flow tests
11
+ // belong with the Tasks UI initiative that owns the coordinator.
12
+
13
+ describe('droppable', () => {
14
+ beforeEach(resetDOM);
15
+
16
+ it('schema shape is defined', () => {
17
+ expect(droppable.schema.name).toBe('droppable');
18
+ expect(droppable.schema.category).toBe('input-interaction');
19
+ expect(droppable.schema.description.length).toBeGreaterThanOrEqual(11);
20
+ expect(droppable.schema.events).toContain('dnd-drop-enter');
21
+ // Source-side `dnd-drop` (on document, from the lifter) and target-side
22
+ // `dnd-drop-receive` (on host) MUST be distinct names — sharing the name
23
+ // recurses infinitely when the host event bubbles back to the document.
24
+ expect(droppable.schema.events).toContain('dnd-drop-receive');
25
+ expect(droppable.schema.events).not.toContain('dnd-drop');
26
+ });
27
+
28
+ it('connect assigns an id + sets the host attribute', () => {
29
+ const host = mountHost('div');
30
+ connectTrait(droppable, host);
31
+ expect(host.getAttribute('data-droppable-id')).toBeTruthy();
32
+ });
33
+
34
+ it('disconnect removes the over attribute if set', () => {
35
+ const host = mountHost('div');
36
+ const inst = droppable();
37
+ inst.connect(host);
38
+ host.setAttribute('data-droppable-over', '');
39
+ inst.disconnect(host);
40
+ expect(host.hasAttribute('data-droppable-over')).toBe(false);
41
+ });
42
+
43
+ it('reconnect with the same host does not throw', () => {
44
+ const host = mountHost('div');
45
+ const inst = droppable();
46
+ inst.connect(host);
47
+ inst.disconnect(host);
48
+ expect(() => {
49
+ const inst2 = droppable();
50
+ inst2.connect(host);
51
+ inst2.disconnect(host);
52
+ }).not.toThrow();
53
+ });
54
+ });