@pdanpdan/virtual-scroll 0.2.0 → 0.2.1

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,19 @@ 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
+ }
57
+
58
+ interface TestCompInstance {
59
+ mockItems: typeof mockItems;
60
+ show: boolean;
61
+ showFooter: boolean;
53
62
  }
54
63
 
55
64
  describe('rendering and structure', () => {
@@ -78,10 +87,49 @@ describe('VirtualScroll component', () => {
78
87
  footer: '<div class="footer">Footer</div>',
79
88
  },
80
89
  });
90
+ expect(wrapper.find('.virtual-scroll-header').exists()).toBe(true);
81
91
  expect(wrapper.find('.header').exists()).toBe(true);
92
+ expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(true);
82
93
  expect(wrapper.find('.footer').exists()).toBe(true);
83
94
  });
84
95
 
96
+ it('should not render header and footer slots when absent', () => {
97
+ const wrapper = mount(VirtualScroll, {
98
+ props: {
99
+ items: mockItems,
100
+ itemSize: 50,
101
+ },
102
+ });
103
+ expect(wrapper.find('.virtual-scroll-header').exists()).toBe(false);
104
+ expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(false);
105
+ });
106
+
107
+ it('should render debug information when debug prop is true', async () => {
108
+ const wrapper = mount(VirtualScroll, {
109
+ props: {
110
+ items: mockItems.slice(0, 5),
111
+ itemSize: 50,
112
+ debug: true,
113
+ },
114
+ });
115
+ await nextTick();
116
+ expect(wrapper.find('.virtual-scroll-debug-info').exists()).toBe(true);
117
+ expect(wrapper.find('.virtual-scroll-item').classes()).toContain('virtual-scroll--debug');
118
+ });
119
+
120
+ it('should not render debug information when debug prop is false', async () => {
121
+ const wrapper = mount(VirtualScroll, {
122
+ props: {
123
+ items: mockItems.slice(0, 5),
124
+ itemSize: 50,
125
+ debug: false,
126
+ },
127
+ });
128
+ await nextTick();
129
+ expect(wrapper.find('.virtual-scroll-debug-info').exists()).toBe(false);
130
+ expect(wrapper.find('.virtual-scroll-item').classes()).not.toContain('virtual-scroll--debug');
131
+ });
132
+
85
133
  it('should handle missing slots gracefully', () => {
86
134
  const wrapper = mount(VirtualScroll, {
87
135
  props: {
@@ -186,9 +234,43 @@ describe('VirtualScroll component', () => {
186
234
  expect(wrapper.find('tr.virtual-scroll-spacer').exists()).toBe(true);
187
235
  expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
188
236
  });
237
+
238
+ it('should handle table rendering without header and footer', async () => {
239
+ const wrapper = mount(VirtualScroll, {
240
+ props: {
241
+ items: mockItems.slice(0, 5),
242
+ containerTag: 'table',
243
+ },
244
+ });
245
+ await nextTick();
246
+ expect(wrapper.find('thead').exists()).toBe(false);
247
+ expect(wrapper.find('tfoot').exists()).toBe(false);
248
+ });
249
+
250
+ it('should cover all template branches for slots and tags', async () => {
251
+ for (const tag of [ 'div', 'table' ] as const) {
252
+ for (const loading of [ true, false ]) {
253
+ for (const withSlots of [ true, false ]) {
254
+ const slots = withSlots
255
+ ? {
256
+ header: tag === 'table' ? '<tr><td>H</td></tr>' : '<div>H</div>',
257
+ footer: tag === 'table' ? '<tr><td>F</td></tr>' : '<div>F</div>',
258
+ loading: tag === 'table' ? '<tr><td>L</td></tr>' : '<div>L</div>',
259
+ }
260
+ : {};
261
+ const wrapper = mount(VirtualScroll, {
262
+ props: { items: mockItems.slice(0, 1), containerTag: tag, loading },
263
+ slots,
264
+ });
265
+ await nextTick();
266
+ wrapper.unmount();
267
+ }
268
+ }
269
+ }
270
+ });
189
271
  });
190
272
 
191
- describe('styling and directions', () => {
273
+ describe('styling and dimensions', () => {
192
274
  it('should render items horizontally when direction is horizontal', async () => {
193
275
  const wrapper = mount(VirtualScroll, {
194
276
  props: {
@@ -280,140 +362,202 @@ describe('VirtualScroll component', () => {
280
362
  await nextTick();
281
363
  // This covers the branch where container is NOT host element and NOT window
282
364
  });
283
- });
284
365
 
285
- describe('events and interaction', () => {
286
- it('should emit scroll event', async () => {
287
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
288
- await nextTick();
289
- expect(wrapper.emitted('scroll')).toBeDefined();
290
- });
291
-
292
- it('should not emit scroll before hydration', async () => {
293
- const wrapper = mount(VirtualScroll, {
366
+ it('should cover object padding branches in virtualScrollProps', () => {
367
+ mount(VirtualScroll, {
294
368
  props: {
295
- items: mockItems.slice(0, 5),
296
- initialScrollIndex: 0,
369
+ items: mockItems.slice(0, 1),
370
+ scrollPaddingStart: { x: 10, y: 20 },
371
+ scrollPaddingEnd: { x: 30, y: 40 },
372
+ },
373
+ });
374
+ mount(VirtualScroll, {
375
+ props: {
376
+ items: mockItems.slice(0, 1),
377
+ direction: 'horizontal',
378
+ scrollPaddingStart: 10,
379
+ scrollPaddingEnd: 20,
297
380
  },
298
381
  });
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
382
  });
383
+ });
311
384
 
312
- it('should emit visibleRangeChange event', async () => {
313
- const wrapper = mount(VirtualScroll, {
385
+ describe('keyboard navigation', () => {
386
+ let wrapper: VueWrapper<VSInstance>;
387
+ let container: DOMWrapper<Element>;
388
+ let el: HTMLElement;
389
+
390
+ beforeEach(async () => {
391
+ wrapper = mount(VirtualScroll, {
314
392
  props: {
315
393
  items: mockItems,
316
394
  itemSize: 50,
395
+ direction: 'vertical',
317
396
  },
318
- });
319
-
397
+ }) as unknown as VueWrapper<VSInstance>;
320
398
  await nextTick();
321
- 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 });
399
+ container = wrapper.find('.virtual-scroll-container');
400
+ el = container.element as HTMLElement;
327
401
 
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();
402
+ // Mock dimensions
403
+ Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
404
+ Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
405
+ Object.defineProperty(el, 'offsetHeight', { value: 500, configurable: true });
406
+ Object.defineProperty(el, 'offsetWidth', { value: 500, configurable: true });
334
407
 
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);
408
+ const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
409
+ observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
410
+ await nextTick();
339
411
  });
340
412
 
341
- it('should handle keyboard navigation', async () => {
342
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
413
+ it('should handle Home key', async () => {
414
+ el.scrollTop = 1000;
415
+ el.scrollLeft = 500;
416
+ await container.trigger('keydown', { key: 'Home' });
343
417
  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 });
418
+ expect(el.scrollTop).toBe(0);
419
+ expect(el.scrollLeft).toBe(0);
420
+ });
348
421
 
422
+ it('should handle End key (vertical)', async () => {
423
+ el.scrollLeft = 0;
349
424
  await container.trigger('keydown', { key: 'End' });
350
425
  await nextTick();
351
- expect(el.scrollTop).toBeGreaterThan(0);
426
+ // totalHeight = 100 items * 50px = 5000px
427
+ // viewportHeight = 500px
428
+ // scrollToIndex(99, 0, 'end') -> targetY = 99 * 50 = 4950
429
+ // alignment 'end' -> targetY = 4950 - (500 - 50) = 4500
430
+ expect(el.scrollTop).toBe(4500);
431
+ expect(el.scrollLeft).toBe(0);
432
+ });
352
433
 
353
- await container.trigger('keydown', { key: 'Home' });
434
+ it('should handle End key (horizontal)', async () => {
435
+ await wrapper.setProps({ direction: 'horizontal' });
354
436
  await nextTick();
437
+ // Trigger resize again for horizontal
438
+ const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
439
+ observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
440
+ await nextTick();
441
+
442
+ el.scrollTop = 0;
443
+ await container.trigger('keydown', { key: 'End' });
444
+ await nextTick();
445
+ expect(el.scrollLeft).toBe(4500);
355
446
  expect(el.scrollTop).toBe(0);
356
447
  });
357
448
 
358
- it('should handle horizontal keyboard navigation', async () => {
359
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50, direction: 'horizontal' } });
449
+ it('should handle End key in both mode', async () => {
450
+ await wrapper.setProps({ columnCount: 5, columnWidth: 100, direction: 'both' });
360
451
  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
452
 
366
- await container.trigger('keydown', { key: 'End' });
453
+ // Trigger a resize
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 } ]));
367
456
  await nextTick();
368
- expect(el.scrollLeft).toBeGreaterThan(0);
369
457
 
370
- await container.trigger('keydown', { key: 'Home' });
458
+ await container.trigger('keydown', { key: 'End' });
371
459
  await nextTick();
460
+
461
+ // items: 100 (rows), height: 50 -> totalHeight: 5000
462
+ // columns: 5, width: 100 -> totalWidth: 500
463
+ // viewport: 500x500
464
+ // scrollToIndex(99, 4, 'end')
465
+ // targetY = 99 * 50 = 4950. end alignment: 4950 - (500 - 50) = 4500
466
+ // targetX = 4 * 100 = 400. end alignment: 400 - (500 - 100) = 0
467
+ expect(el.scrollTop).toBe(4500);
372
468
  expect(el.scrollLeft).toBe(0);
373
469
  });
374
470
 
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
-
471
+ it('should handle End key with empty items', async () => {
472
+ await wrapper.setProps({ items: [] });
383
473
  await nextTick();
384
- const container = wrapper.find('.virtual-scroll-container');
385
- const scrollToSpy = vi.fn();
386
- container.element.scrollTo = scrollToSpy;
474
+ await container.trigger('keydown', { key: 'End' });
475
+ await nextTick();
476
+ });
387
477
 
478
+ it('should handle ArrowDown / ArrowUp', async () => {
479
+ el.scrollLeft = 0;
388
480
  await container.trigger('keydown', { key: 'ArrowDown' });
389
- expect(scrollToSpy).toHaveBeenCalled();
481
+ await nextTick();
482
+ expect(el.scrollTop).toBe(40);
483
+ expect(el.scrollLeft).toBe(0);
390
484
 
391
485
  await container.trigger('keydown', { key: 'ArrowUp' });
392
- expect(scrollToSpy).toHaveBeenCalled();
486
+ await nextTick();
487
+ expect(el.scrollTop).toBe(0);
488
+ expect(el.scrollLeft).toBe(0);
489
+ });
393
490
 
491
+ it('should handle ArrowRight / ArrowLeft', async () => {
492
+ await wrapper.setProps({ direction: 'horizontal' });
493
+ await nextTick();
494
+ el.scrollTop = 0;
394
495
  await container.trigger('keydown', { key: 'ArrowRight' });
395
- expect(scrollToSpy).toHaveBeenCalled();
496
+ await nextTick();
497
+ expect(el.scrollLeft).toBe(40);
498
+ expect(el.scrollTop).toBe(0);
396
499
 
397
500
  await container.trigger('keydown', { key: 'ArrowLeft' });
398
- expect(scrollToSpy).toHaveBeenCalled();
501
+ await nextTick();
502
+ expect(el.scrollLeft).toBe(0);
503
+ expect(el.scrollTop).toBe(0);
504
+ });
399
505
 
506
+ it('should handle PageDown / PageUp', async () => {
507
+ el.scrollLeft = 0;
400
508
  await container.trigger('keydown', { key: 'PageDown' });
401
- expect(scrollToSpy).toHaveBeenCalled();
509
+ await nextTick();
510
+ expect(el.scrollTop).toBe(500);
511
+ expect(el.scrollLeft).toBe(0);
402
512
 
403
513
  await container.trigger('keydown', { key: 'PageUp' });
404
- expect(scrollToSpy).toHaveBeenCalled();
514
+ await nextTick();
515
+ expect(el.scrollTop).toBe(0);
516
+ expect(el.scrollLeft).toBe(0);
405
517
  });
406
518
 
407
- it('should handle unhandled keys in handleKeyDown', async () => {
408
- const wrapper = mount(VirtualScroll, { props: { items: mockItems } });
519
+ it('should handle PageDown / PageUp in horizontal mode', async () => {
520
+ await wrapper.setProps({ direction: 'horizontal' });
521
+ await nextTick();
522
+ el.scrollTop = 0;
523
+ await container.trigger('keydown', { key: 'PageDown' });
524
+ await nextTick();
525
+ expect(el.scrollLeft).toBe(500);
526
+ expect(el.scrollTop).toBe(0);
527
+
528
+ await container.trigger('keydown', { key: 'PageUp' });
529
+ await nextTick();
530
+ expect(el.scrollLeft).toBe(0);
531
+ expect(el.scrollTop).toBe(0);
532
+ });
533
+
534
+ it('should not scroll past the end using PageDown', async () => {
535
+ // items: 100, itemSize: 50 -> totalHeight = 5000
536
+ // viewportHeight: 500 -> maxScroll = 4500
537
+ (wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4400);
538
+ await nextTick();
539
+ await container.trigger('keydown', { key: 'PageDown' });
540
+ await nextTick();
541
+ expect(el.scrollTop).toBe(4500);
542
+ });
543
+
544
+ it('should not scroll past the end using ArrowDown', async () => {
545
+ // maxScroll = 4500
546
+ (wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4480);
409
547
  await nextTick();
410
- const container = wrapper.find('.virtual-scroll-container');
411
548
  await container.trigger('keydown', { key: 'ArrowDown' });
412
- // Should just call stopProgrammaticScroll
549
+ await nextTick();
550
+ expect(el.scrollTop).toBe(4500);
551
+ });
552
+
553
+ it('should handle unhandled keys', async () => {
554
+ const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true });
555
+ el.dispatchEvent(event);
556
+ expect(event.defaultPrevented).toBe(false);
413
557
  });
414
558
  });
415
559
 
416
- describe('lifecycle and observers', () => {
560
+ describe('resize and observers', () => {
417
561
  it('should update item size on resize', async () => {
418
562
  const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
419
563
  await nextTick();
@@ -449,7 +593,6 @@ describe('VirtualScroll component', () => {
449
593
  observer.trigger([ { target: host } ]);
450
594
  }
451
595
  await nextTick();
452
- // Should have called updateHostOffset (internal)
453
596
  });
454
597
 
455
598
  it('should observe cell resize with data-col-index', async () => {
@@ -501,13 +644,6 @@ describe('VirtualScroll component', () => {
501
644
  await nextTick();
502
645
  });
503
646
 
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
647
  it('should observe footer on mount if slot exists', async () => {
512
648
  const wrapper = mount(VirtualScroll, {
513
649
  props: { items: mockItems, itemSize: 50 },
@@ -525,7 +661,7 @@ describe('VirtualScroll component', () => {
525
661
  setup() {
526
662
  const show = ref(true);
527
663
  const showFooter = ref(true);
528
- return { show, showFooter, mockItems };
664
+ return { mockItems, show, showFooter };
529
665
  },
530
666
  template: `
531
667
  <VirtualScroll :items="mockItems">
@@ -537,175 +673,61 @@ describe('VirtualScroll component', () => {
537
673
  const wrapper = mount(TestComp);
538
674
  await nextTick();
539
675
 
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;
543
- await nextTick();
544
-
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;
548
- await nextTick();
549
- });
550
-
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
- });
676
+ (wrapper.vm as unknown as TestCompInstance).show = false;
677
+ (wrapper.vm as unknown as TestCompInstance).showFooter = false;
569
678
  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
679
 
575
- await container.trigger('keydown', { key: 'End' });
680
+ (wrapper.vm as unknown as TestCompInstance).show = true;
681
+ (wrapper.vm as unknown as TestCompInstance).showFooter = true;
576
682
  await nextTick();
577
- expect(el.scrollLeft).toBeGreaterThan(0);
578
683
  });
579
684
 
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 () => {
685
+ it('should cleanup observers on unmount', async () => {
626
686
  const wrapper = mount(VirtualScroll, {
627
- props: { items: mockItems.slice(0, 10), direction: 'horizontal', itemSize: 50, loadDistance: 400 },
687
+ props: { items: mockItems, stickyFooter: true, stickyHeader: true },
688
+ slots: { footer: '<div>F</div>', header: '<div>H</div>' },
628
689
  });
629
690
  await nextTick();
630
- (wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
631
- await nextTick();
632
- await nextTick();
633
- expect(wrapper.emitted('load')).toBeDefined();
691
+ wrapper.unmount();
634
692
  });
635
693
 
636
- it('should cover itemResizeObserver branches', async () => {
637
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
694
+ it('should ignore elements with missing or invalid data attributes in itemResizeObserver', async () => {
695
+ mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
638
696
  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));
697
+ const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ]!;
641
698
 
642
- // Trigger with data-index but without borderBoxSize
643
- observer?.trigger([ { target: item, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
699
+ // 1. Invalid index string
700
+ const div1 = document.createElement('div');
701
+ div1.dataset.index = 'invalid';
702
+ observer.trigger([ { target: div1, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
644
703
 
645
- // Trigger with NaN index
646
- const div = document.createElement('div');
647
- observer?.trigger([ { target: div } ]);
648
- });
704
+ // 2. Missing index and colIndex
705
+ const div2 = document.createElement('div');
706
+ observer.trigger([ { target: div2, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
649
707
 
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
708
  await nextTick();
656
- wrapper.unmount();
657
709
  });
658
710
  });
659
711
 
660
712
  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
-
713
+ it('should cover firstRenderedIndex watcher for grid', async () => {
680
714
  const wrapper = mount(VirtualScroll, {
681
715
  props: {
682
- items: mockItems,
683
- direction: 'both',
716
+ bufferBefore: 2,
684
717
  columnCount: 5,
718
+ direction: 'both',
685
719
  itemSize: 50,
686
- bufferBefore: 2,
687
- bufferAfter: 10,
720
+ items: mockItems,
688
721
  },
689
722
  slots: {
690
723
  item: '<template #item="{ index }"><div class="cell" :data-col-index="0">Item {{ index }}</div></template>',
691
724
  },
692
725
  });
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
726
  await nextTick();
704
-
705
727
  const vm = wrapper.vm as unknown as VSInstance;
706
728
 
707
- // Initial scroll to 10. range starts at 10-2 = 8.
708
- vm.scrollToIndex(10, 0, { behavior: 'auto', align: 'start' });
729
+ // Scroll to 10
730
+ vm.scrollToIndex(10, 0, { align: 'start', behavior: 'auto' });
709
731
  await nextTick();
710
732
  await nextTick();
711
733
 
@@ -713,64 +735,24 @@ describe('VirtualScroll component', () => {
713
735
  const itemResizeObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item8));
714
736
  expect(itemResizeObserver).toBeDefined();
715
737
 
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' });
722
- await nextTick();
723
- await nextTick();
724
-
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' });
738
+ // Scroll to 9
739
+ vm.scrollToIndex(9, 0, { align: 'start', behavior: 'auto' });
737
740
  await nextTick();
738
741
  await nextTick();
739
- });
740
-
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
742
 
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);
743
+ // Scroll to 50
744
+ vm.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
759
745
  await nextTick();
760
- // This should trigger the watcher (oldIdx 0 -> newIdx 10)
761
-
762
- // Scroll back
763
- vm.scrollToIndex(0, 0);
764
746
  await nextTick();
765
747
  });
766
748
 
767
749
  it('should cover firstRenderedIndex watcher when items becomes empty', async () => {
768
750
  const wrapper = mount(VirtualScroll, {
769
751
  props: {
770
- items: mockItems,
771
- direction: 'both',
772
752
  columnCount: 5,
753
+ direction: 'both',
773
754
  itemSize: 50,
755
+ items: mockItems,
774
756
  },
775
757
  });
776
758
  await nextTick();
@@ -779,52 +761,17 @@ describe('VirtualScroll component', () => {
779
761
  });
780
762
  });
781
763
 
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
764
  describe('infinite scroll and loading', () => {
816
765
  it('should emit load event when reaching scroll end (vertical)', async () => {
817
766
  const wrapper = mount(VirtualScroll, {
818
767
  props: {
819
- items: mockItems.slice(0, 10),
820
768
  itemSize: 50,
769
+ items: mockItems.slice(0, 10),
821
770
  loadDistance: 400,
822
- useRAF: false,
823
771
  },
824
772
  });
825
773
  await nextTick();
826
774
 
827
- // Scroll to near end
828
775
  (wrapper.vm as unknown as VSInstance).scrollToOffset(0, 250);
829
776
  await nextTick();
830
777
  await nextTick();
@@ -836,16 +783,14 @@ describe('VirtualScroll component', () => {
836
783
  it('should emit load event when reaching scroll end (horizontal)', async () => {
837
784
  const wrapper = mount(VirtualScroll, {
838
785
  props: {
839
- items: mockItems.slice(0, 10),
840
- itemSize: 50,
841
786
  direction: 'horizontal',
787
+ itemSize: 50,
788
+ items: mockItems.slice(0, 10),
842
789
  loadDistance: 400,
843
- useRAF: false,
844
790
  },
845
791
  });
846
792
  await nextTick();
847
793
 
848
- // Scroll to near end
849
794
  (wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
850
795
  await nextTick();
851
796
  await nextTick();
@@ -857,8 +802,8 @@ describe('VirtualScroll component', () => {
857
802
  it('should not emit load event when loading is true', async () => {
858
803
  const wrapper = mount(VirtualScroll, {
859
804
  props: {
860
- items: mockItems.slice(0, 10),
861
805
  itemSize: 50,
806
+ items: mockItems.slice(0, 10),
862
807
  loadDistance: 100,
863
808
  loading: true,
864
809
  },
@@ -877,11 +822,11 @@ describe('VirtualScroll component', () => {
877
822
  expect(wrapper.emitted('load')).toBeUndefined();
878
823
  });
879
824
 
880
- it('should render loading slot when loading is true', async () => {
825
+ it('should render loading slot correctly', async () => {
881
826
  const wrapper = mount(VirtualScroll, {
882
827
  props: {
883
- items: mockItems.slice(0, 10),
884
828
  itemSize: 50,
829
+ items: mockItems.slice(0, 10),
885
830
  loading: true,
886
831
  },
887
832
  slots: {
@@ -890,23 +835,99 @@ describe('VirtualScroll component', () => {
890
835
  });
891
836
  await nextTick();
892
837
  expect(wrapper.find('.loading-indicator').exists()).toBe(true);
893
- expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('block');
838
+
839
+ await wrapper.setProps({ direction: 'horizontal' });
840
+ await nextTick();
841
+ expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
894
842
  });
895
843
 
896
- it('should render horizontal loading slot correctly', async () => {
844
+ it('should toggle loading slot visibility based on loading prop', async () => {
897
845
  const wrapper = mount(VirtualScroll, {
898
846
  props: {
899
- items: mockItems.slice(0, 10),
900
- itemSize: 50,
901
- direction: 'horizontal',
902
- loading: true,
847
+ items: mockItems.slice(0, 5),
848
+ loading: false,
903
849
  },
904
850
  slots: {
905
- loading: '<div class="loading-indicator">Loading...</div>',
851
+ loading: '<div class="loader">Loading...</div>',
906
852
  },
907
853
  });
908
854
  await nextTick();
909
- expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
855
+
856
+ expect(wrapper.find('.loader').exists()).toBe(false);
857
+ expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
858
+
859
+ await wrapper.setProps({ loading: true });
860
+ await nextTick();
861
+ expect(wrapper.find('.loader').exists()).toBe(true);
862
+ expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(true);
863
+
864
+ await wrapper.setProps({ loading: false });
865
+ await nextTick();
866
+ expect(wrapper.find('.loader').exists()).toBe(false);
867
+ expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
868
+ });
869
+ });
870
+
871
+ describe('internal methods and exports', () => {
872
+ it('should handle setItemRef', async () => {
873
+ const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
874
+ await nextTick();
875
+ const vm = wrapper.vm as unknown as VSInstance;
876
+ const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
877
+ vm.setItemRef(item, 0);
878
+ vm.setItemRef(null, 0);
879
+ });
880
+
881
+ it('should handle setItemRef with NaN index', async () => {
882
+ mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
883
+ await nextTick();
884
+ const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ];
885
+ const div = document.createElement('div');
886
+ observer?.trigger([ { target: div, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
887
+ });
888
+
889
+ it('should expose methods', () => {
890
+ const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
891
+ expect(typeof (wrapper.vm as unknown as VSInstance).scrollToIndex).toBe('function');
892
+ expect(typeof (wrapper.vm as unknown as VSInstance).scrollToOffset).toBe('function');
893
+ });
894
+
895
+ it('should emit visibleRangeChange on scroll and hydration', async () => {
896
+ const wrapper = mount(VirtualScroll, {
897
+ props: { itemSize: 50, items: mockItems },
898
+ });
899
+ await nextTick();
900
+ await nextTick();
901
+ expect(wrapper.emitted('visibleRangeChange')).toBeDefined();
902
+
903
+ const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
904
+ Object.defineProperty(container, 'scrollTop', { value: 500, writable: true });
905
+ await container.dispatchEvent(new Event('scroll'));
906
+ await nextTick();
907
+ await nextTick();
908
+ expect(wrapper.emitted('visibleRangeChange')!.length).toBeGreaterThan(1);
909
+ });
910
+
911
+ it('should not emit scroll event before hydration in watch', async () => {
912
+ // initialScrollIndex triggers delayed hydration via nextTick in useVirtualScroll
913
+ const wrapper = mount(VirtualScroll, {
914
+ props: {
915
+ initialScrollIndex: 5,
916
+ itemSize: 50,
917
+ items: mockItems.slice(0, 10),
918
+ },
919
+ });
920
+
921
+ // Before first nextTick, isHydrated is false.
922
+ // Changing items will trigger scrollDetails update.
923
+ await wrapper.setProps({ items: mockItems.slice(0, 20) });
924
+
925
+ // Line 196 in VirtualScroll.vue should be hit here (return if !isHydrated)
926
+ expect(wrapper.emitted('scroll')).toBeUndefined();
927
+
928
+ await nextTick(); // hydration tick
929
+ await nextTick(); // one more for good measure
930
+ expect(wrapper.emitted('scroll')).toBeDefined();
910
931
  });
911
932
  });
912
933
  });