@pdanpdan/virtual-scroll 0.2.0 → 0.3.0
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/README.md +182 -88
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +100 -35
- package/dist/index.js +1 -823
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +902 -0
- package/dist/index.mjs.map +1 -0
- package/dist/virtual-scroll.css +2 -0
- package/package.json +9 -6
- package/src/components/VirtualScroll.test.ts +397 -329
- package/src/components/VirtualScroll.vue +107 -25
- package/src/composables/useVirtualScroll.test.ts +1029 -255
- package/src/composables/useVirtualScroll.ts +176 -88
- package/src/utils/fenwick-tree.test.ts +80 -65
- package/dist/index.css +0 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { ScrollDetails } from '../composables/useVirtualScroll';
|
|
1
|
+
import type { ScrollAlignment, ScrollAlignmentOptions, ScrollDetails, ScrollToIndexOptions } from '../composables/useVirtualScroll';
|
|
2
|
+
import type { DOMWrapper, VueWrapper } from '@vue/test-utils';
|
|
2
3
|
|
|
3
4
|
import { mount } from '@vue/test-utils';
|
|
4
|
-
import { describe, expect, it
|
|
5
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
5
6
|
import { defineComponent, nextTick, ref } from 'vue';
|
|
6
7
|
|
|
7
8
|
import VirtualScroll from './VirtualScroll.vue';
|
|
@@ -45,11 +46,20 @@ globalThis.ResizeObserver = class {
|
|
|
45
46
|
// eslint-disable-next-line test/prefer-lowercase-title
|
|
46
47
|
describe('VirtualScroll component', () => {
|
|
47
48
|
const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
49
|
+
|
|
48
50
|
interface VSInstance {
|
|
49
|
-
scrollToIndex: (rowIndex: number | null, colIndex: number | null, options?: unknown) => void;
|
|
50
|
-
scrollToOffset: (x: number | null, y: number | null, options?: unknown) => void;
|
|
51
|
-
setItemRef: (el: unknown, index: number) => void;
|
|
52
51
|
scrollDetails: ScrollDetails<unknown>;
|
|
52
|
+
scrollToIndex: (rowIndex: number | null, colIndex: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
|
|
53
|
+
scrollToOffset: (x: number | null, y: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
|
|
54
|
+
setItemRef: (el: unknown, index: number) => void;
|
|
55
|
+
stopProgrammaticScroll: () => void;
|
|
56
|
+
refresh: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface TestCompInstance {
|
|
60
|
+
mockItems: typeof mockItems;
|
|
61
|
+
show: boolean;
|
|
62
|
+
showFooter: boolean;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
describe('rendering and structure', () => {
|
|
@@ -78,10 +88,49 @@ describe('VirtualScroll component', () => {
|
|
|
78
88
|
footer: '<div class="footer">Footer</div>',
|
|
79
89
|
},
|
|
80
90
|
});
|
|
91
|
+
expect(wrapper.find('.virtual-scroll-header').exists()).toBe(true);
|
|
81
92
|
expect(wrapper.find('.header').exists()).toBe(true);
|
|
93
|
+
expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(true);
|
|
82
94
|
expect(wrapper.find('.footer').exists()).toBe(true);
|
|
83
95
|
});
|
|
84
96
|
|
|
97
|
+
it('should not render header and footer slots when absent', () => {
|
|
98
|
+
const wrapper = mount(VirtualScroll, {
|
|
99
|
+
props: {
|
|
100
|
+
items: mockItems,
|
|
101
|
+
itemSize: 50,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expect(wrapper.find('.virtual-scroll-header').exists()).toBe(false);
|
|
105
|
+
expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should render debug information when debug prop is true', async () => {
|
|
109
|
+
const wrapper = mount(VirtualScroll, {
|
|
110
|
+
props: {
|
|
111
|
+
items: mockItems.slice(0, 5),
|
|
112
|
+
itemSize: 50,
|
|
113
|
+
debug: true,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
await nextTick();
|
|
117
|
+
expect(wrapper.find('.virtual-scroll-debug-info').exists()).toBe(true);
|
|
118
|
+
expect(wrapper.find('.virtual-scroll-item').classes()).toContain('virtual-scroll--debug');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should not render debug information when debug prop is false', async () => {
|
|
122
|
+
const wrapper = mount(VirtualScroll, {
|
|
123
|
+
props: {
|
|
124
|
+
items: mockItems.slice(0, 5),
|
|
125
|
+
itemSize: 50,
|
|
126
|
+
debug: false,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
await nextTick();
|
|
130
|
+
expect(wrapper.find('.virtual-scroll-debug-info').exists()).toBe(false);
|
|
131
|
+
expect(wrapper.find('.virtual-scroll-item').classes()).not.toContain('virtual-scroll--debug');
|
|
132
|
+
});
|
|
133
|
+
|
|
85
134
|
it('should handle missing slots gracefully', () => {
|
|
86
135
|
const wrapper = mount(VirtualScroll, {
|
|
87
136
|
props: {
|
|
@@ -186,9 +235,45 @@ describe('VirtualScroll component', () => {
|
|
|
186
235
|
expect(wrapper.find('tr.virtual-scroll-spacer').exists()).toBe(true);
|
|
187
236
|
expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
|
|
188
237
|
});
|
|
238
|
+
|
|
239
|
+
it('should handle table rendering without header and footer', async () => {
|
|
240
|
+
const wrapper = mount(VirtualScroll, {
|
|
241
|
+
props: {
|
|
242
|
+
items: mockItems.slice(0, 5),
|
|
243
|
+
containerTag: 'table',
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
await nextTick();
|
|
247
|
+
expect(wrapper.find('thead').exists()).toBe(false);
|
|
248
|
+
expect(wrapper.find('tfoot').exists()).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should cover all template branches for slots and tags', async () => {
|
|
252
|
+
for (const tag of [ 'div', 'table' ] as const) {
|
|
253
|
+
for (const direction of [ 'vertical', 'horizontal', 'both' ] as const) {
|
|
254
|
+
for (const loading of [ true, false ]) {
|
|
255
|
+
for (const withSlots of [ true, false ]) {
|
|
256
|
+
const slots = withSlots
|
|
257
|
+
? {
|
|
258
|
+
header: tag === 'table' ? '<tr><td>H</td></tr>' : '<div>H</div>',
|
|
259
|
+
footer: tag === 'table' ? '<tr><td>F</td></tr>' : '<div>F</div>',
|
|
260
|
+
loading: tag === 'table' ? '<tr><td>L</td></tr>' : '<div>L</div>',
|
|
261
|
+
}
|
|
262
|
+
: {};
|
|
263
|
+
const wrapper = mount(VirtualScroll, {
|
|
264
|
+
props: { items: mockItems.slice(0, 1), containerTag: tag, loading, direction },
|
|
265
|
+
slots,
|
|
266
|
+
});
|
|
267
|
+
await nextTick();
|
|
268
|
+
wrapper.unmount();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
189
274
|
});
|
|
190
275
|
|
|
191
|
-
describe('styling and
|
|
276
|
+
describe('styling and dimensions', () => {
|
|
192
277
|
it('should render items horizontally when direction is horizontal', async () => {
|
|
193
278
|
const wrapper = mount(VirtualScroll, {
|
|
194
279
|
props: {
|
|
@@ -280,140 +365,215 @@ describe('VirtualScroll component', () => {
|
|
|
280
365
|
await nextTick();
|
|
281
366
|
// This covers the branch where container is NOT host element and NOT window
|
|
282
367
|
});
|
|
283
|
-
});
|
|
284
368
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
369
|
+
it('should handle stickyHeader with window container', async () => {
|
|
370
|
+
const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
371
|
+
mount(VirtualScroll, {
|
|
372
|
+
props: {
|
|
373
|
+
items,
|
|
374
|
+
container: window,
|
|
375
|
+
stickyHeader: true,
|
|
376
|
+
},
|
|
377
|
+
slots: { header: '<div>H</div>' },
|
|
378
|
+
});
|
|
288
379
|
await nextTick();
|
|
289
|
-
expect(wrapper.emitted('scroll')).toBeDefined();
|
|
290
380
|
});
|
|
291
381
|
|
|
292
|
-
it('should
|
|
293
|
-
|
|
382
|
+
it('should cover object padding branches in virtualScrollProps', () => {
|
|
383
|
+
mount(VirtualScroll, {
|
|
294
384
|
props: {
|
|
295
|
-
items: mockItems.slice(0,
|
|
296
|
-
|
|
385
|
+
items: mockItems.slice(0, 1),
|
|
386
|
+
scrollPaddingStart: { x: 10, y: 20 },
|
|
387
|
+
scrollPaddingEnd: { x: 30, y: 40 },
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
mount(VirtualScroll, {
|
|
391
|
+
props: {
|
|
392
|
+
items: mockItems.slice(0, 1),
|
|
393
|
+
direction: 'horizontal',
|
|
394
|
+
scrollPaddingStart: 10,
|
|
395
|
+
scrollPaddingEnd: 20,
|
|
297
396
|
},
|
|
298
397
|
});
|
|
299
|
-
// Hydration is delayed via nextTick in useVirtualScroll when initialScrollIndex is set
|
|
300
|
-
// Trigger scrollDetails update
|
|
301
|
-
await wrapper.setProps({ items: mockItems.slice(0, 10) });
|
|
302
|
-
expect(wrapper.emitted('scroll')).toBeUndefined();
|
|
303
|
-
await nextTick();
|
|
304
|
-
// Still might not be hydrated because it's nextTick within nextTick?
|
|
305
|
-
// Actually, useVirtualScroll uses nextTick inside onMounted.
|
|
306
|
-
// mount() calls onMounted.
|
|
307
|
-
// So we need one nextTick to reach isHydrated = true.
|
|
308
|
-
await nextTick();
|
|
309
|
-
expect(wrapper.emitted('scroll')).toBeDefined();
|
|
310
398
|
});
|
|
399
|
+
});
|
|
311
400
|
|
|
312
|
-
|
|
313
|
-
|
|
401
|
+
describe('keyboard navigation', () => {
|
|
402
|
+
let wrapper: VueWrapper<VSInstance>;
|
|
403
|
+
let container: DOMWrapper<Element>;
|
|
404
|
+
let el: HTMLElement;
|
|
405
|
+
|
|
406
|
+
beforeEach(async () => {
|
|
407
|
+
wrapper = mount(VirtualScroll, {
|
|
314
408
|
props: {
|
|
315
409
|
items: mockItems,
|
|
316
410
|
itemSize: 50,
|
|
411
|
+
direction: 'vertical',
|
|
317
412
|
},
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
await nextTick();
|
|
413
|
+
}) as unknown as VueWrapper<VSInstance>;
|
|
321
414
|
await nextTick();
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
expect(emits).toBeTruthy();
|
|
325
|
-
const firstEmit = (emits as unknown[][])[ 0 ]![ 0 ] as { start: number; };
|
|
326
|
-
expect(firstEmit).toMatchObject({ start: 0 });
|
|
415
|
+
container = wrapper.find('.virtual-scroll-container');
|
|
416
|
+
el = container.element as HTMLElement;
|
|
327
417
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
Object.defineProperty(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
await nextTick();
|
|
418
|
+
// Mock dimensions
|
|
419
|
+
Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
|
|
420
|
+
Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
|
|
421
|
+
Object.defineProperty(el, 'offsetHeight', { value: 500, configurable: true });
|
|
422
|
+
Object.defineProperty(el, 'offsetWidth', { value: 500, configurable: true });
|
|
334
423
|
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
expect(lastEmit.start).toBeGreaterThan(0);
|
|
424
|
+
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
425
|
+
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
426
|
+
await nextTick();
|
|
339
427
|
});
|
|
340
428
|
|
|
341
|
-
it('should handle
|
|
342
|
-
|
|
429
|
+
it('should handle Home key', async () => {
|
|
430
|
+
el.scrollTop = 1000;
|
|
431
|
+
el.scrollLeft = 500;
|
|
432
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
343
433
|
await nextTick();
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
|
|
434
|
+
expect(el.scrollTop).toBe(0);
|
|
435
|
+
expect(el.scrollLeft).toBe(0);
|
|
436
|
+
});
|
|
348
437
|
|
|
438
|
+
it('should handle End key (vertical)', async () => {
|
|
439
|
+
el.scrollLeft = 0;
|
|
349
440
|
await container.trigger('keydown', { key: 'End' });
|
|
350
441
|
await nextTick();
|
|
351
|
-
|
|
442
|
+
// totalHeight = 100 items * 50px = 5000px
|
|
443
|
+
// viewportHeight = 500px
|
|
444
|
+
// scrollToIndex(99, 0, 'end') -> targetY = 99 * 50 = 4950
|
|
445
|
+
// alignment 'end' -> targetY = 4950 - (500 - 50) = 4500
|
|
446
|
+
expect(el.scrollTop).toBe(4500);
|
|
447
|
+
expect(el.scrollLeft).toBe(0);
|
|
448
|
+
});
|
|
352
449
|
|
|
353
|
-
|
|
450
|
+
it('should handle End key (horizontal)', async () => {
|
|
451
|
+
await wrapper.setProps({ direction: 'horizontal' });
|
|
354
452
|
await nextTick();
|
|
453
|
+
// Trigger resize again for horizontal
|
|
454
|
+
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
455
|
+
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
456
|
+
await nextTick();
|
|
457
|
+
|
|
458
|
+
el.scrollTop = 0;
|
|
459
|
+
await container.trigger('keydown', { key: 'End' });
|
|
460
|
+
await nextTick();
|
|
461
|
+
expect(el.scrollLeft).toBe(4500);
|
|
355
462
|
expect(el.scrollTop).toBe(0);
|
|
356
463
|
});
|
|
357
464
|
|
|
358
|
-
it('should handle
|
|
359
|
-
|
|
465
|
+
it('should handle End key in both mode', async () => {
|
|
466
|
+
await wrapper.setProps({ columnCount: 5, columnWidth: 100, direction: 'both' });
|
|
360
467
|
await nextTick();
|
|
361
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
362
|
-
const el = container.element as HTMLElement;
|
|
363
|
-
Object.defineProperty(el, 'scrollWidth', { value: 5000, configurable: true });
|
|
364
|
-
Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
|
|
365
468
|
|
|
366
|
-
|
|
469
|
+
// Trigger a resize
|
|
470
|
+
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
471
|
+
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
367
472
|
await nextTick();
|
|
368
|
-
expect(el.scrollLeft).toBeGreaterThan(0);
|
|
369
473
|
|
|
370
|
-
await container.trigger('keydown', { key: '
|
|
474
|
+
await container.trigger('keydown', { key: 'End' });
|
|
371
475
|
await nextTick();
|
|
476
|
+
|
|
477
|
+
// items: 100 (rows), height: 50 -> totalHeight: 5000
|
|
478
|
+
// columns: 5, width: 100 -> totalWidth: 500
|
|
479
|
+
// viewport: 500x500
|
|
480
|
+
// scrollToIndex(99, 4, 'end')
|
|
481
|
+
// targetY = 99 * 50 = 4950. end alignment: 4950 - (500 - 50) = 4500
|
|
482
|
+
// targetX = 4 * 100 = 400. end alignment: 400 - (500 - 100) = 0
|
|
483
|
+
expect(el.scrollTop).toBe(4500);
|
|
372
484
|
expect(el.scrollLeft).toBe(0);
|
|
373
485
|
});
|
|
374
486
|
|
|
375
|
-
it('should handle
|
|
376
|
-
|
|
377
|
-
props: {
|
|
378
|
-
items: mockItems,
|
|
379
|
-
itemSize: 50,
|
|
380
|
-
},
|
|
381
|
-
});
|
|
382
|
-
|
|
487
|
+
it('should handle End key with empty items', async () => {
|
|
488
|
+
await wrapper.setProps({ items: [] });
|
|
383
489
|
await nextTick();
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
490
|
+
await container.trigger('keydown', { key: 'End' });
|
|
491
|
+
await nextTick();
|
|
492
|
+
});
|
|
387
493
|
|
|
494
|
+
it('should handle ArrowDown / ArrowUp', async () => {
|
|
495
|
+
el.scrollLeft = 0;
|
|
388
496
|
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
389
|
-
|
|
497
|
+
await nextTick();
|
|
498
|
+
expect(el.scrollTop).toBe(40);
|
|
499
|
+
expect(el.scrollLeft).toBe(0);
|
|
390
500
|
|
|
391
501
|
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
392
|
-
|
|
502
|
+
await nextTick();
|
|
503
|
+
expect(el.scrollTop).toBe(0);
|
|
504
|
+
expect(el.scrollLeft).toBe(0);
|
|
505
|
+
});
|
|
393
506
|
|
|
507
|
+
it('should handle ArrowRight / ArrowLeft', async () => {
|
|
508
|
+
await wrapper.setProps({ direction: 'horizontal' });
|
|
509
|
+
await nextTick();
|
|
510
|
+
el.scrollTop = 0;
|
|
394
511
|
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
395
|
-
|
|
512
|
+
await nextTick();
|
|
513
|
+
expect(el.scrollLeft).toBe(40);
|
|
514
|
+
expect(el.scrollTop).toBe(0);
|
|
396
515
|
|
|
397
516
|
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
398
|
-
|
|
517
|
+
await nextTick();
|
|
518
|
+
expect(el.scrollLeft).toBe(0);
|
|
519
|
+
expect(el.scrollTop).toBe(0);
|
|
520
|
+
});
|
|
399
521
|
|
|
522
|
+
it('should handle PageDown / PageUp', async () => {
|
|
523
|
+
el.scrollLeft = 0;
|
|
400
524
|
await container.trigger('keydown', { key: 'PageDown' });
|
|
401
|
-
|
|
525
|
+
await nextTick();
|
|
526
|
+
expect(el.scrollTop).toBe(500);
|
|
527
|
+
expect(el.scrollLeft).toBe(0);
|
|
402
528
|
|
|
403
529
|
await container.trigger('keydown', { key: 'PageUp' });
|
|
404
|
-
|
|
530
|
+
await nextTick();
|
|
531
|
+
expect(el.scrollTop).toBe(0);
|
|
532
|
+
expect(el.scrollLeft).toBe(0);
|
|
405
533
|
});
|
|
406
534
|
|
|
407
|
-
it('should handle
|
|
408
|
-
|
|
535
|
+
it('should handle PageDown / PageUp in horizontal mode', async () => {
|
|
536
|
+
await wrapper.setProps({ direction: 'horizontal' });
|
|
537
|
+
await nextTick();
|
|
538
|
+
el.scrollTop = 0;
|
|
539
|
+
await container.trigger('keydown', { key: 'PageDown' });
|
|
540
|
+
await nextTick();
|
|
541
|
+
expect(el.scrollLeft).toBe(500);
|
|
542
|
+
expect(el.scrollTop).toBe(0);
|
|
543
|
+
|
|
544
|
+
await container.trigger('keydown', { key: 'PageUp' });
|
|
545
|
+
await nextTick();
|
|
546
|
+
expect(el.scrollLeft).toBe(0);
|
|
547
|
+
expect(el.scrollTop).toBe(0);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should not scroll past the end using PageDown', async () => {
|
|
551
|
+
// items: 100, itemSize: 50 -> totalHeight = 5000
|
|
552
|
+
// viewportHeight: 500 -> maxScroll = 4500
|
|
553
|
+
(wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4400);
|
|
554
|
+
await nextTick();
|
|
555
|
+
await container.trigger('keydown', { key: 'PageDown' });
|
|
556
|
+
await nextTick();
|
|
557
|
+
expect(el.scrollTop).toBe(4500);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should not scroll past the end using ArrowDown', async () => {
|
|
561
|
+
// maxScroll = 4500
|
|
562
|
+
(wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4480);
|
|
409
563
|
await nextTick();
|
|
410
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
411
564
|
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
412
|
-
|
|
565
|
+
await nextTick();
|
|
566
|
+
expect(el.scrollTop).toBe(4500);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('should handle unhandled keys', async () => {
|
|
570
|
+
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true });
|
|
571
|
+
el.dispatchEvent(event);
|
|
572
|
+
expect(event.defaultPrevented).toBe(false);
|
|
413
573
|
});
|
|
414
574
|
});
|
|
415
575
|
|
|
416
|
-
describe('
|
|
576
|
+
describe('resize and observers', () => {
|
|
417
577
|
it('should update item size on resize', async () => {
|
|
418
578
|
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
|
|
419
579
|
await nextTick();
|
|
@@ -449,7 +609,6 @@ describe('VirtualScroll component', () => {
|
|
|
449
609
|
observer.trigger([ { target: host } ]);
|
|
450
610
|
}
|
|
451
611
|
await nextTick();
|
|
452
|
-
// Should have called updateHostOffset (internal)
|
|
453
612
|
});
|
|
454
613
|
|
|
455
614
|
it('should observe cell resize with data-col-index', async () => {
|
|
@@ -501,13 +660,6 @@ describe('VirtualScroll component', () => {
|
|
|
501
660
|
await nextTick();
|
|
502
661
|
});
|
|
503
662
|
|
|
504
|
-
it('should handle missing footerRef gracefully in onMounted', () => {
|
|
505
|
-
mount(VirtualScroll, {
|
|
506
|
-
props: { items: mockItems, itemSize: 50 },
|
|
507
|
-
slots: { header: '<div>H</div>' },
|
|
508
|
-
});
|
|
509
|
-
});
|
|
510
|
-
|
|
511
663
|
it('should observe footer on mount if slot exists', async () => {
|
|
512
664
|
const wrapper = mount(VirtualScroll, {
|
|
513
665
|
props: { items: mockItems, itemSize: 50 },
|
|
@@ -525,7 +677,7 @@ describe('VirtualScroll component', () => {
|
|
|
525
677
|
setup() {
|
|
526
678
|
const show = ref(true);
|
|
527
679
|
const showFooter = ref(true);
|
|
528
|
-
return { show, showFooter
|
|
680
|
+
return { mockItems, show, showFooter };
|
|
529
681
|
},
|
|
530
682
|
template: `
|
|
531
683
|
<VirtualScroll :items="mockItems">
|
|
@@ -537,175 +689,61 @@ describe('VirtualScroll component', () => {
|
|
|
537
689
|
const wrapper = mount(TestComp);
|
|
538
690
|
await nextTick();
|
|
539
691
|
|
|
540
|
-
|
|
541
|
-
(wrapper.vm as unknown as
|
|
542
|
-
(wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).showFooter = false;
|
|
692
|
+
(wrapper.vm as unknown as TestCompInstance).show = false;
|
|
693
|
+
(wrapper.vm as unknown as TestCompInstance).showFooter = false;
|
|
543
694
|
await nextTick();
|
|
544
695
|
|
|
545
|
-
|
|
546
|
-
(wrapper.vm as unknown as
|
|
547
|
-
(wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).showFooter = true;
|
|
696
|
+
(wrapper.vm as unknown as TestCompInstance).show = true;
|
|
697
|
+
(wrapper.vm as unknown as TestCompInstance).showFooter = true;
|
|
548
698
|
await nextTick();
|
|
549
699
|
});
|
|
550
700
|
|
|
551
|
-
it('should
|
|
552
|
-
const wrapper = mount(VirtualScroll, {
|
|
553
|
-
props: { items: mockItems.slice(0, 1), direction: 'both', columnCount: 5 },
|
|
554
|
-
slots: { item: '<template #item><div data-col-index="0">cell</div></template>' },
|
|
555
|
-
});
|
|
556
|
-
await nextTick();
|
|
557
|
-
const cell = wrapper.find('[data-col-index="0"]').element;
|
|
558
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(cell));
|
|
559
|
-
if (observer) {
|
|
560
|
-
observer.trigger([ { target: cell } ]);
|
|
561
|
-
}
|
|
562
|
-
await nextTick();
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
it('should handle keyboard navigation End key horizontal', async () => {
|
|
566
|
-
const wrapper = mount(VirtualScroll, {
|
|
567
|
-
props: { items: mockItems, direction: 'horizontal', itemSize: 50 },
|
|
568
|
-
});
|
|
569
|
-
await nextTick();
|
|
570
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
571
|
-
const el = container.element as HTMLElement;
|
|
572
|
-
Object.defineProperty(el, 'scrollWidth', { value: 5000, configurable: true });
|
|
573
|
-
Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
|
|
574
|
-
|
|
575
|
-
await container.trigger('keydown', { key: 'End' });
|
|
576
|
-
await nextTick();
|
|
577
|
-
expect(el.scrollLeft).toBeGreaterThan(0);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('should handle keyboard navigation End key vertical', async () => {
|
|
581
|
-
const wrapper = mount(VirtualScroll, {
|
|
582
|
-
props: { items: mockItems, direction: 'vertical', itemSize: 50 },
|
|
583
|
-
});
|
|
584
|
-
await nextTick();
|
|
585
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
586
|
-
const el = container.element as HTMLElement;
|
|
587
|
-
Object.defineProperty(el, 'scrollHeight', { value: 5000, configurable: true });
|
|
588
|
-
Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
|
|
589
|
-
|
|
590
|
-
await container.trigger('keydown', { key: 'End' });
|
|
591
|
-
await nextTick();
|
|
592
|
-
expect(el.scrollTop).toBeGreaterThan(0);
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
it('should handle keyboard navigation End key with empty items', async () => {
|
|
596
|
-
const wrapper = mount(VirtualScroll, {
|
|
597
|
-
props: { items: [], direction: 'vertical', itemSize: 50 },
|
|
598
|
-
});
|
|
599
|
-
await nextTick();
|
|
600
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
601
|
-
await container.trigger('keydown', { key: 'End' });
|
|
602
|
-
await nextTick();
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
it('should handle keyboard navigation End key with columnCount 0 in both mode', async () => {
|
|
606
|
-
const wrapper = mount(VirtualScroll, {
|
|
607
|
-
props: { items: mockItems, direction: 'both', columnCount: 0, itemSize: 50 },
|
|
608
|
-
});
|
|
609
|
-
await nextTick();
|
|
610
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
611
|
-
await container.trigger('keydown', { key: 'End' });
|
|
612
|
-
await nextTick();
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
it('should handle keyboard navigation End key in both mode', async () => {
|
|
616
|
-
const wrapper = mount(VirtualScroll, {
|
|
617
|
-
props: { items: mockItems, direction: 'both', columnCount: 5, itemSize: 50, columnWidth: 100 },
|
|
618
|
-
});
|
|
619
|
-
await nextTick();
|
|
620
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
621
|
-
await container.trigger('keydown', { key: 'End' });
|
|
622
|
-
await nextTick();
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
it('should handle load event for horizontal direction', async () => {
|
|
701
|
+
it('should cleanup observers on unmount', async () => {
|
|
626
702
|
const wrapper = mount(VirtualScroll, {
|
|
627
|
-
props: { items: mockItems
|
|
703
|
+
props: { items: mockItems, stickyFooter: true, stickyHeader: true },
|
|
704
|
+
slots: { footer: '<div>F</div>', header: '<div>H</div>' },
|
|
628
705
|
});
|
|
629
706
|
await nextTick();
|
|
630
|
-
|
|
631
|
-
await nextTick();
|
|
632
|
-
await nextTick();
|
|
633
|
-
expect(wrapper.emitted('load')).toBeDefined();
|
|
707
|
+
wrapper.unmount();
|
|
634
708
|
});
|
|
635
709
|
|
|
636
|
-
it('should
|
|
637
|
-
|
|
710
|
+
it('should ignore elements with missing or invalid data attributes in itemResizeObserver', async () => {
|
|
711
|
+
mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
638
712
|
await nextTick();
|
|
639
|
-
const
|
|
640
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item));
|
|
713
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ]!;
|
|
641
714
|
|
|
642
|
-
//
|
|
643
|
-
|
|
715
|
+
// 1. Invalid index string
|
|
716
|
+
const div1 = document.createElement('div');
|
|
717
|
+
div1.dataset.index = 'invalid';
|
|
718
|
+
observer.trigger([ { target: div1, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
|
|
644
719
|
|
|
645
|
-
//
|
|
646
|
-
const
|
|
647
|
-
observer
|
|
648
|
-
});
|
|
720
|
+
// 2. Missing index and colIndex
|
|
721
|
+
const div2 = document.createElement('div');
|
|
722
|
+
observer.trigger([ { target: div2, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
|
|
649
723
|
|
|
650
|
-
it('should cleanup observers on unmount', async () => {
|
|
651
|
-
const wrapper = mount(VirtualScroll, {
|
|
652
|
-
props: { items: mockItems, stickyHeader: true, stickyFooter: true },
|
|
653
|
-
slots: { header: '<div>H</div>', footer: '<div>F</div>' },
|
|
654
|
-
});
|
|
655
724
|
await nextTick();
|
|
656
|
-
wrapper.unmount();
|
|
657
725
|
});
|
|
658
726
|
});
|
|
659
727
|
|
|
660
728
|
describe('grid mode logic', () => {
|
|
661
|
-
it('should cover
|
|
662
|
-
mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
663
|
-
await nextTick();
|
|
664
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.callback.toString().includes('colIndex'));
|
|
665
|
-
const div = document.createElement('div');
|
|
666
|
-
div.dataset.colIndex = '0';
|
|
667
|
-
observer!.trigger([ { target: div } ]);
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
it('should cover firstRenderedIndex watcher for grid old/new and other branches', async () => {
|
|
671
|
-
// Test direction !== 'both' branch
|
|
672
|
-
const wrapperV = mount(VirtualScroll, {
|
|
673
|
-
props: { items: mockItems, direction: 'vertical', itemSize: 50 },
|
|
674
|
-
});
|
|
675
|
-
await nextTick();
|
|
676
|
-
(wrapperV.vm as unknown as VSInstance).scrollToIndex(10, 0);
|
|
677
|
-
await nextTick();
|
|
678
|
-
await nextTick();
|
|
679
|
-
|
|
729
|
+
it('should cover firstRenderedIndex watcher for grid', async () => {
|
|
680
730
|
const wrapper = mount(VirtualScroll, {
|
|
681
731
|
props: {
|
|
682
|
-
|
|
683
|
-
direction: 'both',
|
|
732
|
+
bufferBefore: 2,
|
|
684
733
|
columnCount: 5,
|
|
734
|
+
direction: 'both',
|
|
685
735
|
itemSize: 50,
|
|
686
|
-
|
|
687
|
-
bufferAfter: 10,
|
|
736
|
+
items: mockItems,
|
|
688
737
|
},
|
|
689
738
|
slots: {
|
|
690
739
|
item: '<template #item="{ index }"><div class="cell" :data-col-index="0">Item {{ index }}</div></template>',
|
|
691
740
|
},
|
|
692
741
|
});
|
|
693
|
-
const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
|
|
694
|
-
Object.defineProperty(container, 'clientHeight', { value: 200, configurable: true });
|
|
695
|
-
Object.defineProperty(container, 'clientWidth', { value: 200, configurable: true });
|
|
696
|
-
|
|
697
|
-
// Trigger host resize observer
|
|
698
|
-
(globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.forEach((i) => {
|
|
699
|
-
if (i.targets.has(container)) {
|
|
700
|
-
i.trigger([ { target: container } ]);
|
|
701
|
-
}
|
|
702
|
-
});
|
|
703
742
|
await nextTick();
|
|
704
|
-
|
|
705
743
|
const vm = wrapper.vm as unknown as VSInstance;
|
|
706
744
|
|
|
707
|
-
//
|
|
708
|
-
vm.scrollToIndex(10, 0, {
|
|
745
|
+
// Scroll to 10
|
|
746
|
+
vm.scrollToIndex(10, 0, { align: 'start', behavior: 'auto' });
|
|
709
747
|
await nextTick();
|
|
710
748
|
await nextTick();
|
|
711
749
|
|
|
@@ -713,64 +751,24 @@ describe('VirtualScroll component', () => {
|
|
|
713
751
|
const itemResizeObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item8));
|
|
714
752
|
expect(itemResizeObserver).toBeDefined();
|
|
715
753
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
// Scroll to 9. range starts at 9-2 = 7.
|
|
720
|
-
// oldIdx was 8. newIdx is 7. Item 8 is still in DOM.
|
|
721
|
-
vm.scrollToIndex(9, 0, { behavior: 'auto', align: 'start' });
|
|
754
|
+
// Scroll to 9
|
|
755
|
+
vm.scrollToIndex(9, 0, { align: 'start', behavior: 'auto' });
|
|
722
756
|
await nextTick();
|
|
723
757
|
await nextTick();
|
|
724
758
|
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
// Item 7 should have its cells observed
|
|
729
|
-
const item7 = wrapper.find('.virtual-scroll-item[data-index="7"]').element;
|
|
730
|
-
const cell7 = item7.querySelector('[data-col-index="0"]');
|
|
731
|
-
expect(itemResizeObserver!.targets.has(cell7!)).toBe(true);
|
|
732
|
-
|
|
733
|
-
// Scroll to 50. range starts at 50-2 = 48.
|
|
734
|
-
// oldIdx was 7. Item 7 is definitely NOT in DOM anymore.
|
|
735
|
-
// This covers the if (oldEl) branch being false.
|
|
736
|
-
vm.scrollToIndex(50, 0, { behavior: 'auto', align: 'start' });
|
|
759
|
+
// Scroll to 50
|
|
760
|
+
vm.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
|
|
737
761
|
await nextTick();
|
|
738
762
|
await nextTick();
|
|
739
763
|
});
|
|
740
764
|
|
|
741
|
-
it('should cover firstRenderedIndex watcher logic for grid cells', async () => {
|
|
742
|
-
const wrapper = mount(VirtualScroll, {
|
|
743
|
-
props: {
|
|
744
|
-
items: mockItems,
|
|
745
|
-
direction: 'both',
|
|
746
|
-
columnCount: 5,
|
|
747
|
-
itemSize: 50,
|
|
748
|
-
},
|
|
749
|
-
slots: {
|
|
750
|
-
item: '<template #item="{ index }"><div :data-col-index="0">Item {{ index }}</div></template>',
|
|
751
|
-
},
|
|
752
|
-
});
|
|
753
|
-
await nextTick();
|
|
754
|
-
|
|
755
|
-
// Initial state: firstRenderedIndex should be 0.
|
|
756
|
-
// Scroll to change it.
|
|
757
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
758
|
-
vm.scrollToIndex(10, 0);
|
|
759
|
-
await nextTick();
|
|
760
|
-
// This should trigger the watcher (oldIdx 0 -> newIdx 10)
|
|
761
|
-
|
|
762
|
-
// Scroll back
|
|
763
|
-
vm.scrollToIndex(0, 0);
|
|
764
|
-
await nextTick();
|
|
765
|
-
});
|
|
766
|
-
|
|
767
765
|
it('should cover firstRenderedIndex watcher when items becomes empty', async () => {
|
|
768
766
|
const wrapper = mount(VirtualScroll, {
|
|
769
767
|
props: {
|
|
770
|
-
items: mockItems,
|
|
771
|
-
direction: 'both',
|
|
772
768
|
columnCount: 5,
|
|
769
|
+
direction: 'both',
|
|
773
770
|
itemSize: 50,
|
|
771
|
+
items: mockItems,
|
|
774
772
|
},
|
|
775
773
|
});
|
|
776
774
|
await nextTick();
|
|
@@ -779,52 +777,17 @@ describe('VirtualScroll component', () => {
|
|
|
779
777
|
});
|
|
780
778
|
});
|
|
781
779
|
|
|
782
|
-
describe('internal methods and exports', () => {
|
|
783
|
-
it('should handle setItemRef', async () => {
|
|
784
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
785
|
-
await nextTick();
|
|
786
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
787
|
-
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
788
|
-
vm.setItemRef(item, 0);
|
|
789
|
-
vm.setItemRef(null, 0);
|
|
790
|
-
vm.setItemRef(null, 999);
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
it('should handle setItemRef with NaN index', async () => {
|
|
794
|
-
mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
795
|
-
await nextTick();
|
|
796
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ];
|
|
797
|
-
const div = document.createElement('div');
|
|
798
|
-
// No data-index
|
|
799
|
-
observer?.trigger([ { target: div, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
it('should handle firstRenderedIndex being undefined', async () => {
|
|
803
|
-
// items empty
|
|
804
|
-
mount(VirtualScroll, { props: { items: [] } });
|
|
805
|
-
await nextTick();
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
it('should expose methods', () => {
|
|
809
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
810
|
-
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToIndex).toBe('function');
|
|
811
|
-
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToOffset).toBe('function');
|
|
812
|
-
});
|
|
813
|
-
});
|
|
814
|
-
|
|
815
780
|
describe('infinite scroll and loading', () => {
|
|
816
781
|
it('should emit load event when reaching scroll end (vertical)', async () => {
|
|
817
782
|
const wrapper = mount(VirtualScroll, {
|
|
818
783
|
props: {
|
|
819
|
-
items: mockItems.slice(0, 10),
|
|
820
784
|
itemSize: 50,
|
|
785
|
+
items: mockItems.slice(0, 10),
|
|
821
786
|
loadDistance: 400,
|
|
822
|
-
useRAF: false,
|
|
823
787
|
},
|
|
824
788
|
});
|
|
825
789
|
await nextTick();
|
|
826
790
|
|
|
827
|
-
// Scroll to near end
|
|
828
791
|
(wrapper.vm as unknown as VSInstance).scrollToOffset(0, 250);
|
|
829
792
|
await nextTick();
|
|
830
793
|
await nextTick();
|
|
@@ -836,16 +799,14 @@ describe('VirtualScroll component', () => {
|
|
|
836
799
|
it('should emit load event when reaching scroll end (horizontal)', async () => {
|
|
837
800
|
const wrapper = mount(VirtualScroll, {
|
|
838
801
|
props: {
|
|
839
|
-
items: mockItems.slice(0, 10),
|
|
840
|
-
itemSize: 50,
|
|
841
802
|
direction: 'horizontal',
|
|
803
|
+
itemSize: 50,
|
|
804
|
+
items: mockItems.slice(0, 10),
|
|
842
805
|
loadDistance: 400,
|
|
843
|
-
useRAF: false,
|
|
844
806
|
},
|
|
845
807
|
});
|
|
846
808
|
await nextTick();
|
|
847
809
|
|
|
848
|
-
// Scroll to near end
|
|
849
810
|
(wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
|
|
850
811
|
await nextTick();
|
|
851
812
|
await nextTick();
|
|
@@ -857,8 +818,8 @@ describe('VirtualScroll component', () => {
|
|
|
857
818
|
it('should not emit load event when loading is true', async () => {
|
|
858
819
|
const wrapper = mount(VirtualScroll, {
|
|
859
820
|
props: {
|
|
860
|
-
items: mockItems.slice(0, 10),
|
|
861
821
|
itemSize: 50,
|
|
822
|
+
items: mockItems.slice(0, 10),
|
|
862
823
|
loadDistance: 100,
|
|
863
824
|
loading: true,
|
|
864
825
|
},
|
|
@@ -877,11 +838,11 @@ describe('VirtualScroll component', () => {
|
|
|
877
838
|
expect(wrapper.emitted('load')).toBeUndefined();
|
|
878
839
|
});
|
|
879
840
|
|
|
880
|
-
it('should render loading slot
|
|
841
|
+
it('should render loading slot correctly', async () => {
|
|
881
842
|
const wrapper = mount(VirtualScroll, {
|
|
882
843
|
props: {
|
|
883
|
-
items: mockItems.slice(0, 10),
|
|
884
844
|
itemSize: 50,
|
|
845
|
+
items: mockItems.slice(0, 10),
|
|
885
846
|
loading: true,
|
|
886
847
|
},
|
|
887
848
|
slots: {
|
|
@@ -890,23 +851,130 @@ describe('VirtualScroll component', () => {
|
|
|
890
851
|
});
|
|
891
852
|
await nextTick();
|
|
892
853
|
expect(wrapper.find('.loading-indicator').exists()).toBe(true);
|
|
893
|
-
|
|
854
|
+
|
|
855
|
+
await wrapper.setProps({ direction: 'horizontal' });
|
|
856
|
+
await nextTick();
|
|
857
|
+
expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
|
|
894
858
|
});
|
|
895
859
|
|
|
896
|
-
it('should
|
|
860
|
+
it('should toggle loading slot visibility based on loading prop', async () => {
|
|
897
861
|
const wrapper = mount(VirtualScroll, {
|
|
898
862
|
props: {
|
|
899
|
-
items: mockItems.slice(0,
|
|
900
|
-
|
|
901
|
-
direction: 'horizontal',
|
|
902
|
-
loading: true,
|
|
863
|
+
items: mockItems.slice(0, 5),
|
|
864
|
+
loading: false,
|
|
903
865
|
},
|
|
904
866
|
slots: {
|
|
905
|
-
loading: '<div class="
|
|
867
|
+
loading: '<div class="loader">Loading...</div>',
|
|
906
868
|
},
|
|
907
869
|
});
|
|
908
870
|
await nextTick();
|
|
909
|
-
|
|
871
|
+
|
|
872
|
+
expect(wrapper.find('.loader').exists()).toBe(false);
|
|
873
|
+
expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
|
|
874
|
+
|
|
875
|
+
await wrapper.setProps({ loading: true });
|
|
876
|
+
await nextTick();
|
|
877
|
+
expect(wrapper.find('.loader').exists()).toBe(true);
|
|
878
|
+
expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(true);
|
|
879
|
+
|
|
880
|
+
await wrapper.setProps({ loading: false });
|
|
881
|
+
await nextTick();
|
|
882
|
+
expect(wrapper.find('.loader').exists()).toBe(false);
|
|
883
|
+
expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
describe('internal methods and exports', () => {
|
|
888
|
+
it('should handle setItemRef', async () => {
|
|
889
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
890
|
+
await nextTick();
|
|
891
|
+
const vm = wrapper.vm as unknown as VSInstance;
|
|
892
|
+
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
893
|
+
vm.setItemRef(item, 0);
|
|
894
|
+
vm.setItemRef(null, 0);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('should handle setItemRef with NaN index', async () => {
|
|
898
|
+
mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
899
|
+
await nextTick();
|
|
900
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ];
|
|
901
|
+
const div = document.createElement('div');
|
|
902
|
+
observer?.trigger([ { target: div, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('should expose methods', () => {
|
|
906
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
907
|
+
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToIndex).toBe('function');
|
|
908
|
+
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToOffset).toBe('function');
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('should manually re-measure items on refresh', async () => {
|
|
912
|
+
const items = [ { id: 1 } ];
|
|
913
|
+
const wrapper = mount(VirtualScroll, {
|
|
914
|
+
props: { items, itemSize: 0 }, // dynamic
|
|
915
|
+
slots: { item: '<template #item="{ index }"><div class="dynamic-item" :style="{ height: \'100px\' }">Item {{ index }}</div></template>' },
|
|
916
|
+
});
|
|
917
|
+
await nextTick();
|
|
918
|
+
|
|
919
|
+
const el = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
920
|
+
Object.defineProperty(el, 'offsetHeight', { value: 100 });
|
|
921
|
+
Object.defineProperty(el, 'offsetWidth', { value: 100 });
|
|
922
|
+
|
|
923
|
+
// First measurement via refresh (simulating manual trigger or initial measurement)
|
|
924
|
+
const vm = wrapper.vm as unknown as VSInstance;
|
|
925
|
+
vm.refresh();
|
|
926
|
+
await nextTick();
|
|
927
|
+
await nextTick();
|
|
928
|
+
|
|
929
|
+
expect(vm.scrollDetails.totalSize.height).toBe(100);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('should handle refresh with no rendered items', async () => {
|
|
933
|
+
const wrapper = mount(VirtualScroll, {
|
|
934
|
+
props: { items: [], itemSize: 0 },
|
|
935
|
+
});
|
|
936
|
+
await nextTick();
|
|
937
|
+
const vm = wrapper.vm as unknown as VSInstance;
|
|
938
|
+
vm.refresh();
|
|
939
|
+
await nextTick();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('should emit visibleRangeChange on scroll and hydration', async () => {
|
|
943
|
+
const wrapper = mount(VirtualScroll, {
|
|
944
|
+
props: { itemSize: 50, items: mockItems },
|
|
945
|
+
});
|
|
946
|
+
await nextTick();
|
|
947
|
+
await nextTick();
|
|
948
|
+
expect(wrapper.emitted('visibleRangeChange')).toBeDefined();
|
|
949
|
+
|
|
950
|
+
const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
|
|
951
|
+
Object.defineProperty(container, 'scrollTop', { value: 500, writable: true });
|
|
952
|
+
await container.dispatchEvent(new Event('scroll'));
|
|
953
|
+
await nextTick();
|
|
954
|
+
await nextTick();
|
|
955
|
+
expect(wrapper.emitted('visibleRangeChange')!.length).toBeGreaterThan(1);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('should not emit scroll event before hydration in watch', async () => {
|
|
959
|
+
// initialScrollIndex triggers delayed hydration via nextTick in useVirtualScroll
|
|
960
|
+
const wrapper = mount(VirtualScroll, {
|
|
961
|
+
props: {
|
|
962
|
+
initialScrollIndex: 5,
|
|
963
|
+
itemSize: 50,
|
|
964
|
+
items: mockItems.slice(0, 10),
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// Before first nextTick, isHydrated is false.
|
|
969
|
+
// Changing items will trigger scrollDetails update.
|
|
970
|
+
await wrapper.setProps({ items: mockItems.slice(0, 20) });
|
|
971
|
+
|
|
972
|
+
// Line 196 in VirtualScroll.vue should be hit here (return if !isHydrated)
|
|
973
|
+
expect(wrapper.emitted('scroll')).toBeUndefined();
|
|
974
|
+
|
|
975
|
+
await nextTick(); // hydration tick
|
|
976
|
+
await nextTick(); // one more for good measure
|
|
977
|
+
expect(wrapper.emitted('scroll')).toBeDefined();
|
|
910
978
|
});
|
|
911
979
|
});
|
|
912
980
|
});
|