@adia-ai/web-components 0.2.3 → 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 (113) hide show
  1. package/components/button/button.js +3 -0
  2. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  3. package/components/demo-toggle/demo-toggle.css +120 -0
  4. package/components/demo-toggle/demo-toggle.js +144 -0
  5. package/components/demo-toggle/demo-toggle.test.js +102 -0
  6. package/components/demo-toggle/demo-toggle.yaml +144 -0
  7. package/components/index.js +1 -0
  8. package/components/input/input.js +11 -0
  9. package/components/list/list.css +21 -0
  10. package/components/textarea/textarea.js +10 -0
  11. package/core/icons.js +12 -1
  12. package/package.json +1 -1
  13. package/styles/components.css +1 -0
  14. package/styles/typography.css +1 -1
  15. package/traits/_catalog.json +257 -4
  16. package/traits/active-state.test.js +1 -1
  17. package/traits/anchor-positioning.js +205 -52
  18. package/traits/anchor-positioning.test.js +77 -4
  19. package/traits/announcer-stage.js +157 -0
  20. package/traits/announcer.js +145 -0
  21. package/traits/announcer.test.js +268 -0
  22. package/traits/arrow-grid-nav.js +234 -0
  23. package/traits/arrow-grid-nav.test.js +375 -0
  24. package/traits/attention-pulse.js +1 -1
  25. package/traits/attention-pulse.test.js +1 -1
  26. package/traits/confetti-burst.js +67 -63
  27. package/traits/confetti-burst.test.js +16 -8
  28. package/traits/confetti-stage.js +143 -0
  29. package/traits/confetti.js +44 -47
  30. package/traits/confetti.test.js +24 -5
  31. package/traits/count-up.js +31 -6
  32. package/traits/count-up.test.js +1 -1
  33. package/traits/declarative.test.js +1 -1
  34. package/traits/dirty-state.test.js +1 -1
  35. package/traits/drag-ghost.js +43 -3
  36. package/traits/drag-ghost.test.js +1 -1
  37. package/traits/draggable-list-item.js +279 -0
  38. package/traits/draggable-list-item.test.js +51 -0
  39. package/traits/draggable.js +14 -4
  40. package/traits/draggable.test.js +1 -1
  41. package/traits/drop-target.js +223 -0
  42. package/traits/drop-target.test.js +241 -0
  43. package/traits/droppable-collection.js +89 -0
  44. package/traits/droppable-collection.test.js +99 -0
  45. package/traits/droppable.js +125 -0
  46. package/traits/droppable.test.js +54 -0
  47. package/traits/error-shake.js +157 -0
  48. package/traits/error-shake.test.js +114 -0
  49. package/traits/fade-presence.test.js +1 -1
  50. package/traits/focus-restore.js +135 -0
  51. package/traits/focus-restore.test.js +202 -0
  52. package/traits/focus-trap.test.js +1 -1
  53. package/traits/focusable.test.js +1 -1
  54. package/traits/glow-focus.js +1 -1
  55. package/traits/glow-focus.test.js +1 -1
  56. package/traits/gradient-shift.js +1 -1
  57. package/traits/gradient-shift.test.js +1 -1
  58. package/traits/haptic-feedback.test.js +1 -1
  59. package/traits/hotkey.test.js +1 -1
  60. package/traits/hoverable.test.js +1 -1
  61. package/traits/index.js +15 -0
  62. package/traits/inertia-drag.js +9 -0
  63. package/traits/inertia-drag.test.js +1 -1
  64. package/traits/input-mask.js +328 -0
  65. package/traits/input-mask.test.js +151 -0
  66. package/traits/intersection-observer.test.js +1 -1
  67. package/traits/keyboard-nav.test.js +1 -1
  68. package/traits/keyboard-reorderable.js +254 -0
  69. package/traits/keyboard-reorderable.test.js +45 -0
  70. package/traits/layout-animation.js +229 -0
  71. package/traits/layout-animation.test.js +114 -0
  72. package/traits/long-press.js +212 -0
  73. package/traits/long-press.test.js +244 -0
  74. package/traits/magnetic-hover.js +1 -1
  75. package/traits/magnetic-hover.test.js +1 -1
  76. package/traits/noise-texture.js +7 -3
  77. package/traits/noise-texture.test.js +1 -1
  78. package/traits/parallax.js +1 -1
  79. package/traits/parallax.test.js +1 -1
  80. package/traits/portal.test.js +1 -1
  81. package/traits/pressable.test.js +1 -1
  82. package/traits/resettable.js +29 -3
  83. package/traits/resettable.test.js +34 -1
  84. package/traits/resizable.test.js +1 -1
  85. package/traits/resize-observer.test.js +1 -1
  86. package/traits/ripple.js +1 -1
  87. package/traits/ripple.test.js +1 -1
  88. package/traits/roving-tabindex.test.js +1 -1
  89. package/traits/scale-press.test.js +1 -1
  90. package/traits/scroll-lock.test.js +1 -1
  91. package/traits/scroll-progress.js +201 -0
  92. package/traits/scroll-progress.test.js +182 -0
  93. package/traits/shimmer-loading.js +1 -1
  94. package/traits/shimmer-loading.test.js +1 -1
  95. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  96. package/traits/snap-to-grid.test.js +1 -1
  97. package/traits/sound-feedback.test.js +1 -1
  98. package/traits/spring-animate.test.js +1 -1
  99. package/traits/success-checkmark.js +222 -0
  100. package/traits/success-checkmark.test.js +120 -0
  101. package/traits/tilt-hover.js +1 -1
  102. package/traits/tilt-hover.test.js +1 -1
  103. package/traits/tossable.js +9 -0
  104. package/traits/tossable.test.js +1 -1
  105. package/traits/traits-host.test.js +1 -1
  106. package/traits/typeahead.test.js +1 -1
  107. package/traits/typewriter.js +1 -1
  108. package/traits/typewriter.test.js +1 -1
  109. package/traits/validation.test.js +1 -1
  110. package/traits/view-transition.js +140 -0
  111. package/traits/view-transition.test.js +268 -0
  112. /package/traits/{_motion.js → motion.js} +0 -0
  113. /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
+ });
@@ -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 matrix = new DOMMatrixReadOnly(style.transform);
30
- originX = matrix.m41;
31
- originY = matrix.m42;
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', '');
@@ -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 './_test-helpers.js';
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
+ });