@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,458 @@
|
|
|
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 ATTR_GHOST = 'data-draggable-list-item-ghost';
|
|
69
|
+
const ATTR_GHOST_ACTIVE = 'data-draggable-list-item-ghost-active';
|
|
70
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
71
|
+
|
|
72
|
+
/** @type {{ container: Element; index: number } | null} */
|
|
73
|
+
function readSourceContext(host) {
|
|
74
|
+
const container = host.parentElement;
|
|
75
|
+
if (!container) return null;
|
|
76
|
+
const siblings = Array.from(container.children).filter((c) => c.matches?.('[data-draggable-list-item-id]'));
|
|
77
|
+
const index = siblings.indexOf(host);
|
|
78
|
+
return { container, index };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readContainerId(el) {
|
|
82
|
+
// Walk up looking for the nearest `[data-droppable-id]`.
|
|
83
|
+
/** @type {Element | null} */
|
|
84
|
+
let cur = el;
|
|
85
|
+
while (cur && cur !== document.body) {
|
|
86
|
+
if (cur.hasAttribute?.('data-droppable-id')) return cur.getAttribute('data-droppable-id');
|
|
87
|
+
cur = cur.parentElement;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function dropTargetFromPoint(x, y) {
|
|
93
|
+
const el = document.elementFromPoint(x, y);
|
|
94
|
+
if (!el) return null;
|
|
95
|
+
/** @type {Element | null} */
|
|
96
|
+
let cur = el;
|
|
97
|
+
while (cur && cur !== document.body) {
|
|
98
|
+
if (cur.hasAttribute?.('data-droppable-id')) return cur;
|
|
99
|
+
cur = cur.parentElement;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compute the column-relative insertion index at pointer (x, y) within
|
|
106
|
+
* a vertical-list droppable target. The returned index is in
|
|
107
|
+
* POST-REMOVAL coordinates — i.e., it counts items as if the dragging
|
|
108
|
+
* source had already been removed from the list.
|
|
109
|
+
*
|
|
110
|
+
* Why post-removal: the consumer's command (Move/Reorder) splices the
|
|
111
|
+
* source out FIRST, then splices in at `to_position`. If the trait
|
|
112
|
+
* counted the source in pre-removal coordinates, downward intra-list
|
|
113
|
+
* drags would land one slot too far (the source's current slot
|
|
114
|
+
* disappears post-removal, shifting indices down by one). Pre-removal
|
|
115
|
+
* cross-list drags happen to be immune, but the math is identical.
|
|
116
|
+
*
|
|
117
|
+
* Items are filtered to DIRECT CHILDREN with the trait's id attribute —
|
|
118
|
+
* not descendants — to keep nested droppables from poisoning the count.
|
|
119
|
+
* Zero-layout items (display:none, off-flow) are skipped.
|
|
120
|
+
*
|
|
121
|
+
* @param {Element} target — the droppable container ([data-droppable-id])
|
|
122
|
+
* @param {number} _x — pointer client X (unused for vertical-list math; kept for future horizontal-list support)
|
|
123
|
+
* @param {number} y — pointer client Y
|
|
124
|
+
* @param {string | null} sourceId — the dragging source's data-draggable-list-item-id; this item is
|
|
125
|
+
* skipped while counting so the result is in post-removal coordinates
|
|
126
|
+
* @returns {number} insertion index in [0, count_after_removal]
|
|
127
|
+
*/
|
|
128
|
+
function indexWithinTarget(target, _x, y, sourceId) {
|
|
129
|
+
// Direct children only. ChildList is small (usually < 50); filter is O(n).
|
|
130
|
+
// Pre-filter to source-skipped, layout-having items so we know which is
|
|
131
|
+
// truly last in post-removal coordinates.
|
|
132
|
+
const visible = [];
|
|
133
|
+
for (const child of target.children) {
|
|
134
|
+
if (!(child instanceof Element)) continue;
|
|
135
|
+
if (!child.hasAttribute('data-draggable-list-item-id')) continue;
|
|
136
|
+
if (sourceId && child.getAttribute('data-draggable-list-item-id') === sourceId) continue;
|
|
137
|
+
const r = child.getBoundingClientRect();
|
|
138
|
+
if (r.height === 0 && r.width === 0) continue;
|
|
139
|
+
visible.push(r);
|
|
140
|
+
}
|
|
141
|
+
if (visible.length === 0) return 0;
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < visible.length; i++) {
|
|
144
|
+
const r = visible[i];
|
|
145
|
+
const isLast = i === visible.length - 1;
|
|
146
|
+
if (isLast && y >= r.top) {
|
|
147
|
+
// Last item: cursor anywhere ON (or below) the last item's box →
|
|
148
|
+
// drop AFTER the last. Without this rule, dropping in the upper
|
|
149
|
+
// half of the last item resolves to "before last" = no-op for
|
|
150
|
+
// 3-item-list "move item 2 to item 3 slot" (the lifted source's
|
|
151
|
+
// DOM still occupies its old space, so cursor above last's mid
|
|
152
|
+
// is the only reachable target without leaving the section). The
|
|
153
|
+
// generous last-item zone matches user intuition: dragging onto
|
|
154
|
+
// the last item sends it to the bottom.
|
|
155
|
+
return visible.length;
|
|
156
|
+
}
|
|
157
|
+
// Standard midline test for non-last items (and for cursor above the
|
|
158
|
+
// last item's top edge): above midline → insert before this item.
|
|
159
|
+
if (y < r.top + r.height / 2) return i;
|
|
160
|
+
}
|
|
161
|
+
// Cursor is below every item's midline (and below last's top — only
|
|
162
|
+
// possible if the section has padding-bottom and cursor is in that gap).
|
|
163
|
+
return visible.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const draggableListItem = defineTrait({
|
|
167
|
+
name: 'draggable-list-item',
|
|
168
|
+
category: 'motion-positioning',
|
|
169
|
+
description: 'Pointer drag for list reordering; emits dnd-lift/drop-target-change/drop/drop-cancel',
|
|
170
|
+
attributes: [ATTR_LIFTING, ATTR_SOURCE_ID],
|
|
171
|
+
events: ['dnd-lift', 'dnd-drop-target-change', 'dnd-drop', 'dnd-drop-cancel'],
|
|
172
|
+
// Opt-in: when present, the trait renders a positioned clone of the
|
|
173
|
+
// host that follows the cursor during the drag (Pragmatic-DnD style
|
|
174
|
+
// ghost preview). Backward-compatible — absent = no ghost, current
|
|
175
|
+
// behavior. Style the ghost via consumer CSS using the
|
|
176
|
+
// [data-draggable-list-item-ghost-active] attribute.
|
|
177
|
+
config: [ATTR_GHOST],
|
|
178
|
+
setup({ host }) {
|
|
179
|
+
let id = host.getAttribute(ATTR_SOURCE_ID);
|
|
180
|
+
if (!id) {
|
|
181
|
+
id = `dlitem-${crypto.randomUUID().slice(0, 8)}`;
|
|
182
|
+
host.setAttribute(ATTR_SOURCE_ID, id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @type {{ x: number; y: number; pointerId: number; sourceCtx: ReturnType<typeof readSourceContext>; sourceContainerId: string | null } | null} */
|
|
186
|
+
let down = null;
|
|
187
|
+
let lifted = false;
|
|
188
|
+
/** @type {string | null} */
|
|
189
|
+
let lastTargetId = null;
|
|
190
|
+
let lastTargetIndex = -1;
|
|
191
|
+
/** @type {HTMLElement | null} */
|
|
192
|
+
let ghost = null;
|
|
193
|
+
|
|
194
|
+
function makeGhost() {
|
|
195
|
+
if (!host.hasAttribute(ATTR_GHOST)) return;
|
|
196
|
+
const r = host.getBoundingClientRect();
|
|
197
|
+
ghost = /** @type {HTMLElement} */ (host.cloneNode(true));
|
|
198
|
+
// Strip everything that could trigger interactive state on the clone:
|
|
199
|
+
// - trait IDs (registry hygiene)
|
|
200
|
+
// - tabindex (would attract focus when appended to body, painting
|
|
201
|
+
// a focus-visible outline that double-traces the ghost)
|
|
202
|
+
// - aria-* state (clone is presentational; the source remains the
|
|
203
|
+
// accessible focus target during drag)
|
|
204
|
+
// - data-selected (visual selection state belongs to the source)
|
|
205
|
+
// - id (no DOM-id duplicates)
|
|
206
|
+
ghost.removeAttribute(ATTR_SOURCE_ID);
|
|
207
|
+
ghost.removeAttribute(ATTR_LIFTING);
|
|
208
|
+
ghost.removeAttribute('data-keyboard-reorderable-id');
|
|
209
|
+
ghost.removeAttribute('data-keyboard-reorderable-lifting');
|
|
210
|
+
ghost.removeAttribute('data-selected');
|
|
211
|
+
ghost.removeAttribute('id');
|
|
212
|
+
ghost.removeAttribute('tabindex');
|
|
213
|
+
ghost.removeAttribute('aria-selected');
|
|
214
|
+
ghost.setAttribute('aria-hidden', 'true');
|
|
215
|
+
Object.assign(ghost.style, {
|
|
216
|
+
position: 'fixed',
|
|
217
|
+
left: `${r.left}px`,
|
|
218
|
+
top: `${r.top}px`,
|
|
219
|
+
width: `${r.width}px`,
|
|
220
|
+
margin: '0',
|
|
221
|
+
pointerEvents: 'none',
|
|
222
|
+
// Belt-and-suspenders: explicitly suppress outline + animation
|
|
223
|
+
// so the cloned element never paints a focus ring or competes
|
|
224
|
+
// with the consumer's [data-…-ghost-active] CSS for animation.
|
|
225
|
+
outline: 'none',
|
|
226
|
+
// Top-layer-ish; avoid 2147483647 to leave room for genuine top-layer
|
|
227
|
+
// surfaces (toasts, dialogs that may fire during drag).
|
|
228
|
+
zIndex: '10000',
|
|
229
|
+
transition: 'none',
|
|
230
|
+
});
|
|
231
|
+
ghost.setAttribute(ATTR_GHOST_ACTIVE, '');
|
|
232
|
+
document.body.appendChild(ghost);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function moveGhost(x, y) {
|
|
236
|
+
if (!ghost || !down) return;
|
|
237
|
+
// Translate the ghost by the cursor delta from the original pointerdown
|
|
238
|
+
// so the cursor stays at the same relative position inside the ghost.
|
|
239
|
+
ghost.style.translate = `${x - down.x}px ${y - down.y}px`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function removeGhost() {
|
|
243
|
+
if (ghost?.parentNode) ghost.parentNode.removeChild(ghost);
|
|
244
|
+
ghost = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isDisabled() {
|
|
248
|
+
return host.hasAttribute('disabled') || host.getAttribute('aria-disabled') === 'true';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function reset() {
|
|
252
|
+
if (down && host.hasPointerCapture?.(down.pointerId)) {
|
|
253
|
+
try { host.releasePointerCapture(down.pointerId); } catch { /* ignore */ }
|
|
254
|
+
}
|
|
255
|
+
down = null;
|
|
256
|
+
lifted = false;
|
|
257
|
+
lastTargetId = null;
|
|
258
|
+
lastTargetIndex = -1;
|
|
259
|
+
host.removeAttribute(ATTR_LIFTING);
|
|
260
|
+
removeGhost();
|
|
261
|
+
// Always remove the keydown listener — even on disconnect — so a
|
|
262
|
+
// disposed trait can never keep firing Esc-cancel handlers against
|
|
263
|
+
// stale closures.
|
|
264
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
265
|
+
// Always remove the document-level fallback listeners too. They
|
|
266
|
+
// catch the case where pointerup fires outside the host's bounds
|
|
267
|
+
// and pointer capture didn't deliver it (browser quirks under
|
|
268
|
+
// tab-blur, popup-open, focus-loss). Re-armed on the next lift.
|
|
269
|
+
document.removeEventListener('pointerup', onDocPointerUp, true);
|
|
270
|
+
document.removeEventListener('pointercancel', onDocPointerCancel, true);
|
|
271
|
+
window.removeEventListener('blur', onWindowBlur);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Pointer-event safety net — if the host's own pointerup doesn't
|
|
276
|
+
* fire (browser bug, focus loss mid-drag, popup steals events),
|
|
277
|
+
* the document-level handler catches the release and runs the
|
|
278
|
+
* same cancel-or-drop logic. Idempotent with the host handler:
|
|
279
|
+
* whichever fires first runs reset(); the other is gated by the
|
|
280
|
+
* `down=null` short-circuit.
|
|
281
|
+
*/
|
|
282
|
+
function onDocPointerUp(e) {
|
|
283
|
+
if (!down || down.pointerId !== e.pointerId) return;
|
|
284
|
+
onPointerUp(e);
|
|
285
|
+
}
|
|
286
|
+
function onDocPointerCancel(e) {
|
|
287
|
+
if (!down || down.pointerId !== e.pointerId) return;
|
|
288
|
+
if (lifted) {
|
|
289
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
290
|
+
bubbles: true,
|
|
291
|
+
composed: false,
|
|
292
|
+
detail: { source_id: id, reason: 'pointer-cancel' },
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
reset();
|
|
296
|
+
}
|
|
297
|
+
/** Window blur during a drag → cancel. Covers Cmd-Tab / popup-blocker / fullscreen escape. */
|
|
298
|
+
function onWindowBlur() {
|
|
299
|
+
if (!down) return;
|
|
300
|
+
if (lifted) {
|
|
301
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
302
|
+
bubbles: true,
|
|
303
|
+
composed: false,
|
|
304
|
+
detail: { source_id: id, reason: 'blur' },
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
reset();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function onPointerDown(e) {
|
|
311
|
+
if (isDisabled()) return;
|
|
312
|
+
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
313
|
+
|
|
314
|
+
const sourceCtx = readSourceContext(host);
|
|
315
|
+
if (!sourceCtx) return;
|
|
316
|
+
|
|
317
|
+
down = {
|
|
318
|
+
x: e.clientX,
|
|
319
|
+
y: e.clientY,
|
|
320
|
+
pointerId: e.pointerId,
|
|
321
|
+
sourceCtx,
|
|
322
|
+
sourceContainerId: readContainerId(sourceCtx.container),
|
|
323
|
+
};
|
|
324
|
+
// Hold the pointer so we keep getting move/up even outside the host.
|
|
325
|
+
try { host.setPointerCapture(e.pointerId); } catch { /* ignore */ }
|
|
326
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
327
|
+
// Document-level safety net for pointerup / pointercancel. Capture
|
|
328
|
+
// phase (true) so we beat any consumer that calls stopPropagation.
|
|
329
|
+
// host's own pointerup/cancel still fire normally; whichever lands
|
|
330
|
+
// first runs the cleanup, the other no-ops via the down-null guard.
|
|
331
|
+
document.addEventListener('pointerup', onDocPointerUp, true);
|
|
332
|
+
document.addEventListener('pointercancel', onDocPointerCancel, true);
|
|
333
|
+
window.addEventListener('blur', onWindowBlur);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function onPointerMove(e) {
|
|
337
|
+
if (!down) return;
|
|
338
|
+
const dx = e.clientX - down.x;
|
|
339
|
+
const dy = e.clientY - down.y;
|
|
340
|
+
|
|
341
|
+
if (!lifted) {
|
|
342
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
343
|
+
lifted = true;
|
|
344
|
+
host.setAttribute(ATTR_LIFTING, '');
|
|
345
|
+
// Stamp the ghost preview AFTER setting the lifting attribute so
|
|
346
|
+
// the host's lifted state (opacity, etc.) is captured in the
|
|
347
|
+
// clone snapshot — except: we explicitly removed ATTR_LIFTING from
|
|
348
|
+
// the clone in makeGhost() so the ghost itself renders as a
|
|
349
|
+
// non-lifted card. Net effect: source dims, ghost looks "alive".
|
|
350
|
+
makeGhost();
|
|
351
|
+
host.dispatchEvent(new CustomEvent('dnd-lift', {
|
|
352
|
+
bubbles: true,
|
|
353
|
+
composed: false,
|
|
354
|
+
detail: {
|
|
355
|
+
source_id: id,
|
|
356
|
+
source_index: down.sourceCtx.index,
|
|
357
|
+
source_container_id: down.sourceContainerId,
|
|
358
|
+
},
|
|
359
|
+
}));
|
|
360
|
+
announceImmediate(`Picked up item. Press arrow keys to navigate. Press Space to drop. Press Esc to cancel.`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Update the ghost on every move (cheap — single style write).
|
|
364
|
+
moveGhost(e.clientX, e.clientY);
|
|
365
|
+
|
|
366
|
+
const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
|
|
367
|
+
const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
|
|
368
|
+
const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY, id) : -1;
|
|
369
|
+
|
|
370
|
+
if (targetId !== lastTargetId || targetIdx !== lastTargetIndex) {
|
|
371
|
+
lastTargetId = targetId;
|
|
372
|
+
lastTargetIndex = targetIdx;
|
|
373
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-target-change', {
|
|
374
|
+
bubbles: true,
|
|
375
|
+
composed: false,
|
|
376
|
+
detail: {
|
|
377
|
+
source_id: id,
|
|
378
|
+
target_container_id: targetId,
|
|
379
|
+
target_index: targetIdx,
|
|
380
|
+
pointer: { x: e.clientX, y: e.clientY },
|
|
381
|
+
},
|
|
382
|
+
}));
|
|
383
|
+
if (targetId) announceDebounced(`Drop position ${targetIdx + 1} in ${targetId}.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function onPointerUp(e) {
|
|
388
|
+
if (!down) return;
|
|
389
|
+
if (!lifted) { reset(); return; }
|
|
390
|
+
|
|
391
|
+
const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
|
|
392
|
+
const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
|
|
393
|
+
|
|
394
|
+
if (!targetId) {
|
|
395
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
396
|
+
bubbles: true,
|
|
397
|
+
composed: false,
|
|
398
|
+
detail: { source_id: id, reason: 'off-target' },
|
|
399
|
+
}));
|
|
400
|
+
announceImmediate(`Drag cancelled. Item returned to its original position.`);
|
|
401
|
+
} else {
|
|
402
|
+
const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY, id);
|
|
403
|
+
document.dispatchEvent(new CustomEvent('dnd-drop', {
|
|
404
|
+
bubbles: true,
|
|
405
|
+
composed: false,
|
|
406
|
+
detail: {
|
|
407
|
+
source_id: id,
|
|
408
|
+
source_index: down.sourceCtx.index,
|
|
409
|
+
source_container_id: down.sourceContainerId,
|
|
410
|
+
target_container_id: targetId,
|
|
411
|
+
target_index: targetIdx,
|
|
412
|
+
},
|
|
413
|
+
}));
|
|
414
|
+
announceImmediate(`Dropped item in ${targetId} at position ${targetIdx + 1}.`);
|
|
415
|
+
}
|
|
416
|
+
reset();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function onKeyDown(e) {
|
|
420
|
+
if (e.key === 'Escape' && lifted) {
|
|
421
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
422
|
+
bubbles: true,
|
|
423
|
+
composed: false,
|
|
424
|
+
detail: { source_id: id, reason: 'esc' },
|
|
425
|
+
}));
|
|
426
|
+
announceImmediate(`Drag cancelled. Item returned to its original position.`);
|
|
427
|
+
reset();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function onHostPointerCancel() {
|
|
432
|
+
if (lifted) {
|
|
433
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
434
|
+
bubbles: true,
|
|
435
|
+
composed: false,
|
|
436
|
+
detail: { source_id: id, reason: 'pointer-cancel' },
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
reset();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
host.addEventListener('pointerdown', onPointerDown);
|
|
443
|
+
host.addEventListener('pointermove', onPointerMove);
|
|
444
|
+
host.addEventListener('pointerup', onPointerUp);
|
|
445
|
+
host.addEventListener('pointercancel', onHostPointerCancel);
|
|
446
|
+
|
|
447
|
+
return () => {
|
|
448
|
+
host.removeEventListener('pointerdown', onPointerDown);
|
|
449
|
+
host.removeEventListener('pointermove', onPointerMove);
|
|
450
|
+
host.removeEventListener('pointerup', onPointerUp);
|
|
451
|
+
host.removeEventListener('pointercancel', onHostPointerCancel);
|
|
452
|
+
// reset() removes the document-level + keydown + window-blur
|
|
453
|
+
// listeners and the host-level lifting/ghost state.
|
|
454
|
+
reset();
|
|
455
|
+
host.removeAttribute(ATTR_SOURCE_ID);
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { draggableListItem } from './draggable-list-item.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
// Minimal smoke coverage. The draggable-list-item trait is the pointer
|
|
6
|
+
// driver for the docs/projects/tasks-playground/ DnD model. The cases
|
|
7
|
+
// below exercise only its own surface (schema, attribute lifecycle,
|
|
8
|
+
// id assignment, idempotent disconnect). Full pointer-flow tests need a
|
|
9
|
+
// laid-out DOM with sibling droppables and live elementFromPoint —
|
|
10
|
+
// those belong with the playground integration suite, not the trait
|
|
11
|
+
// gate.
|
|
12
|
+
|
|
13
|
+
describe('draggable-list-item', () => {
|
|
14
|
+
beforeEach(resetDOM);
|
|
15
|
+
|
|
16
|
+
it('schema shape is defined', () => {
|
|
17
|
+
expect(draggableListItem.schema.name).toBe('draggable-list-item');
|
|
18
|
+
expect(draggableListItem.schema.category).toBe('motion-positioning');
|
|
19
|
+
expect(draggableListItem.schema.description.length).toBeGreaterThanOrEqual(11);
|
|
20
|
+
expect(draggableListItem.schema.events).toContain('dnd-lift');
|
|
21
|
+
expect(draggableListItem.schema.events).toContain('dnd-drop');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('connect assigns an id + sets the host attribute', () => {
|
|
25
|
+
const host = mountHost('div');
|
|
26
|
+
connectTrait(draggableListItem, host);
|
|
27
|
+
expect(host.getAttribute('data-draggable-list-item-id')).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('disconnect clears its attributes', () => {
|
|
31
|
+
const host = mountHost('div');
|
|
32
|
+
const inst = draggableListItem();
|
|
33
|
+
inst.connect(host);
|
|
34
|
+
host.setAttribute('data-draggable-list-item-lifting', '');
|
|
35
|
+
inst.disconnect(host);
|
|
36
|
+
expect(host.hasAttribute('data-draggable-list-item-lifting')).toBe(false);
|
|
37
|
+
expect(host.hasAttribute('data-draggable-list-item-id')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('reconnect with the same host does not throw', () => {
|
|
41
|
+
const host = mountHost('div');
|
|
42
|
+
const inst = draggableListItem();
|
|
43
|
+
inst.connect(host);
|
|
44
|
+
inst.disconnect(host);
|
|
45
|
+
expect(() => {
|
|
46
|
+
const inst2 = draggableListItem();
|
|
47
|
+
inst2.connect(host);
|
|
48
|
+
inst2.disconnect(host);
|
|
49
|
+
}).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
package/traits/draggable.js
CHANGED
|
@@ -24,11 +24,21 @@ export const draggable = defineTrait({
|
|
|
24
24
|
startX = e.clientX;
|
|
25
25
|
startY = e.clientY;
|
|
26
26
|
|
|
27
|
-
// Parse current translate values
|
|
27
|
+
// Parse current translate values. The trait writes to `style.translate`
|
|
28
|
+
// (independent of `transform` in the modern CSS model). Read translate
|
|
29
|
+
// first so subsequent drags pick up the current position, then fall back
|
|
30
|
+
// to the transform matrix for callers that nudged via `transform`.
|
|
28
31
|
const style = getComputedStyle(host);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const t = style.translate;
|
|
33
|
+
if (t && t !== 'none') {
|
|
34
|
+
const parts = t.split(/\s+/).map(parseFloat);
|
|
35
|
+
originX = parts[0] || 0;
|
|
36
|
+
originY = parts[1] || 0;
|
|
37
|
+
} else {
|
|
38
|
+
const matrix = new DOMMatrixReadOnly(style.transform);
|
|
39
|
+
originX = matrix.m41;
|
|
40
|
+
originY = matrix.m42;
|
|
41
|
+
}
|
|
32
42
|
|
|
33
43
|
host.setPointerCapture(e.pointerId);
|
|
34
44
|
host.setAttribute('data-draggable-dragging', '');
|
package/traits/draggable.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { draggable } from './draggable.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
function pointer(host, type, x, y) {
|
|
6
6
|
const ev = new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true });
|