@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,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);
@@ -105,11 +117,11 @@ describe('useVirtualScroll', () => {
105
117
 
106
118
  it('should recalculate when gaps change', async () => {
107
119
  const { result, props } = setup({ ...defaultProps, gap: 10 });
108
- expect(result.totalHeight.value).toBe(6000); // 100 * (50 + 10)
120
+ expect(result.totalHeight.value).toBe(5990); // 100 * (50 + 10) - 10
109
121
 
110
122
  props.value.gap = 20;
111
123
  await nextTick();
112
- expect(result.totalHeight.value).toBe(7000); // 100 * (50 + 20)
124
+ expect(result.totalHeight.value).toBe(6980); // 100 * (50 + 20) - 20
113
125
  });
114
126
 
115
127
  it('should handle itemSize as a function', async () => {
@@ -118,6 +130,7 @@ describe('useVirtualScroll', () => {
118
130
  itemSize: (_item: { id: number; }, index: number) => 50 + index,
119
131
  });
120
132
  // 50*100 + (0+99)*100/2 = 5000 + 4950 = 9950
133
+ // 9950 - gap(0) = 9950
121
134
  expect(result.totalHeight.value).toBe(9950);
122
135
  });
123
136
 
@@ -128,13 +141,13 @@ describe('useVirtualScroll', () => {
128
141
  columnCount: 10,
129
142
  columnWidth: 100,
130
143
  });
131
- expect(result.totalWidth.value).toBe(1000);
132
- expect(result.totalHeight.value).toBe(5000);
144
+ expect(result.totalWidth.value).toBe(1000); // 10 * 100 - 0
145
+ expect(result.totalHeight.value).toBe(5000); // 100 * 50 - 0
133
146
  });
134
147
 
135
148
  it('should handle horizontal direction', async () => {
136
149
  const { result } = setup({ ...defaultProps, direction: 'horizontal' });
137
- expect(result.totalWidth.value).toBe(5000);
150
+ expect(result.totalWidth.value).toBe(5000); // 100 * 50 - 0
138
151
  expect(result.totalHeight.value).toBe(0);
139
152
  });
140
153
 
@@ -147,7 +160,7 @@ describe('useVirtualScroll', () => {
147
160
  });
148
161
  });
149
162
 
