@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.
- package/components/agent-trace/agent-trace.css +24 -3
- 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/index.js +1 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +66 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +13 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +258 -5
- 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 +90 -60
- 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 +55 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +279 -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 +125 -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.js +8 -3
- 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,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,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 './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('attention-pulse', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
package/traits/confetti-burst.js
CHANGED
|
@@ -1,91 +1,121 @@
|
|
|
1
1
|
import { defineTrait } from './define.js';
|
|
2
|
-
import { prefersReducedMotion } from './
|
|
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',
|
|
6
25
|
category: 'interaction-delight',
|
|
7
|
-
description: 'Upward fountain particle burst',
|
|
26
|
+
description: 'Upward fountain particle burst — fires on each `press` event',
|
|
8
27
|
attributes: ['data-confetti-burst-active'],
|
|
9
28
|
events: ['confetti-burst-done'],
|
|
10
29
|
config: [],
|
|
11
30
|
setup({ host }) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
host.setAttribute('data-confetti-burst-active', '');
|
|
15
|
-
queueMicrotask(() => {
|
|
16
|
-
host.removeAttribute('data-confetti-burst-active');
|
|
17
|
-
host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
|
|
18
|
-
});
|
|
19
|
-
return () => host.removeAttribute('data-confetti-burst-active');
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const canvas = document.createElement('canvas');
|
|
23
|
-
canvas.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9999;';
|
|
24
|
-
host.style.position = host.style.position || 'relative';
|
|
25
|
-
host.appendChild(canvas);
|
|
31
|
+
const inflight = new Set();
|
|
32
|
+
const burstTimers = new Set();
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
let rafId = null;
|
|
29
|
-
|
|
30
|
-
// Bail gracefully when canvas 2D isn't available (SSR, JSDOM, happy-dom).
|
|
31
|
-
if (!ctx) {
|
|
34
|
+
function reducedMotionBurst() {
|
|
32
35
|
host.setAttribute('data-confetti-burst-active', '');
|
|
33
|
-
// Fire the done event on next tick so listeners still hear it.
|
|
34
36
|
queueMicrotask(() => {
|
|
35
37
|
host.removeAttribute('data-confetti-burst-active');
|
|
36
38
|
host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
|
|
37
39
|
});
|
|
38
|
-
return () => {
|
|
39
|
-
canvas.remove();
|
|
40
|
-
host.removeAttribute('data-confetti-burst-active');
|
|
41
|
-
};
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
vy: (Math.random() - 0.5) * 8 - 2,
|
|
50
|
-
size: Math.random() * 4 + 2,
|
|
51
|
-
color: colors[Math.floor(Math.random() * colors.length)],
|
|
52
|
-
life: 1,
|
|
53
|
-
}));
|
|
42
|
+
function fireBurst() {
|
|
43
|
+
if (prefersReducedMotion()) {
|
|
44
|
+
reducedMotionBurst();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
const stage = getStage();
|
|
49
|
+
const center = viewportCenterOf(host);
|
|
50
|
+
host.setAttribute('data-confetti-burst-active', '');
|
|
57
51
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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) {
|
|
56
|
+
queueMicrotask(() => {
|
|
57
|
+
host.removeAttribute('data-confetti-burst-active');
|
|
58
|
+
host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
|
|
59
|
+
});
|
|
64
60
|
return;
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
63
|
+
const burstId = Symbol('burst');
|
|
64
|
+
inflight.add(burstId);
|
|
70
65
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
81
|
+
|
|
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`;
|
|
79
87
|
}
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
|
|
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);
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
|
|
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).
|
|
106
|
+
fireBurst();
|
|
107
|
+
|
|
108
|
+
// Listen for `press` so the declarative case
|
|
109
|
+
// (`<button-ui traits="confetti-burst">`) fires a fresh burst on
|
|
110
|
+
// each click. button-ui dispatches `press` from its #onClick.
|
|
111
|
+
const onPress = () => fireBurst();
|
|
112
|
+
host.addEventListener('press', onPress);
|
|
85
113
|
|
|
86
114
|
return () => {
|
|
87
|
-
|
|
88
|
-
|
|
115
|
+
host.removeEventListener('press', onPress);
|
|
116
|
+
for (const t of burstTimers) clearTimeout(t);
|
|
117
|
+
burstTimers.clear();
|
|
118
|
+
inflight.clear();
|
|
89
119
|
host.removeAttribute('data-confetti-burst-active');
|
|
90
120
|
};
|
|
91
121
|
},
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { confettiBurst } from './confetti-burst.js';
|
|
3
|
-
import {
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
19
|
-
// graceful-degradation path
|
|
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
|
|
36
|
+
it('host stays unmodified by particle injection', () => {
|
|
33
37
|
const host = mountHost();
|
|
34
|
-
connectTrait(confettiBurst, host);
|
|
35
|
-
//
|
|
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
|
});
|