@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,143 @@
1
+ /**
2
+ * Shared particle-stage for confetti + confetti-burst traits.
3
+ *
4
+ * Strategy: a session-long body-level singleton `<div popover="manual">`
5
+ * that the Popover API auto-promotes to the browser's top-layer once
6
+ * `showPopover()` is called. Particles spawn as absolutely-positioned
7
+ * `<span>` children of this stage with viewport-relative coordinates
8
+ * read from each host's `getBoundingClientRect()`. Effect: bursts
9
+ * escape `overflow: hidden` ancestors and render above modals,
10
+ * drawers, and any z-stacking context — same anchoring as a
11
+ * top-layer popover.
12
+ *
13
+ * The stage is created lazily on first call, never removed. Both
14
+ * `confetti` and `confetti-burst` import `getStage()` and share the
15
+ * singleton — there's no per-trait container, no z-index war, and no
16
+ * tear-down race when bursts overlap.
17
+ *
18
+ * Notes:
19
+ * - happy-dom stubs `showPopover` to a no-op (see test-setup.js), so
20
+ * the try/catch only matters in browsers without the Popover API
21
+ * (none of our baseline browsers since 2026; Chromium 125+, Safari
22
+ * 17+, Firefox 125+ all ship it).
23
+ * - Each particle self-removes on `animationend`. The stage is never
24
+ * torn down — it's a session-long shared resource.
25
+ */
26
+
27
+ const STAGE_ID = 'adia-confetti-stage';
28
+
29
+ let stage = null;
30
+ let stylesInjected = false;
31
+
32
+ /**
33
+ * Lazily create + return the singleton popover stage. Idempotent —
34
+ * subsequent calls return the cached node (or rehydrate if it was
35
+ * removed externally, e.g. by a hot-reload).
36
+ */
37
+ export function getStage() {
38
+ if (stage && stage.isConnected) return stage;
39
+
40
+ // Existing instance from a prior session / hot-reload?
41
+ const existing = document.getElementById(STAGE_ID);
42
+ if (existing) {
43
+ stage = existing;
44
+ showSilently(stage);
45
+ ensureStyles();
46
+ return stage;
47
+ }
48
+
49
+ const el = document.createElement('div');
50
+ el.id = STAGE_ID;
51
+ el.setAttribute('aria-hidden', 'true');
52
+ // popover="manual" — auto-promoted to the top-layer once shown,
53
+ // and immune to light-dismiss (no spurious close on outside click).
54
+ if ('popover' in HTMLElement.prototype) el.setAttribute('popover', 'manual');
55
+ el.style.cssText =
56
+ 'position:fixed;inset:0;margin:0;padding:0;border:0;background:transparent;' +
57
+ 'pointer-events:none;overflow:visible;z-index:99999;';
58
+ document.body.appendChild(el);
59
+ showSilently(el);
60
+ stage = el;
61
+ ensureStyles();
62
+ return stage;
63
+ }
64
+
65
+ function showSilently(el) {
66
+ // showPopover throws InvalidStateError if already shown. Guard with
67
+ // try/catch — cheaper than checking matches(':popover-open'), which
68
+ // happy-dom doesn't implement either.
69
+ try { el.showPopover?.(); } catch { /* already shown or unsupported */ }
70
+ }
71
+
72
+ /**
73
+ * Compute a host's center in viewport coordinates. Returns null if
74
+ * the host is detached (rect = 0×0 at 0,0 — bursts on a detached
75
+ * host would render at the viewport corner, so callers should bail).
76
+ */
77
+ export function viewportCenterOf(host) {
78
+ const r = host.getBoundingClientRect();
79
+ // Detached / display:none host — width+height both 0 with all-zero
80
+ // coords. Treat as "no anchor" and let the trait fall back.
81
+ if (r.width === 0 && r.height === 0 && r.left === 0 && r.top === 0) return null;
82
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
83
+ }
84
+
85
+ /**
86
+ * Spawn a single particle <span> into the stage. Caller passes the
87
+ * starting viewport coords (from `viewportCenterOf(host)` plus any
88
+ * per-particle jitter the physics layer adds) and the visual props.
89
+ *
90
+ * The returned element self-removes on `animationend`. Caller is
91
+ * responsible for triggering motion (CSS transition, Web Animations
92
+ * API keyframes, or an inline `animation: …` rule).
93
+ */
94
+ export function spawnParticle(stage, x, y, { size = 6, color = '#f44' } = {}) {
95
+ const p = document.createElement('span');
96
+ p.style.cssText =
97
+ 'position:absolute;' +
98
+ `left:${x}px;top:${y}px;` +
99
+ `width:${size}px;height:${size}px;background:${color};` +
100
+ 'border-radius:1px;pointer-events:none;will-change:transform,opacity;';
101
+ stage.appendChild(p);
102
+ p.addEventListener('animationend', () => p.remove(), { once: true });
103
+ return p;
104
+ }
105
+
106
+ /**
107
+ * Inject the @keyframes blocks the particles animate on. One-time,
108
+ * idempotent — guarded by a module flag. The keyframes drift each
109
+ * particle along a random vector; per-particle randomness is set via
110
+ * inline CSS custom properties (`--dx`, `--dy`, `--rot`).
111
+ */
112
+ function ensureStyles() {
113
+ if (stylesInjected) return;
114
+ if (typeof document === 'undefined') return;
115
+ const styleEl = document.createElement('style');
116
+ styleEl.id = 'adia-confetti-stage-styles';
117
+ styleEl.textContent = `
118
+ @keyframes adia-confetti-fountain {
119
+ 0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
120
+ 100% { transform: translate(var(--dx, 0), var(--dy, 0)) rotate(var(--rot, 0deg)); opacity: 0; }
121
+ }
122
+ @keyframes adia-confetti-fall {
123
+ 0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
124
+ 100% { transform: translate(var(--dx, 0), var(--dy, 0)) rotate(var(--rot, 0deg)); opacity: 0; }
125
+ }
126
+ `;
127
+ document.head?.appendChild(styleEl);
128
+ stylesInjected = true;
129
+ }
130
+
131
+ export const SHARED_COLORS = ['#f44', '#4a4', '#44f', '#ff4', '#f4f', '#4ff'];
132
+
133
+ /**
134
+ * Test-only: tear down the stage so test suites don't leak particles
135
+ * across cases. Not part of the public surface.
136
+ */
137
+ export function _resetStage() {
138
+ if (stage && stage.parentNode) stage.remove();
139
+ const styleEl = document.getElementById('adia-confetti-stage-styles');
140
+ if (styleEl) styleEl.remove();
141
+ stage = null;
142
+ stylesInjected = false;
143
+ }
@@ -1,6 +1,21 @@
1
1
  import { defineTrait } from './define.js';