150
- describe('range and rendered items', () => {
163
+ describe('range calculation', () => {
151
164
  it('should calculate rendered items based on scroll position', async () => {
152
165
  const { result } = setup({ ...defaultProps });
153
166
  expect(result.renderedItems.value.length).toBeGreaterThan(0);
@@ -175,45 +188,97 @@ describe('useVirtualScroll', () => {
175
188
  await nextTick();
176
189
  expect(result.scrollDetails.value.currentIndex).toBeGreaterThan(0);
177
190
  });
191
+ });
192
+
193
+ describe('dynamic sizing', () => {
194
+ it('should handle columnCount fallback in updateItemSizes', async () => {
195
+ const { result, props } = setup({
196
+ ...defaultProps,
197
+ direction: 'both',
198
+ columnCount: 10,
199
+ columnWidth: undefined,
200
+ });
201
+ await nextTick();
202
+
203
+ const cell = document.createElement('div');
204
+ cell.dataset.colIndex = '0';
205
+
206
+ // Getter that returns 10 first time (for guard) and null second time (for fallback)
207
+ let count = 0;
208
+ Object.defineProperty(props.value, 'columnCount', {
209
+ get() {
210
+ count++;
211
+ return count === 1 ? 10 : null;
212
+ },
213
+ configurable: true,
214
+ });
215
+
216
+ result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
217
+ await nextTick();
218
+ });
219
+
220
+ it('should handle updateItemSizes with direct cell element', async () => {
221
+ const { result } = setup({
222
+ ...defaultProps,
223
+ direction: 'both',
224
+ columnCount: 2,
225
+ columnWidth: undefined,
226
+ });
227
+ await nextTick();
178
228
 
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();
229
+ const cell = document.createElement('div');
230
+ Object.defineProperty(cell, 'offsetWidth', { value: 200 });
231
+ cell.dataset.colIndex = '0';
232
+
233
+ result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
234
+ await nextTick();
235
+ expect(result.getColumnWidth(0)).toBe(200);
182
236
  });
183
237
 
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();
238
+ it('should handle updateItemSizes initial measurement even if smaller than estimate', async () => {
239
+ // Horizontal
240
+ const { result: rH } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
241
+ await nextTick();
242
+ // Estimate is 40 (new DEFAULT_ITEM_SIZE). Update with 30.
243
+ rH.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
244
+ await nextTick();
245
+ expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(30);
246
+
247
+ // Subsequent update with smaller size should also be applied now
248
+ rH.updateItemSizes([ { index: 0, inlineSize: 25, blockSize: 25 } ]);
249
+ await nextTick();
250
+ expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(25);
188
251
 
189
- // Scroll near item 50
190
- result.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
252
+ // Vertical
253
+ const { result: rV } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
254
+ await nextTick();
255
+ rV.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
191
256
  await nextTick();
257
+ expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(30);
192
258
 
193
- const item50 = result.renderedItems.value.find((i) => i.index === 50);
194
- expect(item50).toBeDefined();
195
- expect(item50!.isSticky).toBe(true);
259
+ // Subsequent update with smaller size should be applied
260
+ rV.updateItemSizes([ { index: 0, inlineSize: 20, blockSize: 20 } ]);
261
+ await nextTick();
262
+ expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(20);
196
263
  });
197
- });
198
264
 
199
- describe('dynamic sizing and updateItemSize', () => {
200
- it('should update item size and trigger reactivity', async () => {
265
+ it('should handle updateItemSize and trigger reactivity', async () => {
201
266
  const { result } = setup({ ...defaultProps, itemSize: undefined });
202
- expect(result.totalHeight.value).toBe(5000); // Default estimate
267
+ expect(result.totalHeight.value).toBe(4000); // 100 * 40
203
268
 
204
269
  result.updateItemSize(0, 100, 100);
205
270
  await nextTick();
206
- expect(result.totalHeight.value).toBe(5050);
271
+ expect(result.totalHeight.value).toBe(4060); // 4000 - 40 + 100
207
272
  expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
208
273
  });
209
274
 
210
275
  it('should treat 0, null, undefined as dynamic itemSize', async () => {
211
276
  for (const val of [ 0, null, undefined ]) {
212
277
  const { result } = setup({ ...defaultProps, itemSize: val as unknown as undefined });
213
- expect(result.totalHeight.value).toBe(5000);
278
+ expect(result.totalHeight.value).toBe(4000);
214
279
  result.updateItemSize(0, 100, 100);
215
280
  await nextTick();
216
- expect(result.totalHeight.value).toBe(5050);
281
+ expect(result.totalHeight.value).toBe(4060);
217
282
  }
218
283
  });
219
284
 
@@ -225,7 +290,7 @@ describe('useVirtualScroll', () => {
225
290
  columnCount: 2,
226
291
  columnWidth: val as unknown as undefined,
227
292
  });
228
- expect(result.getColumnWidth(0)).toBe(150);
293
+ expect(result.getColumnWidth(0)).toBe(100); // DEFAULT_COLUMN_WIDTH
229
294
  const parent = document.createElement('div');
230
295
  const col0 = document.createElement('div');
231
296
  Object.defineProperty(col0, 'offsetWidth', { value: 200, configurable: true });
@@ -233,7 +298,7 @@ describe('useVirtualScroll', () => {
233
298
  parent.appendChild(col0);
234
299
  result.updateItemSize(0, 200, 50, parent);
235
300
  await nextTick();
236
- expect(result.totalWidth.value).toBe(350);
301
+ expect(result.totalWidth.value).toBe(300); // 200 + 100
237
302
  }
238
303
  });
239
304
 
@@ -281,14 +346,21 @@ describe('useVirtualScroll', () => {
281
346
  expect(result.totalWidth.value).toBe(10 * 200); // 10 columns * 200 defaultColumnWidth
282
347
  });
283
348
 
284
- it('should ignore small delta updates in updateItemSize', async () => {
349
+ it('should ignore small delta updates in updateItemSize only after first measurement', async () => {
285
350
  const { result } = setup({ ...defaultProps, itemSize: undefined });
286
- result.updateItemSize(0, 50.1, 50.1);
351
+ // Default is 40. 40.1 is < 0.5 delta.
352
+ // First measurement should be accepted even if small delta from estimate.
353
+ result.updateItemSize(0, 40.1, 40.1);
287
354
  await nextTick();
288
- expect(result.totalHeight.value).toBe(5000);
355
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(40.1);
356
+
357
+ // Second measurement with small delta from first should be ignored.
358
+ result.updateItemSize(0, 40.2, 40.2);
359
+ await nextTick();
360
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(40.1);
289
361
  });
290
362
 
291
- it('should not shrink item height in both mode encountered so far', async () => {
363
+ it('should update item height in both mode now (allow decreases)', async () => {
292
364
  const { result } = setup({ ...defaultProps, direction: 'both', itemSize: undefined, columnCount: 2 });
293
365
  result.updateItemSize(0, 100, 100);
294
366
  await nextTick();
@@ -296,7 +368,7 @@ describe('useVirtualScroll', () => {
296
368
 
297
369
  result.updateItemSize(0, 100, 80);
298
370
  await nextTick();
299
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
371
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(80);
300
372
  });
301
373
 
302
374
  it('should update item height in vertical mode', async () => {
@@ -304,116 +376,54 @@ describe('useVirtualScroll', () => {
304
376
  result.updateItemSize(0, 100, 100);
305
377
  await nextTick();
306
378
  expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
379
+
380
+ result.updateItemSize(0, 100, 70);
381
+ await nextTick();
382
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(70);
307
383
  });
308
384
 
309
385
  it('should handle updateItemSize for horizontal direction', async () => {
310
386
  const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
311
387
  result.updateItemSize(0, 100, 50);
312
388
  await nextTick();
313
- expect(result.totalWidth.value).toBe(5050);
389
+ expect(result.totalWidth.value).toBe(4060); // 4000 - 40 + 100
314
390
  });
315
391
 
316
392
  it('should preserve measurements in initializeSizes when dynamic', async () => {
317
393
  const { result, props } = setup({ ...defaultProps, itemSize: undefined });
318
394
  result.updateItemSize(0, 100, 100);
319
395
  await nextTick();
320
- expect(result.totalHeight.value).toBe(5050);
396
+ expect(result.totalHeight.value).toBe(4060);
321
397
 
322
398
  // Trigger initializeSizes by changing length
323
399
  props.value.items = Array.from({ length: 101 }, (_, i) => ({ id: i }));
324
400
  await nextTick();
325
- // Should still be 100 for index 0, not reset to default 50
326
- expect(result.totalHeight.value).toBe(5050 + 50);
327
- });
328
-
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
401
+ // Should still be 100 for index 0, not reset to default 40
402
+ expect(result.totalHeight.value).toBe(4060 + 40);
337
403
  });
404
+ });
338
405
 
339
- it('should cover spacer skip heuristic in updateItemSize', async () => {
406
+ describe('scrolling and API', () => {
407
+ it('should handle scrollToIndex with horizontal direction and dynamic item size', async () => {
340
408
  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 });
343
- 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);
409
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
410
+ const { result } = setup({ ...defaultProps, container, direction: 'horizontal', itemSize: undefined });
349
411
  await nextTick();
350
- });
351
-
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
412
 
363
- // First measurement is 100
364
- result.updateItemSize(0, 100, 50, parent);
413
+ // index 10. itemSize is 40 by default. totalWidth = 4000.
414
+ result.scrollToIndex(null, 10, { align: 'start', behavior: 'auto' });
365
415
  await nextTick();
366
- expect(result.getColumnWidth(0)).toBe(100);
416
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(400);
367
417
  });
368
418
 
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);
376
- 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);
419
+ it('should handle scrollToIndex with window fallback when container is missing', async () => {
420
+ const { result } = setup({ ...defaultProps, container: undefined });
386
421
  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 } ]);
422
+ result.scrollToIndex(10, 0);
410
423
  await nextTick();
411
- expect(result.getColumnWidth(0)).toBe(200);
412
- expect(result.getColumnWidth(1)).toBe(300);
424
+ expect(window.scrollTo).toHaveBeenCalled();
413
425
  });
414
- });
415
426
 
416
- describe('scroll and offsets', () => {
417
427
  it('should handle scrollToIndex out of bounds', async () => {
418
428
  const { result } = setup({ ...defaultProps });
419
429
  // Row past end
@@ -443,7 +453,7 @@ describe('useVirtualScroll', () => {
443
453
 
444
454
  // Current visible range: [scrollTop + paddingStart, scrollTop + viewport - paddingEnd] = [300, 700]
445
455
  // Scroll to item at y=250. 250 < 300, so not visible.
446
- // targetY < relativeScrollY + paddingStart (250 < 200 + 100) -> hit line 729
456
+ // targetY < relativeScrollY + paddingStart (250 < 200 + 100)
447
457
  result.scrollToIndex(5, null, 'auto');
448
458
  await nextTick();
449
459
  });
@@ -466,6 +476,59 @@ describe('useVirtualScroll', () => {
466
476
  expect(container.scrollTo).toHaveBeenCalled();
467
477
  });
468
478
 
479
+ it('should handle scrollToOffset with currentX/currentY fallbacks', async () => {
480
+ const container = document.createElement('div');
481
+ Object.defineProperty(container, 'scrollLeft', { value: 50, writable: true });
482
+ Object.defineProperty(container, 'scrollTop', { value: 60, writable: true });
483
+
484
+ const { result } = setup({ ...defaultProps, container });
485
+ await nextTick();
486
+
487
+ // Pass null to x and y to trigger fallbacks to currentX and currentY
488
+ result.scrollToOffset(null, null);
489
+ await nextTick();
490
+
491
+ // scrollOffset.x = targetX - hostOffset.x + (isHorizontal ? paddingStartX : 0)
492
+ // targetX = currentX = 50. hostOffset.x = 0. isHorizontal = false.
493
+ // So scrollOffset.x = 50.
494
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(50);
495
+ expect(result.scrollDetails.value.scrollOffset.y).toBe(60);
496
+ });
497
+
498
+ it('should handle scrollToOffset with restricted direction for padding fallback', async () => {
499
+ const container = document.createElement('div');
500
+ container.scrollTo = vi.fn();
501
+
502
+ // Horizontal direction: isVertical will be false, so targetY padding fallback will be 0
503
+ const { result } = setup({ ...defaultProps, container, direction: 'horizontal', scrollPaddingStart: 10 });
504
+ await nextTick();
505
+
506
+ result.scrollToOffset(100, 100);
507
+ await nextTick();
508
+ // targetY = 100 + hostOffset.y - (isVertical ? paddingStartY : 0)
509
+ // Since isVertical is false, it uses 0. hostOffset.y is 0 here.
510
+ expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
511
+ top: 100,
512
+ }));
513
+
514
+ // Vertical direction: isHorizontal will be false, so targetX padding fallback will be 0
515
+ const { result: r2 } = setup({ ...defaultProps, container, direction: 'vertical', scrollPaddingStart: 10 });
516
+ await nextTick();
517
+ r2.scrollToOffset(100, 100);
518
+ await nextTick();
519
+ expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
520
+ left: 100,
521
+ }));
522
+ });
523
+
524
+ it('should handle scrollToOffset with window fallback when container is missing', async () => {
525
+ const { result } = setup({ ...defaultProps, container: undefined });
526
+ await nextTick();
527
+ result.scrollToOffset(100, 200);
528
+ await nextTick();
529
+ expect(window.scrollTo).toHaveBeenCalled();
530
+ });
531
+
469
532
  it('should handle scrollToIndex with null indices', async () => {
470
533
  const { result } = setup({ ...defaultProps });
471
534
  result.scrollToIndex(null, null);
@@ -580,57 +643,54 @@ describe('useVirtualScroll', () => {
580
643
  expect(container.scrollTop).toBe(400);
581
644
  });
582
645
 
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
- });
646
+ it('should stop programmatic scroll', async () => {
647
+ const { result } = setup(defaultProps);
648
+ result.scrollToIndex(10, null, { behavior: 'smooth' });
649
+ expect(result.scrollDetails.value.isProgrammaticScroll).toBe(true);
588
650
 
589
- it('should cover scrollToIndex row >= length branch', async () => {
590
- const { result } = setup({ ...defaultProps });
591
- result.scrollToIndex(200, null);
592
- await nextTick();
651
+ result.stopProgrammaticScroll();
652
+ expect(result.scrollDetails.value.isProgrammaticScroll).toBe(false);
593
653
  });
594
654
 
595
- it('should handle scrollToIndex horizontal alignment branches', async () => {
655
+ it('should handle scrollToIndex with element container having scrollTo', async () => {
596
656
  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 });
657
+ container.scrollTo = vi.fn();
658
+ const { result } = setup({ ...defaultProps, container });
604
659
  await nextTick();
605
660
 
606
- // targetX = 5 * 50 = 250. relativeScrollX = 1000. paddingStart = 100.
607
- // targetX < relativeScrollX + paddingStart (250 < 1100)
608
- result.scrollToIndex(null, 5, 'auto');
661
+ result.scrollToIndex(10, 0, { behavior: 'auto' });
609
662
  await nextTick();
610
- expect(container.scrollLeft).toBeLessThan(1000);
663
+ expect(container.scrollTo).toHaveBeenCalled();
664
+ });
611
665
 
612
- // End alignment
613
- result.scrollToIndex(null, 5, 'end');
666
+ it('should handle scrollToIndex fallback when scrollTo is missing', async () => {
667
+ const container = document.createElement('div');
668
+ (container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
669
+ const { result } = setup({ ...defaultProps, container });
614
670
  await nextTick();
615
671
 
616
- // Center alignment
617
- result.scrollToIndex(null, 5, 'center');
672
+ // row only
673
+ result.scrollToIndex(10, null, { behavior: 'auto' });
618
674
  await nextTick();
619
- });
675
+ expect(container.scrollTop).toBeGreaterThan(0);
620
676
 
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 });
677
+ // col only
678
+ const { result: resH } = setup({ ...defaultProps, container, direction: 'horizontal' });
679
+ await nextTick();
680
+ resH.scrollToIndex(null, 10, { behavior: 'auto' });
623
681
  await nextTick();
624
- // Y padding should be 10, X padding should be 0
682
+ expect(container.scrollLeft).toBeGreaterThan(0);
625
683
  });
