@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.
@@ -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, vi } from 'vitest';
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 directions', () => {
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
- describe('events and interaction', () => {
286
- it('should emit scroll event', async () => {
287
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
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 not emit scroll before hydration', async () => {
293
- const wrapper = mount(VirtualScroll, {
382
+ it('should cover object padding branches in virtualScrollProps', () => {
383
+ mount(VirtualScroll, {
294
384
  props: {
295
- items: mockItems.slice(0, 5),
296
- initialScrollIndex: 0,
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
- it('should emit visibleRangeChange event', async () => {
313
- const wrapper = mount(VirtualScroll, {
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
- // Initially it should emit on mount (via scrollDetails watch)
323
- const emits = wrapper.emitted('visibleRangeChange');
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
- // Scroll to trigger change
329
- const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
330
- Object.defineProperty(container, 'scrollTop', { value: 500, writable: true });
331
- await container.dispatchEvent(new Event('scroll'));
332
- await nextTick();
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 lastEmits = wrapper.emitted('visibleRangeChange') as unknown[][];
336
- expect(lastEmits).toBeTruthy();
337
- const lastEmit = lastEmits[ lastEmits.length - 1 ]![ 0 ] as { start: number; };
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 keyboard navigation', async () => {
342
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
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
- const container = wrapper.find('.virtual-scroll-container');
345
- const el = container.element as HTMLElement;
346
- Object.defineProperty(el, 'scrollHeight', { value: 5000, configurable: true });
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
- expect(el.scrollTop).toBeGreaterThan(0);
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
- await container.trigger('keydown', { key: 'Home' });
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 horizontal keyboard navigation', async () => {
359
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50, direction: 'horizontal' } });
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
- await container.trigger('keydown', { key: 'End' });
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: 'Home' });
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 handled keys in handleKeyDown', async () => {
376
- const wrapper = mount(VirtualScroll, {
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
- const container = wrapper.find('.virtual-scroll-container');
385
- const scrollToSpy = vi.fn();
386
- container.element.scrollTo = scrollToSpy;
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
- expect(scrollToSpy).toHaveBeenCalled();
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
- expect(scrollToSpy).toHaveBeenCalled();
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
- expect(scrollToSpy).toHaveBeenCalled();
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
- expect(scrollToSpy).toHaveBeenCalled();
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
- expect(scrollToSpy).toHaveBeenCalled();
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
- expect(scrollToSpy).toHaveBeenCalled();
530
+ await nextTick();
531
+ expect(el.scrollTop).toBe(0);
532
+ expect(el.scrollLeft).toBe(0);
405
533
  });
406
534
 
407
- it('should handle unhandled keys in handleKeyDown', async () => {
408
- const wrapper = mount(VirtualScroll, { props: { items: mockItems } });
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
- // Should just call stopProgrammaticScroll
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('lifecycle and observers', () => {
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, mockItems };
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
- // Toggle off to trigger 'unobserve'
541
- (wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).show = false;
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
- // Toggle on to trigger 'observe' (newEl)
546
- (wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).show = true;
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 cover ResizeObserver cell measurement', async () => {
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.slice(0, 10), direction: 'horizontal', itemSize: 50, loadDistance: 400 },
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
- (wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
631
- await nextTick();
632
- await nextTick();
633
- expect(wrapper.emitted('load')).toBeDefined();
707
+ wrapper.unmount();
634
708
  });
635
709
 
636
- it('should cover itemResizeObserver branches', async () => {
637
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
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 item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
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
- // Trigger with data-index but without borderBoxSize
643
- observer?.trigger([ { target: item, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
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
- // Trigger with NaN index
646
- const div = document.createElement('div');
647
- observer?.trigger([ { target: div } ]);
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 colIndex measurement in itemResizeObserver', async () => {
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
- items: mockItems,
683
- direction: 'both',
732
+ bufferBefore: 2,
684
733
  columnCount: 5,
734
+ direction: 'both',
685
735
  itemSize: 50,
686
- bufferBefore: 2,
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
- // Initial scroll to 10. range starts at 10-2 = 8.
708
- vm.scrollToIndex(10, 0, { behavior: 'auto', align: 'start' });
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
- const cell8 = item8.querySelector('[data-col-index="0"]');
717
- expect(itemResizeObserver!.targets.has(cell8!)).toBe(true);
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
- // Item 8 should have its cells unobserved
726
- expect(itemResizeObserver!.targets.has(cell8!)).toBe(false);
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 when loading is true', async () => {
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
- expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('block');
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 render horizontal loading slot correctly', async () => {
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, 10),
900
- itemSize: 50,
901
- direction: 'horizontal',
902
- loading: true,
863
+ items: mockItems.slice(0, 5),
864
+ loading: false,
903
865
  },
904
866
  slots: {
905
- loading: '<div class="loading-indicator">Loading...</div>',
867
+ loading: '<div class="loader">Loading...</div>',
906
868
  },
907
869
  });
908
870
  await nextTick();
909
- expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
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
  });