2
- import { prefersReducedMotion } from './_motion.js';
2
+ import { prefersReducedMotion } from './motion.js';
3
+ import { getStage, viewportCenterOf, spawnParticle, SHARED_COLORS } from './confetti-stage.js';
3
4
 
5
+ /**
6
+ * `confetti` — falling-shower particle burst on connect.
7
+ *
8
+ * Particles are pushed into the singleton body-level
9
+ * `<div popover="manual" id="adia-confetti-stage">`, anchored to the
10
+ * host's viewport position at fire-time. The Popover API auto-promotes
11
+ * the stage to the browser's top-layer, so the shower escapes
12
+ * `overflow: hidden` ancestors and renders above modals, drawers, and
13
+ * any z-stacking context.
14
+ *
15
+ * Anchor model: viewport-fixed at fire-time. If the user scrolls
16
+ * during the 1.5s shower, the particles continue from their original
17
+ * viewport position — same UX as standard web-confetti libraries.
18
+ */
4
19
  export const confetti = defineTrait({
5
20
  name: 'confetti',
6
21
  category: 'interaction-delight',
@@ -15,61 +30,43 @@ export const confetti = defineTrait({
15
30
  return () => host.removeAttribute('data-confetti-active');
16
31
  }
17
32
 
18
- const canvas = document.createElement('canvas');
19
- canvas.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9999;';
20
- host.style.position = host.style.position || 'relative';
21
- host.appendChild(canvas);
33
+ const stage = getStage();
34
+ const center = viewportCenterOf(host);
22
35
 
23
- const ctx = canvas.getContext('2d');
24
- let rafId = null;
25
- let running = true;
36
+ host.setAttribute('data-confetti-active', '');
26
37
 
27
- // Bail gracefully when canvas 2D isn't available (SSR, JSDOM, happy-dom).
28
- if (!ctx) {
29
- host.setAttribute('data-confetti-active', '');
30
- return () => {
31
- canvas.remove();
32
- host.removeAttribute('data-confetti-active');
33
- };
34
- }
38
+ // Fire one shower of ~60 particles. They fall from a band centered
39
+ // on the host's top edge, drift sideways under wind, and fade out.
40
+ if (center) {
41
+ const rect = host.getBoundingClientRect();
42
+ const halfW = Math.max(rect.width, 80) / 2;
43
+ // Band starts a hair above the host so the first frame lands
44
+ // visibly inside the host's bounds, then drifts down.
45
+ const startY = center.y - rect.height / 2 - 8;
35
46
 
36
- const colors = ['#f44', '#4a4', '#44f', '#ff4', '#f4f', '#4ff'];
37
- const particles = Array.from({ length: 60 }, () => ({
38
- x: Math.random(),
39
- y: Math.random() * -1,
40
- vx: (Math.random() - 0.5) * 2,
41
- vy: Math.random() * 2 + 1,
42
- size: Math.random() * 4 + 2,
43
- color: colors[Math.floor(Math.random() * colors.length)],
44
- }));
47
+ for (let i = 0; i < 60; i++) {
48
+ const x = center.x + (Math.random() - 0.5) * halfW * 2;
49
+ const y = startY + Math.random() * 12;
50
+ const dx = (Math.random() - 0.5) * 80;
51
+ const dy = 200 + Math.random() * 240;
52
+ const rot = (Math.random() - 0.5) * 540;
53
+ const size = Math.random() * 4 + 2;
54
+ const color = SHARED_COLORS[Math.floor(Math.random() * SHARED_COLORS.length)];
55
+ const delay = Math.random() * 600;
56
+ const dur = 900 + Math.random() * 700;
45
57
 
46
- function resize() {
47
- canvas.width = host.offsetWidth;
48
- canvas.height = host.offsetHeight;
49
- }
50
-
51
- function tick() {
52
- if (!running) return;
53
- resize();
54
- ctx.clearRect(0, 0, canvas.width, canvas.height);
55
- for (const p of particles) {
56
- p.x += p.vx * 0.002;
57
- p.y += p.vy * 0.003;
58
- if (p.y > 1.1) { p.y = -0.1; p.x = Math.random(); }
59
- ctx.fillStyle = p.color;
60
- ctx.fillRect(p.x * canvas.width, p.y * canvas.height, p.size, p.size);
58
+ const p = spawnParticle(stage, x, y, { size, color });
59
+ p.style.setProperty('--dx', `${dx}px`);
60
+ p.style.setProperty('--dy', `${dy}px`);
61
+ p.style.setProperty('--rot', `${rot}deg`);
62
+ p.style.animation = `adia-confetti-fall ${dur}ms ${delay}ms ease-in forwards`;
61
63
  }
62
- rafId = requestAnimationFrame(tick);
63
64
  }
64
65
 
65
- host.setAttribute('data-confetti-active', '');
66
- tick();
67
-
68
66
  return () => {
69
- running = false;
70
- if (rafId) cancelAnimationFrame(rafId);
71
- canvas.remove();
72
67
  host.removeAttribute('data-confetti-active');
68
+ // In-flight particles self-remove on animationend; nothing to
69
+ // tear down on the stage (it's a session-long singleton).
73
70
  };
74
71
  },
75
72
  });
@@ -1,9 +1,13 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { confetti } from './confetti.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { _resetStage } from './confetti-stage.js';
4
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
5
 
5
6
  describe('confetti', () => {
6
- beforeEach(resetDOM);
7
+ beforeEach(() => {
8
+ resetDOM();
9
+ _resetStage();
10
+ });
7
11
 
8
12
  it('connect sets data-confetti-active without throwing', () => {
9
13
  const host = mountHost();
@@ -18,10 +22,25 @@ describe('confetti', () => {
18
22
  expect(host.hasAttribute('data-confetti-active')).toBe(false);
19
23
  });
20
24
 
21
- it('without canvas (happy-dom): graceful degradation', () => {
22
- // happy-dom returns null from canvas.getContext('2d'). The trait must
23
- // null-check and not crash. Caught one of the original brittleness bugs.
25
+ it('no canvas: graceful degradation in happy-dom', () => {
26
+ // Old contract: this trait used a host-scoped <canvas>. New
27
+ // contract: it pushes <span> particles into the body-level
28
+ // popover stage (no canvas anywhere). The test name is kept for
29
+ // continuity — the assertion is the same: connect must not throw
30
+ // in environments without real layout (rect = 0,0,0,0 in
31
+ // happy-dom; viewportCenterOf returns null and the trait skips
32
+ // particle spawning gracefully).
24
33
  const host = mountHost();
25
34
  expect(() => connectTrait(confetti, host)).not.toThrow();
26
35
  });
36
+
37
+ it('does not append particles inside the host (stage is body-level)', () => {
38
+ const host = mountHost();
39
+ const inst = connectTrait(confetti, host);
40
+ // The old implementation appended a <canvas> child to the host.
41
+ // The new implementation must leave the host's children alone —
42
+ // every particle belongs to the body-level popover stage.
43
+ expect(host.children.length).toBe(0);
44
+ inst.disconnect(host);
45
+ });
27
46
  });
@@ -1,5 +1,5 @@
1
1
  import { defineTrait } from './define.js';
2
- import { prefersReducedMotion } from './_motion.js';
2
+ import { prefersReducedMotion } from './motion.js';
3
3
 
4
4
  export const countUp = defineTrait({
5
5
  name: 'count-up',
@@ -7,14 +7,34 @@ export const countUp = defineTrait({
7
7
  description: 'Animated numeric transitions',
8
8
  attributes: ['data-count-up-active'],
9
9
  events: ['count-up-done'],
10
- config: ['data-count-up-target', 'data-count-duration'],
10
+ config: [
11
+ 'data-count-up-target',
12
+ 'data-count-duration',
13
+ 'data-count-prefix',
14
+ 'data-count-suffix',
15
+ 'data-count-decimals',
16
+ 'data-count-locale',
17
+ ],
11
18
  setup({ host }) {
12
- const target = parseFloat(host.getAttribute('data-count-up-target')) || 0;
19
+ const target = parseFloat(host.getAttribute('data-count-up-target')) || 0;
13
20
  const duration = parseInt(host.getAttribute('data-count-duration'), 10) || 1000;
21
+ const prefix = host.getAttribute('data-count-prefix') ?? '';
22
+ const suffix = host.getAttribute('data-count-suffix') ?? '';
23
+ const decimals = Math.max(0, parseInt(host.getAttribute('data-count-decimals'), 10) || 0);
24
+ const locale = host.getAttribute('data-count-locale') || undefined;
25
+
26
+ // Locale-aware separators ("2,438,000" not "2438000"). The decimals attr
27
+ // controls the fraction-digit count for currency / percentage cases
28
+ // ($2,438,000.00 with decimals=2; 12.5% with decimals=1).
29
+ const formatter = new Intl.NumberFormat(locale, {
30
+ minimumFractionDigits: decimals,
31
+ maximumFractionDigits: decimals,
32
+ });
33
+ const fmt = (value) => `${prefix}${formatter.format(value)}${suffix}`;
14
34
 
15
35
  // Reduced-motion: jump straight to target and fire done event.
16
36
  if (prefersReducedMotion()) {
17
- host.textContent = target;
37
+ host.textContent = fmt(target);
18
38
  host.setAttribute('data-count-up-active', '');
19
39
  queueMicrotask(() => {
20
40
  host.removeAttribute('data-count-up-active');
@@ -38,12 +58,17 @@ export const countUp = defineTrait({
38
58
  const elapsed = now - start;
39
59
  const progress = Math.min(elapsed / duration, 1);
40
60
  const value = target * easeOutQuart(progress);
41
- host.textContent = Math.round(value);
61
+ // Round to the configured decimal precision so the displayed value
62
+ // doesn't flicker between fraction-bearing intermediates.
63
+ const rounded = decimals > 0
64
+ ? Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals)
65
+ : Math.round(value);
66
+ host.textContent = fmt(rounded);
42
67
 
43
68
  if (progress < 1) {
44
69
  rafId = requestAnimationFrame(tick);
45
70
  } else {
46
- host.textContent = target;
71
+ host.textContent = fmt(target);
47
72
  host.removeAttribute('data-count-up-active');
48
73
  host.dispatchEvent(new CustomEvent('count-up-done', { bubbles: true }));
49
74
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { countUp } from './count-up.js';
3
- import { mountHost, connectTrait, spyEvent, resetDOM, wait, raf } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM, wait, raf } from './test-helpers.js';
4
4
 
5
5
  describe('count-up', () => {
6
6
  beforeEach(resetDOM);
@@ -16,7 +16,7 @@ import { UIElement } from '../core/element.js';
16
16
  import { pressable } from './pressable.js';
17
17
  import { focusable } from './focusable.js';
18
18
  import { hoverable } from './hoverable.js';
19
- import { resetDOM } from './_test-helpers.js';
19
+ import { resetDOM } from './test-helpers.js';
20
20
 
21
21
  let counter = 0;
22
22
  function defineUniqueTag(BaseImpl) {
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { dirtyState } from './dirty-state.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('dirty-state', () => {
6
6
  beforeEach(resetDOM);
@@ -9,16 +9,66 @@ export const dragGhost = defineTrait({
9
9
  config: [],
10
10
  setup({ host }) {
11
11
  let ghost = null;
12
+ let clickOffsetX = 0;
13
+ let clickOffsetY = 0;
14
+
15
+ function onPointerDown(e) {
16
+ // Capture click offset against host's visual box (post-transform).
17
+ const rect = host.getBoundingClientRect();
18
+ clickOffsetX = e.clientX - rect.left;
19
+ clickOffsetY = e.clientY - rect.top;
20
+ }
12
21
 
13
22
  function onDragStart(e) {
23
+ // Snapshot is taken synchronously at setDragImage() time. We position
24
+ // the ghost over the host's exact viewport rect so the bitmap is
25
+ // pixel-identical to what the user clicked — no CSS-scope drift, no
26
+ // box-sizing surprises, no transform mismatch. The clone is removed
27
+ // before the browser paints its next frame (drag image is the
28
+ // browser's own translucent overlay on the cursor), so the user
29
+ // never sees a doubled element.
30
+ //
31
+ // The data-drag-ghost-active attribute is set AFTER setDragImage(),
32
+ // so the ghost snapshot doesn't pick up the dragged-state styling
33
+ // (e.g. opacity: 0.4 on the original).
34
+ const rect = host.getBoundingClientRect();
35
+ const cs = getComputedStyle(host);
36
+
14
37
  ghost = host.cloneNode(true);
38
+ // box-sizing: border-box makes width == rect.width regardless of
39
+ // whether the host was content-box; rect.width/.height already are
40
+ // the host's border-box dimensions (getBoundingClientRect post-
41
+ // transform visual box). margin: 0 prevents collapse-into-body
42
+ // margins from offsetting the ghost.
15
43
  ghost.style.cssText = `
16
- position: fixed; top: -9999px; left: -9999px;
17
- pointer-events: none; opacity: 0.8; z-index: 99999;
44
+ position: fixed;
45
+ left: ${rect.left}px; top: ${rect.top}px;
46
+ width: ${rect.width}px; height: ${rect.height}px;
47
+ box-sizing: border-box;
48
+ margin: 0;
49
+ pointer-events: none;
50
+ opacity: 0.8;
51
+ z-index: 99999;
18
52
  `;
53
+ // Preserve the host's transform so a scaled/rotated host produces a
54
+ // matching ghost bitmap.
55
+ if (cs.transform && cs.transform !== 'none') {
56
+ ghost.style.transform = cs.transform;
57
+ ghost.style.transformOrigin = cs.transformOrigin;
58
+ }
19
59
  document.body.appendChild(ghost);
20
- e.dataTransfer?.setDragImage(ghost, 0, 0);
60
+ e.dataTransfer?.setDragImage(ghost, clickOffsetX, clickOffsetY);
21
61
  host.setAttribute('data-drag-ghost-active', '');
62
+ // Hide the ghost in the next microtask — setDragImage already took
63
+ // its snapshot synchronously. We hide rather than remove so any
64
+ // browser that re-reads the element during the drag cycle (rare,
65
+ // but Safari has historically done this on drop) still finds it.
66
+ // We hide via visibility (paint suppressed) rather than
67
+ // display:none (which would zero the bitmap if the browser ever
68
+ // re-snapshots).
69
+ queueMicrotask(() => {
70
+ if (ghost) ghost.style.visibility = 'hidden';
71
+ });
22
72
  }
23
73
 
24
74
  function onDragEnd() {
@@ -27,10 +77,12 @@ export const dragGhost = defineTrait({
27
77
  }
28
78
 
29
79
  host.setAttribute('draggable', 'true');
80
+ host.addEventListener('pointerdown', onPointerDown);
30
81
  host.addEventListener('dragstart', onDragStart);
31
82
  host.addEventListener('dragend', onDragEnd);
32
83
 
33
84
  return () => {
85
+ host.removeEventListener('pointerdown', onPointerDown);
34
86
  host.removeEventListener('dragstart', onDragStart);
35
87
  host.removeEventListener('dragend', onDragEnd);
36
88
  if (ghost) { ghost.remove(); ghost = null; }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { dragGhost } from './drag-ghost.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('drag-ghost', () => {
6
6
  beforeEach(resetDOM);