@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,3 +1,4 @@
1
+ /* global ScrollToOptions */
1
2
  import type { VirtualScrollProps } from './useVirtualScroll';
2
3
  import type { Ref } from 'vue';
3
4
 
@@ -75,11 +76,22 @@ describe('useVirtualScroll', () => {
75
76
  beforeEach(() => {
76
77
  window.scrollX = 0;
77
78
  window.scrollY = 0;
79
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
80
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
81
+ window.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
82
+ if (options.left !== undefined) {
83
+ window.scrollX = options.left;
84
+ }
85
+ if (options.top !== undefined) {
86
+ window.scrollY = options.top;
87
+ }
88
+ window.dispatchEvent(new Event('scroll'));
89
+ });
78
90
  vi.clearAllMocks();
79
91
  vi.useRealTimers();
80
92
  });
81
93
 
82
- describe('initialization and total size', () => {
94
+ describe('initialization and dimensions', () => {
83
95
  it('should initialize with correct total height', async () => {
84
96
  const { result } = setup({ ...defaultProps });
85
97
  expect(result.totalHeight.value).toBe(5000);
@@ -147,7 +159,7 @@ describe('useVirtualScroll', () => {
147
159
  });
148
160
  });
149
161
 
150
- describe('range and rendered items', () => {
162
+ describe('range calculation', () => {
151
163
  it('should calculate rendered items based on scroll position', async () => {
152
164
  const { result } = setup({ ...defaultProps });
153
165
  expect(result.renderedItems.value.length).toBeGreaterThan(0);
@@ -175,29 +187,81 @@ describe('useVirtualScroll', () => {
175
187
  await nextTick();
176
188
  expect(result.scrollDetails.value.currentIndex).toBeGreaterThan(0);
177
189
  });
190
+ });
191
+
192
+ describe('dynamic sizing', () => {
193
+ it('should handle columnCount fallback in updateItemSizes', async () => {
194
+ const { result, props } = setup({
195
+ ...defaultProps,
196
+ direction: 'both',
197
+ columnCount: 10,
198
+ columnWidth: undefined,
199
+ });
200
+ await nextTick();
201
+
202
+ const cell = document.createElement('div');
203
+ cell.dataset.colIndex = '0';
204
+
205
+ // Getter that returns 10 first time (for guard) and null second time (for fallback)
206
+ let count = 0;
207
+ Object.defineProperty(props.value, 'columnCount', {
208
+ get() {
209
+ count++;
210
+ return count === 1 ? 10 : null;
211
+ },
212
+ configurable: true,
213
+ });
214
+
215
+ result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
216
+ await nextTick();
217
+ });
218
+
219
+ it('should handle updateItemSizes with direct cell element', async () => {
220
+ const { result } = setup({
221
+ ...defaultProps,
222
+ direction: 'both',
223
+ columnCount: 2,
224
+ columnWidth: undefined,
225
+ });
226
+ await nextTick();
178
227
 
179
- it('should handle undefined items in renderedItems (out of bounds)', async () => {
180
- const { result } = setup({ ...defaultProps, stickyIndices: [ 200 ] });
181
- expect(result.renderedItems.value.find((i) => i.index === 200)).toBeUndefined();
228
+ const cell = document.createElement('div');
229
+ Object.defineProperty(cell, 'offsetWidth', { value: 200 });
230
+ cell.dataset.colIndex = '0';
231
+
232
+ result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
233
+ await nextTick();
234
+ expect(result.getColumnWidth(0)).toBe(200);
182
235
  });
183
236
 
184
- it('should include sticky items in renderedItems only when relevant', async () => {
185
- const { result } = setup({ ...defaultProps, stickyIndices: [ 50 ] });
186
- // Initially at top, item 50 is far away and should NOT be in renderedItems
187
- expect(result.renderedItems.value.find((i) => i.index === 50)).toBeUndefined();
237
+ it('should handle updateItemSizes initial measurement even if smaller than estimate', async () => {
238
+ // Horizontal
239
+ const { result: rH } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
240
+ await nextTick();
241
+ // Estimate is 50. Update with 40.
242
+ rH.updateItemSizes([ { index: 0, inlineSize: 40, blockSize: 40 } ]);
243
+ await nextTick();
244
+ expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(40);
245
+
246
+ // Subsequent update with smaller size should be ignored
247
+ rH.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
248
+ await nextTick();
249
+ expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(40);
188
250
 
189
- // Scroll near item 50
190
- result.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
251
+ // Vertical
252
+ const { result: rV } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
191
253
  await nextTick();
254
+ rV.updateItemSizes([ { index: 0, inlineSize: 40, blockSize: 40 } ]);
255
+ await nextTick();
256
+ expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(40);
192
257
 
193
- const item50 = result.renderedItems.value.find((i) => i.index === 50);
194
- expect(item50).toBeDefined();
195
- expect(item50!.isSticky).toBe(true);
258
+ // Subsequent update with smaller size should be ignored
259
+ rV.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
260
+ await nextTick();
261
+ expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(40);
196
262
  });
197
- });
198
263
 
199
- describe('dynamic sizing and updateItemSize', () => {
200
- it('should update item size and trigger reactivity', async () => {
264
+ it('should handle updateItemSize and trigger reactivity', async () => {
201
265
  const { result } = setup({ ...defaultProps, itemSize: undefined });
202
266
  expect(result.totalHeight.value).toBe(5000); // Default estimate
203
267
 
@@ -325,95 +389,29 @@ describe('useVirtualScroll', () => {
325
389
  // Should still be 100 for index 0, not reset to default 50
326
390
  expect(result.totalHeight.value).toBe(5050 + 50);
327
391
  });
392
+ });
328
393
 
329
- it('should track max dimensions in updateItemSize', async () => {
330
- const { result } = setup({ ...defaultProps, direction: 'both', itemSize: undefined, columnCount: 2 });
331
- // Initial maxWidth is 0 (since vertical direction didn't set it for X)
332
- // Wait, in 'both' mode, initializeSizes sets it.
333
-
334
- result.updateItemSize(0, 5000, 6000);
335
- await nextTick();
336
- // Should have hit maxWidth.value = width
337
- });
338
-
339
- it('should cover spacer skip heuristic in updateItemSize', async () => {
394
+ describe('scrolling and API', () => {
395
+ it('should handle scrollToIndex with horizontal direction and dynamic item size', async () => {
340
396
  const container = document.createElement('div');
341
- Object.defineProperty(container, 'clientWidth', { value: 500 });
342
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 2, itemSize: 0, columnWidth: 0, container });
397
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
398
+ const { result } = setup({ ...defaultProps, container, direction: 'horizontal', itemSize: undefined });
343
399
  await nextTick();
344
- const parent = document.createElement('div');
345
- const spacer = document.createElement('div');
346
- Object.defineProperty(spacer, 'offsetWidth', { value: 1000 });
347
- parent.appendChild(spacer);
348
- result.updateItemSize(0, 100, 50, parent);
349
- await nextTick();
350
- });
351
400
 
352
- it('should allow columns to shrink on first measurement', async () => {
353
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 2, columnWidth: undefined });
354
- // Default estimate is 150
355
- expect(result.getColumnWidth(0)).toBe(150);
356
-
357
- const parent = document.createElement('div');
358
- const child = document.createElement('div');
359
- Object.defineProperty(child, 'offsetWidth', { value: 100 });
360
- child.dataset.colIndex = '0';
361
- parent.appendChild(child);
362
-
363
- // First measurement is 100
364
- result.updateItemSize(0, 100, 50, parent);
401
+ // index 10. itemSize is 50 by default. totalWidth = 5000.
402
+ result.scrollToIndex(null, 10, { align: 'start', behavior: 'auto' });
365
403
  await nextTick();
366
- expect(result.getColumnWidth(0)).toBe(100);
404
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(500);
367
405
  });
368
406
 
369
- it('should allow shrinking on first measurement', async () => {
370
- const { result } = setup({ ...defaultProps, itemSize: undefined });
371
- // Default estimate is 50
372
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(50);
373
-
374
- // First measurement is 20 (smaller than 50)
375
- result.updateItemSize(0, 50, 20);
407
+ it('should handle scrollToIndex with window fallback when container is missing', async () => {
408
+ const { result } = setup({ ...defaultProps, container: undefined });
376
409
  await nextTick();
377
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(20);
378
-
379
- // Second measurement is 10 (smaller than 20) - should NOT shrink
380
- result.updateItemSize(0, 50, 10);
381
- await nextTick();
382
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(20);
383
-
384
- // Third measurement is 30 (larger than 20) - SHOULD grow
385
- result.updateItemSize(0, 50, 30);
410
+ result.scrollToIndex(10, 0);
386
411
  await nextTick();
387
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(30);
388
- });
389
-
390
- it('should handle cells querySelector in updateItemSizes', async () => {
391
- const { result } = setup({
392
- ...defaultProps,
393
- direction: 'both',
394
- columnCount: 2,
395
- columnWidth: undefined,
396
- });
397
-
398
- const parent = document.createElement('div');
399
- const child1 = document.createElement('div');
400
- Object.defineProperty(child1, 'offsetWidth', { value: 200 });
401
- child1.dataset.colIndex = '0';
402
- const child2 = document.createElement('div');
403
- Object.defineProperty(child2, 'offsetWidth', { value: 300 });
404
- child2.dataset.colIndex = '1';
405
-
406
- parent.appendChild(child1);
407
- parent.appendChild(child2);
408
-
409
- result.updateItemSizes([ { index: 0, inlineSize: 500, blockSize: 50, element: parent } ]);
410
- await nextTick();
411
- expect(result.getColumnWidth(0)).toBe(200);
412
- expect(result.getColumnWidth(1)).toBe(300);
412
+ expect(window.scrollTo).toHaveBeenCalled();
413
413
  });
414
- });
415
414
 
416
- describe('scroll and offsets', () => {
417
415
  it('should handle scrollToIndex out of bounds', async () => {
418
416
  const { result } = setup({ ...defaultProps });
419
417
  // Row past end
@@ -443,7 +441,7 @@ describe('useVirtualScroll', () => {
443
441
 
444
442
  // Current visible range: [scrollTop + paddingStart, scrollTop + viewport - paddingEnd] = [300, 700]
445
443
  // Scroll to item at y=250. 250 < 300, so not visible.
446
- // targetY < relativeScrollY + paddingStart (250 < 200 + 100) -> hit line 729
444
+ // targetY < relativeScrollY + paddingStart (250 < 200 + 100)
447
445
  result.scrollToIndex(5, null, 'auto');
448
446
  await nextTick();
449
447
  });
@@ -466,6 +464,59 @@ describe('useVirtualScroll', () => {
466
464
  expect(container.scrollTo).toHaveBeenCalled();
467
465
  });
468
466
 
467
+ it('should handle scrollToOffset with currentX/currentY fallbacks', async () => {
468
+ const container = document.createElement('div');
469
+ Object.defineProperty(container, 'scrollLeft', { value: 50, writable: true });
470
+ Object.defineProperty(container, 'scrollTop', { value: 60, writable: true });
471
+
472
+ const { result } = setup({ ...defaultProps, container });
473
+ await nextTick();
474
+
475
+ // Pass null to x and y to trigger fallbacks to currentX and currentY
476
+ result.scrollToOffset(null, null);
477
+ await nextTick();
478
+
479
+ // scrollOffset.x = targetX - hostOffset.x + (isHorizontal ? paddingStartX : 0)
480
+ // targetX = currentX = 50. hostOffset.x = 0. isHorizontal = false.
481
+ // So scrollOffset.x = 50.
482
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(50);
483
+ expect(result.scrollDetails.value.scrollOffset.y).toBe(60);
484
+ });
485
+
486
+ it('should handle scrollToOffset with restricted direction for padding fallback', async () => {
487
+ const container = document.createElement('div');
488
+ container.scrollTo = vi.fn();
489
+
490
+ // Horizontal direction: isVertical will be false, so targetY padding fallback will be 0
491
+ const { result } = setup({ ...defaultProps, container, direction: 'horizontal', scrollPaddingStart: 10 });
492
+ await nextTick();
493
+
494
+ result.scrollToOffset(100, 100);
495
+ await nextTick();
496
+ // targetY = 100 + hostOffset.y - (isVertical ? paddingStartY : 0)
497
+ // Since isVertical is false, it uses 0. hostOffset.y is 0 here.
498
+ expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
499
+ top: 100,
500
+ }));
501
+
502
+ // Vertical direction: isHorizontal will be false, so targetX padding fallback will be 0
503
+ const { result: r2 } = setup({ ...defaultProps, container, direction: 'vertical', scrollPaddingStart: 10 });
504
+ await nextTick();
505
+ r2.scrollToOffset(100, 100);
506
+ await nextTick();
507
+ expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
508
+ left: 100,
509
+ }));
510
+ });
511
+
512
+ it('should handle scrollToOffset with window fallback when container is missing', async () => {
513
+ const { result } = setup({ ...defaultProps, container: undefined });
514
+ await nextTick();
515
+ result.scrollToOffset(100, 200);
516
+ await nextTick();
517
+ expect(window.scrollTo).toHaveBeenCalled();
518
+ });
519
+
469
520
  it('should handle scrollToIndex with null indices', async () => {
470
521
  const { result } = setup({ ...defaultProps });
471
522
  result.scrollToIndex(null, null);
@@ -580,57 +631,54 @@ describe('useVirtualScroll', () => {
580
631
  expect(container.scrollTop).toBe(400);
581
632
  });
582
633
 
583
- it('should clear pendingScroll when reached', async () => {
584
- const { result } = setup({ ...defaultProps, itemSize: undefined });
585
- result.scrollToIndex(10, 0, { isCorrection: true });
586
- await nextTick();
587
- });
634
+ it('should stop programmatic scroll', async () => {
635
+ const { result } = setup(defaultProps);
636
+ result.scrollToIndex(10, null, { behavior: 'smooth' });
637
+ expect(result.scrollDetails.value.isProgrammaticScroll).toBe(true);
588
638
 
589
- it('should cover scrollToIndex row >= length branch', async () => {
590
- const { result } = setup({ ...defaultProps });
591
- result.scrollToIndex(200, null);
592
- await nextTick();
639
+ result.stopProgrammaticScroll();
640
+ expect(result.scrollDetails.value.isProgrammaticScroll).toBe(false);
593
641
  });
594
642
 
595
- it('should handle scrollToIndex horizontal alignment branches', async () => {
643
+ it('should handle scrollToIndex with element container having scrollTo', async () => {
596
644
  const container = document.createElement('div');
597
- Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
598
- Object.defineProperty(container, 'scrollLeft', { value: 1000, writable: true, configurable: true });
599
- container.scrollTo = vi.fn().mockImplementation((options) => {
600
- container.scrollLeft = options.left;
601
- });
602
-
603
- const { result } = setup({ ...defaultProps, direction: 'horizontal', container, itemSize: 50, scrollPaddingStart: 100 });
645
+ container.scrollTo = vi.fn();
646
+ const { result } = setup({ ...defaultProps, container });
604
647
  await nextTick();
605
648
 
606
- // targetX = 5 * 50 = 250. relativeScrollX = 1000. paddingStart = 100.
607
- // targetX < relativeScrollX + paddingStart (250 < 1100)
608
- result.scrollToIndex(null, 5, 'auto');
649
+ result.scrollToIndex(10, 0, { behavior: 'auto' });
609
650
  await nextTick();
610
- expect(container.scrollLeft).toBeLessThan(1000);
651
+ expect(container.scrollTo).toHaveBeenCalled();
652
+ });
611
653
 
612
- // End alignment
613
- result.scrollToIndex(null, 5, 'end');
654
+ it('should handle scrollToIndex fallback when scrollTo is missing', async () => {
655
+ const container = document.createElement('div');
656
+ (container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
657
+ const { result } = setup({ ...defaultProps, container });
614
658
  await nextTick();
615
659
 
616
- // Center alignment
617
- result.scrollToIndex(null, 5, 'center');
660
+ // row only
661
+ result.scrollToIndex(10, null, { behavior: 'auto' });
618
662
  await nextTick();
619
- });
663
+ expect(container.scrollTop).toBeGreaterThan(0);
620
664
 
621
- it('should only apply scrollPaddingStart to Y axis in "both" mode if it is a number', async () => {
622
- setup({ ...defaultProps, direction: 'both', scrollPaddingStart: 10 });
665
+ // col only
666
+ const { result: resH } = setup({ ...defaultProps, container, direction: 'horizontal' });
667
+ await nextTick();
668
+ resH.scrollToIndex(null, 10, { behavior: 'auto' });
623
669
  await nextTick();
624
- // Y padding should be 10, X padding should be 0
670
+ expect(container.scrollLeft).toBeGreaterThan(0);
625
671
  });
626
672
 
627
- it('should stop programmatic scroll', async () => {
628
- const { result } = setup(defaultProps);
629
- result.scrollToIndex(10, null, { behavior: 'smooth' });
630
- expect(result.scrollDetails.value.isProgrammaticScroll).toBe(true);
631
-
632
- result.stopProgrammaticScroll();
633
- expect(result.scrollDetails.value.isProgrammaticScroll).toBe(false);
673
+ it('should skip undefined items in renderedItems', async () => {
674
+ const items = Array.from({ length: 10 }) as unknown[];
675
+ items[ 0 ] = { id: 0 };
676
+ // other indices are undefined
677
+ const { result } = setup({ ...defaultProps, items, itemSize: 50 });
678
+ await nextTick();
679
+ // only index 0 should be rendered
680
+ expect(result.renderedItems.value.length).toBe(1);
681
+ expect(result.renderedItems.value[ 0 ]?.index).toBe(0);
634
682
  });
635
683
  });
636
684
 
@@ -643,6 +691,72 @@ describe('useVirtualScroll', () => {
643
691
  await nextTick();
644
692
  });
645
693
 
694
+ it('should cover fallback branches for unknown targets and directions', async () => {
695
+ // 1. Unknown container type (hits 408, 445, 513, 718 else branches)
696
+ const unknownContainer = {
697
+ addEventListener: vi.fn(),
698
+ removeEventListener: vi.fn(),
699
+ } as unknown as HTMLElement;
700
+
701
+ const { result } = setup({
702
+ ...defaultProps,
703
+ container: unknownContainer,
704
+ hostElement: document.createElement('div'),
705
+ });
706
+ await nextTick();
707
+
708
+ result.scrollToIndex(10, 0);
709
+ result.scrollToOffset(100, 100);
710
+ result.updateHostOffset();
711
+
712
+ // 2. Invalid direction (hits 958 else branch)
713
+ const { result: r2 } = setup({
714
+ ...defaultProps,
715
+ direction: undefined as unknown as 'vertical',
716
+ stickyIndices: [ 0 ],
717
+ });
718
+ await nextTick();
719
+ window.dispatchEvent(new Event('scroll'));
720
+ await nextTick();
721
+ expect(r2.renderedItems.value.find((i) => i.index === 0)).toBeDefined();
722
+
723
+ // 3. Unknown target in handleScroll (hits 1100 else branch)
724
+ const container = document.createElement('div');
725
+ setup({ ...defaultProps, container });
726
+ const event = new Event('scroll');
727
+ Object.defineProperty(event, 'target', { value: { } });
728
+ container.dispatchEvent(event);
729
+ });
730
+
731
+ it('should cleanup events and observers when container changes', async () => {
732
+ const container = document.createElement('div');
733
+ const removeSpy = vi.spyOn(container, 'removeEventListener');
734
+ const { props } = setup({ ...defaultProps, container });
735
+ await nextTick();
736
+
737
+ // Change container to trigger cleanup of old one
738
+ props.value.container = document.createElement('div');
739
+ await nextTick();
740
+
741
+ expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
742
+ });
743
+
744
+ it('should cleanup when unmounted and container is window', async () => {
745
+ const { wrapper } = setup({ ...defaultProps, container: window });
746
+ await nextTick();
747
+ wrapper.unmount();
748
+ });
749
+
750
+ it('should cleanup when unmounted', async () => {
751
+ const container = document.createElement('div');
752
+ const removeSpy = vi.spyOn(container, 'removeEventListener');
753
+ const { wrapper } = setup({ ...defaultProps, container });
754
+ await nextTick();
755
+
756
+ wrapper.unmount();
757
+ expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
758
+ });
759
+
646
760
  it('should handle document scroll events', async () => {
647
761
  setup({ ...defaultProps });
648
762
  document.dispatchEvent(new Event('scroll'));
@@ -698,7 +812,7 @@ describe('useVirtualScroll', () => {
698
812
 
699
813
  it('should handle window resize events', async () => {
700
814
  setup({ ...defaultProps, container: window });
701
- window.innerWidth = 1200;
815
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 });
702
816
  window.dispatchEvent(new Event('resize'));
703
817
  await nextTick();
704
818
  });
@@ -803,71 +917,43 @@ describe('useVirtualScroll', () => {
803
917
  });
804
918
  });
805
919
 
806
- describe('lifecycle and logic branches', () => {
807
- it('should trigger scroll correction when isScrolling becomes false', async () => {
808
- vi.useFakeTimers();
809
- const { result } = setup({ ...defaultProps, container: window, itemSize: undefined });
920
+ describe('sticky and pushed items', () => {
921
+ it('should identify sticky items', async () => {
922
+ const { result } = setup({ ...defaultProps, stickyIndices: [ 0, 10 ] });
810
923
  await nextTick();
811
- result.scrollToIndex(10, 0, 'start');
812
- document.dispatchEvent(new Event('scroll'));
813
- expect(result.scrollDetails.value.isScrolling).toBe(true);
814
- vi.advanceTimersByTime(250);
815
- await nextTick();
816
- expect(result.scrollDetails.value.isScrolling).toBe(false);
817
- vi.useRealTimers();
818
- });
819
924
 
820
- it('should trigger scroll correction when treeUpdateFlag changes', async () => {
821
- const { result } = setup({ ...defaultProps, itemSize: undefined });
822
- await nextTick();
823
- result.scrollToIndex(10, 0, 'start');
824
- // Trigger tree update
825
- result.updateItemSize(5, 100, 100);
826
- await nextTick();
925
+ const items = result.renderedItems.value;
926
+ const item0 = items.find((i) => i.index === 0);
927
+ const item10 = items.find((i) => i.index === 10);
928
+ expect(item0?.isSticky).toBe(true);
929
+ expect(item10?.isSticky).toBe(true);
827
930
  });
828
931
 
829
- it('should cover updateHostOffset when container is window', async () => {
830
- const { result, props } = setup({ ...defaultProps, container: window });
831
- const host = document.createElement('div');
832
- props.value.hostElement = host;
932
+ it('should make sticky items active when scrolled past', async () => {
933
+ const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ] });
833
934
  await nextTick();
834
- result.updateHostOffset();
835
- });
836
935
 
837
- it('should cover updateHostOffset when container is hostElement', async () => {
838
- const host = document.createElement('div');
839
- const { result } = setup({ ...defaultProps, container: host, hostElement: host });
936
+ result.scrollToOffset(0, 100);
840
937
  await nextTick();
841
- result.updateHostOffset();
842
- });
843
938
 
844
- it('should correctly calculate hostOffset when container is an HTMLElement', async () => {
845
- const container = document.createElement('div');
846
- const hostElement = document.createElement('div');
847
-
848
- container.getBoundingClientRect = vi.fn(() => ({ top: 100, left: 100, bottom: 200, right: 200, width: 100, height: 100, x: 100, y: 100, toJSON: () => '' }));
849
- hostElement.getBoundingClientRect = vi.fn(() => ({ top: 150, left: 150, bottom: 200, right: 200, width: 50, height: 50, x: 150, y: 150, toJSON: () => '' }));
850
- Object.defineProperty(container, 'scrollTop', { value: 50, writable: true, configurable: true });
851
-
852
- const { result } = setup({ ...defaultProps, container, hostElement });
853
- await nextTick();
854
- result.updateHostOffset();
855
- expect(result.scrollDetails.value.scrollOffset.y).toBeDefined();
939
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
940
+ expect(item0?.isStickyActive).toBe(true);
856
941
  });
857
942
 
858
- it('should cover refresh method', async () => {
859
- const { result } = setup({ ...defaultProps, itemSize: 0 });
860
- result.updateItemSize(0, 100, 100);
943
+ it('should include current sticky item in rendered items even if range is ahead', async () => {
944
+ const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ], bufferBefore: 0 });
861
945
  await nextTick();
862
- expect(result.totalHeight.value).toBe(5050);
863
946
 
864
- result.refresh();
947
+ // Scroll to index 20. Range starts at 20.
948
+ result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
865
949
  await nextTick();
866
- expect(result.totalHeight.value).toBe(5000);
950
+
951
+ expect(result.scrollDetails.value.range.start).toBe(20);
952
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
953
+ expect(item0).toBeDefined();
954
+ expect(item0?.isStickyActive).toBe(true);
867
955
  });
868
- });
869
956
 
870
- describe('sticky header pushing', () => {
871
957
  it('should push sticky item when next sticky item approaches (vertical)', async () => {
872
958
  const container = document.createElement('div');
873
959
  Object.defineProperty(container, 'clientHeight', { value: 500 });
@@ -900,6 +986,50 @@ describe('useVirtualScroll', () => {
900
986
  const item0 = result.renderedItems.value.find((i) => i.index === 0);
901
987
  expect(item0!.offset.x).toBeLessThanOrEqual(450);
902
988
  });
989
+
990
+ it('should handle dynamic sticky item pushing in vertical mode', async () => {
991
+ const container = document.createElement('div');
992
+ Object.defineProperty(container, 'clientHeight', { value: 500 });
993
+ Object.defineProperty(container, 'scrollTop', { value: 460, writable: true });
994
+
995
+ const { result } = setup({
996
+ ...defaultProps,
997
+ container,
998
+ itemSize: undefined, // dynamic
999
+ stickyIndices: [ 0, 10 ],
1000
+ });
1001
+ await nextTick();
1002
+
1003
+ // Item 0 is sticky. Item 10 is next sticky.
1004
+ // Default size = 50.
1005
+ // nextStickyY = itemSizesY.query(10) = 500.
1006
+ // distance = 500 - 460 = 40.
1007
+ // 40 < 50 (item 0 height), so it should be pushed.
1008
+ // stickyOffset.y = -(50 - 40) = -10.
1009
+ const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1010
+ expect(stickyItem?.stickyOffset.y).toBe(-10);
1011
+ });
1012
+
1013
+ it('should handle dynamic sticky item pushing in horizontal mode', async () => {
1014
+ const container = document.createElement('div');
1015
+ Object.defineProperty(container, 'clientWidth', { value: 500 });
1016
+ Object.defineProperty(container, 'scrollLeft', { value: 460, writable: true });
1017
+
1018
+ const { result } = setup({
1019
+ ...defaultProps,
1020
+ container,
1021
+ direction: 'horizontal',
1022
+ itemSize: undefined, // dynamic
1023
+ stickyIndices: [ 0, 10 ],
1024
+ });
1025
+ await nextTick();
1026
+
1027
+ // nextStickyX = itemSizesX.query(10) = 500.
1028
+ // distance = 500 - 460 = 40.
1029
+ // 40 < 50, so stickyOffset.x = -10.
1030
+ const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1031
+ expect(stickyItem?.stickyOffset.x).toBe(-10);
1032
+ });
903
1033
  });
904
1034
 
905
1035
  describe('scroll restoration', () => {
@@ -970,6 +1100,35 @@ describe('useVirtualScroll', () => {
970
1100
  vi.useRealTimers();
971
1101
  });
972
1102
 
1103
+ it('should restore scroll position with itemSize as function when prepending', async () => {
1104
+ vi.useFakeTimers();
1105
+ const container = document.createElement('div');
1106
+ Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1107
+ container.scrollTo = vi.fn().mockImplementation((options) => {
1108
+ container.scrollTop = options.top;
1109
+ });
1110
+
1111
+ const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1112
+ const { props } = setup({
1113
+ ...defaultProps,
1114
+ items,
1115
+ container,
1116
+ itemSize: (item: { id: number; }) => (item.id < 0 ? 100 : 50),
1117
+ restoreScrollOnPrepend: true,
1118
+ });
1119
+ await nextTick();
1120
+
1121
+ // Prepend 1 item with id -1 (size 100)
1122
+ const newItems = [ { id: -1 }, ...items ];
1123
+ props.value.items = newItems;
1124
+ await nextTick();
1125
+ await nextTick();
1126
+
1127
+ // Should have adjusted scroll by 100px. New scrollTop should be 200.
1128
+ expect(container.scrollTop).toBe(200);
1129
+ vi.useRealTimers();
1130
+ });
1131
+
973
1132
  it('should NOT restore scroll position when restoreScrollOnPrepend is false', async () => {
974
1133
  const container = document.createElement('div');
975
1134
  Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
@@ -1009,12 +1168,90 @@ describe('useVirtualScroll', () => {
1009
1168
  props.value.items = [ { id: -1 }, ...props.value.items ];
1010
1169
  await nextTick();
1011
1170
  });
1171
+ });
1012
1172
 
1013
- it('should handle updateItemSizes for horizontal direction', async () => {
1014
- const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
1015
- result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 50 } ]);
1173
+ describe('advanced logic and edge cases', () => {
1174
+ it('should trigger scroll correction when isScrolling becomes false', async () => {
1175
+ vi.useFakeTimers();
1176
+ const { result } = setup({ ...defaultProps, container: window, itemSize: undefined });
1177
+ await nextTick();
1178
+ result.scrollToIndex(10, 0, 'start');
1179
+ document.dispatchEvent(new Event('scroll'));
1180
+ expect(result.scrollDetails.value.isScrolling).toBe(true);
1181
+ vi.advanceTimersByTime(250);
1182
+ await nextTick();
1183
+ expect(result.scrollDetails.value.isScrolling).toBe(false);
1184
+ vi.useRealTimers();
1185
+ });
1186
+
1187
+ it('should trigger scroll correction when treeUpdateFlag changes', async () => {
1188
+ const { result } = setup({ ...defaultProps, itemSize: undefined });
1189
+ await nextTick();
1190
+ result.scrollToIndex(10, 0, 'start');
1191
+ // Trigger tree update
1192
+ result.updateItemSize(5, 100, 100);
1193
+ await nextTick();
1194
+ });
1195
+
1196
+ it('should cover updateHostOffset when container is window', async () => {
1197
+ const { result, props } = setup({ ...defaultProps, container: window });
1198
+ const host = document.createElement('div');
1199
+ props.value.hostElement = host;
1200
+ await nextTick();
1201
+ result.updateHostOffset();
1202
+ });
1203
+
1204
+ it('should cover updateHostOffset when container is hostElement', async () => {
1205
+ const host = document.createElement('div');
1206
+ const { result } = setup({ ...defaultProps, container: host, hostElement: host });
1207
+ await nextTick();
1208
+ result.updateHostOffset();
1209
+ });
1210
+
1211
+ it('should handle updateHostOffset with window fallback when container is missing', async () => {
1212
+ const { result, props } = setup({ ...defaultProps, container: undefined });
1213
+ const host = document.createElement('div');
1214
+ props.value.hostElement = host;
1215
+ await nextTick();
1216
+ result.updateHostOffset();
1217
+ });
1218
+
1219
+ it('should correctly calculate hostOffset when container is an HTMLElement', async () => {
1220
+ const container = document.createElement('div');
1221
+ const hostElement = document.createElement('div');
1222
+
1223
+ container.getBoundingClientRect = vi.fn(() => ({ top: 100, left: 100, bottom: 200, right: 200, width: 100, height: 100, x: 100, y: 100, toJSON: () => '' }));
1224
+ hostElement.getBoundingClientRect = vi.fn(() => ({ top: 150, left: 150, bottom: 200, right: 200, width: 50, height: 50, x: 150, y: 150, toJSON: () => '' }));
1225
+ Object.defineProperty(container, 'scrollTop', { value: 50, writable: true, configurable: true });
1226
+
1227
+ const { result } = setup({ ...defaultProps, container, hostElement });
1228
+ await nextTick();
1229
+ result.updateHostOffset();
1230
+ expect(result.scrollDetails.value.scrollOffset.y).toBeDefined();
1231
+ });
1232
+
1233
+ it('should cover refresh method', async () => {
1234
+ const { result } = setup({ ...defaultProps, itemSize: 0 });
1235
+ result.updateItemSize(0, 100, 100);
1236
+ await nextTick();
1237
+ expect(result.totalHeight.value).toBe(5050);
1238
+
1239
+ result.refresh();
1240
+ await nextTick();
1241
+ expect(result.totalHeight.value).toBe(5000);
1242
+ });
1243
+
1244
+ it('should trigger scroll correction on tree update with string alignment', async () => {
1245
+ const container = document.createElement('div');
1246
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1247
+ Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1248
+ const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1249
+ // Set a pending scroll with string alignment
1250
+ result.scrollToIndex(10, null, 'start');
1251
+
1252
+ // Trigger tree update
1253
+ result.updateItemSize(0, 100, 100);
1016
1254
  await nextTick();
1017
- expect(result.totalWidth.value).toBe(5050);
1018
1255
  });
1019
1256
 
1020
1257
  it('should trigger scroll correction on tree update with pending scroll', async () => {
@@ -1053,6 +1290,92 @@ describe('useVirtualScroll', () => {
1053
1290
 
1054
1291
  // eslint-disable-next-line test/prefer-lowercase-title
1055
1292
  describe('SSR support', () => {
1293
+ it('should handle colBuffer when ssrRange is present and not scrolling', async () => {
1294
+ vi.useFakeTimers();
1295
+ const container = document.createElement('div');
1296
+ Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
1297
+ Object.defineProperty(container, 'scrollLeft', { value: 0, writable: true, configurable: true });
1298
+ container.scrollTo = vi.fn().mockImplementation((options) => {
1299
+ if (options.left !== undefined) {
1300
+ Object.defineProperty(container, 'scrollLeft', { value: options.left, writable: true, configurable: true });
1301
+ }
1302
+ });
1303
+
1304
+ const { result } = setup({
1305
+ ...defaultProps,
1306
+ container,
1307
+ direction: 'both',
1308
+ columnCount: 20,
1309
+ columnWidth: 100,
1310
+ ssrRange: { start: 0, end: 10, colStart: 1, colEnd: 2 }, // SSR values
1311
+ initialScrollIndex: 0,
1312
+ });
1313
+
1314
+ await nextTick(); // onMounted schedules hydration
1315
+ await nextTick(); // hydration tick 1
1316
+ await nextTick(); // hydration tick 2 (isHydrating = false)
1317
+
1318
+ expect(result.isHydrated.value).toBe(true);
1319
+
1320
+ // Scroll to col 5 (offset 500)
1321
+ result.scrollToIndex(null, 5, { align: 'start', behavior: 'auto' });
1322
+ await nextTick();
1323
+
1324
+ vi.runAllTimers(); // Clear isScrolling timeout
1325
+ await nextTick();
1326
+
1327
+ // start = findLowerBound(500) = 5.
1328
+ // colBuffer should be 0 because ssrRange is present and isScrolling is false.
1329
+ expect(result.columnRange.value.start).toBe(5);
1330
+
1331
+ // Now trigger a scroll to make isScrolling true
1332
+ container.dispatchEvent(new Event('scroll'));
1333
+ await nextTick();
1334
+ // isScrolling is now true. colBuffer should be 2.
1335
+ expect(result.columnRange.value.start).toBe(3);
1336
+ vi.useRealTimers();
1337
+ });
1338
+
1339
+ it('should handle bufferBefore when ssrRange is present and not scrolling', async () => {
1340
+ vi.useFakeTimers();
1341
+ const container = document.createElement('div');
1342
+ Object.defineProperty(container, 'clientHeight', { value: 500 });
1343
+ Object.defineProperty(container, 'scrollTop', { value: 0, writable: true, configurable: true });
1344
+ container.scrollTo = vi.fn().mockImplementation((options) => {
1345
+ if (options.top !== undefined) {
1346
+ Object.defineProperty(container, 'scrollTop', { value: options.top, writable: true, configurable: true });
1347
+ }
1348
+ });
1349
+
1350
+ const { result } = setup({
1351
+ ...defaultProps,
1352
+ container,
1353
+ itemSize: 50,
1354
+ bufferBefore: 5,
1355
+ ssrRange: { start: 0, end: 10 },
1356
+ initialScrollIndex: 10,
1357
+ });
1358
+
1359
+ await nextTick(); // schedules hydration
1360
+ await nextTick(); // hydration tick scrolls to 10
1361
+ await nextTick();
1362
+
1363
+ vi.runAllTimers(); // Clear isScrolling timeout
1364
+ await nextTick();
1365
+
1366
+ expect(result.isHydrated.value).toBe(true);
1367
+ // start = floor(500 / 50) = 10.
1368
+ // Since ssrRange is present and isScrolling is false, bufferBefore should be 0.
1369
+ expect(result.renderedItems.value[ 0 ]?.index).toBe(10);
1370
+
1371
+ // Now trigger a scroll to make isScrolling true
1372
+ container.dispatchEvent(new Event('scroll'));
1373
+ await nextTick();
1374
+ // isScrolling is now true. bufferBefore should be 5.
1375
+ expect(result.renderedItems.value[ 0 ]?.index).toBe(5);
1376
+ vi.useRealTimers();
1377
+ });
1378
+
1056
1379
  it('should handle SSR range in range calculation', () => {
1057
1380
  const props = ref({
1058
1381
  items: mockItems,
@@ -1072,6 +1395,17 @@ describe('useVirtualScroll', () => {
1072
1395
  expect(result.columnRange.value.end).toBe(5);
1073
1396
  });
1074
1397
 
1398
+ it('should handle SSR range with colEnd fallback in columnRange calculation', () => {
1399
+ const props = ref({
1400
+ items: mockItems,
1401
+ columnCount: 10,
1402
+ ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 0 },
1403
+ }) as Ref<VirtualScrollProps<unknown>>;
1404
+ const result = useVirtualScroll(props);
1405
+ // colEnd is 0, so it should use columnCount (10)
1406
+ expect(result.columnRange.value.end).toBe(10);
1407
+ });
1408
+
1075
1409
  it('should handle SSR range with both directions for total sizes', () => {
1076
1410
  const props = ref({
1077
1411
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
@@ -1097,26 +1431,27 @@ describe('useVirtualScroll', () => {
1097
1431
  expect(result.totalWidth.value).toBe(500); // (20-10) * 50
1098
1432
  });
1099
1433
 
1100
- it('should handle SSR range with fixed size horizontal for total sizes', () => {
1434
+ it('should handle SSR range with vertical offset in renderedItems', () => {
1101
1435
  const props = ref({
1102
1436
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1103
- direction: 'horizontal',
1437
+ direction: 'vertical',
1104
1438
  itemSize: 50,
1105
1439
  ssrRange: { start: 10, end: 20 },
1106
1440
  }) as Ref<VirtualScrollProps<unknown>>;
1107
1441
  const result = useVirtualScroll(props);
1108
- expect(result.totalWidth.value).toBe(500); // (20-10) * 50
1442
+ expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1109
1443
  });
1110
1444
 
1111
- it('should handle SSR range with vertical offset in renderedItems', () => {
1445
+ it('should handle SSR range with dynamic horizontal offsets in renderedItems', () => {
1112
1446
  const props = ref({
1113
1447
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1114
- direction: 'vertical',
1115
- itemSize: 50,
1448
+ direction: 'horizontal',
1449
+ itemSize: undefined, // dynamic
1116
1450
  ssrRange: { start: 10, end: 20 },
1117
1451
  }) as Ref<VirtualScrollProps<unknown>>;
1118
1452
  const result = useVirtualScroll(props);
1119
- expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1453
+ // ssrOffsetX = itemSizesX.query(10) = 10 * 50 = 500
1454
+ expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(500);
1120
1455
  });
1121
1456
 
1122
1457
  it('should handle SSR range with dynamic sizes for total sizes', () => {
@@ -1184,6 +1519,20 @@ describe('useVirtualScroll', () => {
1184
1519
  expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-500);
1185
1520
  });
1186
1521
 
1522
+ it('should handle SSR range with direction "both" and colEnd falsy', () => {
1523
+ const propsValue = ref({
1524
+ columnCount: 10,
1525
+ columnWidth: 100,
1526
+ direction: 'both' as const,
1527
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1528
+ ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
1529
+ }) as Ref<VirtualScrollProps<unknown>>;
1530
+ const result = useVirtualScroll(propsValue);
1531
+ // colEnd is 0, so it should use colCount (10)
1532
+ // totalWidth = columnSizes.query(10) - columnSizes.query(5) = 1000 - 500 = 500
1533
+ expect(result.totalWidth.value).toBe(500);
1534
+ });
1535
+
1187
1536
  it('should handle SSR range with colCount > 0 in totalWidth', () => {
1188
1537
  const props = ref({
1189
1538
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
@@ -1195,17 +1544,9 @@ describe('useVirtualScroll', () => {
1195
1544
  const result = useVirtualScroll(props);
1196
1545
  expect(result.totalWidth.value).toBe(500);
1197
1546
  });
1547
+ });
1198
1548
 
1199
- it('should skip undefined items in renderedItems', async () => {
1200
- // items array is mockItems (length 100)
1201
- const { result } = setup({ ...defaultProps, stickyIndices: [ 1000 ] });
1202
- // Scroll way past the end
1203
- result.scrollToOffset(0, 100000);
1204
- await nextTick();
1205
- // prevStickyIdx will be 1000, which is out of bounds
1206
- expect(result.renderedItems.value.length).toBe(0);
1207
- });
1208
-
1549
+ describe('helpers', () => {
1209
1550
  it('should cover object padding branches in helpers', () => {
1210
1551
  expect(getPaddingX({ x: 10 }, 'horizontal')).toBe(10);
1211
1552
  expect(getPaddingY({ y: 20 }, 'vertical')).toBe(20);