626
684
 
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);
685
+ it('should skip undefined items in renderedItems', async () => {
686
+ const items = Array.from({ length: 10 }) as unknown[];
687
+ items[ 0 ] = { id: 0 };
688
+ // other indices are undefined
689
+ const { result } = setup({ ...defaultProps, items, itemSize: 50 });
690
+ await nextTick();
691
+ // only index 0 should be rendered
692
+ expect(result.renderedItems.value.length).toBe(1);
693
+ expect(result.renderedItems.value[ 0 ]?.index).toBe(0);
634
694
  });
635
695
  });
636
696
 
@@ -643,6 +703,72 @@ describe('useVirtualScroll', () => {
643
703
  await nextTick();
644
704
  });
645
705
 
706
+ it('should cover fallback branches for unknown targets and directions', async () => {
707
+ // 1. Unknown container type (hits 408, 445, 513, 718 else branches)
708
+ const unknownContainer = {
709
+ addEventListener: vi.fn(),
710
+ removeEventListener: vi.fn(),
711
+ } as unknown as HTMLElement;
712
+
713
+ const { result } = setup({
714
+ ...defaultProps,
715
+ container: unknownContainer,
716
+ hostElement: document.createElement('div'),
717
+ });
718
+ await nextTick();
719
+
720
+ result.scrollToIndex(10, 0);
721
+ result.scrollToOffset(100, 100);
722
+ result.updateHostOffset();
723
+
724
+ // 2. Invalid direction (hits 958 else branch)
725
+ const { result: r2 } = setup({
726
+ ...defaultProps,
727
+ direction: undefined as unknown as 'vertical',
728
+ stickyIndices: [ 0 ],
729
+ });
730
+ await nextTick();
731
+ window.dispatchEvent(new Event('scroll'));
732
+ await nextTick();
733
+ expect(r2.renderedItems.value.find((i) => i.index === 0)).toBeDefined();
734
+
735
+ // 3. Unknown target in handleScroll (hits 1100 else branch)
736
+ const container = document.createElement('div');
737
+ setup({ ...defaultProps, container });
738
+ const event = new Event('scroll');
739
+ Object.defineProperty(event, 'target', { value: { } });
740
+ container.dispatchEvent(event);
741
+ });
742
+
743
+ it('should cleanup events and observers when container changes', async () => {
744
+ const container = document.createElement('div');
745
+ const removeSpy = vi.spyOn(container, 'removeEventListener');
746
+ const { props } = setup({ ...defaultProps, container });
747
+ await nextTick();
748
+
749
+ // Change container to trigger cleanup of old one
750
+ props.value.container = document.createElement('div');
751
+ await nextTick();
752
+
753
+ expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
754
+ });
755
+
756
+ it('should cleanup when unmounted and container is window', async () => {
757
+ const { wrapper } = setup({ ...defaultProps, container: window });
758
+ await nextTick();
759
+ wrapper.unmount();
760
+ });
761
+
762
+ it('should cleanup when unmounted', async () => {
763
+ const container = document.createElement('div');
764
+ const removeSpy = vi.spyOn(container, 'removeEventListener');
765
+ const { wrapper } = setup({ ...defaultProps, container });
766
+ await nextTick();
767
+
768
+ wrapper.unmount();
769
+ expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
770
+ });
771
+
646
772
  it('should handle document scroll events', async () => {
647
773
  setup({ ...defaultProps });
648
774
  document.dispatchEvent(new Event('scroll'));
@@ -698,7 +824,7 @@ describe('useVirtualScroll', () => {
698
824
 
699
825
  it('should handle window resize events', async () => {
700
826
  setup({ ...defaultProps, container: window });
701
- window.innerWidth = 1200;
827
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 });
702
828
  window.dispatchEvent(new Event('resize'));
703
829
  await nextTick();
704
830
  });
@@ -744,7 +870,7 @@ describe('useVirtualScroll', () => {
744
870
  columnCount: 2,
745
871
  columnWidth: [ 0 ] as unknown as number[],
746
872
  });
747
- expect(result.getColumnWidth(0)).toBe(150); // DEFAULT_COLUMN_WIDTH
873
+ expect(result.getColumnWidth(0)).toBe(100); // DEFAULT_COLUMN_WIDTH
748
874
  });
749
875
 
750
876
  it('should handle columnWidth as a function', async () => {
@@ -755,7 +881,7 @@ describe('useVirtualScroll', () => {
755
881
  columnWidth: (index: number) => (index % 2 === 0 ? 100 : 200),
756
882
  });
757
883
  expect(result.getColumnWidth(0)).toBe(100);
758
- expect(result.totalWidth.value).toBe(1500);
884
+ expect(result.totalWidth.value).toBe(1500); // 5*100 + 5*200 - 0
759
885
  });
760
886
 
761
887
  it('should handle getColumnWidth fallback when dynamic', async () => {
@@ -765,7 +891,7 @@ describe('useVirtualScroll', () => {
765
891
  columnCount: 2,
766
892
  columnWidth: undefined,
767
893
  });
768
- expect(result.getColumnWidth(0)).toBe(150);
894
+ expect(result.getColumnWidth(0)).toBe(100);
769
895
  });
770
896
 
771
897
  it('should handle columnRange while loop coverage', async () => {
@@ -803,71 +929,43 @@ describe('useVirtualScroll', () => {
803
929
  });
804
930
  });
805
931
 
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 });
810
- 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);
932
+ describe('sticky and pushed items', () => {
933
+ it('should identify sticky items', async () => {
934
+ const { result } = setup({ ...defaultProps, stickyIndices: [ 0, 10 ] });
815
935
  await nextTick();
816
- expect(result.scrollDetails.value.isScrolling).toBe(false);
817
- vi.useRealTimers();
818
- });
819
936
 
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();
937
+ const items = result.renderedItems.value;
938
+ const item0 = items.find((i) => i.index === 0);
939
+ const item10 = items.find((i) => i.index === 10);
940
+ expect(item0?.isSticky).toBe(true);
941
+ expect(item10?.isSticky).toBe(true);
827
942
  });
828
943
 
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;
944
+ it('should make sticky items active when scrolled past', async () => {
945
+ const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ] });
833
946
  await nextTick();
834
- result.updateHostOffset();
835
- });
836
947
 
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 });
948
+ result.scrollToOffset(0, 100);
840
949
  await nextTick();
841
- result.updateHostOffset();
842
- });
843
-
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
950
 
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();
951
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
952
+ expect(item0?.isStickyActive).toBe(true);
856
953
  });
857
954
 
