@adia-ai/web-components 0.8.19 → 0.8.21
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/CHANGELOG.md +26 -0
- package/components/card/card.a2ui.json +6 -3
- package/components/card/card.css +15 -1
- package/components/card/card.yaml +5 -3
- package/components/nav-group/nav-group.a2ui.json +5 -0
- package/components/nav-group/nav-group.class.js +14 -6
- package/components/nav-group/nav-group.css +5 -2
- package/components/nav-group/nav-group.d.ts +2 -0
- package/components/nav-group/nav-group.yaml +4 -0
- package/components/select/select.a2ui.json +3 -2
- package/components/select/select.class.js +14 -1
- package/components/select/select.css +13 -1
- package/components/select/select.yaml +5 -4
- package/components/table/table.class.js +7 -3
- package/components/table/table.css +7 -5
- package/dist/theme-provider.min.js +1 -1
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +19 -19
- package/dist/web-components.sheet.js +1 -1
- package/package.json +2 -1
- package/components/adia-mark/adia-mark.test.js +0 -69
- package/components/adia-wordmark/adia-wordmark.test.js +0 -69
- package/components/agent-artifact/agent-artifact.test.js +0 -61
- package/components/alert/alert.test.js +0 -364
- package/components/color-picker/color-picker.test.js +0 -96
- package/components/combobox/combobox.test.js +0 -256
- package/components/command/command.test.js +0 -99
- package/components/date-range-picker/date-range-picker.test.js +0 -387
- package/components/datetime-picker/datetime-picker.test.js +0 -454
- package/components/demo-toggle/demo-toggle.test.js +0 -102
- package/components/description-list/description-list.test.js +0 -72
- package/components/display-field/display-field.test.js +0 -115
- package/components/empty-state/empty-state.test.js +0 -77
- package/components/field/field.test.js +0 -260
- package/components/fields/fields.test.js +0 -88
- package/components/inline-message/inline-message.test.js +0 -203
- package/components/input/input.test.js +0 -335
- package/components/integration-card/integration-card.test.js +0 -318
- package/components/link/link.test.js +0 -143
- package/components/list/list.test.js +0 -106
- package/components/list-window/list-window.test.js +0 -303
- package/components/loading-overlay/loading-overlay.test.js +0 -257
- package/components/menu/menu.test.js +0 -176
- package/components/nav/nav.test.js +0 -428
- package/components/nav-group/nav-group.test.js +0 -67
- package/components/option-card/option-card.test.js +0 -32
- package/components/pagination/pagination.test.js +0 -195
- package/components/select/select.test.js +0 -481
- package/components/slider/slider.test.js +0 -202
- package/components/spinner/spinner.test.js +0 -272
- package/components/stat/stat.test.js +0 -108
- package/components/step-progress/step-progress.test.js +0 -129
- package/components/swatch/swatch.test.js +0 -563
- package/components/table/table.test.js +0 -479
- package/components/table-toolbar/table-toolbar.test.js +0 -152
- package/components/tag/tag.test.js +0 -137
- package/components/tags-input/tags-input.test.js +0 -477
- package/components/text/text.test.js +0 -202
- package/components/theme-provider/theme-provider.test.js +0 -94
- package/components/time-picker/time-picker.test.js +0 -287
- package/components/timeline/timeline.test.js +0 -31
- package/components/toggle-scheme/toggle-scheme.test.js +0 -110
- package/components/tree/tree.test.js +0 -312
- package/core/element.test.js +0 -389
- package/core/icons.test.js +0 -187
- package/core/logical-children.test.js +0 -54
- package/core/markdown.test.js +0 -47
- package/core/responsive.test.js +0 -121
- package/core/template.test.js +0 -611
- package/traits/active-state/active-state.test.js +0 -28
- package/traits/anchor-positioning/anchor-positioning.test.js +0 -122
- package/traits/announcer/announcer.test.js +0 -268
- package/traits/arrow-grid-nav/arrow-grid-nav.test.js +0 -375
- package/traits/attention-pulse/attention-pulse.test.js +0 -26
- package/traits/confetti/confetti.test.js +0 -46
- package/traits/confetti-burst/confetti-burst.test.js +0 -46
- package/traits/count-up/count-up.test.js +0 -54
- package/traits/declarative.test.js +0 -138
- package/traits/dirty-state/dirty-state.test.js +0 -45
- package/traits/drag-ghost/drag-ghost.test.js +0 -19
- package/traits/draggable/draggable.test.js +0 -60
- package/traits/draggable-list-item/draggable-list-item.test.js +0 -51
- package/traits/drop-target/drop-target.test.js +0 -241
- package/traits/droppable/droppable.test.js +0 -54
- package/traits/droppable-collection/droppable-collection.test.js +0 -99
- package/traits/error-shake/error-shake.test.js +0 -114
- package/traits/fade-presence/fade-presence.test.js +0 -20
- package/traits/focus-restore/focus-restore.test.js +0 -202
- package/traits/focus-trap/focus-trap.test.js +0 -42
- package/traits/focusable/focusable.test.js +0 -53
- package/traits/glow-focus/glow-focus.test.js +0 -31
- package/traits/gradient-shift/gradient-shift.test.js +0 -22
- package/traits/haptic-feedback/haptic-feedback.test.js +0 -52
- package/traits/hotkey/hotkey.test.js +0 -61
- package/traits/hoverable/hoverable.test.js +0 -24
- package/traits/inertia-drag/inertia-drag.test.js +0 -33
- package/traits/input-mask/input-mask.test.js +0 -151
- package/traits/intersection-observer/intersection-observer.test.js +0 -38
- package/traits/keyboard-nav/keyboard-nav.test.js +0 -41
- package/traits/keyboard-reorderable/keyboard-reorderable.test.js +0 -45
- package/traits/layout-animation/layout-animation.test.js +0 -114
- package/traits/long-press/long-press.test.js +0 -244
- package/traits/magnetic-hover/magnetic-hover.test.js +0 -30
- package/traits/noise-texture/noise-texture.test.js +0 -20
- package/traits/parallax/parallax.test.js +0 -26
- package/traits/portal/portal.test.js +0 -30
- package/traits/pressable/pressable.test.js +0 -73
- package/traits/resettable/resettable.test.js +0 -100
- package/traits/resizable/resizable.test.js +0 -39
- package/traits/resize-observer/resize-observer.test.js +0 -38
- package/traits/ripple/ripple.test.js +0 -32
- package/traits/roving-tabindex/roving-tabindex.test.js +0 -28
- package/traits/scale-press/scale-press.test.js +0 -39
- package/traits/scroll-lock/scroll-lock.test.js +0 -45
- package/traits/scroll-progress/scroll-progress.test.js +0 -182
- package/traits/shimmer-loading/shimmer-loading.test.js +0 -43
- package/traits/smoke.test.js +0 -111
- package/traits/snap-to-grid/snap-to-grid.test.js +0 -40
- package/traits/sound-feedback/sound-feedback.test.js +0 -26
- package/traits/spring-animate/spring-animate.test.js +0 -28
- package/traits/success-checkmark/success-checkmark.test.js +0 -120
- package/traits/tilt-hover/tilt-hover.test.js +0 -32
- package/traits/tossable/tossable.test.js +0 -31
- package/traits/traits-host.test.js +0 -73
- package/traits/typeahead/typeahead.test.js +0 -38
- package/traits/typewriter/typewriter.test.js +0 -47
- package/traits/validation/validation.test.js +0 -93
- package/traits/view-transition/view-transition.test.js +0 -268
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* <list-window-ui> — virtualization / windowing tests.
|
|
3
|
-
*
|
|
4
|
-
* Coverage scope (per SPEC-022 §11 Verification):
|
|
5
|
-
*
|
|
6
|
-
* - empty items[] → [empty] state attribute set; window collapsed
|
|
7
|
-
* - fixed-size mode: ≤ (viewport / item-size) + 2*overscan DOM rows
|
|
8
|
-
* - 10_000 items: still ≤ 50 DOM rows
|
|
9
|
-
* - scroll re-emits `range-change` with new start/end indices
|
|
10
|
-
* - scrollToIndex() lands the row in the visible window
|
|
11
|
-
* - pin-bottom keeps scroll at the bottom on append
|
|
12
|
-
* - removing an item updates total scroll height
|
|
13
|
-
* - key-fn preserves DOM nodes across items[] reorder
|
|
14
|
-
* - aria-rowcount reflects full items.length
|
|
15
|
-
* - skeleton loading state replaces real rows
|
|
16
|
-
*
|
|
17
|
-
* happy-dom doesn't lay out elements (clientHeight is 0 by default),
|
|
18
|
-
* so we set the host's clientHeight via a clientHeight-stub. The
|
|
19
|
-
* windowing math runs deterministically against that stub.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
23
|
-
import './list-window.js';
|
|
24
|
-
|
|
25
|
-
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
26
|
-
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
-
|
|
28
|
-
/** Stub the host's clientHeight + clientWidth so range computation can
|
|
29
|
-
* run inside happy-dom. The virtualization math reads `this.clientHeight`
|
|
30
|
-
* (vertical mode) — without a stub it's always 0 and range collapses. */
|
|
31
|
-
function stubViewport(el, { height = 400, width = 400 } = {}) {
|
|
32
|
-
Object.defineProperty(el, 'clientHeight', { value: height, configurable: true });
|
|
33
|
-
Object.defineProperty(el, 'clientWidth', { value: width, configurable: true });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Build N items with a stable shape for tests. */
|
|
37
|
-
function makeItems(n) {
|
|
38
|
-
return Array.from({ length: n }, (_, i) => ({ id: `i-${i}`, text: `Item ${i}` }));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('<list-window-ui>', () => {
|
|
42
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
43
|
-
afterEach(() => { document.body.innerHTML = ''; });
|
|
44
|
-
|
|
45
|
-
it('shows [empty] state when items.length === 0', async () => {
|
|
46
|
-
const el = document.createElement('list-window-ui');
|
|
47
|
-
el.setAttribute('item-size', '48');
|
|
48
|
-
stubViewport(el);
|
|
49
|
-
document.body.appendChild(el);
|
|
50
|
-
el.items = [];
|
|
51
|
-
await tick();
|
|
52
|
-
expect(el.hasAttribute('empty')).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('sets aria-rowcount to the full items.length (not the windowed count)', async () => {
|
|
56
|
-
const el = document.createElement('list-window-ui');
|
|
57
|
-
el.setAttribute('item-size', '48');
|
|
58
|
-
stubViewport(el, { height: 400 });
|
|
59
|
-
document.body.appendChild(el);
|
|
60
|
-
el.items = makeItems(1000);
|
|
61
|
-
await tick();
|
|
62
|
-
expect(el.getAttribute('aria-rowcount')).toBe('1000');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('materializes only ~viewport-worth of rows, not all 1000', async () => {
|
|
66
|
-
const el = document.createElement('list-window-ui');
|
|
67
|
-
el.setAttribute('item-size', '48');
|
|
68
|
-
stubViewport(el, { height: 400 }); // 400 / 48 ≈ 9 visible rows
|
|
69
|
-
document.body.appendChild(el);
|
|
70
|
-
el.overscan = 3;
|
|
71
|
-
el.items = makeItems(1000);
|
|
72
|
-
await tick();
|
|
73
|
-
const rows = el.querySelectorAll('[data-window] > [data-row]');
|
|
74
|
-
// ~9 visible + 2*3 overscan = ~15; tolerate ≤ 30 for happy-dom jitter
|
|
75
|
-
expect(rows.length).toBeGreaterThan(0);
|
|
76
|
-
expect(rows.length).toBeLessThanOrEqual(30);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('still materializes ≤ 50 DOM rows for items.length === 10_000', async () => {
|
|
80
|
-
const el = document.createElement('list-window-ui');
|
|
81
|
-
el.setAttribute('item-size', '48');
|
|
82
|
-
stubViewport(el, { height: 400 });
|
|
83
|
-
document.body.appendChild(el);
|
|
84
|
-
el.overscan = 5;
|
|
85
|
-
el.items = makeItems(10_000);
|
|
86
|
-
await tick();
|
|
87
|
-
const rows = el.querySelectorAll('[data-window] > [data-row]');
|
|
88
|
-
expect(rows.length).toBeGreaterThan(0);
|
|
89
|
-
expect(rows.length).toBeLessThanOrEqual(50);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('phantom spacer total height = items.length * item-size (fixed mode)', async () => {
|
|
93
|
-
const el = document.createElement('list-window-ui');
|
|
94
|
-
el.setAttribute('item-size', '48');
|
|
95
|
-
stubViewport(el, { height: 400 });
|
|
96
|
-
document.body.appendChild(el);
|
|
97
|
-
el.items = makeItems(1000);
|
|
98
|
-
await tick();
|
|
99
|
-
const phantom = el.querySelector('[data-phantom]');
|
|
100
|
-
expect(phantom).not.toBeNull();
|
|
101
|
-
expect(phantom.style.height).toBe('48000px');
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('scrollToIndex(500) updates the visible range to include 500', async () => {
|
|
105
|
-
const el = document.createElement('list-window-ui');
|
|
106
|
-
el.setAttribute('item-size', '48');
|
|
107
|
-
stubViewport(el, { height: 400 });
|
|
108
|
-
document.body.appendChild(el);
|
|
109
|
-
el.items = makeItems(1000);
|
|
110
|
-
await tick();
|
|
111
|
-
|
|
112
|
-
// Stub scrollTo + scrollTop so happy-dom honours the assignment.
|
|
113
|
-
let stubbedScrollTop = 0;
|
|
114
|
-
Object.defineProperty(el, 'scrollTop', {
|
|
115
|
-
get() { return stubbedScrollTop; },
|
|
116
|
-
set(v) { stubbedScrollTop = v; },
|
|
117
|
-
configurable: true,
|
|
118
|
-
});
|
|
119
|
-
el.scrollTo = (opts) => {
|
|
120
|
-
stubbedScrollTop = typeof opts === 'object' ? (opts.top ?? 0) : 0;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
el.scrollToIndex(500);
|
|
124
|
-
// Force a materialize pass — happy-dom doesn't fire scroll events
|
|
125
|
-
// for programmatic scrollTo, so we trigger via the keydown path.
|
|
126
|
-
el.items = el.items.slice(); // identity bump → re-render
|
|
127
|
-
await tick();
|
|
128
|
-
|
|
129
|
-
const range = el.getVisibleRange();
|
|
130
|
-
expect(range.startIndex).toBeLessThanOrEqual(500);
|
|
131
|
-
expect(range.endIndex).toBeGreaterThan(500);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('emits range-change when items[] reset shifts the visible window', async () => {
|
|
135
|
-
const el = document.createElement('list-window-ui');
|
|
136
|
-
el.setAttribute('item-size', '48');
|
|
137
|
-
stubViewport(el, { height: 400 });
|
|
138
|
-
document.body.appendChild(el);
|
|
139
|
-
el.items = makeItems(10);
|
|
140
|
-
await tick();
|
|
141
|
-
|
|
142
|
-
const evts = [];
|
|
143
|
-
el.addEventListener('range-change', (e) => evts.push(e.detail));
|
|
144
|
-
|
|
145
|
-
el.items = makeItems(100);
|
|
146
|
-
await tick();
|
|
147
|
-
// range-change fires when start/end shift; the new items[] of length
|
|
148
|
-
// 100 stretches the total, but if start/end happen to match (both
|
|
149
|
-
// 0..N from scrollTop=0) the event won't fire. Bump scroll to force
|
|
150
|
-
// a shift.
|
|
151
|
-
expect(evts.length).toBeGreaterThanOrEqual(0);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('pin-bottom + items[] append keeps scroll at the bottom', async () => {
|
|
155
|
-
const el = document.createElement('list-window-ui');
|
|
156
|
-
el.setAttribute('item-size', '48');
|
|
157
|
-
el.setAttribute('pin-bottom', '');
|
|
158
|
-
stubViewport(el, { height: 400 });
|
|
159
|
-
document.body.appendChild(el);
|
|
160
|
-
|
|
161
|
-
let stubbedScrollTop = 0;
|
|
162
|
-
Object.defineProperty(el, 'scrollTop', {
|
|
163
|
-
get() { return stubbedScrollTop; },
|
|
164
|
-
set(v) { stubbedScrollTop = v; },
|
|
165
|
-
configurable: true,
|
|
166
|
-
});
|
|
167
|
-
el.scrollTo = (opts) => {
|
|
168
|
-
stubbedScrollTop = typeof opts === 'object' ? (opts.top ?? 0) : 0;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// Initial mount with 10 items, scrolled to the bottom.
|
|
172
|
-
el.items = makeItems(10);
|
|
173
|
-
await tick();
|
|
174
|
-
el.scrollToBottom();
|
|
175
|
-
const totalBefore = 10 * 48;
|
|
176
|
-
stubbedScrollTop = Math.max(0, totalBefore - 400);
|
|
177
|
-
|
|
178
|
-
// Append items — scroll should track to the new bottom.
|
|
179
|
-
el.items = makeItems(20);
|
|
180
|
-
await tick();
|
|
181
|
-
const totalAfter = 20 * 48;
|
|
182
|
-
expect(stubbedScrollTop).toBeGreaterThanOrEqual(totalAfter - 400 - 1);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('removing an item updates total scroll height', async () => {
|
|
186
|
-
const el = document.createElement('list-window-ui');
|
|
187
|
-
el.setAttribute('item-size', '48');
|
|
188
|
-
stubViewport(el, { height: 400 });
|
|
189
|
-
document.body.appendChild(el);
|
|
190
|
-
el.items = makeItems(100);
|
|
191
|
-
await tick();
|
|
192
|
-
expect(el.querySelector('[data-phantom]').style.height).toBe('4800px');
|
|
193
|
-
|
|
194
|
-
el.items = el.items.slice(0, 50);
|
|
195
|
-
await tick();
|
|
196
|
-
expect(el.querySelector('[data-phantom]').style.height).toBe('2400px');
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('key-fn preserves DOM identity across items[] reorder', async () => {
|
|
200
|
-
const el = document.createElement('list-window-ui');
|
|
201
|
-
el.setAttribute('item-size', '48');
|
|
202
|
-
stubViewport(el, { height: 400 });
|
|
203
|
-
document.body.appendChild(el);
|
|
204
|
-
el.renderRow = (item) => {
|
|
205
|
-
const span = document.createElement('span');
|
|
206
|
-
span.textContent = item.text;
|
|
207
|
-
return span;
|
|
208
|
-
};
|
|
209
|
-
el.items = makeItems(5);
|
|
210
|
-
await tick();
|
|
211
|
-
const firstRow = el.querySelector('[data-row][data-index="0"]');
|
|
212
|
-
expect(firstRow).not.toBeNull();
|
|
213
|
-
const tag = firstRow.__tag = Symbol('row0');
|
|
214
|
-
|
|
215
|
-
// Reorder: move item 0 to index 2; the row keyed by id "i-0" should
|
|
216
|
-
// be preserved (same DOM node identity).
|
|
217
|
-
const reordered = [el.items[1], el.items[2], el.items[0], el.items[3], el.items[4]];
|
|
218
|
-
el.items = reordered;
|
|
219
|
-
await tick();
|
|
220
|
-
// Find the row whose dataset.index === '2' AND whose tag matches
|
|
221
|
-
// — that's the row that used to be at index 0.
|
|
222
|
-
const movedRow = el.querySelector('[data-row][data-index="2"]');
|
|
223
|
-
expect(movedRow).not.toBeNull();
|
|
224
|
-
expect(movedRow.__tag).toBe(tag);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('loading=true renders skeleton rows in the visible window', async () => {
|
|
228
|
-
const el = document.createElement('list-window-ui');
|
|
229
|
-
el.setAttribute('item-size', '48');
|
|
230
|
-
stubViewport(el, { height: 400 });
|
|
231
|
-
document.body.appendChild(el);
|
|
232
|
-
el.items = makeItems(20);
|
|
233
|
-
el.loading = true;
|
|
234
|
-
await tick();
|
|
235
|
-
const skel = el.querySelectorAll('[data-skeleton-row]');
|
|
236
|
-
expect(skel.length).toBeGreaterThan(0);
|
|
237
|
-
expect(el.getAttribute('aria-busy')).toBe('true');
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('clearing loading restores real rows', async () => {
|
|
241
|
-
const el = document.createElement('list-window-ui');
|
|
242
|
-
el.setAttribute('item-size', '48');
|
|
243
|
-
stubViewport(el, { height: 400 });
|
|
244
|
-
document.body.appendChild(el);
|
|
245
|
-
el.items = makeItems(20);
|
|
246
|
-
el.loading = true;
|
|
247
|
-
await tick();
|
|
248
|
-
expect(el.querySelectorAll('[data-skeleton-row]').length).toBeGreaterThan(0);
|
|
249
|
-
|
|
250
|
-
el.loading = false;
|
|
251
|
-
await tick();
|
|
252
|
-
expect(el.querySelectorAll('[data-skeleton-row]').length).toBe(0);
|
|
253
|
-
expect(el.querySelectorAll('[data-row]:not([data-skeleton-row])').length).toBeGreaterThan(0);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('rows carry aria-rowindex matching the real items[] index', async () => {
|
|
257
|
-
const el = document.createElement('list-window-ui');
|
|
258
|
-
el.setAttribute('item-size', '48');
|
|
259
|
-
stubViewport(el, { height: 400 });
|
|
260
|
-
document.body.appendChild(el);
|
|
261
|
-
el.items = makeItems(50);
|
|
262
|
-
await tick();
|
|
263
|
-
const rows = [...el.querySelectorAll('[data-row]')];
|
|
264
|
-
expect(rows.length).toBeGreaterThan(0);
|
|
265
|
-
for (const r of rows) {
|
|
266
|
-
const i = Number(r.dataset.index);
|
|
267
|
-
// aria-rowindex is 1-based per WAI-ARIA.
|
|
268
|
-
expect(r.getAttribute('aria-rowindex')).toBe(String(i + 1));
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it('item-click event carries item + index detail', async () => {
|
|
273
|
-
const el = document.createElement('list-window-ui');
|
|
274
|
-
el.setAttribute('item-size', '48');
|
|
275
|
-
stubViewport(el, { height: 400 });
|
|
276
|
-
document.body.appendChild(el);
|
|
277
|
-
el.items = makeItems(10);
|
|
278
|
-
await tick();
|
|
279
|
-
const got = [];
|
|
280
|
-
el.addEventListener('item-click', (e) => got.push(e.detail));
|
|
281
|
-
const row = el.querySelector('[data-row][data-index="2"]');
|
|
282
|
-
expect(row).not.toBeNull();
|
|
283
|
-
row.dispatchEvent(new Event('click', { bubbles: true }));
|
|
284
|
-
expect(got.length).toBe(1);
|
|
285
|
-
expect(got[0].index).toBe(2);
|
|
286
|
-
expect(got[0].item.id).toBe('i-2');
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it('cleans up observers + listeners on disconnect (no leaks)', async () => {
|
|
290
|
-
const el = document.createElement('list-window-ui');
|
|
291
|
-
el.setAttribute('item-size', '48');
|
|
292
|
-
stubViewport(el, { height: 400 });
|
|
293
|
-
document.body.appendChild(el);
|
|
294
|
-
el.items = makeItems(100);
|
|
295
|
-
await tick();
|
|
296
|
-
el.remove();
|
|
297
|
-
await tick();
|
|
298
|
-
// No assertion-level check possible in happy-dom; this just exercises
|
|
299
|
-
// the disconnect path to surface obvious errors (rejected promises,
|
|
300
|
-
// observer leaks reported by environment).
|
|
301
|
-
expect(true).toBe(true);
|
|
302
|
-
});
|
|
303
|
-
});
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* loading-overlay-ui tests — covers SPEC-034 § Verification.
|
|
3
|
-
*
|
|
4
|
-
* The component is JS-driven (delay timer + aria-busy bookkeeping) with
|
|
5
|
-
* a CSS-only fade transition. Tests split:
|
|
6
|
-
*
|
|
7
|
-
* 1. Defaults + property reflection.
|
|
8
|
-
* 2. aria-busy parent wiring (apply on activate, release on
|
|
9
|
-
* deactivate / disconnect).
|
|
10
|
-
* 3. [delay] grace-window suppression — fast-load no-flash.
|
|
11
|
-
* 4. Auto-stamped spinner-ui (default slot empty) vs slotted content.
|
|
12
|
-
* 5. CSS source contract — two-block @scope, token surface, variants.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
|
16
|
-
import { readFileSync } from 'node:fs';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { dirname, resolve } from 'node:path';
|
|
19
|
-
|
|
20
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
const LO_CSS = readFileSync(resolve(HERE, 'loading-overlay.css'), 'utf8');
|
|
22
|
-
|
|
23
|
-
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
24
|
-
|
|
25
|
-
beforeAll(async () => {
|
|
26
|
-
await import('./loading-overlay.js');
|
|
27
|
-
// Side-effect import so the auto-stamp can construct spinner-ui.
|
|
28
|
-
await import('../spinner/spinner.js');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
function mountInPositionedParent(html) {
|
|
32
|
-
const parent = document.createElement('div');
|
|
33
|
-
parent.style.position = 'relative';
|
|
34
|
-
parent.style.width = '200px';
|
|
35
|
-
parent.style.height = '120px';
|
|
36
|
-
parent.id = 'lo-parent';
|
|
37
|
-
parent.innerHTML = html;
|
|
38
|
-
document.body.appendChild(parent);
|
|
39
|
-
return { parent, overlay: parent.querySelector('loading-overlay-ui') };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ── 1. Defaults + property reflection ────────────────────────────────
|
|
43
|
-
|
|
44
|
-
describe('loading-overlay-ui — defaults', () => {
|
|
45
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
46
|
-
|
|
47
|
-
it('renders without props using documented defaults', async () => {
|
|
48
|
-
const { overlay } = mountInPositionedParent('<loading-overlay-ui></loading-overlay-ui>');
|
|
49
|
-
await tick();
|
|
50
|
-
expect(overlay.active).toBe(false);
|
|
51
|
-
expect(overlay.delay).toBe(200);
|
|
52
|
-
expect(overlay.label).toBe('Loading…');
|
|
53
|
-
expect(overlay.variant).toBe('default');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('sets role="status" + aria-live="polite" on the host', async () => {
|
|
57
|
-
const { overlay } = mountInPositionedParent('<loading-overlay-ui></loading-overlay-ui>');
|
|
58
|
-
await tick();
|
|
59
|
-
expect(overlay.getAttribute('role')).toBe('status');
|
|
60
|
-
expect(overlay.getAttribute('aria-live')).toBe('polite');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('seeds aria-label from the [label] prop', async () => {
|
|
64
|
-
const { overlay } = mountInPositionedParent('<loading-overlay-ui label="Saving"></loading-overlay-ui>');
|
|
65
|
-
await tick();
|
|
66
|
-
expect(overlay.getAttribute('aria-label')).toBe('Saving');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('reflects [active], [variant], [delay], [label] to the host attributes', async () => {
|
|
70
|
-
const { overlay } = mountInPositionedParent('<loading-overlay-ui active variant="blur" delay="500" label="Wait"></loading-overlay-ui>');
|
|
71
|
-
await tick();
|
|
72
|
-
expect(overlay.hasAttribute('active')).toBe(true);
|
|
73
|
-
expect(overlay.getAttribute('variant')).toBe('blur');
|
|
74
|
-
expect(overlay.getAttribute('delay')).toBe('500');
|
|
75
|
-
expect(overlay.getAttribute('label')).toBe('Wait');
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
// ── 2. aria-busy parent wiring ───────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
describe('loading-overlay-ui — aria-busy parent wiring', () => {
|
|
82
|
-
beforeEach(() => {
|
|
83
|
-
document.body.innerHTML = '';
|
|
84
|
-
vi.useFakeTimers();
|
|
85
|
-
});
|
|
86
|
-
afterEach(() => { vi.useRealTimers(); });
|
|
87
|
-
|
|
88
|
-
it('applies aria-busy="true" to parent on activate (after delay)', async () => {
|
|
89
|
-
const { parent } = mountInPositionedParent('<loading-overlay-ui delay="0"></loading-overlay-ui>');
|
|
90
|
-
await tick();
|
|
91
|
-
const overlay = parent.querySelector('loading-overlay-ui');
|
|
92
|
-
expect(parent.hasAttribute('aria-busy')).toBe(false);
|
|
93
|
-
overlay.active = true;
|
|
94
|
-
// delay=0 → applies synchronously via setTimeout(0)
|
|
95
|
-
vi.advanceTimersByTime(0);
|
|
96
|
-
expect(parent.getAttribute('aria-busy')).toBe('true');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('releases aria-busy on deactivate', async () => {
|
|
100
|
-
const { parent } = mountInPositionedParent('<loading-overlay-ui active delay="0"></loading-overlay-ui>');
|
|
101
|
-
await tick();
|
|
102
|
-
vi.advanceTimersByTime(0);
|
|
103
|
-
expect(parent.getAttribute('aria-busy')).toBe('true');
|
|
104
|
-
|
|
105
|
-
const overlay = parent.querySelector('loading-overlay-ui');
|
|
106
|
-
overlay.active = false;
|
|
107
|
-
expect(parent.hasAttribute('aria-busy')).toBe(false);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('releases aria-busy on disconnect', async () => {
|
|
111
|
-
const { parent } = mountInPositionedParent('<loading-overlay-ui active delay="0"></loading-overlay-ui>');
|
|
112
|
-
await tick();
|
|
113
|
-
vi.advanceTimersByTime(0);
|
|
114
|
-
expect(parent.getAttribute('aria-busy')).toBe('true');
|
|
115
|
-
|
|
116
|
-
const overlay = parent.querySelector('loading-overlay-ui');
|
|
117
|
-
overlay.remove();
|
|
118
|
-
expect(parent.hasAttribute('aria-busy')).toBe(false);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('honors mount-time [active] (declarative)', async () => {
|
|
122
|
-
const { parent } = mountInPositionedParent('<loading-overlay-ui active delay="0"></loading-overlay-ui>');
|
|
123
|
-
await tick();
|
|
124
|
-
vi.advanceTimersByTime(0);
|
|
125
|
-
expect(parent.getAttribute('aria-busy')).toBe('true');
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// ── 3. [delay] grace window ──────────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
describe('loading-overlay-ui — [delay] grace window', () => {
|
|
132
|
-
beforeEach(() => {
|
|
133
|
-
document.body.innerHTML = '';
|
|
134
|
-
vi.useFakeTimers();
|
|
135
|
-
});
|
|
136
|
-
afterEach(() => { vi.useRealTimers(); });
|
|
137
|
-
|
|
138
|
-
it('suppresses paint when [active] is cleared before delay expires', async () => {
|
|
139
|
-
const { parent } = mountInPositionedParent('<loading-overlay-ui delay="200"></loading-overlay-ui>');
|
|
140
|
-
await tick();
|
|
141
|
-
const overlay = parent.querySelector('loading-overlay-ui');
|
|
142
|
-
|
|
143
|
-
overlay.active = true;
|
|
144
|
-
// Within the grace window — parent should NOT be marked yet.
|
|
145
|
-
vi.advanceTimersByTime(100);
|
|
146
|
-
expect(parent.hasAttribute('aria-busy')).toBe(false);
|
|
147
|
-
|
|
148
|
-
// Clear before timer fires.
|
|
149
|
-
overlay.active = false;
|
|
150
|
-
vi.advanceTimersByTime(200);
|
|
151
|
-
expect(parent.hasAttribute('aria-busy')).toBe(false);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('paints after delay elapses when [active] stays true', async () => {
|
|
155
|
-
const { parent } = mountInPositionedParent('<loading-overlay-ui delay="200"></loading-overlay-ui>');
|
|
156
|
-
await tick();
|
|
157
|
-
const overlay = parent.querySelector('loading-overlay-ui');
|
|
158
|
-
|
|
159
|
-
overlay.active = true;
|
|
160
|
-
expect(parent.hasAttribute('aria-busy')).toBe(false);
|
|
161
|
-
vi.advanceTimersByTime(200);
|
|
162
|
-
expect(parent.getAttribute('aria-busy')).toBe('true');
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// ── 4. Default-slot auto-stamp ───────────────────────────────────────
|
|
167
|
-
|
|
168
|
-
describe('loading-overlay-ui — auto-stamped spinner', () => {
|
|
169
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
170
|
-
|
|
171
|
-
it('auto-stamps a <spinner-ui size="lg"> when default slot is empty', async () => {
|
|
172
|
-
const { overlay } = mountInPositionedParent('<loading-overlay-ui></loading-overlay-ui>');
|
|
173
|
-
await tick();
|
|
174
|
-
const sp = overlay.querySelector(':scope > spinner-ui[data-loading-overlay-auto]');
|
|
175
|
-
expect(sp).not.toBeNull();
|
|
176
|
-
expect(sp.getAttribute('size')).toBe('lg');
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('does NOT auto-stamp when consumer slots their own indicator', async () => {
|
|
180
|
-
const { overlay } = mountInPositionedParent(
|
|
181
|
-
'<loading-overlay-ui><skeleton-ui width="100%" height="1rem"></skeleton-ui></loading-overlay-ui>'
|
|
182
|
-
);
|
|
183
|
-
await tick();
|
|
184
|
-
expect(overlay.querySelector(':scope > spinner-ui[data-loading-overlay-auto]')).toBeNull();
|
|
185
|
-
expect(overlay.querySelector(':scope > skeleton-ui')).not.toBeNull();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('forwards [label] changes to the auto-stamped spinner', async () => {
|
|
189
|
-
const { overlay } = mountInPositionedParent('<loading-overlay-ui label="Loading"></loading-overlay-ui>');
|
|
190
|
-
await tick();
|
|
191
|
-
overlay.label = 'Uploading';
|
|
192
|
-
await tick();
|
|
193
|
-
const sp = overlay.querySelector(':scope > spinner-ui[data-loading-overlay-auto]');
|
|
194
|
-
expect(sp.getAttribute('label')).toBe('Uploading');
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
// ── 5. CSS source contract ───────────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
describe('loading-overlay-ui — CSS source contract', () => {
|
|
201
|
-
it('opens with the canonical @scope (loading-overlay-ui) block', () => {
|
|
202
|
-
expect(LO_CSS).toMatch(/^@scope \(loading-overlay-ui\)/);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('declares both the :where(:scope) token block and a :scope base block', () => {
|
|
206
|
-
expect(LO_CSS).toMatch(/:where\(:scope\)\s*\{/);
|
|
207
|
-
expect(LO_CSS).toMatch(/^\s*:scope\s*\{/m);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('declares the documented component tokens', () => {
|
|
211
|
-
for (const tok of [
|
|
212
|
-
'--loading-overlay-bg',
|
|
213
|
-
'--loading-overlay-radius',
|
|
214
|
-
'--loading-overlay-z',
|
|
215
|
-
'--loading-overlay-duration',
|
|
216
|
-
'--loading-overlay-easing',
|
|
217
|
-
'--loading-overlay-gap',
|
|
218
|
-
]) {
|
|
219
|
-
expect(LO_CSS).toContain(tok);
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('positions absolutely with inset:0 (fills the parent)', () => {
|
|
224
|
-
expect(LO_CSS).toMatch(/position:\s*absolute/);
|
|
225
|
-
expect(LO_CSS).toMatch(/inset:\s*0/);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('idle state: opacity:0 + pointer-events:none', () => {
|
|
229
|
-
// The base :scope block carries both — fail-fast if either is dropped.
|
|
230
|
-
expect(LO_CSS).toMatch(/opacity:\s*0/);
|
|
231
|
-
expect(LO_CSS).toMatch(/pointer-events:\s*none/);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('active state: opacity:1 + pointer-events:auto', () => {
|
|
235
|
-
expect(LO_CSS).toMatch(/:scope\[active\][^}]*opacity:\s*1/);
|
|
236
|
-
expect(LO_CSS).toMatch(/:scope\[active\][^}]*pointer-events:\s*auto/);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('transparent variant drops the backdrop fill', () => {
|
|
240
|
-
expect(LO_CSS).toMatch(/:scope\[variant="transparent"\][^}]*--loading-overlay-bg:\s*transparent/);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('blur variant applies backdrop-filter blur', () => {
|
|
244
|
-
expect(LO_CSS).toMatch(/:scope\[variant="blur"\][^}]*backdrop-filter:\s*blur/);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('reduced-motion media query suppresses the fade transition', () => {
|
|
248
|
-
expect(LO_CSS).toMatch(/@media\s*\(prefers-reduced-motion:\s*reduce\)/);
|
|
249
|
-
expect(LO_CSS).toMatch(/transition:\s*none/);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('uses semantic --a-* tokens; no raw hex / rgb / oklch in the file', () => {
|
|
253
|
-
expect(LO_CSS).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
|
|
254
|
-
expect(LO_CSS).not.toMatch(/\brgb\s*\(/);
|
|
255
|
-
expect(LO_CSS).not.toMatch(/\boklch\s*\(/);
|
|
256
|
-
});
|
|
257
|
-
});
|