@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,375 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { arrowGridNav } from './arrow-grid-nav.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
+
5
+ /**
6
+ * Build a row-flat grid with `total` cells laid out in `cols` columns.
7
+ * Returns the host element pre-populated with <button> children.
8
+ */
9
+ function flatGrid(cols, total) {
10
+ const host = mountHost('div', { 'data-grid-columns': String(cols) });
11
+ for (let i = 0; i < total; i++) {
12
+ const btn = document.createElement('button');
13
+ btn.textContent = `cell-${i}`;
14
+ btn.setAttribute('data-cell-index', String(i));
15
+ host.appendChild(btn);
16
+ }
17
+ return host;
18
+ }
19
+
20
+ /**
21
+ * Build a row-nested grid: each direct child is a row, each row's children
22
+ * are cells. `rowSpec` is an array of column counts per row.
23
+ */
24
+ function nestedGrid(rowSpec) {
25
+ const host = mountHost('div', { 'data-grid-mode': 'row-nested' });
26
+ rowSpec.forEach((cols, rowIdx) => {
27
+ const row = document.createElement('div');
28
+ for (let c = 0; c < cols; c++) {
29
+ const btn = document.createElement('button');
30
+ btn.textContent = `r${rowIdx}c${c}`;
31
+ row.appendChild(btn);
32
+ }
33
+ host.appendChild(row);
34
+ });
35
+ return host;
36
+ }
37
+
38
+ function key(host, k, modifiers = {}) {
39
+ host.dispatchEvent(new KeyboardEvent('keydown', {
40
+ key: k,
41
+ bubbles: true,
42
+ ...modifiers,
43
+ }));
44
+ }
45
+
46
+ describe('arrow-grid-nav', () => {
47
+ beforeEach(resetDOM);
48
+
49
+ it('paints active row + col on connect at (0, 0)', () => {
50
+ const host = flatGrid(7, 42);
51
+ connectTrait(arrowGridNav, host);
52
+ expect(host.getAttribute('data-grid-active-row')).toBe('0');
53
+ expect(host.getAttribute('data-grid-active-col')).toBe('0');
54
+ const first = host.children[0];
55
+ expect(first.hasAttribute('data-grid-active')).toBe(true);
56
+ expect(first.getAttribute('tabindex')).toBe('0');
57
+ });
58
+
59
+ it('sets host tabindex to 0 when not pre-set', () => {
60
+ const host = flatGrid(7, 42);
61
+ connectTrait(arrowGridNav, host);
62
+ expect(host.getAttribute('tabindex')).toBe('0');
63
+ });
64
+
65
+ it('preserves a pre-set host tabindex', () => {
66
+ const host = flatGrid(7, 42);
67
+ host.setAttribute('tabindex', '5');
68
+ connectTrait(arrowGridNav, host);
69
+ expect(host.getAttribute('tabindex')).toBe('5');
70
+ });
71
+
72
+ it('non-active cells receive tabindex="-1"', () => {
73
+ const host = flatGrid(7, 42);
74
+ connectTrait(arrowGridNav, host);
75
+ expect(host.children[1].getAttribute('tabindex')).toBe('-1');
76
+ expect(host.children[10].getAttribute('tabindex')).toBe('-1');
77
+ });
78
+
79
+ it('ArrowRight increments col within the row', () => {
80
+ const host = flatGrid(7, 42);
81
+ connectTrait(arrowGridNav, host);
82
+ key(host, 'ArrowRight');
83
+ expect(host.getAttribute('data-grid-active-col')).toBe('1');
84
+ expect(host.getAttribute('data-grid-active-row')).toBe('0');
85
+ expect(host.children[1].hasAttribute('data-grid-active')).toBe(true);
86
+ expect(host.children[0].hasAttribute('data-grid-active')).toBe(false);
87
+ });
88
+
89
+ it('ArrowLeft decrements col', () => {
90
+ const host = flatGrid(7, 42);
91
+ connectTrait(arrowGridNav, host);
92
+ key(host, 'ArrowRight');
93
+ key(host, 'ArrowRight');
94
+ key(host, 'ArrowLeft');
95
+ expect(host.getAttribute('data-grid-active-col')).toBe('1');
96
+ });
97
+
98
+ it('ArrowDown increments row, keeps col', () => {
99
+ const host = flatGrid(7, 42);
100
+ connectTrait(arrowGridNav, host);
101
+ key(host, 'ArrowRight');
102
+ key(host, 'ArrowRight');
103
+ key(host, 'ArrowRight');
104
+ key(host, 'ArrowDown');
105
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
106
+ expect(host.getAttribute('data-grid-active-col')).toBe('3');
107
+ // index 3 + 7 = 10
108
+ expect(host.children[10].hasAttribute('data-grid-active')).toBe(true);
109
+ });
110
+
111
+ it('ArrowUp decrements row', () => {
112
+ const host = flatGrid(7, 42);
113
+ connectTrait(arrowGridNav, host);
114
+ key(host, 'ArrowDown');
115
+ key(host, 'ArrowDown');
116
+ key(host, 'ArrowUp');
117
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
118
+ });
119
+
120
+ it('Home moves to col 0 within current row', () => {
121
+ const host = flatGrid(7, 42);
122
+ connectTrait(arrowGridNav, host);
123
+ key(host, 'ArrowDown');
124
+ key(host, 'ArrowRight');
125
+ key(host, 'ArrowRight');
126
+ expect(host.getAttribute('data-grid-active-col')).toBe('2');
127
+ key(host, 'Home');
128
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
129
+ expect(host.getAttribute('data-grid-active-col')).toBe('0');
130
+ });
131
+
132
+ it('Ctrl+Home moves to (0, 0)', () => {
133
+ const host = flatGrid(7, 42);
134
+ connectTrait(arrowGridNav, host);
135
+ key(host, 'ArrowDown');
136
+ key(host, 'ArrowDown');
137
+ key(host, 'ArrowRight');
138
+ key(host, 'Home', { ctrlKey: true });
139
+ expect(host.getAttribute('data-grid-active-row')).toBe('0');
140
+ expect(host.getAttribute('data-grid-active-col')).toBe('0');
141
+ });
142
+
143
+ it('End moves to last col in current row', () => {
144
+ const host = flatGrid(7, 42);
145
+ connectTrait(arrowGridNav, host);
146
+ key(host, 'End');
147
+ expect(host.getAttribute('data-grid-active-col')).toBe('6');
148
+ expect(host.getAttribute('data-grid-active-row')).toBe('0');
149
+ });
150
+
151
+ it('Ctrl+End moves to (lastRow, lastCol)', () => {
152
+ const host = flatGrid(7, 42); // 6 rows × 7 cols
153
+ connectTrait(arrowGridNav, host);
154
+ key(host, 'End', { ctrlKey: true });
155
+ expect(host.getAttribute('data-grid-active-row')).toBe('5');
156
+ expect(host.getAttribute('data-grid-active-col')).toBe('6');
157
+ });
158
+
159
+ it('Meta+End is treated as Ctrl+End on Mac', () => {
160
+ const host = flatGrid(7, 42);
161
+ connectTrait(arrowGridNav, host);
162
+ key(host, 'End', { metaKey: true });
163
+ expect(host.getAttribute('data-grid-active-row')).toBe('5');
164
+ expect(host.getAttribute('data-grid-active-col')).toBe('6');
165
+ });
166
+
167
+ it('Enter dispatches grid-activate with row, col, element', () => {
168
+ const host = flatGrid(7, 42);
169
+ connectTrait(arrowGridNav, host);
170
+ const spy = spyEvent(host, 'grid-activate');
171
+ key(host, 'ArrowRight');
172
+ key(host, 'ArrowDown');
173
+ key(host, 'Enter');
174
+ expect(spy.count).toBe(1);
175
+ expect(spy.last).toMatchObject({ row: 1, col: 1 });
176
+ expect(spy.last.element).toBe(host.children[8]); // row 1 col 1 = index 8
177
+ });
178
+
179
+ it('Space dispatches grid-activate', () => {
180
+ const host = flatGrid(7, 42);
181
+ connectTrait(arrowGridNav, host);
182
+ const spy = spyEvent(host, 'grid-activate');
183
+ key(host, ' ');
184
+ expect(spy.count).toBe(1);
185
+ expect(spy.last).toMatchObject({ row: 0, col: 0 });
186
+ });
187
+
188
+ it('emits grid-edge "left" at column 0', () => {
189
+ const host = flatGrid(7, 42);
190
+ connectTrait(arrowGridNav, host);
191
+ const spy = spyEvent(host, 'grid-edge');
192
+ key(host, 'ArrowLeft');
193
+ expect(spy.count).toBe(1);
194
+ expect(spy.last.edge).toBe('left');
195
+ });
196
+
197
+ it('emits grid-edge "right" at last column', () => {
198
+ const host = flatGrid(7, 42);
199
+ connectTrait(arrowGridNav, host);
200
+ key(host, 'End');
201
+ const spy = spyEvent(host, 'grid-edge');
202
+ key(host, 'ArrowRight');
203
+ expect(spy.count).toBe(1);
204
+ expect(spy.last.edge).toBe('right');
205
+ });
206
+
207
+ it('emits grid-edge "top" at row 0', () => {
208
+ const host = flatGrid(7, 42);
209
+ connectTrait(arrowGridNav, host);
210
+ const spy = spyEvent(host, 'grid-edge');
211
+ key(host, 'ArrowUp');
212
+ expect(spy.count).toBe(1);
213
+ expect(spy.last.edge).toBe('top');
214
+ });
215
+
216
+ it('emits grid-edge "bottom" at last row', () => {
217
+ const host = flatGrid(7, 42);
218
+ connectTrait(arrowGridNav, host);
219
+ key(host, 'End', { ctrlKey: true });
220
+ const spy = spyEvent(host, 'grid-edge');
221
+ key(host, 'ArrowDown');
222
+ expect(spy.count).toBe(1);
223
+ expect(spy.last.edge).toBe('bottom');
224
+ });
225
+
226
+ it('grid-cell-change fires when active changes', () => {
227
+ const host = flatGrid(7, 42);
228
+ connectTrait(arrowGridNav, host);
229
+ const spy = spyEvent(host, 'grid-cell-change');
230
+ key(host, 'ArrowRight');
231
+ key(host, 'ArrowRight');
232
+ expect(spy.count).toBe(2);
233
+ expect(spy.last).toMatchObject({ row: 0, col: 2 });
234
+ });
235
+
236
+ it('grid-cell-change does NOT fire when no movement happens at edge', () => {
237
+ const host = flatGrid(7, 42);
238
+ connectTrait(arrowGridNav, host);
239
+ const spy = spyEvent(host, 'grid-cell-change');
240
+ key(host, 'ArrowLeft'); // already at col 0
241
+ expect(spy.count).toBe(0);
242
+ });
243
+
244
+ it('row-nested mode infers rows from container children', () => {
245
+ const host = nestedGrid([3, 3, 3]); // 3x3 grid
246
+ connectTrait(arrowGridNav, host);
247
+ expect(host.getAttribute('data-grid-active-row')).toBe('0');
248
+ expect(host.getAttribute('data-grid-active-col')).toBe('0');
249
+ key(host, 'ArrowDown');
250
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
251
+ const row1 = host.children[1];
252
+ expect(row1.children[0].hasAttribute('data-grid-active')).toBe(true);
253
+ });
254
+
255
+ it('row-nested mode handles ragged rows by clamping col on row change', () => {
256
+ const host = nestedGrid([5, 2, 4]); // row 0 has 5 cols, row 1 has 2, row 2 has 4
257
+ connectTrait(arrowGridNav, host);
258
+ key(host, 'End'); // col 4 in row 0
259
+ expect(host.getAttribute('data-grid-active-col')).toBe('4');
260
+ key(host, 'ArrowDown'); // row 1 only has 2 cols → clamp to 1
261
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
262
+ expect(host.getAttribute('data-grid-active-col')).toBe('1');
263
+ });
264
+
265
+ it('disabled cells are skipped in row-flat mode', () => {
266
+ const host = flatGrid(7, 42);
267
+ host.children[1].setAttribute('disabled', '');
268
+ connectTrait(arrowGridNav, host);
269
+ key(host, 'ArrowRight');
270
+ // Disabled child #1 is filtered out → row-flat splits the remaining
271
+ // 41 cells; index 1 in the post-filter list is original cell #2.
272
+ expect(host.children[2].hasAttribute('data-grid-active')).toBe(true);
273
+ });
274
+
275
+ it('does not steal arrow keys from <input> within a cell', () => {
276
+ const host = mountHost('div', { 'data-grid-columns': '3' });
277
+ for (let i = 0; i < 3; i++) {
278
+ const cell = document.createElement('div');
279
+ const inp = document.createElement('input');
280
+ cell.appendChild(inp);
281
+ host.appendChild(cell);
282
+ }
283
+ connectTrait(arrowGridNav, host);
284
+ const inp = host.querySelector('input');
285
+ inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
286
+ // No movement — col remains 0.
287
+ expect(host.getAttribute('data-grid-active-col')).toBe('0');
288
+ });
289
+
290
+ it('does not respond to non-handled keys', () => {
291
+ const host = flatGrid(7, 42);
292
+ connectTrait(arrowGridNav, host);
293
+ const spy = spyEvent(host, 'grid-cell-change');
294
+ key(host, 'a');
295
+ key(host, 'Tab');
296
+ key(host, 'Escape');
297
+ expect(spy.count).toBe(0);
298
+ });
299
+
300
+ it('MutationObserver re-paints when children are added', async () => {
301
+ const host = flatGrid(7, 7); // 1 row of 7
302
+ connectTrait(arrowGridNav, host);
303
+ key(host, 'End'); // col 6
304
+ expect(host.getAttribute('data-grid-active-col')).toBe('6');
305
+ // Add 7 more children → row 1 exists.
306
+ for (let i = 0; i < 7; i++) {
307
+ const btn = document.createElement('button');
308
+ btn.textContent = `new-${i}`;
309
+ host.appendChild(btn);
310
+ }
311
+ await new Promise((r) => setTimeout(r, 10));
312
+ // Active should still be at (0, 6).
313
+ expect(host.getAttribute('data-grid-active-row')).toBe('0');
314
+ // Now arrow-down should reach the new row.
315
+ key(host, 'ArrowDown');
316
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
317
+ });
318
+
319
+ it('disconnect removes all managed attrs from host and active cell', () => {
320
+ const host = flatGrid(7, 42);
321
+ const inst = connectTrait(arrowGridNav, host);
322
+ key(host, 'ArrowRight');
323
+ inst.disconnect(host);
324
+ expect(host.hasAttribute('data-arrow-grid-nav-active')).toBe(false);
325
+ expect(host.hasAttribute('data-grid-active-row')).toBe(false);
326
+ expect(host.hasAttribute('data-grid-active-col')).toBe(false);
327
+ for (const child of host.children) {
328
+ expect(child.hasAttribute('data-grid-active')).toBe(false);
329
+ }
330
+ });
331
+
332
+ it('disconnect removes the keydown listener', () => {
333
+ const host = flatGrid(7, 42);
334
+ const inst = connectTrait(arrowGridNav, host);
335
+ inst.disconnect(host);
336
+ const spy = spyEvent(host, 'grid-cell-change');
337
+ key(host, 'ArrowRight');
338
+ expect(spy.count).toBe(0);
339
+ });
340
+
341
+ it('reconnect after disconnect works', () => {
342
+ const host = flatGrid(7, 42);
343
+ const inst1 = connectTrait(arrowGridNav, host);
344
+ inst1.disconnect(host);
345
+ const inst2 = connectTrait(arrowGridNav, host);
346
+ expect(host.getAttribute('data-grid-active-row')).toBe('0');
347
+ expect(host.children[0].hasAttribute('data-grid-active')).toBe(true);
348
+ inst2.disconnect(host);
349
+ });
350
+
351
+ it('default mode is row-flat when data-grid-mode missing', () => {
352
+ const host = flatGrid(4, 12);
353
+ connectTrait(arrowGridNav, host);
354
+ key(host, 'ArrowDown');
355
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
356
+ expect(host.children[4].hasAttribute('data-grid-active')).toBe(true);
357
+ });
358
+
359
+ it('missing data-grid-columns falls back to 1 (single column)', () => {
360
+ const host = mountHost('div');
361
+ for (let i = 0; i < 4; i++) {
362
+ const btn = document.createElement('button');
363
+ host.appendChild(btn);
364
+ }
365
+ connectTrait(arrowGridNav, host);
366
+ key(host, 'ArrowDown');
367
+ expect(host.getAttribute('data-grid-active-row')).toBe('1');
368
+ expect(host.getAttribute('data-grid-active-col')).toBe('0');
369
+ });
370
+
371
+ it('empty grid does not throw', () => {
372
+ const host = mountHost('div', { 'data-grid-columns': '7' });
373
+ expect(() => connectTrait(arrowGridNav, host)).not.toThrow();
374
+ });
375
+ });
@@ -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 attentionPulse = defineTrait({
5
5
  name: 'attention-pulse',
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { attentionPulse } from './attention-pulse.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('attention-pulse', () => {
6
6
  beforeEach(resetDOM);
@@ -1,5 +1,24 @@
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';
4
+
5
+ /**
6
+ * `confetti-burst` — upward fountain on connect + on every `press`.
7
+ *
8
+ * Each burst pushes ~80 particles into the singleton body-level
9
+ * `<div popover="manual" id="adia-confetti-stage">`. Particles are
10
+ * anchored to the host's viewport position at fire-time via
11
+ * `getBoundingClientRect()` — they escape `overflow: hidden`
12
+ * ancestors and render above modals, drawers, and any z-stacking
13
+ * context (the Popover API's top-layer promotion).
14
+ *
15
+ * The trait listens for `press` (button-ui's normalized click event)
16
+ * so declarative usage — `<button-ui traits="confetti-burst">` —
17
+ * fires a fresh burst on each click. Programmatic usage that calls
18
+ * `confettiBurst().connect(el)` per-click gets its single burst at
19
+ * connect-time, matching the existing animation-page contract.
20
+ */
21
+ const BURST_DURATION_MS = 1500;
3
22
 
4
23
  export const confettiBurst = defineTrait({
5
24
  name: 'confetti-burst',
@@ -9,14 +28,8 @@ export const confettiBurst = defineTrait({
9
28
  events: ['confetti-burst-done'],
10
29
  config: [],
11
30
  setup({ host }) {
12
- // Per-burst state — separate from the trait's lifecycle so multiple
13
- // presses queue up cleanly. Each `fireBurst()` spawns its own canvas +
14
- // raf loop and tears them down 2s later. Listening for `press` lets
15
- // declarative usage (`<button-ui traits="confetti-burst">`) fire on
16
- // every click; programmatic usage (animation page's
17
- // `confettiBurst().connect(el)` per-click) gets the immediate burst
18
- // it expects via the on-connect call below.
19
31
  const inflight = new Set();
32
+ const burstTimers = new Set();
20
33
 
21
34
  function reducedMotionBurst() {
22
35
  host.setAttribute('data-confetti-burst-active', '');
@@ -32,74 +45,64 @@ export const confettiBurst = defineTrait({
32
45
  return;
33
46
  }
34
47
 
35
- const canvas = document.createElement('canvas');
36
- canvas.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9999;';
37
- host.style.position = host.style.position || 'relative';
38
- host.appendChild(canvas);
39
-
40
- const ctx = canvas.getContext('2d');
41
- let rafId = null;
48
+ const stage = getStage();
49
+ const center = viewportCenterOf(host);
50
+ host.setAttribute('data-confetti-burst-active', '');
42
51
 
43
- // Bail gracefully when canvas 2D isn't available (SSR, JSDOM, happy-dom).
44
- if (!ctx) {
45
- host.setAttribute('data-confetti-burst-active', '');
52
+ // Detached host (rect = 0,0,0,0): mark active, fire done on
53
+ // next tick. The original implementation's no-canvas path did
54
+ // the same shape — graceful degrade, never throw.
55
+ if (!center) {
46
56
  queueMicrotask(() => {
47
57
  host.removeAttribute('data-confetti-burst-active');
48
58
  host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
49
59
  });
50
- canvas.remove();
51
60
  return;
52
61
  }
53
62
 
54
- const colors = ['#f44', '#4a4', '#44f', '#ff4', '#f4f', '#4ff'];
55
- const particles = Array.from({ length: 80 }, () => ({
56
- x: 0.5,
57
- y: 0.5,
58
- vx: (Math.random() - 0.5) * 8,
59
- vy: (Math.random() - 0.5) * 8 - 2,
60
- size: Math.random() * 4 + 2,
61
- color: colors[Math.floor(Math.random() * colors.length)],
62
- life: 1,
63
- }));
63
+ const burstId = Symbol('burst');
64
+ inflight.add(burstId);
64
65
 
65
- host.setAttribute('data-confetti-burst-active', '');
66
- const startTime = performance.now();
67
- const burstHandle = { canvas, cancel: () => { if (rafId) cancelAnimationFrame(rafId); canvas.remove(); } };
68
- inflight.add(burstHandle);
66
+ // Spawn ~80 fountain particles. Each gets a random vector
67
+ // weighted upward (vy < 0), plus gravity baked into a steeper
68
+ // dy so the trajectory looks parabolic. The keyframes do the
69
+ // motion; we set dx/dy/rot via custom properties.
70
+ for (let i = 0; i < 80; i++) {
71
+ const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9;
72
+ const speed = 80 + Math.random() * 200;
73
+ const dx = Math.cos(angle) * speed;
74
+ // Gravity: launch up, fall back. dy is the final offset after
75
+ // the keyframe — start up by `speed`, end below by gravity.
76
+ const dy = Math.sin(angle) * speed + 280;
77
+ const rot = (Math.random() - 0.5) * 720;
78
+ const size = Math.random() * 4 + 2;
79
+ const color = SHARED_COLORS[Math.floor(Math.random() * SHARED_COLORS.length)];
80
+ const dur = 1200 + Math.random() * 300;
69
81
 
70
- function tick(now) {
71
- const elapsed = now - startTime;
72
- if (elapsed > 2000) {
73
- inflight.delete(burstHandle);
74
- canvas.remove();
75
- if (inflight.size === 0) host.removeAttribute('data-confetti-burst-active');
76
- host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
77
- return;
78
- }
79
-
80
- canvas.width = host.offsetWidth;
81
- canvas.height = host.offsetHeight;
82
- ctx.clearRect(0, 0, canvas.width, canvas.height);
83
-
84
- for (const p of particles) {
85
- p.x += p.vx * 0.004;
86
- p.y += p.vy * 0.004;
87
- p.vy += 0.15;
88
- p.life = Math.max(0, 1 - elapsed / 2000);
89
- ctx.globalAlpha = p.life;
90
- ctx.fillStyle = p.color;
91
- ctx.fillRect(p.x * canvas.width, p.y * canvas.height, p.size, p.size);
92
- }
93
- ctx.globalAlpha = 1;
94
- rafId = requestAnimationFrame(tick);
82
+ const p = spawnParticle(stage, center.x, center.y, { size, color });
83
+ p.style.setProperty('--dx', `${dx}px`);
84
+ p.style.setProperty('--dy', `${dy}px`);
85
+ p.style.setProperty('--rot', `${rot}deg`);
86
+ p.style.animation = `adia-confetti-fountain ${dur}ms ease-out forwards`;
95
87
  }
96
88
 
97
- rafId = requestAnimationFrame(tick);
89
+ // Fire `confetti-burst-done` once the burst's lifetime elapses.
90
+ // We use a single timeout per burst (vs listening for the last
91
+ // particle's animationend) — robust to detached/early-removed
92
+ // particles, and matches the original 2s tear-down semantic.
93
+ const timer = setTimeout(() => {
94
+ inflight.delete(burstId);
95
+ if (inflight.size === 0) host.removeAttribute('data-confetti-burst-active');
96
+ host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
97
+ }, BURST_DURATION_MS);
98
+
99
+ // Track the timer so disconnect can cancel.
100
+ burstTimers.add(timer);
98
101
  }
99
102
 
100
- // Fire one burst at connect-time. Preserves the existing animation
101
- // page semantic (`confettiBurst().connect(burstEl)` on every click of
102
- // a separate trigger button).
103
+ // Fire one burst at connect-time. Preserves the existing
104
+ // animation-page semantic (`confettiBurst().connect(el)` on every
105
+ // click of a separate trigger button).
103
106
  fireBurst();
104
107
 
105
108
  // Listen for `press` so the declarative case
@@ -110,7 +113,8 @@ export const confettiBurst = defineTrait({
110
113
 
111
114
  return () => {
112
115
  host.removeEventListener('press', onPress);
113
- for (const handle of inflight) handle.cancel();
116
+ for (const t of burstTimers) clearTimeout(t);
117
+ burstTimers.clear();
114
118
  inflight.clear();
115
119
  host.removeAttribute('data-confetti-burst-active');
116
120
  };
@@ -1,9 +1,13 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { confettiBurst } from './confetti-burst.js';
3
- import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './_test-helpers.js';
3
+ import { _resetStage } from './confetti-stage.js';
4
+ import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './test-helpers.js';
4
5
 
5
6
  describe('confetti-burst', () => {
6
- beforeEach(resetDOM);
7
+ beforeEach(() => {
8
+ resetDOM();
9
+ _resetStage();
10
+ });
7
11
 
8
12
  it('sets data-confetti-burst-active on connect', () => {
9
13
  const host = mountHost();
@@ -11,12 +15,12 @@ describe('confetti-burst', () => {
11
15
  expect(host.hasAttribute('data-confetti-burst-active')).toBe(true);
12
16
  });
13
17
 
14
- it('without canvas (happy-dom), still fires confetti-burst-done', async () => {
18
+ it('fires confetti-burst-done even without real layout (happy-dom)', async () => {
15
19
  const host = mountHost();
16
20
  const spy = spyEvent(host, 'confetti-burst-done');
17
21
  connectTrait(confettiBurst, host);
18
- // In happy-dom, canvas.getContext('2d') returns null. The trait's
19
- // graceful-degradation path bails immediately and queues the done event.
22
+ // happy-dom returns rect = 0,0,0,0 for unattached layout. The
23
+ // trait's graceful-degradation path queues `done` on next tick.
20
24
  await wait(50);
21
25
  expect(spy.count).toBeGreaterThanOrEqual(1);
22
26
  });
@@ -29,10 +33,14 @@ describe('confetti-burst', () => {
29
33
  expect(host.hasAttribute('data-confetti-burst-active')).toBe(false);
30
34
  });
31
35
 
32
- it('host position is set to relative for canvas to anchor', () => {
36
+ it('host stays unmodified by particle injection', () => {
33
37
  const host = mountHost();
34
- connectTrait(confettiBurst, host);
35
- // Either the canvas-based path or the no-op path leaves the host stable.
38
+ const inst = connectTrait(confettiBurst, host);
39
+ // New contract: particles live in the body-level popover stage,
40
+ // not on the host. The host must not gain children or inline
41
+ // position/cssText hacks (the old canvas-injection path did).
36
42
  expect(host.tagName).toBe('DIV');
43
+ expect(host.children.length).toBe(0);
44
+ inst.disconnect(host);
37
45
  });
38
46
  });