858
- it('should cover refresh method', async () => {
859
- const { result } = setup({ ...defaultProps, itemSize: 0 });
860
- result.updateItemSize(0, 100, 100);
955
+ it('should include current sticky item in rendered items even if range is ahead', async () => {
956
+ const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ], bufferBefore: 0 });
861
957
  await nextTick();
862
- expect(result.totalHeight.value).toBe(5050);
863
958
 
864
- result.refresh();
959
+ // Scroll to index 20. Range starts at 20.
960
+ result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
865
961
  await nextTick();
866
- expect(result.totalHeight.value).toBe(5000);
962
+
963
+ expect(result.scrollDetails.value.range.start).toBe(20);
964
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
965
+ expect(item0).toBeDefined();
966
+ expect(item0?.isStickyActive).toBe(true);
867
967
  });
868
- });
869
968
 
870
- describe('sticky header pushing', () => {
871
969
  it('should push sticky item when next sticky item approaches (vertical)', async () => {
872
970
  const container = document.createElement('div');
873
971
  Object.defineProperty(container, 'clientHeight', { value: 500 });
@@ -875,7 +973,6 @@ describe('useVirtualScroll', () => {
875
973
  const { result } = setup({ ...defaultProps, container, stickyIndices: [ 0, 10 ], itemSize: 50 });
876
974
  // We need to trigger scroll to update scrollY
877
975
  container.dispatchEvent(new Event('scroll'));
878
- await nextTick();
879
976
 
880
977
  const item0 = result.renderedItems.value.find((i) => i.index === 0);
881
978
  expect(item0!.offset.y).toBeLessThanOrEqual(450);
@@ -895,38 +992,79 @@ describe('useVirtualScroll', () => {
895
992
  columnGap: 0,
896
993
  });
897
994
  container.dispatchEvent(new Event('scroll'));
898
- await nextTick();
899
995
 
900
996
  const item0 = result.renderedItems.value.find((i) => i.index === 0);
901
997
  expect(item0!.offset.x).toBeLessThanOrEqual(450);
902
998
  });
903
- });
904
999
 
905
- describe('scroll restoration', () => {
906
- it('should restore scroll position when items are prepended', async () => {
907
- vi.useFakeTimers();
1000
+ it('should handle dynamic sticky item pushing in vertical mode', async () => {
908
1001
  const container = document.createElement('div');
909
1002
  Object.defineProperty(container, 'clientHeight', { value: 500 });
910
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
911
- container.scrollTo = vi.fn().mockImplementation((options) => {
912
- container.scrollTop = options.top;
913
- });
1003
+ Object.defineProperty(container, 'scrollTop', { value: 380, writable: true });
914
1004
 
915
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
916
- const { result, props } = setup({
1005
+ const { result } = setup({
917
1006
  ...defaultProps,
918
- items,
919
1007
  container,
920
- itemSize: 50,
921
- restoreScrollOnPrepend: true,
1008
+ itemSize: undefined, // dynamic
1009
+ stickyIndices: [ 0, 10 ],
922
1010
  });
923
- container.dispatchEvent(new Event('scroll'));
924
- await nextTick();
925
1011
 
926
- expect(result.scrollDetails.value.scrollOffset.y).toBe(100);
1012
+ // Item 0 is sticky. Item 10 is next sticky.
1013
+ // Default size = 40.
1014
+ // nextStickyY = itemSizesY.query(10) = 400.
1015
+ // distance = 400 - 380 = 20.
1016
+ // 20 < 40 (item 0 height), so it should be pushed.
1017
+ // stickyOffset.y = -(40 - 20) = -20.
1018
+ const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1019
+ expect(stickyItem?.stickyOffset.y).toBe(-20);
1020
+ });
927
1021
 
928
- // Prepend 2 items
929
- const newItems = [ { id: -1 }, { id: -2 }, ...items ];
1022
+ it('should handle dynamic sticky item pushing in horizontal mode', async () => {
1023
+ const container = document.createElement('div');
1024
+ Object.defineProperty(container, 'clientWidth', { value: 500 });
1025
+ Object.defineProperty(container, 'scrollLeft', { value: 380, writable: true });
1026
+
1027
+ const { result } = setup({
1028
+ ...defaultProps,
1029
+ container,
1030
+ direction: 'horizontal',
1031
+ itemSize: undefined, // dynamic
1032
+ stickyIndices: [ 0, 10 ],
1033
+ });
1034
+
1035
+ // nextStickyX = itemSizesX.query(10) = 400.
1036
+ // distance = 400 - 380 = 20.
1037
+ // 20 < 40, so stickyOffset.x = -20.
1038
+ const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1039
+ expect(stickyItem?.stickyOffset.x).toBe(-20);
1040
+ });
1041
+ });
1042
+
1043
+ describe('scroll restoration', () => {
1044
+ it('should restore scroll position when items are prepended', async () => {
1045
+ vi.useFakeTimers();
1046
+ const container = document.createElement('div');
1047
+ Object.defineProperty(container, 'clientHeight', { value: 500 });
1048
+ Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1049
+ container.scrollTo = vi.fn().mockImplementation((options) => {
1050
+ container.scrollTop = options.top;
1051
+ });
1052
+
1053
+ const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1054
+ const { result, props } = setup({
1055
+ ...defaultProps,
1056
+ items,
1057
+ container,
1058
+ itemSize: 50,
1059
+ restoreScrollOnPrepend: true,
1060
+ });
1061
+ container.dispatchEvent(new Event('scroll'));
1062
+ await nextTick();
1063
+
1064
+ expect(result.scrollDetails.value.scrollOffset.y).toBe(100);
1065
+
1066
+ // Prepend 2 items
1067
+ const newItems = [ { id: -1 }, { id: -2 }, ...items ];
930
1068
  props.value.items = newItems;
931
1069
  await nextTick();
932
1070
  // Trigger initializeSizes
@@ -970,6 +1108,35 @@ describe('useVirtualScroll', () => {
970
1108
  vi.useRealTimers();
971
1109
  });
972
1110
 
1111
+ it('should restore scroll position with itemSize as function when prepending', async () => {
1112
+ vi.useFakeTimers();
1113
+ const container = document.createElement('div');
1114
+ Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1115
+ container.scrollTo = vi.fn().mockImplementation((options) => {
1116
+ container.scrollTop = options.top;
1117
+ });
1118
+
1119
+ const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1120
+ const { props } = setup({
1121
+ ...defaultProps,
1122
+ items,
1123
+ container,
1124
+ itemSize: (item: { id: number; }) => (item.id < 0 ? 100 : 50),
1125
+ restoreScrollOnPrepend: true,
1126
+ });
1127
+ await nextTick();
1128
+
1129
+ // Prepend 1 item with id -1 (size 100)
1130
+ const newItems = [ { id: -1 }, ...items ];
1131
+ props.value.items = newItems;
1132
+ await nextTick();
1133
+ await nextTick();
1134
+
1135
+ // Should have adjusted scroll by 100px. New scrollTop should be 200.
1136
+ expect(container.scrollTop).toBe(200);
1137
+ vi.useRealTimers();
1138
+ });
1139
+
973
1140
  it('should NOT restore scroll position when restoreScrollOnPrepend is false', async () => {
974
1141
  const container = document.createElement('div');
975
1142
  Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
@@ -991,7 +1158,7 @@ describe('useVirtualScroll', () => {
991
1158
  const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: true });
992
1159
  await nextTick();
993
1160
 
994
- const newItems = [ { id: -1 }, { id: 9999 } ]; // completely different
1161
+ const newItems = [ { id: -1 }, { id: 9999 } ];
995
1162
  props.value.items = newItems;
996
1163
  await nextTick();
997
1164
  await nextTick();
@@ -1003,18 +1170,96 @@ describe('useVirtualScroll', () => {
1003
1170
  Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1004
1171
  Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1005
1172
  const { result, props } = setup({ ...defaultProps, container, restoreScrollOnPrepend: true });
1006
- result.scrollToIndex(10, null, { behavior: 'smooth' });
1007
- // pendingScroll should be set because it's not reached yet
1173
+ await nextTick();
1008
1174
 
1175
+ result.scrollToIndex(10, null, { behavior: 'smooth' });
1009
1176
  props.value.items = [ { id: -1 }, ...props.value.items ];
1010
1177
  await nextTick();
1011
1178
  });
1179
+ });
1012
1180
 
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 } ]);
1181
+ describe('advanced logic and edge cases', () => {
1182
+ it('should trigger scroll correction when isScrolling becomes false', async () => {
1183
+ vi.useFakeTimers();
1184
+ const { result } = setup({ ...defaultProps, container: window, itemSize: undefined });
1185
+ await nextTick();
1186
+ result.scrollToIndex(10, 0, 'start');
1187
+ document.dispatchEvent(new Event('scroll'));
1188
+ expect(result.scrollDetails.value.isScrolling).toBe(true);
1189
+ vi.advanceTimersByTime(250);
1190
+ await nextTick();
1191
+ expect(result.scrollDetails.value.isScrolling).toBe(false);
1192
+ vi.useRealTimers();
1193
+ });
1194
+
1195
+ it('should trigger scroll correction when treeUpdateFlag changes', async () => {
1196
+ const { result } = setup({ ...defaultProps, itemSize: undefined });
1197
+ await nextTick();
1198
+ result.scrollToIndex(10, 0, 'start');
1199
+ // Trigger tree update
1200
+ result.updateItemSize(5, 100, 100);
1201
+ await nextTick();
1202
+ });
1203
+
1204
+ it('should cover updateHostOffset when container is window', async () => {
1205
+ const { result, props } = setup({ ...defaultProps, container: window });
1206
+ const host = document.createElement('div');
1207
+ props.value.hostElement = host;
1208
+ await nextTick();
1209
+ result.updateHostOffset();
1210
+ });
1211
+
1212
+ it('should cover updateHostOffset when container is hostElement', async () => {
1213
+ const host = document.createElement('div');
1214
+ const { result } = setup({ ...defaultProps, container: host, hostElement: host });
1215
+ await nextTick();
1216
+ result.updateHostOffset();
1217
+ });
1218
+
1219
+ it('should handle updateHostOffset with window fallback when container is missing', async () => {
1220
+ const { result, props } = setup({ ...defaultProps, container: undefined });
1221
+ const host = document.createElement('div');
1222
+ props.value.hostElement = host;
1223
+ await nextTick();
1224
+ result.updateHostOffset();
1225
+ });
1226
+
1227
+ it('should correctly calculate hostOffset when container is an HTMLElement', async () => {
1228
+ const container = document.createElement('div');
1229
+ const hostElement = document.createElement('div');
1230
+
1231
+ container.getBoundingClientRect = vi.fn(() => ({ top: 100, left: 100, bottom: 200, right: 200, width: 100, height: 100, x: 100, y: 100, toJSON: () => '' }));
1232
+ hostElement.getBoundingClientRect = vi.fn(() => ({ top: 150, left: 150, bottom: 200, right: 200, width: 50, height: 50, x: 150, y: 150, toJSON: () => '' }));
1233
+ Object.defineProperty(container, 'scrollTop', { value: 50, writable: true, configurable: true });
1234
+
1235
+ const { result } = setup({ ...defaultProps, container, hostElement });
1236
+ await nextTick();
1237
+ result.updateHostOffset();
1238
+ expect(result.scrollDetails.value.scrollOffset.y).toBeDefined();
1239
+ });
1240
+
1241
+ it('should cover refresh method', async () => {
1242
+ const { result } = setup({ ...defaultProps, itemSize: 0 });
1243
+ result.updateItemSize(0, 100, 100);
1244
+ await nextTick();
1245
+ expect(result.totalHeight.value).toBe(4060);
1246
+
1247
+ result.refresh();
1248
+ await nextTick();
1249
+ expect(result.totalHeight.value).toBe(4000);
1250
+ });
1251
+
1252
+ it('should trigger scroll correction on tree update with string alignment', async () => {
1253
+ const container = document.createElement('div');
1254
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1255
+ Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1256
+ const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1257
+ // Set a pending scroll with string alignment
1258
+ result.scrollToIndex(10, null, 'start');
1259
+
1260
+ // Trigger tree update
1261
+ result.updateItemSize(0, 100, 100);
1016
1262
  await nextTick();
1017
- expect(result.totalWidth.value).toBe(5050);
1018
1263
  });
1019
1264
 
1020
1265
  it('should trigger scroll correction on tree update with pending scroll', async () => {
@@ -1049,10 +1294,168 @@ describe('useVirtualScroll', () => {
1049
1294
  expect(result.scrollDetails.value.isScrolling).toBe(false);
1050
1295
  vi.useRealTimers();
1051
1296
  });
1297
+
1298
+ it('should update totals when function-based itemSize dependencies change and refresh is called', async () => {
1299
+ const defaultHeight = ref(50);
1300
+ const getRowHeight = () => defaultHeight.value;
1301
+
1302
+ const propsValue = ref({
1303
+ items: mockItems,
1304
+ direction: 'vertical' as const,
1305
+ itemSize: getRowHeight,
1306
+ }) as Ref<VirtualScrollProps<unknown>>;
1307
+
1308
+ const result = useVirtualScroll(propsValue);
1309
+ expect(result.totalHeight.value).toBe(5000); // 100 * 50
1310
+
1311
+ defaultHeight.value = 60;
1312
+ // Total height should still be 5000 because getRowHeight reference didn't change
1313
+ // and initializeSizes hasn't been called automatically.
1314
+ expect(result.totalHeight.value).toBe(5000);
1315
+
1316
+ result.refresh();
1317
+ await nextTick();
1318
+ expect(result.totalHeight.value).toBe(6000);
1319
+ });
1320
+
1321
+ it('should update totals via measurements even if itemSize is a function', async () => {
1322
+ const getRowHeight = () => 50;
1323
+
1324
+ const propsValue = ref({
1325
+ items: mockItems,
1326
+ direction: 'vertical' as const,
1327
+ itemSize: getRowHeight,
1328
+ }) as Ref<VirtualScrollProps<unknown>>;
1329
+
1330
+ const result = useVirtualScroll(propsValue);
1331
+ expect(result.totalHeight.value).toBe(5000);
1332
+
1333
+ // Simulate ResizeObserver measurement
1334
+ result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 70 } ]);
1335
+ await nextTick();
1336
+
1337
+ // Item 0 is now 70 instead of 50. Total: 50 * 99 + 70 = 4950 + 70 = 5020.
1338
+ expect(result.totalHeight.value).toBe(5020);
1339
+ });
1340
+
1341
+ it('should update totals via measurements even if columnWidth is a function', async () => {
1342
+ const getColWidth = () => 100;
1343
+
1344
+ const propsValue = ref({
1345
+ items: mockItems,
1346
+ direction: 'both' as const,
1347
+ columnCount: 5,
1348
+ columnWidth: getColWidth,
1349
+ itemSize: 50,
1350
+ }) as Ref<VirtualScrollProps<unknown>>;
1351
+
1352
+ const result = useVirtualScroll(propsValue);
1353
+ expect(result.totalWidth.value).toBe(500);
1354
+
1355
+ // Simulate ResizeObserver measurement on a cell
1356
+ // We need to provide an element with data-col-index
1357
+ const element = document.createElement('div');
1358
+ const cell = document.createElement('div');
1359
+ cell.dataset.colIndex = '0';
1360
+ Object.defineProperty(cell, 'offsetWidth', { value: 120 });
1361
+ element.appendChild(cell);
1362
+
1363
+ result.updateItemSizes([ { index: 0, inlineSize: 120, blockSize: 50, element } ]);
1364
+ await nextTick();
1365
+
1366
+ // Column 0 is now 120 instead of 100. Total: 100 * 4 + 120 = 520.
1367
+ expect(result.totalWidth.value).toBe(520);
1368
+ });
1052
1369
  });
1053
1370
 
1054
1371
  // eslint-disable-next-line test/prefer-lowercase-title
1055
1372
  describe('SSR support', () => {
1373
+ it('should handle colBuffer when ssrRange is present and not scrolling', async () => {
1374
+ vi.useFakeTimers();
1375
+ const container = document.createElement('div');
1376
+ Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
1377
+ Object.defineProperty(container, 'scrollLeft', { value: 0, writable: true, configurable: true });
1378
+ container.scrollTo = vi.fn().mockImplementation((options) => {
1379
+ if (options.left !== undefined) {
1380
+ Object.defineProperty(container, 'scrollLeft', { value: options.left, writable: true, configurable: true });
1381
+ }
1382
+ });
1383
+
1384
+ const { result } = setup({
1385
+ ...defaultProps,
1386
+ container,
1387
+ direction: 'both',
1388
+ columnCount: 20,
1389
+ columnWidth: 100,
1390
+ ssrRange: { start: 0, end: 10, colStart: 1, colEnd: 2 },
1391
+ initialScrollIndex: 0,
1392
+ });
1393
+
1394
+ await nextTick(); // onMounted schedules hydration
1395
+ await nextTick(); // hydration tick 1
1396
+ await nextTick(); // hydration tick 2 (isHydrating = false)
1397
+
1398
+ expect(result.isHydrated.value).toBe(true);
1399
+
1400
+ // Scroll to col 5 (offset 500)
1401
+ result.scrollToIndex(null, 5, { align: 'start', behavior: 'auto' });
1402
+ await nextTick();
1403
+
1404
+ vi.runAllTimers(); // Clear isScrolling timeout
1405
+ await nextTick();
1406
+
1407
+ // start = findLowerBound(500) = 5.
1408
+ // colBuffer should be 0 because ssrRange is present and isScrolling is false.
1409
+ expect(result.columnRange.value.start).toBe(5);
1410
+
1411
+ // Now trigger a scroll to make isScrolling true
1412
+ container.dispatchEvent(new Event('scroll'));
1413
+ await nextTick();
1414
+ // isScrolling is now true. colBuffer should be 2.
1415
+ expect(result.columnRange.value.start).toBe(3);
1416
+ vi.useRealTimers();
1417
+ });
1418
+
1419
+ it('should handle bufferBefore when ssrRange is present and not scrolling', async () => {
1420
+ vi.useFakeTimers();
1421
+ const container = document.createElement('div');
1422
+ Object.defineProperty(container, 'clientHeight', { value: 500 });
1423
+ Object.defineProperty(container, 'scrollTop', { value: 0, writable: true, configurable: true });
1424
+ container.scrollTo = vi.fn().mockImplementation((options) => {
1425
+ if (options.top !== undefined) {
1426
+ Object.defineProperty(container, 'scrollTop', { value: options.top, writable: true, configurable: true });
1427
+ }
1428
+ });
1429
+
1430
+ const { result } = setup({
1431
+ ...defaultProps,
1432
+ container,
1433
+ itemSize: 50,
1434
+ bufferBefore: 5,
1435
+ ssrRange: { start: 0, end: 10 },
1436
+ initialScrollIndex: 10,
1437
+ });
1438
+
1439
+ await nextTick(); // schedules hydration
1440
+ await nextTick(); // hydration tick scrolls to 10
1441
+ await nextTick();
1442
+
1443
+ vi.runAllTimers(); // Clear isScrolling timeout
1444
+ await nextTick();
1445
+
1446
+ expect(result.isHydrated.value).toBe(true);
1447
+ // start = floor(500 / 50) = 10.
1448
+ // Since ssrRange is present and isScrolling is false, bufferBefore should be 0.
1449
+ expect(result.renderedItems.value[ 0 ]?.index).toBe(10);
1450
+
1451
+ // Now trigger a scroll to make isScrolling true
1452
+ container.dispatchEvent(new Event('scroll'));
1453
+ await nextTick();
1454
+ // isScrolling is now true. bufferBefore should be 5.
1455
+ expect(result.renderedItems.value[ 0 ]?.index).toBe(5);
1456
+ vi.useRealTimers();
1457
+ });
1458
+
1056
1459
  it('should handle SSR range in range calculation', () => {
1057
1460
  const props = ref({
1058
1461
  items: mockItems,
@@ -1072,6 +1475,17 @@ describe('useVirtualScroll', () => {
1072
1475
  expect(result.columnRange.value.end).toBe(5);
1073
1476
  });
1074
1477
 
1478
+ it('should handle SSR range with colEnd fallback in columnRange calculation', () => {
1479
+ const props = ref({
1480
+ items: mockItems,
1481
+ columnCount: 10,
1482
+ ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 0 },
1483
+ }) as Ref<VirtualScrollProps<unknown>>;
1484
+ const result = useVirtualScroll(props);
1485
+ // colEnd is 0, so it should use columnCount (10)
1486
+ expect(result.columnRange.value.end).toBe(10);
1487
+ });
1488
+
1075
1489
  it('should handle SSR range with both directions for total sizes', () => {
1076
1490
  const props = ref({
1077
1491
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
@@ -1082,8 +1496,8 @@ describe('useVirtualScroll', () => {
1082
1496
  ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1083
1497
  }) as Ref<VirtualScrollProps<unknown>>;
1084
1498
  const result = useVirtualScroll(props);
1085
- expect(result.totalWidth.value).toBe(300); // (5-2) * 100
1086
- expect(result.totalHeight.value).toBe(500); // (20-10) * 50
1499
+ expect(result.totalWidth.value).toBe(300); // (5-2) * 100 - gap(0)
1500
+ expect(result.totalHeight.value).toBe(500); // (20-10) * 50 - gap(0)
1087
1501
  });
1088
1502
 
1089
1503
  it('should handle SSR range with horizontal direction for total sizes', () => {
@@ -1094,29 +1508,30 @@ describe('useVirtualScroll', () => {
1094
1508
  ssrRange: { start: 10, end: 20 },
1095
1509
  }) as Ref<VirtualScrollProps<unknown>>;
1096
1510
  const result = useVirtualScroll(props);
1097
- expect(result.totalWidth.value).toBe(500); // (20-10) * 50
1511
+ expect(result.totalWidth.value).toBe(500); // (20-10) * 50 - gap(0)
1098
1512
  });
1099
1513
 
1100
- it('should handle SSR range with fixed size horizontal for total sizes', () => {
1514
+ it('should handle SSR range with vertical offset in renderedItems', () => {
1101
1515
  const props = ref({
1102
1516
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1103
- direction: 'horizontal',
1517
+ direction: 'vertical',
1104
1518
  itemSize: 50,
1105
1519
  ssrRange: { start: 10, end: 20 },
1106
1520
  }) as Ref<VirtualScrollProps<unknown>>;
1107
1521
  const result = useVirtualScroll(props);
1108
- expect(result.totalWidth.value).toBe(500); // (20-10) * 50
1522
+ expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1109
1523
  });
1110
1524
 
1111
- it('should handle SSR range with vertical offset in renderedItems', () => {
1525
+ it('should handle SSR range with dynamic horizontal offsets in renderedItems', () => {
1112
1526
  const props = ref({
1113
1527
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1114
- direction: 'vertical',
1115
- itemSize: 50,
1528
+ direction: 'horizontal',
1529
+ itemSize: undefined, // dynamic
1116
1530
  ssrRange: { start: 10, end: 20 },
1117
1531
  }) as Ref<VirtualScrollProps<unknown>>;
1118
1532
  const result = useVirtualScroll(props);
1119
- expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1533
+ // ssrOffsetX = itemSizesX.query(10) = 10 * 40 = 400
1534
+ expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(400);
1120
1535
  });
1121
1536
 
1122
1537
  it('should handle SSR range with dynamic sizes for total sizes', () => {
@@ -1127,7 +1542,7 @@ describe('useVirtualScroll', () => {
1127
1542
  ssrRange: { start: 10, end: 20 },
1128
1543
  }) as Ref<VirtualScrollProps<unknown>>;
1129
1544
  const result = useVirtualScroll(props);
1130
- expect(result.totalHeight.value).toBe(500);
1545
+ expect(result.totalHeight.value).toBe(400); // (20-10) * 40 - gap(0)
1131
1546
  });
1132
1547
 
1133
1548
  it('should handle SSR range with dynamic horizontal sizes for total sizes', () => {
@@ -1138,7 +1553,7 @@ describe('useVirtualScroll', () => {
1138
1553
  ssrRange: { start: 10, end: 20 },
1139
1554
  }) as Ref<VirtualScrollProps<unknown>>;
1140
1555
  const result = useVirtualScroll(props);
1141
- expect(result.totalWidth.value).toBe(500);
1556
+ expect(result.totalWidth.value).toBe(400); // (20-10) * 40 - 0 gap
1142
1557
  });
1143
1558
 
1144
1559
  it('should handle SSR range with both directions and dynamic offsets', () => {
@@ -1150,8 +1565,8 @@ describe('useVirtualScroll', () => {
1150
1565
  ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1151
1566
  }) as Ref<VirtualScrollProps<unknown>>;
1152
1567
  const result = useVirtualScroll(props);
1153
- expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1154
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-300);
1568
+ expect(result.totalWidth.value).toBe(300); // (5-2) * 100
1569
+ expect(result.totalHeight.value).toBe(400); // (20-10) * 40
1155
1570
  });
1156
1571
 
1157
1572
  it('should scroll to ssrRange on mount', async () => {
@@ -1184,6 +1599,20 @@ describe('useVirtualScroll', () => {
1184
1599
  expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-500);
1185
1600
  });
1186
1601
 
1602
+ it('should handle SSR range with direction "both" and colEnd falsy', () => {
1603
+ const propsValue = ref({
1604
+ columnCount: 10,
1605
+ columnWidth: 100,
1606
+ direction: 'both' as const,
1607
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1608
+ ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
1609
+ }) as Ref<VirtualScrollProps<unknown>>;
1610
+ const result = useVirtualScroll(propsValue);
1611
+ // colEnd is 0, so it should use colCount (10)
1612
+ // totalWidth = columnSizes.query(10) - columnSizes.query(5) = 1000 - 500 = 500
1613
+ expect(result.totalWidth.value).toBe(500);
1614
+ });
1615
+
1187
1616
  it('should handle SSR range with colCount > 0 in totalWidth', () => {
1188
1617
  const props = ref({
1189
1618
  items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
@@ -1195,20 +1624,365 @@ describe('useVirtualScroll', () => {
1195
1624
  const result = useVirtualScroll(props);
1196
1625
  expect(result.totalWidth.value).toBe(500);
1197
1626
  });
1627
+ });
1198
1628
 
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);
1629
+ describe('helpers', () => {
1630
+ it('should handle zero column count in totalWidth', async () => {
1631
+ const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 0 });
1632
+ expect(result.totalWidth.value).toBe(0);
1633
+ });
1634
+
1635
+ it('should handle vertical direction in totalWidth', () => {
1636
+ const { result } = setup({ ...defaultProps, direction: 'vertical' });
1637
+ expect(result.totalWidth.value).toBe(0);
1638
+ });
1639
+
1640
+ it('should handle horizontal direction in totalHeight', () => {
1641
+ const { result } = setup({ ...defaultProps, direction: 'horizontal' });
1642
+ expect(result.totalHeight.value).toBe(0);
1643
+ });
1644
+
1645
+ it('should handle zero items in totalWidth/totalHeight', async () => {
1646
+ const { result } = setup({ ...defaultProps, items: [] });
1647
+ expect(result.totalHeight.value).toBe(0);
1648
+
1649
+ const { result: rH } = setup({ ...defaultProps, direction: 'horizontal', items: [] });
1650
+ expect(rH.totalWidth.value).toBe(0);
1651
+ });
1652
+
1653
+ it('should cover SSR with zero items/columns', () => {
1654
+ const props = ref({
1655
+ items: [],
1656
+ direction: 'both',
1657
+ columnCount: 0,
1658
+ ssrRange: { start: 0, end: 0, colStart: 0, colEnd: 0 },
1659
+ }) as Ref<VirtualScrollProps<unknown>>;
1660
+ const result = useVirtualScroll(props);
1661
+ expect(result.totalWidth.value).toBe(0);
1662
+ expect(result.totalHeight.value).toBe(0);
1663
+ });
1664
+
1665
+ it('should handle SSR range with both directions and no columns', () => {
1666
+ const props = ref({
1667
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1668
+ direction: 'both',
1669
+ columnCount: 0,
1670
+ ssrRange: { start: 10, end: 20, colStart: 0, colEnd: 0 },
1671
+ }) as Ref<VirtualScrollProps<unknown>>;
1672
+ const result = useVirtualScroll(props);
1673
+ expect(result.totalWidth.value).toBe(0);
1674
+ });
1675
+
1676
+ it('should handle SSR range with direction both and colCount > 0 but colEnd <= colStart', () => {
1677
+ const props = ref({
1678
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1679
+ direction: 'both',
1680
+ columnCount: 10,
1681
+ ssrRange: { start: 0, end: 10, colStart: 5, colEnd: 5 },
1682
+ }) as Ref<VirtualScrollProps<unknown>>;
1683
+ const result = useVirtualScroll(props);
1684
+ expect(result.totalWidth.value).toBe(0);
1685
+ });
1686
+
1687
+ it('should handle SSR range with vertical/both and end <= start', () => {
1688
+ const props = ref({
1689
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1690
+ direction: 'vertical',
1691
+ ssrRange: { start: 10, end: 10 },
1692
+ }) as Ref<VirtualScrollProps<unknown>>;
1693
+ const result = useVirtualScroll(props);
1694
+ expect(result.totalHeight.value).toBe(0);
1695
+ });
1696
+
1697
+ it('should handle SSR range with dynamic horizontal sizes for total sizes', () => {
1698
+ const props = ref({
1699
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1700
+ direction: 'horizontal',
1701
+ itemSize: 0,
1702
+ ssrRange: { start: 10, end: 20 },
1703
+ }) as Ref<VirtualScrollProps<unknown>>;
1704
+ const result = useVirtualScroll(props);
1705
+ expect(result.totalWidth.value).toBe(400); // (20-10) * 40
1706
+ });
1707
+
1708
+ it('should handle SSR range with both directions and dynamic offsets for total width', () => {
1709
+ const props = ref({
1710
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1711
+ direction: 'both',
1712
+ columnCount: 10,
1713
+ itemSize: 0,
1714
+ ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1715
+ }) as Ref<VirtualScrollProps<unknown>>;
1716
+ const result = useVirtualScroll(props);
1717
+ expect(result.totalWidth.value).toBe(300);
1718
+ });
1719
+
1720
+ it('should handle updateItemSizes with index < 0', async () => {
1721
+ const { result } = setup({ ...defaultProps, itemSize: undefined });
1722
+ result.updateItemSizes([ { index: -1, inlineSize: 100, blockSize: 100 } ]);
1204
1723
  await nextTick();
1205
- // prevStickyIdx will be 1000, which is out of bounds
1206
- expect(result.renderedItems.value.length).toBe(0);
1724
+ // Should not change total height
1725
+ expect(result.totalHeight.value).toBe(4000);
1207
1726
  });
1208
1727
 
1209
- it('should cover object padding branches in helpers', () => {
1728
+ it('should handle updateItemSizes with direction vertical and dynamic itemSize for X', async () => {
1729
+ const { result } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
1730
+ // Measured Items X should not be updated if direction is vertical
1731
+ result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 100 } ]);
1732
+ await nextTick();
1733
+ expect(result.totalWidth.value).toBe(0);
1734
+ });
1735
+
1736
+ it('should handle SSR with horizontal direction and fixedItemSize', () => {
1737
+ const propsValue = ref({
1738
+ direction: 'horizontal' as const,
1739
+ itemSize: 50,
1740
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1741
+ ssrRange: { end: 20, start: 10 },
1742
+ }) as Ref<VirtualScrollProps<unknown>>;
1743
+ const result = useVirtualScroll(propsValue);
1744
+ expect(result.totalWidth.value).toBe(500); // (20-10) * 50 - 0 gap
1745
+ });
1746
+
1747
+ it('should handle SSR with vertical direction and fixedItemSize', () => {
1748
+ const propsValue = ref({
1749
+ direction: 'vertical' as const,
1750
+ itemSize: 50,
1751
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1752
+ ssrRange: { end: 20, start: 10 },
1753
+ }) as Ref<VirtualScrollProps<unknown>>;
1754
+ const result = useVirtualScroll(propsValue);
1755
+ expect(result.totalHeight.value).toBe(500); // (20-10) * 50 - 0 gap
1756
+ });
1757
+
1758
+ it('should handle SSR with direction both and fixedItemSize for totalHeight', () => {
1759
+ const propsValue = ref({
1760
+ direction: 'both' as const,
1761
+ columnCount: 10,
1762
+ itemSize: 50,
1763
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1764
+ ssrRange: { end: 20, start: 10 },
1765
+ }) as Ref<VirtualScrollProps<unknown>>;
1766
+ const result = useVirtualScroll(propsValue);
1767
+ expect(result.totalHeight.value).toBe(500);
1768
+ });
1769
+
1770
+ it('should handle SSR range with direction both and colEnd falsy', () => {
1771
+ const propsValue = ref({
1772
+ columnCount: 10,
1773
+ columnWidth: 100,
1774
+ direction: 'both' as const,
1775
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1776
+ ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
1777
+ }) as Ref<VirtualScrollProps<unknown>>;
1778
+ const result = useVirtualScroll(propsValue);
1779
+ // colEnd is 0, so it should use colCount (10)
1780
+ // totalWidth = (10 - 5) * 100 = 500
1781
+ expect(result.totalWidth.value).toBe(500);
1782
+ });
1783
+
1784
+ it('should handle updateItemSizes with direction both and dynamic itemSize for Y', async () => {
1785
+ const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 2, itemSize: undefined });
1786
+ // First measurement
1787
+ result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 100 } ]);
1788
+ await nextTick();
1789
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
1790
+
1791
+ // Increase
1792
+ result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 120 } ]);
1793
+ await nextTick();
1794
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(120);
1795
+
1796
+ // Significant decrease
1797
+ result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 100 } ]);
1798
+ await nextTick();
1799
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
1800
+ });
1801
+
1802
+ it('should handle object padding branches in helpers', () => {
1210
1803
  expect(getPaddingX({ x: 10 }, 'horizontal')).toBe(10);
1211
1804
  expect(getPaddingY({ y: 20 }, 'vertical')).toBe(20);
1212
1805
  });
1806
+
1807
+ it('should cover totalWidth SSR len <= 0', () => {
1808
+ const propsValue = ref({
1809
+ items: mockItems,
1810
+ direction: 'horizontal' as const,
1811
+ itemSize: 50,
1812
+ ssrRange: { start: 10, end: 10 },
1813
+ }) as Ref<VirtualScrollProps<unknown>>;
1814
+ const result = useVirtualScroll(propsValue);
1815
+ expect(result.totalWidth.value).toBe(0);
1816
+ });
1817
+
1818
+ it('should cover totalWidth SSR end <= start for dynamic sizes', () => {
1819
+ const propsValue = ref({
1820
+ items: mockItems,
1821
+ direction: 'horizontal' as const,
1822
+ itemSize: undefined,
1823
+ ssrRange: { start: 10, end: 10 },
1824
+ }) as Ref<VirtualScrollProps<unknown>>;
1825
+ const result = useVirtualScroll(propsValue);
1826
+ expect(result.totalWidth.value).toBe(0);
1827
+ });
1828
+
1829
+ it('should cover totalWidth non-SSR items.length <= 0 for dynamic sizes', () => {
1830
+ const propsValue = ref({
1831
+ items: [],
1832
+ direction: 'horizontal' as const,
1833
+ itemSize: undefined,
1834
+ }) as Ref<VirtualScrollProps<unknown>>;
1835
+ const result = useVirtualScroll(propsValue);
1836
+ expect(result.totalWidth.value).toBe(0);
1837
+ });
1838
+
1839
+ it('should cover totalHeight SSR len <= 0', () => {
1840
+ const propsValue = ref({
1841
+ items: mockItems,
1842
+ direction: 'vertical' as const,
1843
+ itemSize: 50,
1844
+ ssrRange: { start: 10, end: 10 },
1845
+ }) as Ref<VirtualScrollProps<unknown>>;
1846
+ const result = useVirtualScroll(propsValue);
1847
+ expect(result.totalHeight.value).toBe(0);
1848
+ });
1849
+
1850
+ it('should cover totalHeight SSR end <= start for dynamic sizes', () => {
1851
+ const propsValue = ref({
1852
+ items: mockItems,
1853
+ direction: 'vertical' as const,
1854
+ itemSize: undefined,
1855
+ ssrRange: { start: 10, end: 10 },
1856
+ }) as Ref<VirtualScrollProps<unknown>>;
1857
+ const result = useVirtualScroll(propsValue);
1858
+ expect(result.totalHeight.value).toBe(0);
1859
+ });
1860
+
1861
+ it('should cover totalHeight non-SSR items.length <= 0 for dynamic sizes', () => {
1862
+ const propsValue = ref({
1863
+ items: [],
1864
+ direction: 'vertical' as const,
1865
+ itemSize: undefined,
1866
+ }) as Ref<VirtualScrollProps<unknown>>;
1867
+ const result = useVirtualScroll(propsValue);
1868
+ expect(result.totalHeight.value).toBe(0);
1869
+ });
1870
+
1871
+ describe('internal sizing stabilization and edge cases', () => {
1872
+ const mockItems = Array.from({ length: 10 }, (_, i) => ({ id: i }));
1873
+
1874
+ it('should skip re-initializing sizes for already measured dynamic items', async () => {
1875
+ const props = ref({
1876
+ items: mockItems,
1877
+ direction: 'both' as const,
1878
+ columnCount: 2,
1879
+ }) as Ref<VirtualScrollProps<{ id: number; }>>;
1880
+
1881
+ const result = useVirtualScroll(props);
1882
+ await nextTick();
1883
+
1884
+ // Measure item 0 and col 0
1885
+ const parent = document.createElement('div');
1886
+ const col0 = document.createElement('div');
1887
+ col0.dataset.colIndex = '0';
1888
+ Object.defineProperty(col0, 'offsetWidth', { value: 200 });
1889
+ parent.appendChild(col0);
1890
+
1891
+ result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 150, element: parent } ]);
1892
+ await nextTick();
1893
+
1894
+ expect(result.getColumnWidth(0)).toBe(200);
1895
+ expect(result.renderedItems.value[ 0 ]?.size.height).toBe(150);
1896
+
1897
+ // Trigger initializeSizes by changing items length
1898
+ props.value.items = Array.from({ length: 11 }, (_, i) => ({ id: i }));
1899
+ await nextTick();
1900
+
1901
+ // Should NOT reset already measured item 0
1902
+ expect(result.getColumnWidth(0)).toBe(200);
1903
+ expect(result.renderedItems.value[ 0 ]?.size.height).toBe(150);
1904
+ });
1905
+
1906
+ it('should mark items as measured when fixed size matches current size within tolerance', async () => {
1907
+ const props = ref({
1908
+ items: mockItems,
1909
+ direction: 'horizontal' as const,
1910
+ itemSize: 50,
1911
+ }) as Ref<VirtualScrollProps<{ id: number; }>>;
1912
+
1913
+ useVirtualScroll(props);
1914
+ await nextTick();
1915
+
1916
+ // Trigger initializeSizes again with same prop
1917
+ props.value.columnGap = 0;
1918
+ await nextTick();
1919
+ // Hits the branch where Math.abs(current - target) <= 0.5
1920
+ });
1921
+
1922
+ it('should mark columns as measured when fixed width matches current width within tolerance', async () => {
1923
+ const props = ref({
1924
+ items: mockItems,
1925
+ direction: 'both' as const,
1926
+ columnCount: 2,
1927
+ columnWidth: 100,
1928
+ }) as Ref<VirtualScrollProps<{ id: number; }>>;
1929
+
1930
+ useVirtualScroll(props);
1931
+ await nextTick();
1932
+
1933
+ props.value.columnGap = 0;
1934
+ await nextTick();
1935
+ });
1936
+
1937
+ it('should reset item sizes when switching between horizontal and vertical directions', async () => {
1938
+ const props = ref({
1939
+ items: mockItems,
1940
+ direction: 'horizontal' as const,
1941
+ itemSize: 50,
1942
+ }) as Ref<VirtualScrollProps<{ id: number; }>>;
1943
+
1944
+ const result = useVirtualScroll(props);
1945
+ await nextTick();
1946
+ expect(result.totalWidth.value).toBe(500);
1947
+
1948
+ // Switch to vertical (resets X)
1949
+ props.value.direction = 'vertical';
1950
+ await nextTick();
1951
+ expect(result.totalWidth.value).toBe(0);
1952
+
1953
+ // Switch to both
1954
+ props.value.direction = 'both';
1955
+ props.value.columnCount = 10;
1956
+ props.value.columnWidth = 100;
1957
+ await nextTick();
1958
+ expect(result.totalHeight.value).toBe(500);
1959
+ expect(result.totalWidth.value).toBe(1000);
1960
+
1961
+ // Switch to horizontal (resets Y)
1962
+ props.value.direction = 'horizontal';
1963
+ await nextTick();
1964
+ await nextTick();
1965
+ expect(result.totalHeight.value).toBe(0);
1966
+ });
1967
+
1968
+ it('should skip re-initialization if dynamic size is already measured and non-zero', async () => {
1969
+ const props = ref({
1970
+ items: mockItems,
1971
+ direction: 'horizontal' as const,
1972
+ itemSize: undefined, // dynamic
1973
+ }) as Ref<VirtualScrollProps<{ id: number; }>>;
1974
+
1975
+ const result = useVirtualScroll(props);
1976
+ await nextTick();
1977
+
1978
+ result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 50, element: document.createElement('div') } ]);
1979
+ await nextTick();
1980
+
1981
+ props.value.gap = 1;
1982
+ await nextTick();
1983
+
1984
+ expect(result.totalWidth.value).toBeGreaterThan(0);
1985
+ });
1986
+ });
1213
1987
  });
1214
1988
  });