@pdanpdan/virtual-scroll 0.1.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.
@@ -0,0 +1,1214 @@
1
+ import type { VirtualScrollProps } from './useVirtualScroll';
2
+ import type { Ref } from 'vue';
3
+
4
+ import { mount } from '@vue/test-utils';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { defineComponent, nextTick, ref } from 'vue';
7
+
8
+ import { getPaddingX, getPaddingY } from '../utils/scroll';
9
+ import { useVirtualScroll } from './useVirtualScroll';
10
+
11
+ type ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
12
+
13
+ // Mock ResizeObserver
14
+ interface ResizeObserverMock {
15
+ callback: ResizeObserverCallback;
16
+ targets: Set<Element>;
17
+ trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
18
+ }
19
+
20
+ globalThis.ResizeObserver = class {
21
+ callback: ResizeObserverCallback;
22
+ static instances: ResizeObserverMock[] = [];
23
+ targets: Set<Element> = new Set();
24
+
25
+ constructor(callback: ResizeObserverCallback) {
26
+ this.callback = callback;
27
+ (this.constructor as unknown as { instances: ResizeObserverMock[]; }).instances.push(this as unknown as ResizeObserverMock);
28
+ }
29
+
30
+ observe(target: Element) {
31
+ this.targets.add(target);
32
+ }
33
+
34
+ unobserve(target: Element) {
35
+ this.targets.delete(target);
36
+ }
37
+
38
+ disconnect() {
39
+ this.targets.clear();
40
+ }
41
+
42
+ trigger(entries: Partial<ResizeObserverEntry>[]) {
43
+ this.callback(entries as ResizeObserverEntry[], this as unknown as ResizeObserver);
44
+ }
45
+ } as unknown as typeof ResizeObserver & { instances: ResizeObserverMock[]; };
46
+
47
+ globalThis.window.scrollTo = vi.fn();
48
+
49
+ // Helper to test composable within a component context
50
+ function setup<T>(propsValue: VirtualScrollProps<T>) {
51
+ const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
52
+ let result: ReturnType<typeof useVirtualScroll<T>>;
53
+
54
+ const TestComponent = defineComponent({
55
+ setup() {
56
+ result = useVirtualScroll(props);
57
+ return () => null;
58
+ },
59
+ });
60
+ const wrapper = mount(TestComponent);
61
+ return { result: result!, props, wrapper };
62
+ }
63
+
64
+ const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
65
+ const defaultProps: VirtualScrollProps<{ id: number; }> = {
66
+ items: mockItems,
67
+ itemSize: 50,
68
+ direction: 'vertical' as const,
69
+ bufferBefore: 2,
70
+ bufferAfter: 2,
71
+ container: window,
72
+ };
73
+
74
+ describe('useVirtualScroll', () => {
75
+ beforeEach(() => {
76
+ window.scrollX = 0;
77
+ window.scrollY = 0;
78
+ vi.clearAllMocks();
79
+ vi.useRealTimers();
80
+ });
81
+
82
+ describe('initialization and total size', () => {
83
+ it('should initialize with correct total height', async () => {
84
+ const { result } = setup({ ...defaultProps });
85
+ expect(result.totalHeight.value).toBe(5000);
86
+ });
87
+
88
+ it('should update total size when items length changes', async () => {
89
+ const { result, props } = setup({ ...defaultProps });
90
+ expect(result.totalHeight.value).toBe(5000);
91
+
92
+ props.value.items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
93
+ await nextTick();
94
+ expect(result.totalHeight.value).toBe(2500);
95
+ });
96
+
97
+ it('should recalculate totalHeight when itemSize changes', async () => {
98
+ const { result, props } = setup({ ...defaultProps });
99
+ expect(result.totalHeight.value).toBe(5000);
100
+
101
+ props.value.itemSize = 100;
102
+ await nextTick();
103
+ expect(result.totalHeight.value).toBe(10000);
104
+ });
105
+
106
+ it('should recalculate when gaps change', async () => {
107
+ const { result, props } = setup({ ...defaultProps, gap: 10 });
108
+ expect(result.totalHeight.value).toBe(6000); // 100 * (50 + 10)
109
+
110
+ props.value.gap = 20;
111
+ await nextTick();
112
+ expect(result.totalHeight.value).toBe(7000); // 100 * (50 + 20)
113
+ });
114
+
115
+ it('should handle itemSize as a function', async () => {
116
+ const { result } = setup({
117
+ ...defaultProps,
118
+ itemSize: (_item: { id: number; }, index: number) => 50 + index,
119
+ });
120
+ // 50*100 + (0+99)*100/2 = 5000 + 4950 = 9950
121
+ expect(result.totalHeight.value).toBe(9950);
122
+ });
123
+
124
+ it('should handle direction both (grid mode)', async () => {
125
+ const { result } = setup({
126
+ ...defaultProps,
127
+ direction: 'both',
128
+ columnCount: 10,
129
+ columnWidth: 100,
130
+ });
131
+ expect(result.totalWidth.value).toBe(1000);
132
+ expect(result.totalHeight.value).toBe(5000);
133
+ });
134
+
135
+ it('should handle horizontal direction', async () => {
136
+ const { result } = setup({ ...defaultProps, direction: 'horizontal' });
137
+ expect(result.totalWidth.value).toBe(5000);
138
+ expect(result.totalHeight.value).toBe(0);
139
+ });
140
+
141
+ it('should cover default values for buffer and gap', async () => {
142
+ const { result } = setup({
143
+ items: mockItems,
144
+ itemSize: 50,
145
+ } as unknown as VirtualScrollProps<{ id: number; }>);
146
+ expect(result.renderedItems.value.length).toBeGreaterThan(0);
147
+ });
148
+ });
149
+
150
+ describe('range and rendered items', () => {
151
+ it('should calculate rendered items based on scroll position', async () => {
152
+ const { result } = setup({ ...defaultProps });
153
+ expect(result.renderedItems.value.length).toBeGreaterThan(0);
154
+ expect(result.scrollDetails.value.currentIndex).toBe(0);
155
+ });
156
+
157
+ it('should handle horizontal direction in range/renderedItems', async () => {
158
+ const { result } = setup({ ...defaultProps, direction: 'horizontal' });
159
+ expect(result.renderedItems.value.length).toBeGreaterThan(0);
160
+ expect(result.scrollDetails.value.currentIndex).toBe(0);
161
+ });
162
+
163
+ it('should handle horizontal non-fixed size range', async () => {
164
+ const container = document.createElement('div');
165
+ Object.defineProperty(container, 'clientWidth', { value: 500 });
166
+ Object.defineProperty(container, 'clientHeight', { value: 500 });
167
+ const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined, container });
168
+ for (let i = 0; i < 20; i++) {
169
+ result.updateItemSize(i, 50, 50);
170
+ }
171
+ await nextTick();
172
+
173
+ container.scrollLeft = 100;
174
+ container.dispatchEvent(new Event('scroll'));
175
+ await nextTick();
176
+ expect(result.scrollDetails.value.currentIndex).toBeGreaterThan(0);
177
+ });
178
+
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();
182
+ });
183
+
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();
188
+
189
+ // Scroll near item 50
190
+ result.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
191
+ await nextTick();
192
+
193
+ const item50 = result.renderedItems.value.find((i) => i.index === 50);
194
+ expect(item50).toBeDefined();
195
+ expect(item50!.isSticky).toBe(true);
196
+ });
197
+ });
198
+
199
+ describe('dynamic sizing and updateItemSize', () => {
200
+ it('should update item size and trigger reactivity', async () => {
201
+ const { result } = setup({ ...defaultProps, itemSize: undefined });
202
+ expect(result.totalHeight.value).toBe(5000); // Default estimate
203
+
204
+ result.updateItemSize(0, 100, 100);
205
+ await nextTick();
206
+ expect(result.totalHeight.value).toBe(5050);
207
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
208
+ });
209
+
210
+ it('should treat 0, null, undefined as dynamic itemSize', async () => {
211
+ for (const val of [ 0, null, undefined ]) {
212
+ const { result } = setup({ ...defaultProps, itemSize: val as unknown as undefined });
213
+ expect(result.totalHeight.value).toBe(5000);
214
+ result.updateItemSize(0, 100, 100);
215
+ await nextTick();
216
+ expect(result.totalHeight.value).toBe(5050);
217
+ }
218
+ });
219
+
220
+ it('should treat 0, null, undefined as dynamic columnWidth', async () => {
221
+ for (const val of [ 0, null, undefined ]) {
222
+ const { result } = setup({
223
+ ...defaultProps,
224
+ direction: 'both',
225
+ columnCount: 2,
226
+ columnWidth: val as unknown as undefined,
227
+ });
228
+ expect(result.getColumnWidth(0)).toBe(150);
229
+ const parent = document.createElement('div');
230
+ const col0 = document.createElement('div');
231
+ Object.defineProperty(col0, 'offsetWidth', { value: 200, configurable: true });
232
+ col0.dataset.colIndex = '0';
233
+ parent.appendChild(col0);
234
+ result.updateItemSize(0, 200, 50, parent);
235
+ await nextTick();
236
+ expect(result.totalWidth.value).toBe(350);
237
+ }
238
+ });
239
+
240
+ it('should handle dynamic column width with data-col-index', async () => {
241
+ const { result } = setup({
242
+ ...defaultProps,
243
+ direction: 'both',
244
+ columnCount: 2,
245
+ columnWidth: undefined,
246
+ });
247
+ const parent = document.createElement('div');
248
+ const child1 = document.createElement('div');
249
+ Object.defineProperty(child1, 'offsetWidth', { value: 200 });
250
+ child1.dataset.colIndex = '0';
251
+ const child2 = document.createElement('div');
252
+ Object.defineProperty(child2, 'offsetWidth', { value: 300 });
253
+ child2.dataset.colIndex = '1';
254
+ parent.appendChild(child1);
255
+ parent.appendChild(child2);
256
+
257
+ result.updateItemSize(0, 500, 50, parent);
258
+ await nextTick();
259
+ expect(result.getColumnWidth(0)).toBe(200);
260
+ expect(result.getColumnWidth(1)).toBe(300);
261
+ });
262
+
263
+ it('should return early in updateItemSize if itemSize is fixed', async () => {
264
+ const { result } = setup({ ...defaultProps, itemSize: 50 });
265
+ result.updateItemSize(0, 100, 100);
266
+ expect(result.totalHeight.value).toBe(5000);
267
+ });
268
+
269
+ it('should use defaultItemSize and defaultColumnWidth when provided', () => {
270
+ const { result } = setup({
271
+ ...defaultProps,
272
+ itemSize: undefined,
273
+ columnWidth: undefined,
274
+ defaultItemSize: 100,
275
+ defaultColumnWidth: 200,
276
+ direction: 'both',
277
+ columnCount: 10,
278
+ });
279
+
280
+ expect(result.totalHeight.value).toBe(100 * 100); // 100 items * 100 defaultItemSize
281
+ expect(result.totalWidth.value).toBe(10 * 200); // 10 columns * 200 defaultColumnWidth
282
+ });
283
+
284
+ it('should ignore small delta updates in updateItemSize', async () => {
285
+ const { result } = setup({ ...defaultProps, itemSize: undefined });
286
+ result.updateItemSize(0, 50.1, 50.1);
287
+ await nextTick();
288
+ expect(result.totalHeight.value).toBe(5000);
289
+ });
290
+
291
+ it('should not shrink item height in both mode encountered so far', async () => {
292
+ const { result } = setup({ ...defaultProps, direction: 'both', itemSize: undefined, columnCount: 2 });
293
+ result.updateItemSize(0, 100, 100);
294
+ await nextTick();
295
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
296
+
297
+ result.updateItemSize(0, 100, 80);
298
+ await nextTick();
299
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
300
+ });
301
+
302
+ it('should update item height in vertical mode', async () => {
303
+ const { result } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
304
+ result.updateItemSize(0, 100, 100);
305
+ await nextTick();
306
+ expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
307
+ });
308
+
309
+ it('should handle updateItemSize for horizontal direction', async () => {
310
+ const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
311
+ result.updateItemSize(0, 100, 50);
312
+ await nextTick();
313
+ expect(result.totalWidth.value).toBe(5050);
314
+ });
315
+
316
+ it('should preserve measurements in initializeSizes when dynamic', async () => {
317
+ const { result, props } = setup({ ...defaultProps, itemSize: undefined });
318
+ result.updateItemSize(0, 100, 100);
319
+ await nextTick();
320
+ expect(result.totalHeight.value).toBe(5050);
321
+
322
+ // Trigger initializeSizes by changing length
323
+ props.value.items = Array.from({ length: 101 }, (_, i) => ({ id: i }));
324
+ 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
337
+ });
338
+
339
+ it('should cover spacer skip heuristic in updateItemSize', async () => {
340
+ 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);
349
+ 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
+
363
+ // First measurement is 100
364
+ result.updateItemSize(0, 100, 50, parent);
365
+ await nextTick();
366
+ expect(result.getColumnWidth(0)).toBe(100);
367
+ });
368
+
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);
386
+ 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);
413
+ });
414
+ });
415
+
416
+ describe('scroll and offsets', () => {
417
+ it('should handle scrollToIndex out of bounds', async () => {
418
+ const { result } = setup({ ...defaultProps });
419
+ // Row past end
420
+ result.scrollToIndex(mockItems.length + 10, 0);
421
+ await nextTick();
422
+ expect(window.scrollTo).toHaveBeenCalled();
423
+
424
+ // Col past end (in grid mode)
425
+ const { result: r_grid } = setup({ ...defaultProps, direction: 'both', columnCount: 5, columnWidth: 100 });
426
+ r_grid.scrollToIndex(0, 10);
427
+ await nextTick();
428
+ expect(window.scrollTo).toHaveBeenCalled();
429
+
430
+ // Column past end in horizontal mode
431
+ const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal' });
432
+ r_horiz.scrollToIndex(0, 200);
433
+ await nextTick();
434
+ });
435
+
436
+ it('should handle scrollToIndex auto alignment with padding', async () => {
437
+ const container = document.createElement('div');
438
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
439
+ Object.defineProperty(container, 'scrollTop', { value: 200, writable: true, configurable: true });
440
+
441
+ const { result } = setup({ ...defaultProps, container, itemSize: 50, scrollPaddingStart: 100 });
442
+ await nextTick();
443
+
444
+ // Current visible range: [scrollTop + paddingStart, scrollTop + viewport - paddingEnd] = [300, 700]
445
+ // Scroll to item at y=250. 250 < 300, so not visible.
446
+ // targetY < relativeScrollY + paddingStart (250 < 200 + 100) -> hit line 729
447
+ result.scrollToIndex(5, null, 'auto');
448
+ await nextTick();
449
+ });
450
+
451
+ it('should hit scrollToIndex X calculation branches', async () => {
452
+ const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', itemSize: 100 });
453
+ await nextTick();
454
+ // colIndex null
455
+ r_horiz.scrollToIndex(0, null);
456
+ // rowIndex null
457
+ r_horiz.scrollToIndex(null, 5);
458
+ await nextTick();
459
+ });
460
+
461
+ it('should handle scrollToOffset with element container and scrollTo method', async () => {
462
+ const container = document.createElement('div');
463
+ container.scrollTo = vi.fn();
464
+ const { result } = setup({ ...defaultProps, container });
465
+ result.scrollToOffset(100, 200);
466
+ expect(container.scrollTo).toHaveBeenCalled();
467
+ });
468
+
469
+ it('should handle scrollToIndex with null indices', async () => {
470
+ const { result } = setup({ ...defaultProps });
471
+ result.scrollToIndex(null, null);
472
+ await nextTick();
473
+ result.scrollToIndex(10, null);
474
+ await nextTick();
475
+ result.scrollToIndex(null, 10);
476
+ await nextTick();
477
+ });
478
+
479
+ it('should handle scrollToIndex auto alignment', async () => {
480
+ const container = document.createElement('div');
481
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
482
+ Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
483
+
484
+ const { result } = setup({ ...defaultProps, container, itemSize: 50 });
485
+ await nextTick();
486
+
487
+ // Scroll down so some items are above
488
+ result.scrollToIndex(20, 0, 'start');
489
+ await nextTick();
490
+
491
+ // Auto align: already visible
492
+ result.scrollToIndex(20, null, 'auto');
493
+ await nextTick();
494
+
495
+ // Auto align: above viewport (scroll up)
496
+ result.scrollToIndex(5, null, 'auto');
497
+ await nextTick();
498
+
499
+ // Auto align: below viewport (scroll down)
500
+ result.scrollToIndex(50, null, 'auto');
501
+ await nextTick();
502
+
503
+ // Horizontal auto align
504
+ const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', container, itemSize: 100 });
505
+ await nextTick();
506
+
507
+ r_horiz.scrollToIndex(0, 20, 'start');
508
+ await nextTick();
509
+
510
+ r_horiz.scrollToIndex(null, 5, 'auto');
511
+ await nextTick();
512
+
513
+ r_horiz.scrollToIndex(null, 50, 'auto');
514
+ await nextTick();
515
+ });
516
+
517
+ it('should handle scrollToIndex with various alignments', async () => {
518
+ const { result } = setup({ ...defaultProps });
519
+ result.scrollToIndex(50, 0, 'center');
520
+ await nextTick();
521
+ result.scrollToIndex(50, 0, 'end');
522
+ await nextTick();
523
+ result.scrollToIndex(50, 0, { x: 'center', y: 'end' });
524
+ await nextTick();
525
+ });
526
+
527
+ it('should handle scrollToOffset with window container', async () => {
528
+ const { result } = setup({ ...defaultProps, container: window });
529
+ result.scrollToOffset(null, 100);
530
+ expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 100 }));
531
+
532
+ result.scrollToOffset(null, 200, { behavior: 'smooth' });
533
+ expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200, behavior: 'smooth' }));
534
+ });
535
+
536
+ it('should handle scrollToIndex with auto alignment and axis preservation', async () => {
537
+ const { result } = setup({ ...defaultProps });
538
+ // Axis preservation (null index)
539
+ result.scrollToIndex(10, null, 'auto');
540
+ await nextTick();
541
+ result.scrollToIndex(null, 5, 'auto');
542
+ await nextTick();
543
+ });
544
+
545
+ it('should handle scrollToOffset with nulls to keep current position', async () => {
546
+ const { result } = setup({ ...defaultProps, container: window });
547
+ window.scrollX = 50;
548
+ window.scrollY = 60;
549
+
550
+ // Pass null to keep current Y while updating X
551
+ result.scrollToOffset(100, null);
552
+ expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 }));
553
+
554
+ // Pass null to keep current X while updating Y
555
+ result.scrollToOffset(null, 200);
556
+ expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 }));
557
+ });
558
+
559
+ it('should handle scrollToOffset with both axes', async () => {
560
+ const { result } = setup({ ...defaultProps });
561
+ result.scrollToOffset(100, 200);
562
+ expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100, top: 200 }));
563
+ });
564
+
565
+ it('should handle scrollToOffset fallback when scrollTo is missing', async () => {
566
+ const container = document.createElement('div');
567
+ (container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
568
+ const { result } = setup({ ...defaultProps, container });
569
+
570
+ result.scrollToOffset(100, 200);
571
+ expect(container.scrollTop).toBe(200);
572
+ expect(container.scrollLeft).toBe(100);
573
+
574
+ // X only
575
+ result.scrollToOffset(300, null);
576
+ expect(container.scrollLeft).toBe(300);
577
+
578
+ // Y only
579
+ result.scrollToOffset(null, 400);
580
+ expect(container.scrollTop).toBe(400);
581
+ });
582
+
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
+ });
588
+
589
+ it('should cover scrollToIndex row >= length branch', async () => {
590
+ const { result } = setup({ ...defaultProps });
591
+ result.scrollToIndex(200, null);
592
+ await nextTick();
593
+ });
594
+
595
+ it('should handle scrollToIndex horizontal alignment branches', async () => {
596
+ 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 });
604
+ await nextTick();
605
+
606
+ // targetX = 5 * 50 = 250. relativeScrollX = 1000. paddingStart = 100.
607
+ // targetX < relativeScrollX + paddingStart (250 < 1100)
608
+ result.scrollToIndex(null, 5, 'auto');
609
+ await nextTick();
610
+ expect(container.scrollLeft).toBeLessThan(1000);
611
+
612
+ // End alignment
613
+ result.scrollToIndex(null, 5, 'end');
614
+ await nextTick();
615
+
616
+ // Center alignment
617
+ result.scrollToIndex(null, 5, 'center');
618
+ await nextTick();
619
+ });
620
+
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 });
623
+ await nextTick();
624
+ // Y padding should be 10, X padding should be 0
625
+ });
626
+
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);
634
+ });
635
+ });
636
+
637
+ describe('event handling and viewport', () => {
638
+ it('should handle window scroll events', async () => {
639
+ setup({ ...defaultProps });
640
+ window.scrollX = 150;
641
+ window.scrollY = 250;
642
+ window.dispatchEvent(new Event('scroll'));
643
+ await nextTick();
644
+ });
645
+
646
+ it('should handle document scroll events', async () => {
647
+ setup({ ...defaultProps });
648
+ document.dispatchEvent(new Event('scroll'));
649
+ await nextTick();
650
+ });
651
+
652
+ it('should handle scroll events on container element', async () => {
653
+ const container = document.createElement('div');
654
+ setup({ ...defaultProps, container });
655
+ Object.defineProperty(container, 'scrollTop', { value: 100 });
656
+ container.dispatchEvent(new Event('scroll'));
657
+ await nextTick();
658
+ });
659
+
660
+ it('should update viewport size on container resize', async () => {
661
+ const container = document.createElement('div');
662
+ Object.defineProperty(container, 'clientWidth', { value: 500, writable: true });
663
+ Object.defineProperty(container, 'clientHeight', { value: 500, writable: true });
664
+ const { result } = setup({ ...defaultProps, container });
665
+ await nextTick();
666
+ expect(result.scrollDetails.value.viewportSize.width).toBe(500);
667
+
668
+ Object.defineProperty(container, 'clientWidth', { value: 800 });
669
+ const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(container));
670
+ if (observer) {
671
+ observer.trigger([ { target: container } ]);
672
+ }
673
+ await nextTick();
674
+ expect(result.scrollDetails.value.viewportSize.width).toBe(800);
675
+ });
676
+
677
+ it('should handle isScrolling timeout', async () => {
678
+ vi.useFakeTimers();
679
+ const container = document.createElement('div');
680
+ const { result } = setup({ ...defaultProps, container });
681
+ container.dispatchEvent(new Event('scroll'));
682
+ await nextTick();
683
+ expect(result.scrollDetails.value.isScrolling).toBe(true);
684
+ vi.advanceTimersByTime(250);
685
+ await nextTick();
686
+ expect(result.scrollDetails.value.isScrolling).toBe(false);
687
+ vi.useRealTimers();
688
+ });
689
+
690
+ it('should handle container change in mount watcher', async () => {
691
+ const { props } = setup({ ...defaultProps });
692
+ await nextTick();
693
+ props.value.container = null;
694
+ await nextTick();
695
+ props.value.container = window;
696
+ await nextTick();
697
+ });
698
+
699
+ it('should handle window resize events', async () => {
700
+ setup({ ...defaultProps, container: window });
701
+ window.innerWidth = 1200;
702
+ window.dispatchEvent(new Event('resize'));
703
+ await nextTick();
704
+ });
705
+
706
+ it('should cover handleScroll with document target', async () => {
707
+ setup({ ...defaultProps, container: window });
708
+ document.dispatchEvent(new Event('scroll'));
709
+ await nextTick();
710
+ });
711
+
712
+ it('should handle undefined window in handleScroll', async () => {
713
+ const originalWindow = globalThis.window;
714
+ const container = document.createElement('div');
715
+ setup({ ...defaultProps, container });
716
+
717
+ try {
718
+ (globalThis as unknown as { window: unknown; }).window = undefined;
719
+ container.dispatchEvent(new Event('scroll'));
720
+ await nextTick();
721
+ } finally {
722
+ globalThis.window = originalWindow;
723
+ }
724
+ });
725
+ });
726
+
727
+ describe('column widths and ranges', () => {
728
+ it('should handle columnWidth as an array', async () => {
729
+ const { result } = setup({
730
+ ...defaultProps,
731
+ direction: 'both',
732
+ columnCount: 4,
733
+ columnWidth: [ 100, 200 ],
734
+ });
735
+ expect(result.getColumnWidth(0)).toBe(100);
736
+ expect(result.getColumnWidth(1)).toBe(200);
737
+ expect(result.totalWidth.value).toBe(600);
738
+ });
739
+
740
+ it('should handle columnWidth array fallback for falsy values', async () => {
741
+ const { result } = setup({
742
+ ...defaultProps,
743
+ direction: 'both',
744
+ columnCount: 2,
745
+ columnWidth: [ 0 ] as unknown as number[],
746
+ });
747
+ expect(result.getColumnWidth(0)).toBe(150); // DEFAULT_COLUMN_WIDTH
748
+ });
749
+
750
+ it('should handle columnWidth as a function', async () => {
751
+ const { result } = setup({
752
+ ...defaultProps,
753
+ direction: 'both',
754
+ columnCount: 10,
755
+ columnWidth: (index: number) => (index % 2 === 0 ? 100 : 200),
756
+ });
757
+ expect(result.getColumnWidth(0)).toBe(100);
758
+ expect(result.totalWidth.value).toBe(1500);
759
+ });
760
+
761
+ it('should handle getColumnWidth fallback when dynamic', async () => {
762
+ const { result } = setup({
763
+ ...defaultProps,
764
+ direction: 'both',
765
+ columnCount: 2,
766
+ columnWidth: undefined,
767
+ });
768
+ expect(result.getColumnWidth(0)).toBe(150);
769
+ });
770
+
771
+ it('should handle columnRange while loop coverage', async () => {
772
+ const container = document.createElement('div');
773
+ const { result } = setup({
774
+ ...defaultProps,
775
+ direction: 'both',
776
+ columnCount: 50,
777
+ columnWidth: undefined,
778
+ container,
779
+ });
780
+ // Initialize some column widths
781
+ for (let i = 0; i < 20; i++) {
782
+ const parent = document.createElement('div');
783
+ const child = document.createElement('div');
784
+ Object.defineProperty(child, 'offsetWidth', { value: 100 });
785
+ child.dataset.colIndex = String(i);
786
+ parent.appendChild(child);
787
+ result.updateItemSize(0, 100, 50, parent);
788
+ }
789
+ await nextTick();
790
+ expect(result.columnRange.value.end).toBeGreaterThan(result.columnRange.value.start);
791
+ });
792
+
793
+ it('should handle zero column count', async () => {
794
+ const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 0 });
795
+ await nextTick();
796
+ expect(result.columnRange.value.end).toBe(0);
797
+ });
798
+
799
+ it('should cover columnRange safeStart clamp', async () => {
800
+ const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 10, columnWidth: 100 });
801
+ await nextTick();
802
+ expect(result.columnRange.value.start).toBe(0);
803
+ });
804
+ });
805
+
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);
815
+ await nextTick();
816
+ expect(result.scrollDetails.value.isScrolling).toBe(false);
817
+ vi.useRealTimers();
818
+ });
819
+
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();
827
+ });
828
+
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;
833
+ await nextTick();
834
+ result.updateHostOffset();
835
+ });
836
+
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 });
840
+ 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
+
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();
856
+ });
857
+
858
+ it('should cover refresh method', async () => {
859
+ const { result } = setup({ ...defaultProps, itemSize: 0 });
860
+ result.updateItemSize(0, 100, 100);
861
+ await nextTick();
862
+ expect(result.totalHeight.value).toBe(5050);
863
+
864
+ result.refresh();
865
+ await nextTick();
866
+ expect(result.totalHeight.value).toBe(5000);
867
+ });
868
+ });
869
+
870
+ describe('sticky header pushing', () => {
871
+ it('should push sticky item when next sticky item approaches (vertical)', async () => {
872
+ const container = document.createElement('div');
873
+ Object.defineProperty(container, 'clientHeight', { value: 500 });
874
+ Object.defineProperty(container, 'scrollTop', { value: 480, writable: true });
875
+ const { result } = setup({ ...defaultProps, container, stickyIndices: [ 0, 10 ], itemSize: 50 });
876
+ // We need to trigger scroll to update scrollY
877
+ container.dispatchEvent(new Event('scroll'));
878
+ await nextTick();
879
+
880
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
881
+ expect(item0!.offset.y).toBeLessThanOrEqual(450);
882
+ });
883
+
884
+ it('should push sticky item when next sticky item approaches (horizontal)', async () => {
885
+ const container = document.createElement('div');
886
+ Object.defineProperty(container, 'clientWidth', { value: 500 });
887
+ Object.defineProperty(container, 'scrollLeft', { value: 480, writable: true });
888
+
889
+ const { result } = setup({
890
+ ...defaultProps,
891
+ direction: 'horizontal',
892
+ container,
893
+ stickyIndices: [ 0, 10 ],
894
+ itemSize: 50,
895
+ columnGap: 0,
896
+ });
897
+ container.dispatchEvent(new Event('scroll'));
898
+ await nextTick();
899
+
900
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
901
+ expect(item0!.offset.x).toBeLessThanOrEqual(450);
902
+ });
903
+ });
904
+
905
+ describe('scroll restoration', () => {
906
+ it('should restore scroll position when items are prepended', async () => {
907
+ vi.useFakeTimers();
908
+ const container = document.createElement('div');
909
+ 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
+ });
914
+
915
+ const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
916
+ const { result, props } = setup({
917
+ ...defaultProps,
918
+ items,
919
+ container,
920
+ itemSize: 50,
921
+ restoreScrollOnPrepend: true,
922
+ });
923
+ container.dispatchEvent(new Event('scroll'));
924
+ await nextTick();
925
+
926
+ expect(result.scrollDetails.value.scrollOffset.y).toBe(100);
927
+
928
+ // Prepend 2 items
929
+ const newItems = [ { id: -1 }, { id: -2 }, ...items ];
930
+ props.value.items = newItems;
931
+ await nextTick();
932
+ // Trigger initializeSizes
933
+ await nextTick();
934
+
935
+ // Should have adjusted scroll by 2 * 50 = 100px. New scrollTop should be 200.
936
+ expect(container.scrollTop).toBe(200);
937
+ vi.useRealTimers();
938
+ });
939
+
940
+ it('should restore scroll position when items are prepended (horizontal)', async () => {
941
+ vi.useFakeTimers();
942
+ const container = document.createElement('div');
943
+ Object.defineProperty(container, 'clientWidth', { value: 500 });
944
+ Object.defineProperty(container, 'scrollLeft', { value: 100, writable: true });
945
+ container.scrollTo = vi.fn().mockImplementation((options) => {
946
+ container.scrollLeft = options.left;
947
+ });
948
+
949
+ const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
950
+ const { result, props } = setup({
951
+ ...defaultProps,
952
+ direction: 'horizontal',
953
+ items,
954
+ container,
955
+ itemSize: 50,
956
+ restoreScrollOnPrepend: true,
957
+ });
958
+ container.dispatchEvent(new Event('scroll'));
959
+ await nextTick();
960
+
961
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
962
+
963
+ // Prepend 2 items
964
+ const newItems = [ { id: -1 }, { id: -2 }, ...items ];
965
+ props.value.items = newItems;
966
+ await nextTick();
967
+ await nextTick();
968
+
969
+ expect(container.scrollLeft).toBe(200);
970
+ vi.useRealTimers();
971
+ });
972
+
973
+ it('should NOT restore scroll position when restoreScrollOnPrepend is false', async () => {
974
+ const container = document.createElement('div');
975
+ Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
976
+ const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
977
+ const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: false });
978
+ await nextTick();
979
+
980
+ const newItems = [ { id: -1 }, ...items ];
981
+ props.value.items = newItems;
982
+ await nextTick();
983
+ await nextTick();
984
+ expect(container.scrollTop).toBe(100);
985
+ });
986
+
987
+ it('should NOT restore scroll position when first item does not match', async () => {
988
+ const container = document.createElement('div');
989
+ Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
990
+ const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
991
+ const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: true });
992
+ await nextTick();
993
+
994
+ const newItems = [ { id: -1 }, { id: 9999 } ]; // completely different
995
+ props.value.items = newItems;
996
+ await nextTick();
997
+ await nextTick();
998
+ expect(container.scrollTop).toBe(100);
999
+ });
1000
+
1001
+ it('should update pendingScroll rowIndex when items are prepended', async () => {
1002
+ const container = document.createElement('div');
1003
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1004
+ Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1005
+ 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
1008
+
1009
+ props.value.items = [ { id: -1 }, ...props.value.items ];
1010
+ await nextTick();
1011
+ });
1012
+
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 } ]);
1016
+ await nextTick();
1017
+ expect(result.totalWidth.value).toBe(5050);
1018
+ });
1019
+
1020
+ it('should trigger scroll correction on tree update with pending scroll', async () => {
1021
+ const container = document.createElement('div');
1022
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1023
+ Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1024
+ const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1025
+ // Set a pending scroll
1026
+ result.scrollToIndex(10, null, { behavior: 'smooth' });
1027
+
1028
+ // Trigger tree update
1029
+ result.updateItemSize(0, 100, 100);
1030
+ await nextTick();
1031
+ });
1032
+
1033
+ it('should trigger scroll correction when scrolling stops with pending scroll', async () => {
1034
+ vi.useFakeTimers();
1035
+ const container = document.createElement('div');
1036
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1037
+ Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1038
+ const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1039
+ result.scrollToIndex(10, null, { behavior: 'smooth' });
1040
+
1041
+ // Start scrolling
1042
+ container.dispatchEvent(new Event('scroll'));
1043
+ await nextTick();
1044
+ expect(result.scrollDetails.value.isScrolling).toBe(true);
1045
+
1046
+ // Wait for scroll timeout
1047
+ vi.advanceTimersByTime(250);
1048
+ await nextTick();
1049
+ expect(result.scrollDetails.value.isScrolling).toBe(false);
1050
+ vi.useRealTimers();
1051
+ });
1052
+ });
1053
+
1054
+ // eslint-disable-next-line test/prefer-lowercase-title
1055
+ describe('SSR support', () => {
1056
+ it('should handle SSR range in range calculation', () => {
1057
+ const props = ref({
1058
+ items: mockItems,
1059
+ ssrRange: { start: 0, end: 10 },
1060
+ }) as Ref<VirtualScrollProps<unknown>>;
1061
+ const result = useVirtualScroll(props);
1062
+ expect(result.renderedItems.value.length).toBe(10);
1063
+ });
1064
+
1065
+ it('should handle SSR range in columnRange calculation', () => {
1066
+ const props = ref({
1067
+ items: mockItems,
1068
+ columnCount: 10,
1069
+ ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
1070
+ }) as Ref<VirtualScrollProps<unknown>>;
1071
+ const result = useVirtualScroll(props);
1072
+ expect(result.columnRange.value.end).toBe(5);
1073
+ });
1074
+
1075
+ it('should handle SSR range with both directions for total sizes', () => {
1076
+ const props = ref({
1077
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1078
+ direction: 'both',
1079
+ columnCount: 10,
1080
+ columnWidth: 100,
1081
+ itemSize: 50,
1082
+ ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1083
+ }) as Ref<VirtualScrollProps<unknown>>;
1084
+ const result = useVirtualScroll(props);
1085
+ expect(result.totalWidth.value).toBe(300); // (5-2) * 100
1086
+ expect(result.totalHeight.value).toBe(500); // (20-10) * 50
1087
+ });
1088
+
1089
+ it('should handle SSR range with horizontal direction for total sizes', () => {
1090
+ const props = ref({
1091
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1092
+ direction: 'horizontal',
1093
+ itemSize: 50,
1094
+ ssrRange: { start: 10, end: 20 },
1095
+ }) as Ref<VirtualScrollProps<unknown>>;
1096
+ const result = useVirtualScroll(props);
1097
+ expect(result.totalWidth.value).toBe(500); // (20-10) * 50
1098
+ });
1099
+
1100
+ it('should handle SSR range with fixed size horizontal for total sizes', () => {
1101
+ const props = ref({
1102
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1103
+ direction: 'horizontal',
1104
+ itemSize: 50,
1105
+ ssrRange: { start: 10, end: 20 },
1106
+ }) as Ref<VirtualScrollProps<unknown>>;
1107
+ const result = useVirtualScroll(props);
1108
+ expect(result.totalWidth.value).toBe(500); // (20-10) * 50
1109
+ });
1110
+
1111
+ it('should handle SSR range with vertical offset in renderedItems', () => {
1112
+ const props = ref({
1113
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1114
+ direction: 'vertical',
1115
+ itemSize: 50,
1116
+ ssrRange: { start: 10, end: 20 },
1117
+ }) as Ref<VirtualScrollProps<unknown>>;
1118
+ const result = useVirtualScroll(props);
1119
+ expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1120
+ });
1121
+
1122
+ it('should handle SSR range with dynamic sizes for total sizes', () => {
1123
+ const props = ref({
1124
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1125
+ direction: 'vertical',
1126
+ itemSize: 0,
1127
+ ssrRange: { start: 10, end: 20 },
1128
+ }) as Ref<VirtualScrollProps<unknown>>;
1129
+ const result = useVirtualScroll(props);
1130
+ expect(result.totalHeight.value).toBe(500);
1131
+ });
1132
+
1133
+ it('should handle SSR range with dynamic horizontal sizes for total sizes', () => {
1134
+ const props = ref({
1135
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1136
+ direction: 'horizontal',
1137
+ itemSize: 0,
1138
+ ssrRange: { start: 10, end: 20 },
1139
+ }) as Ref<VirtualScrollProps<unknown>>;
1140
+ const result = useVirtualScroll(props);
1141
+ expect(result.totalWidth.value).toBe(500);
1142
+ });
1143
+
1144
+ it('should handle SSR range with both directions and dynamic offsets', () => {
1145
+ const props = ref({
1146
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1147
+ direction: 'both',
1148
+ columnCount: 10,
1149
+ itemSize: 0,
1150
+ ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1151
+ }) as Ref<VirtualScrollProps<unknown>>;
1152
+ const result = useVirtualScroll(props);
1153
+ expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1154
+ expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-300);
1155
+ });
1156
+
1157
+ it('should scroll to ssrRange on mount', async () => {
1158
+ setup({ ...defaultProps, ssrRange: { start: 50, end: 60 } });
1159
+ await nextTick();
1160
+ expect(window.scrollTo).toHaveBeenCalled();
1161
+ });
1162
+
1163
+ it('should handle SSR range with horizontal direction and colStart', () => {
1164
+ const props = ref({
1165
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1166
+ direction: 'horizontal',
1167
+ itemSize: 50,
1168
+ ssrRange: { start: 0, end: 10, colStart: 5 },
1169
+ }) as Ref<VirtualScrollProps<unknown>>;
1170
+ const result = useVirtualScroll(props);
1171
+ expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-250);
1172
+ });
1173
+
1174
+ it('should handle SSR range with direction "both" and colStart', () => {
1175
+ const props = ref({
1176
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1177
+ direction: 'both',
1178
+ columnCount: 20,
1179
+ columnWidth: 100,
1180
+ ssrRange: { start: 0, end: 10, colStart: 5, colEnd: 15 },
1181
+ }) as Ref<VirtualScrollProps<unknown>>;
1182
+ const result = useVirtualScroll(props);
1183
+ // ssrOffsetX = columnSizes.query(5) = 5 * 100 = 500
1184
+ expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-500);
1185
+ });
1186
+
1187
+ it('should handle SSR range with colCount > 0 in totalWidth', () => {
1188
+ const props = ref({
1189
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1190
+ direction: 'both',
1191
+ columnCount: 10,
1192
+ columnWidth: 100,
1193
+ ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
1194
+ }) as Ref<VirtualScrollProps<unknown>>;
1195
+ const result = useVirtualScroll(props);
1196
+ expect(result.totalWidth.value).toBe(500);
1197
+ });
1198
+
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
+
1209
+ it('should cover object padding branches in helpers', () => {
1210
+ expect(getPaddingX({ x: 10 }, 'horizontal')).toBe(10);
1211
+ expect(getPaddingY({ y: 20 }, 'vertical')).toBe(20);
1212
+ });
1213
+ });
1214
+ });