@pdanpdan/virtual-scroll 0.2.1 → 0.4.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,53 +1,39 @@
1
1
  /* global ScrollToOptions */
2
- import type { VirtualScrollProps } from './useVirtualScroll';
2
+ import type { VirtualScrollProps } from '../types';
3
3
  import type { Ref } from 'vue';
4
4
 
5
5
  import { mount } from '@vue/test-utils';
6
6
  import { beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { defineComponent, nextTick, ref } from 'vue';
8
8
 
9
- import { getPaddingX, getPaddingY } from '../utils/scroll';
10
9
  import { useVirtualScroll } from './useVirtualScroll';
11
10
 
12
- type ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
11
+ // --- Mocks ---
13
12
 
14
- // Mock ResizeObserver
15
- interface ResizeObserverMock {
16
- callback: ResizeObserverCallback;
17
- targets: Set<Element>;
18
- trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
19
- }
20
-
21
- globalThis.ResizeObserver = class {
22
- callback: ResizeObserverCallback;
23
- static instances: ResizeObserverMock[] = [];
24
- targets: Set<Element> = new Set();
25
-
26
- constructor(callback: ResizeObserverCallback) {
27
- this.callback = callback;
28
- (this.constructor as unknown as { instances: ResizeObserverMock[]; }).instances.push(this as unknown as ResizeObserverMock);
29
- }
30
-
31
- observe(target: Element) {
32
- this.targets.add(target);
33
- }
13
+ globalThis.ResizeObserver = class ResizeObserver {
14
+ observe = vi.fn();
15
+ unobserve = vi.fn();
16
+ disconnect = vi.fn();
17
+ };
34
18
 
35
- unobserve(target: Element) {
36
- this.targets.delete(target);
37
- }
19
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
20
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
21
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 500 });
22
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 500 });
23
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
24
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
38
25
 
39
- disconnect() {
40
- this.targets.clear();
26
+ globalThis.window.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
27
+ if (options.left !== undefined) {
28
+ Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
41
29
  }
42
-
43
- trigger(entries: Partial<ResizeObserverEntry>[]) {
44
- this.callback(entries as ResizeObserverEntry[], this as unknown as ResizeObserver);
30
+ if (options.top !== undefined) {
31
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
45
32
  }
46
- } as unknown as typeof ResizeObserver & { instances: ResizeObserverMock[]; };
47
-
48
- globalThis.window.scrollTo = vi.fn();
33
+ document.dispatchEvent(new Event('scroll'));
34
+ });
49
35
 
50
- // Helper to test composable within a component context
36
+ // Helper to test composable
51
37
  function setup<T>(propsValue: VirtualScrollProps<T>) {
52
38
  const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
53
39
  let result: ReturnType<typeof useVirtualScroll<T>>;
@@ -59,1497 +45,305 @@ function setup<T>(propsValue: VirtualScrollProps<T>) {
59
45
  },
60
46
  });
61
47
  const wrapper = mount(TestComponent);
62
- return { result: result!, props, wrapper };
48
+ return { props, result: result!, wrapper };
63
49
  }
64
50
 
65
- const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
66
- const defaultProps: VirtualScrollProps<{ id: number; }> = {
67
- items: mockItems,
68
- itemSize: 50,
69
- direction: 'vertical' as const,
70
- bufferBefore: 2,
71
- bufferAfter: 2,
72
- container: window,
73
- };
74
-
75
51
  describe('useVirtualScroll', () => {
52
+ const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
53
+
76
54
  beforeEach(() => {
77
- window.scrollX = 0;
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
- });
90
55
  vi.clearAllMocks();
91
- vi.useRealTimers();
56
+ Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
57
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
92
58
  });
93
59
 
94
- describe('initialization and dimensions', () => {
95
- it('should initialize with correct total height', async () => {
96
- const { result } = setup({ ...defaultProps });
97
- expect(result.totalHeight.value).toBe(5000);
98
- });
99
-
100
- it('should update total size when items length changes', async () => {
101
- const { result, props } = setup({ ...defaultProps });
102
- expect(result.totalHeight.value).toBe(5000);
103
-
104
- props.value.items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
105
- await nextTick();
106
- expect(result.totalHeight.value).toBe(2500);
60
+ it('calculates total dimensions correctly', async () => {
61
+ const { result } = setup({
62
+ container: window,
63
+ direction: 'vertical',
64
+ itemSize: 50,
65
+ items: mockItems,
107
66
  });
108
67
 
109
- it('should recalculate totalHeight when itemSize changes', async () => {
110
- const { result, props } = setup({ ...defaultProps });
111
- expect(result.totalHeight.value).toBe(5000);
112
-
113
- props.value.itemSize = 100;
114
- await nextTick();
115
- expect(result.totalHeight.value).toBe(10000);
116
- });
117
-
118
- it('should recalculate when gaps change', async () => {
119
- const { result, props } = setup({ ...defaultProps, gap: 10 });
120
- expect(result.totalHeight.value).toBe(6000); // 100 * (50 + 10)
121
-
122
- props.value.gap = 20;
123
- await nextTick();
124
- expect(result.totalHeight.value).toBe(7000); // 100 * (50 + 20)
125
- });
126
-
127
- it('should handle itemSize as a function', async () => {
128
- const { result } = setup({
129
- ...defaultProps,
130
- itemSize: (_item: { id: number; }, index: number) => 50 + index,
131
- });
132
- // 50*100 + (0+99)*100/2 = 5000 + 4950 = 9950
133
- expect(result.totalHeight.value).toBe(9950);
134
- });
135
-
136
- it('should handle direction both (grid mode)', async () => {
137
- const { result } = setup({
138
- ...defaultProps,
139
- direction: 'both',
140
- columnCount: 10,
141
- columnWidth: 100,
142
- });
143
- expect(result.totalWidth.value).toBe(1000);
144
- expect(result.totalHeight.value).toBe(5000);
145
- });
146
-
147
- it('should handle horizontal direction', async () => {
148
- const { result } = setup({ ...defaultProps, direction: 'horizontal' });
149
- expect(result.totalWidth.value).toBe(5000);
150
- expect(result.totalHeight.value).toBe(0);
151
- });
152
-
153
- it('should cover default values for buffer and gap', async () => {
154
- const { result } = setup({
155
- items: mockItems,
156
- itemSize: 50,
157
- } as unknown as VirtualScrollProps<{ id: number; }>);
158
- expect(result.renderedItems.value.length).toBeGreaterThan(0);
159
- });
68
+ expect(result.totalHeight.value).toBe(5000);
69
+ expect(result.totalWidth.value).toBe(500);
160
70
  });
161
71
 
162
- describe('range calculation', () => {
163
- it('should calculate rendered items based on scroll position', async () => {
164
- const { result } = setup({ ...defaultProps });
165
- expect(result.renderedItems.value.length).toBeGreaterThan(0);
166
- expect(result.scrollDetails.value.currentIndex).toBe(0);
72
+ it('provides rendered items for the visible range', async () => {
73
+ const { result } = setup({
74
+ container: window,
75
+ direction: 'vertical',
76
+ itemSize: 50,
77
+ items: mockItems,
167
78
  });
168
79
 
169
- it('should handle horizontal direction in range/renderedItems', async () => {
170
- const { result } = setup({ ...defaultProps, direction: 'horizontal' });
171
- expect(result.renderedItems.value.length).toBeGreaterThan(0);
172
- expect(result.scrollDetails.value.currentIndex).toBe(0);
173
- });
174
-
175
- it('should handle horizontal non-fixed size range', async () => {
176
- const container = document.createElement('div');
177
- Object.defineProperty(container, 'clientWidth', { value: 500 });
178
- Object.defineProperty(container, 'clientHeight', { value: 500 });
179
- const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined, container });
180
- for (let i = 0; i < 20; i++) {
181
- result.updateItemSize(i, 50, 50);
182
- }
183
- await nextTick();
80
+ await nextTick();
81
+ await nextTick();
184
82
 
185
- container.scrollLeft = 100;
186
- container.dispatchEvent(new Event('scroll'));
187
- await nextTick();
188
- expect(result.scrollDetails.value.currentIndex).toBeGreaterThan(0);
189
- });
83
+ // viewport 500, item 50 => 10 items + buffer 5 = 15 items
84
+ expect(result.renderedItems.value.length).toBe(15);
85
+ expect(result.renderedItems.value[ 0 ]!.index).toBe(0);
190
86
  });
191
87
 
192
- describe('dynamic sizing', () => {
193
- it('should handle columnCount fallback in updateItemSizes', async () => {
194
- const { result, props } = setup({
195
- ...defaultProps,
196
- direction: 'both',
197
- columnCount: 10,
198
- columnWidth: undefined,
199
- });
200
- await nextTick();
201
-
202
- const cell = document.createElement('div');
203
- cell.dataset.colIndex = '0';
204
-
205
- // Getter that returns 10 first time (for guard) and null second time (for fallback)
206
- let count = 0;
207
- Object.defineProperty(props.value, 'columnCount', {
208
- get() {
209
- count++;
210
- return count === 1 ? 10 : null;
211
- },
212
- configurable: true,
213
- });
214
-
215
- result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
216
- await nextTick();
217
- });
218
-
219
- it('should handle updateItemSizes with direct cell element', async () => {
220
- const { result } = setup({
221
- ...defaultProps,
222
- direction: 'both',
223
- columnCount: 2,
224
- columnWidth: undefined,
225
- });
226
- await nextTick();
227
-
228
- const cell = document.createElement('div');
229
- Object.defineProperty(cell, 'offsetWidth', { value: 200 });
230
- cell.dataset.colIndex = '0';
231
-
232
- result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
233
- await nextTick();
234
- expect(result.getColumnWidth(0)).toBe(200);
235
- });
236
-
237
- it('should handle updateItemSizes initial measurement even if smaller than estimate', async () => {
238
- // Horizontal
239
- const { result: rH } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
240
- await nextTick();
241
- // Estimate is 50. Update with 40.
242
- rH.updateItemSizes([ { index: 0, inlineSize: 40, blockSize: 40 } ]);
243
- await nextTick();
244
- expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(40);
245
-
246
- // Subsequent update with smaller size should be ignored
247
- rH.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
248
- await nextTick();
249
- expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(40);
250
-
251
- // Vertical
252
- const { result: rV } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
253
- await nextTick();
254
- rV.updateItemSizes([ { index: 0, inlineSize: 40, blockSize: 40 } ]);
255
- await nextTick();
256
- expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(40);
257
-
258
- // Subsequent update with smaller size should be ignored
259
- rV.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
260
- await nextTick();
261
- expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(40);
262
- });
263
-
264
- it('should handle updateItemSize and trigger reactivity', async () => {
265
- const { result } = setup({ ...defaultProps, itemSize: undefined });
266
- expect(result.totalHeight.value).toBe(5000); // Default estimate
267
-
268
- result.updateItemSize(0, 100, 100);
269
- await nextTick();
270
- expect(result.totalHeight.value).toBe(5050);
271
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
272
- });
273
-
274
- it('should treat 0, null, undefined as dynamic itemSize', async () => {
275
- for (const val of [ 0, null, undefined ]) {
276
- const { result } = setup({ ...defaultProps, itemSize: val as unknown as undefined });
277
- expect(result.totalHeight.value).toBe(5000);
278
- result.updateItemSize(0, 100, 100);
279
- await nextTick();
280
- expect(result.totalHeight.value).toBe(5050);
281
- }
282
- });
283
-
284
- it('should treat 0, null, undefined as dynamic columnWidth', async () => {
285
- for (const val of [ 0, null, undefined ]) {
286
- const { result } = setup({
287
- ...defaultProps,
288
- direction: 'both',
289
- columnCount: 2,
290
- columnWidth: val as unknown as undefined,
291
- });
292
- expect(result.getColumnWidth(0)).toBe(150);
293
- const parent = document.createElement('div');
294
- const col0 = document.createElement('div');
295
- Object.defineProperty(col0, 'offsetWidth', { value: 200, configurable: true });
296
- col0.dataset.colIndex = '0';
297
- parent.appendChild(col0);
298
- result.updateItemSize(0, 200, 50, parent);
299
- await nextTick();
300
- expect(result.totalWidth.value).toBe(350);
301
- }
302
- });
303
-
304
- it('should handle dynamic column width with data-col-index', async () => {
305
- const { result } = setup({
306
- ...defaultProps,
307
- direction: 'both',
308
- columnCount: 2,
309
- columnWidth: undefined,
310
- });
311
- const parent = document.createElement('div');
312
- const child1 = document.createElement('div');
313
- Object.defineProperty(child1, 'offsetWidth', { value: 200 });
314
- child1.dataset.colIndex = '0';
315
- const child2 = document.createElement('div');
316
- Object.defineProperty(child2, 'offsetWidth', { value: 300 });
317
- child2.dataset.colIndex = '1';
318
- parent.appendChild(child1);
319
- parent.appendChild(child2);
320
-
321
- result.updateItemSize(0, 500, 50, parent);
322
- await nextTick();
323
- expect(result.getColumnWidth(0)).toBe(200);
324
- expect(result.getColumnWidth(1)).toBe(300);
325
- });
326
-
327
- it('should return early in updateItemSize if itemSize is fixed', async () => {
328
- const { result } = setup({ ...defaultProps, itemSize: 50 });
329
- result.updateItemSize(0, 100, 100);
330
- expect(result.totalHeight.value).toBe(5000);
331
- });
332
-
333
- it('should use defaultItemSize and defaultColumnWidth when provided', () => {
334
- const { result } = setup({
335
- ...defaultProps,
336
- itemSize: undefined,
337
- columnWidth: undefined,
338
- defaultItemSize: 100,
339
- defaultColumnWidth: 200,
340
- direction: 'both',
341
- columnCount: 10,
342
- });
343
-
344
- expect(result.totalHeight.value).toBe(100 * 100); // 100 items * 100 defaultItemSize
345
- expect(result.totalWidth.value).toBe(10 * 200); // 10 columns * 200 defaultColumnWidth
346
- });
347
-
348
- it('should ignore small delta updates in updateItemSize', async () => {
349
- const { result } = setup({ ...defaultProps, itemSize: undefined });
350
- result.updateItemSize(0, 50.1, 50.1);
351
- await nextTick();
352
- expect(result.totalHeight.value).toBe(5000);
88
+ it('updates when scroll position changes', async () => {
89
+ const { result } = setup({
90
+ container: window,
91
+ direction: 'vertical',
92
+ itemSize: 50,
93
+ items: mockItems,
353
94
  });
354
95
 
355
- it('should not shrink item height in both mode encountered so far', async () => {
356
- const { result } = setup({ ...defaultProps, direction: 'both', itemSize: undefined, columnCount: 2 });
357
- result.updateItemSize(0, 100, 100);
358
- await nextTick();
359
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
96
+ await nextTick();
97
+ await nextTick();
360
98
 
361
- result.updateItemSize(0, 100, 80);
362
- await nextTick();
363
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
364
- });
365
-
366
- it('should update item height in vertical mode', async () => {
367
- const { result } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
368
- result.updateItemSize(0, 100, 100);
369
- await nextTick();
370
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
371
- });
99
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 500, writable: true });
100
+ document.dispatchEvent(new Event('scroll'));
372
101
 
373
- it('should handle updateItemSize for horizontal direction', async () => {
374
- const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
375
- result.updateItemSize(0, 100, 50);
376
- await nextTick();
377
- expect(result.totalWidth.value).toBe(5050);
378
- });
102
+ await nextTick();
103
+ await nextTick();
379
104
 
380
- it('should preserve measurements in initializeSizes when dynamic', async () => {
381
- const { result, props } = setup({ ...defaultProps, itemSize: undefined });
382
- result.updateItemSize(0, 100, 100);
383
- await nextTick();
384
- expect(result.totalHeight.value).toBe(5050);
385
-
386
- // Trigger initializeSizes by changing length
387
- props.value.items = Array.from({ length: 101 }, (_, i) => ({ id: i }));
388
- await nextTick();
389
- // Should still be 100 for index 0, not reset to default 50
390
- expect(result.totalHeight.value).toBe(5050 + 50);
391
- });
105
+ // At 500px, start index is 500/50 = 10. With buffer 5, start is 5.
106
+ expect(result.scrollDetails.value.currentIndex).toBe(10);
107
+ expect(result.renderedItems.value[ 0 ]!.index).toBe(5);
392
108
  });
393
109
 
394
- describe('scrolling and API', () => {
395
- it('should handle scrollToIndex with horizontal direction and dynamic item size', async () => {
396
- const container = document.createElement('div');
397
- Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
398
- const { result } = setup({ ...defaultProps, container, direction: 'horizontal', itemSize: undefined });
399
- await nextTick();
400
-
401
- // index 10. itemSize is 50 by default. totalWidth = 5000.
402
- result.scrollToIndex(null, 10, { align: 'start', behavior: 'auto' });
403
- await nextTick();
404
- expect(result.scrollDetails.value.scrollOffset.x).toBe(500);
405
- });
406
-
407
- it('should handle scrollToIndex with window fallback when container is missing', async () => {
408
- const { result } = setup({ ...defaultProps, container: undefined });
409
- await nextTick();
410
- result.scrollToIndex(10, 0);
411
- await nextTick();
412
- expect(window.scrollTo).toHaveBeenCalled();
413
- });
414
-
415
- it('should handle scrollToIndex out of bounds', async () => {
416
- const { result } = setup({ ...defaultProps });
417
- // Row past end
418
- result.scrollToIndex(mockItems.length + 10, 0);
419
- await nextTick();
420
- expect(window.scrollTo).toHaveBeenCalled();
421
-
422
- // Col past end (in grid mode)
423
- const { result: r_grid } = setup({ ...defaultProps, direction: 'both', columnCount: 5, columnWidth: 100 });
424
- r_grid.scrollToIndex(0, 10);
425
- await nextTick();
426
- expect(window.scrollTo).toHaveBeenCalled();
427
-
428
- // Column past end in horizontal mode
429
- const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal' });
430
- r_horiz.scrollToIndex(0, 200);
431
- await nextTick();
432
- });
433
-
434
- it('should handle scrollToIndex auto alignment with padding', async () => {
435
- const container = document.createElement('div');
436
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
437
- Object.defineProperty(container, 'scrollTop', { value: 200, writable: true, configurable: true });
438
-
439
- const { result } = setup({ ...defaultProps, container, itemSize: 50, scrollPaddingStart: 100 });
440
- await nextTick();
441
-
442
- // Current visible range: [scrollTop + paddingStart, scrollTop + viewport - paddingEnd] = [300, 700]
443
- // Scroll to item at y=250. 250 < 300, so not visible.
444
- // targetY < relativeScrollY + paddingStart (250 < 200 + 100)
445
- result.scrollToIndex(5, null, 'auto');
446
- await nextTick();
447
- });
448
-
449
- it('should hit scrollToIndex X calculation branches', async () => {
450
- const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', itemSize: 100 });
451
- await nextTick();
452
- // colIndex null
453
- r_horiz.scrollToIndex(0, null);
454
- // rowIndex null
455
- r_horiz.scrollToIndex(null, 5);
456
- await nextTick();
110
+ it('supports programmatic scrolling', async () => {
111
+ const { result } = setup({
112
+ container: window,
113
+ direction: 'vertical',
114
+ itemSize: 50,
115
+ items: mockItems,
457
116
  });
458
117
 
459
- it('should handle scrollToOffset with element container and scrollTo method', async () => {
460
- const container = document.createElement('div');
461
- container.scrollTo = vi.fn();
462
- const { result } = setup({ ...defaultProps, container });
463
- result.scrollToOffset(100, 200);
464
- expect(container.scrollTo).toHaveBeenCalled();
465
- });
466
-
467
- it('should handle scrollToOffset with currentX/currentY fallbacks', async () => {
468
- const container = document.createElement('div');
469
- Object.defineProperty(container, 'scrollLeft', { value: 50, writable: true });
470
- Object.defineProperty(container, 'scrollTop', { value: 60, writable: true });
471
-
472
- const { result } = setup({ ...defaultProps, container });
473
- await nextTick();
474
-
475
- // Pass null to x and y to trigger fallbacks to currentX and currentY
476
- result.scrollToOffset(null, null);
477
- await nextTick();
478
-
479
- // scrollOffset.x = targetX - hostOffset.x + (isHorizontal ? paddingStartX : 0)
480
- // targetX = currentX = 50. hostOffset.x = 0. isHorizontal = false.
481
- // So scrollOffset.x = 50.
482
- expect(result.scrollDetails.value.scrollOffset.x).toBe(50);
483
- expect(result.scrollDetails.value.scrollOffset.y).toBe(60);
484
- });
485
-
486
- it('should handle scrollToOffset with restricted direction for padding fallback', async () => {
487
- const container = document.createElement('div');
488
- container.scrollTo = vi.fn();
489
-
490
- // Horizontal direction: isVertical will be false, so targetY padding fallback will be 0
491
- const { result } = setup({ ...defaultProps, container, direction: 'horizontal', scrollPaddingStart: 10 });
492
- await nextTick();
493
-
494
- result.scrollToOffset(100, 100);
495
- await nextTick();
496
- // targetY = 100 + hostOffset.y - (isVertical ? paddingStartY : 0)
497
- // Since isVertical is false, it uses 0. hostOffset.y is 0 here.
498
- expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
499
- top: 100,
500
- }));
501
-
502
- // Vertical direction: isHorizontal will be false, so targetX padding fallback will be 0
503
- const { result: r2 } = setup({ ...defaultProps, container, direction: 'vertical', scrollPaddingStart: 10 });
504
- await nextTick();
505
- r2.scrollToOffset(100, 100);
506
- await nextTick();
507
- expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
508
- left: 100,
509
- }));
510
- });
511
-
512
- it('should handle scrollToOffset with window fallback when container is missing', async () => {
513
- const { result } = setup({ ...defaultProps, container: undefined });
514
- await nextTick();
515
- result.scrollToOffset(100, 200);
516
- await nextTick();
517
- expect(window.scrollTo).toHaveBeenCalled();
518
- });
519
-
520
- it('should handle scrollToIndex with null indices', async () => {
521
- const { result } = setup({ ...defaultProps });
522
- result.scrollToIndex(null, null);
523
- await nextTick();
524
- result.scrollToIndex(10, null);
525
- await nextTick();
526
- result.scrollToIndex(null, 10);
527
- await nextTick();
528
- });
529
-
530
- it('should handle scrollToIndex auto alignment', async () => {
531
- const container = document.createElement('div');
532
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
533
- Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
534
-
535
- const { result } = setup({ ...defaultProps, container, itemSize: 50 });
536
- await nextTick();
537
-
538
- // Scroll down so some items are above
539
- result.scrollToIndex(20, 0, 'start');
540
- await nextTick();
541
-
542
- // Auto align: already visible
543
- result.scrollToIndex(20, null, 'auto');
544
- await nextTick();
118
+ await nextTick();
119
+ await nextTick();
545
120
 
546
- // Auto align: above viewport (scroll up)
547
- result.scrollToIndex(5, null, 'auto');
548
- await nextTick();
549
-
550
- // Auto align: below viewport (scroll down)
551
- result.scrollToIndex(50, null, 'auto');
552
- await nextTick();
553
-
554
- // Horizontal auto align
555
- const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', container, itemSize: 100 });
556
- await nextTick();
557
-
558
- r_horiz.scrollToIndex(0, 20, 'start');
559
- await nextTick();
560
-
561
- r_horiz.scrollToIndex(null, 5, 'auto');
562
- await nextTick();
563
-
564
- r_horiz.scrollToIndex(null, 50, 'auto');
565
- await nextTick();
566
- });
567
-
568
- it('should handle scrollToIndex with various alignments', async () => {
569
- const { result } = setup({ ...defaultProps });
570
- result.scrollToIndex(50, 0, 'center');
571
- await nextTick();
572
- result.scrollToIndex(50, 0, 'end');
573
- await nextTick();
574
- result.scrollToIndex(50, 0, { x: 'center', y: 'end' });
575
- await nextTick();
576
- });
577
-
578
- it('should handle scrollToOffset with window container', async () => {
579
- const { result } = setup({ ...defaultProps, container: window });
580
- result.scrollToOffset(null, 100);
581
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 100 }));
582
-
583
- result.scrollToOffset(null, 200, { behavior: 'smooth' });
584
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200, behavior: 'smooth' }));
585
- });
586
-
587
- it('should handle scrollToIndex with auto alignment and axis preservation', async () => {
588
- const { result } = setup({ ...defaultProps });
589
- // Axis preservation (null index)
590
- result.scrollToIndex(10, null, 'auto');
591
- await nextTick();
592
- result.scrollToIndex(null, 5, 'auto');
593
- await nextTick();
594
- });
595
-
596
- it('should handle scrollToOffset with nulls to keep current position', async () => {
597
- const { result } = setup({ ...defaultProps, container: window });
598
- window.scrollX = 50;
599
- window.scrollY = 60;
600
-
601
- // Pass null to keep current Y while updating X
602
- result.scrollToOffset(100, null);
603
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 }));
604
-
605
- // Pass null to keep current X while updating Y
606
- result.scrollToOffset(null, 200);
607
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 }));
608
- });
609
-
610
- it('should handle scrollToOffset with both axes', async () => {
611
- const { result } = setup({ ...defaultProps });
612
- result.scrollToOffset(100, 200);
613
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100, top: 200 }));
614
- });
615
-
616
- it('should handle scrollToOffset fallback when scrollTo is missing', async () => {
617
- const container = document.createElement('div');
618
- (container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
619
- const { result } = setup({ ...defaultProps, container });
620
-
621
- result.scrollToOffset(100, 200);
622
- expect(container.scrollTop).toBe(200);
623
- expect(container.scrollLeft).toBe(100);
624
-
625
- // X only
626
- result.scrollToOffset(300, null);
627
- expect(container.scrollLeft).toBe(300);
628
-
629
- // Y only
630
- result.scrollToOffset(null, 400);
631
- expect(container.scrollTop).toBe(400);
632
- });
633
-
634
- it('should stop programmatic scroll', async () => {
635
- const { result } = setup(defaultProps);
636
- result.scrollToIndex(10, null, { behavior: 'smooth' });
637
- expect(result.scrollDetails.value.isProgrammaticScroll).toBe(true);
638
-
639
- result.stopProgrammaticScroll();
640
- expect(result.scrollDetails.value.isProgrammaticScroll).toBe(false);
641
- });
642
-
643
- it('should handle scrollToIndex with element container having scrollTo', async () => {
644
- const container = document.createElement('div');
645
- container.scrollTo = vi.fn();
646
- const { result } = setup({ ...defaultProps, container });
647
- await nextTick();
648
-
649
- result.scrollToIndex(10, 0, { behavior: 'auto' });
650
- await nextTick();
651
- expect(container.scrollTo).toHaveBeenCalled();
652
- });
121
+ result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
653
122
 
654
- it('should handle scrollToIndex fallback when scrollTo is missing', async () => {
655
- const container = document.createElement('div');
656
- (container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
657
- const { result } = setup({ ...defaultProps, container });
658
- await nextTick();
659
-
660
- // row only
661
- result.scrollToIndex(10, null, { behavior: 'auto' });
662
- await nextTick();
663
- expect(container.scrollTop).toBeGreaterThan(0);
664
-
665
- // col only
666
- const { result: resH } = setup({ ...defaultProps, container, direction: 'horizontal' });
667
- await nextTick();
668
- resH.scrollToIndex(null, 10, { behavior: 'auto' });
669
- await nextTick();
670
- expect(container.scrollLeft).toBeGreaterThan(0);
671
- });
123
+ await nextTick();
124
+ await nextTick();
672
125
 
673
- it('should skip undefined items in renderedItems', async () => {
674
- const items = Array.from({ length: 10 }) as unknown[];
675
- items[ 0 ] = { id: 0 };
676
- // other indices are undefined
677
- const { result } = setup({ ...defaultProps, items, itemSize: 50 });
678
- await nextTick();
679
- // only index 0 should be rendered
680
- expect(result.renderedItems.value.length).toBe(1);
681
- expect(result.renderedItems.value[ 0 ]?.index).toBe(0);
682
- });
126
+ expect(window.scrollTo).toHaveBeenCalled();
127
+ expect(result.scrollDetails.value.currentIndex).toBe(20);
683
128
  });
684
129
 
685
- describe('event handling and viewport', () => {
686
- it('should handle window scroll events', async () => {
687
- setup({ ...defaultProps });
688
- window.scrollX = 150;
689
- window.scrollY = 250;
690
- window.dispatchEvent(new Event('scroll'));
691
- await nextTick();
692
- });
693
-
694
- it('should cover fallback branches for unknown targets and directions', async () => {
695
- // 1. Unknown container type (hits 408, 445, 513, 718 else branches)
696
- const unknownContainer = {
697
- addEventListener: vi.fn(),
698
- removeEventListener: vi.fn(),
699
- } as unknown as HTMLElement;
700
-
701
- const { result } = setup({
702
- ...defaultProps,
703
- container: unknownContainer,
704
- hostElement: document.createElement('div'),
705
- });
706
- await nextTick();
707
-
708
- result.scrollToIndex(10, 0);
709
- result.scrollToOffset(100, 100);
710
- result.updateHostOffset();
711
-
712
- // 2. Invalid direction (hits 958 else branch)
713
- const { result: r2 } = setup({
714
- ...defaultProps,
715
- direction: undefined as unknown as 'vertical',
716
- stickyIndices: [ 0 ],
717
- });
718
- await nextTick();
719
- window.dispatchEvent(new Event('scroll'));
720
- await nextTick();
721
- expect(r2.renderedItems.value.find((i) => i.index === 0)).toBeDefined();
722
-
723
- // 3. Unknown target in handleScroll (hits 1100 else branch)
724
- const container = document.createElement('div');
725
- setup({ ...defaultProps, container });
726
- const event = new Event('scroll');
727
- Object.defineProperty(event, 'target', { value: { } });
728
- container.dispatchEvent(event);
729
- });
730
-
731
- it('should cleanup events and observers when container changes', async () => {
732
- const container = document.createElement('div');
733
- const removeSpy = vi.spyOn(container, 'removeEventListener');
734
- const { props } = setup({ ...defaultProps, container });
735
- await nextTick();
736
-
737
- // Change container to trigger cleanup of old one
738
- props.value.container = document.createElement('div');
739
- await nextTick();
740
-
741
- expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
130
+ it('handles dynamic item sizes', async () => {
131
+ const { result } = setup({
132
+ container: window,
133
+ direction: 'vertical',
134
+ itemSize: 0, // dynamic
135
+ items: mockItems,
742
136
  });
743
137
 
744
- it('should cleanup when unmounted and container is window', async () => {
745
- const { wrapper } = setup({ ...defaultProps, container: window });
746
- await nextTick();
747
- wrapper.unmount();
748
- });
749
-
750
- it('should cleanup when unmounted', async () => {
751
- const container = document.createElement('div');
752
- const removeSpy = vi.spyOn(container, 'removeEventListener');
753
- const { wrapper } = setup({ ...defaultProps, container });
754
- await nextTick();
755
-
756
- wrapper.unmount();
757
- expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
758
- });
138
+ await nextTick();
139
+ await nextTick();
759
140
 
760
- it('should handle document scroll events', async () => {
761
- setup({ ...defaultProps });
762
- document.dispatchEvent(new Event('scroll'));
763
- await nextTick();
764
- });
141
+ // Initial estimate 100 * 40 = 4000
142
+ expect(result.totalHeight.value).toBe(4000);
765
143
 
766
- it('should handle scroll events on container element', async () => {
767
- const container = document.createElement('div');
768
- setup({ ...defaultProps, container });
769
- Object.defineProperty(container, 'scrollTop', { value: 100 });
770
- container.dispatchEvent(new Event('scroll'));
771
- await nextTick();
772
- });
144
+ result.updateItemSize(0, 100, 100);
145
+ await nextTick();
773
146
 
774
- it('should update viewport size on container resize', async () => {
775
- const container = document.createElement('div');
776
- Object.defineProperty(container, 'clientWidth', { value: 500, writable: true });
777
- Object.defineProperty(container, 'clientHeight', { value: 500, writable: true });
778
- const { result } = setup({ ...defaultProps, container });
779
- await nextTick();
780
- expect(result.scrollDetails.value.viewportSize.width).toBe(500);
781
-
782
- Object.defineProperty(container, 'clientWidth', { value: 800 });
783
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(container));
784
- if (observer) {
785
- observer.trigger([ { target: container } ]);
786
- }
787
- await nextTick();
788
- expect(result.scrollDetails.value.viewportSize.width).toBe(800);
789
- });
790
-
791
- it('should handle isScrolling timeout', async () => {
792
- vi.useFakeTimers();
793
- const container = document.createElement('div');
794
- const { result } = setup({ ...defaultProps, container });
795
- container.dispatchEvent(new Event('scroll'));
796
- await nextTick();
797
- expect(result.scrollDetails.value.isScrolling).toBe(true);
798
- vi.advanceTimersByTime(250);
799
- await nextTick();
800
- expect(result.scrollDetails.value.isScrolling).toBe(false);
801
- vi.useRealTimers();
802
- });
803
-
804
- it('should handle container change in mount watcher', async () => {
805
- const { props } = setup({ ...defaultProps });
806
- await nextTick();
807
- props.value.container = null;
808
- await nextTick();
809
- props.value.container = window;
810
- await nextTick();
811
- });
812
-
813
- it('should handle window resize events', async () => {
814
- setup({ ...defaultProps, container: window });
815
- Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 });
816
- window.dispatchEvent(new Event('resize'));
817
- await nextTick();
818
- });
819
-
820
- it('should cover handleScroll with document target', async () => {
821
- setup({ ...defaultProps, container: window });
822
- document.dispatchEvent(new Event('scroll'));
823
- await nextTick();
824
- });
825
-
826
- it('should handle undefined window in handleScroll', async () => {
827
- const originalWindow = globalThis.window;
828
- const container = document.createElement('div');
829
- setup({ ...defaultProps, container });
830
-
831
- try {
832
- (globalThis as unknown as { window: unknown; }).window = undefined;
833
- container.dispatchEvent(new Event('scroll'));
834
- await nextTick();
835
- } finally {
836
- globalThis.window = originalWindow;
837
- }
838
- });
147
+ // Now 1*100 + 99*40 = 100 + 3960 = 4060
148
+ expect(result.totalHeight.value).toBe(4060);
839
149
  });
840
150
 
841
- describe('column widths and ranges', () => {
842
- it('should handle columnWidth as an array', async () => {
843
- const { result } = setup({
844
- ...defaultProps,
845
- direction: 'both',
846
- columnCount: 4,
847
- columnWidth: [ 100, 200 ],
848
- });
849
- expect(result.getColumnWidth(0)).toBe(100);
850
- expect(result.getColumnWidth(1)).toBe(200);
851
- expect(result.totalWidth.value).toBe(600);
151
+ it('restores scroll position when items are prepended', async () => {
152
+ const items = Array.from({ length: 20 }, (_, i) => ({ id: i }));
153
+ const { props, result } = setup({
154
+ container: window,
155
+ direction: 'vertical',
156
+ itemSize: 50,
157
+ items,
158
+ restoreScrollOnPrepend: true,
852
159
  });
853
160
 
854
- it('should handle columnWidth array fallback for falsy values', async () => {
855
- const { result } = setup({
856
- ...defaultProps,
857
- direction: 'both',
858
- columnCount: 2,
859
- columnWidth: [ 0 ] as unknown as number[],
860
- });
861
- expect(result.getColumnWidth(0)).toBe(150); // DEFAULT_COLUMN_WIDTH
862
- });
161
+ await nextTick();
162
+ await nextTick();
863
163
 
864
- it('should handle columnWidth as a function', async () => {
865
- const { result } = setup({
866
- ...defaultProps,
867
- direction: 'both',
868
- columnCount: 10,
869
- columnWidth: (index: number) => (index % 2 === 0 ? 100 : 200),
870
- });
871
- expect(result.getColumnWidth(0)).toBe(100);
872
- expect(result.totalWidth.value).toBe(1500);
873
- });
164
+ // Scroll to index 5 (250px)
165
+ result.scrollToOffset(0, 250, { behavior: 'auto' });
166
+ await nextTick();
167
+ await nextTick();
874
168
 
875
- it('should handle getColumnWidth fallback when dynamic', async () => {
876
- const { result } = setup({
877
- ...defaultProps,
878
- direction: 'both',
879
- columnCount: 2,
880
- columnWidth: undefined,
881
- });
882
- expect(result.getColumnWidth(0)).toBe(150);
883
- });
169
+ expect(window.scrollY).toBe(250);
884
170
 
885
- it('should handle columnRange while loop coverage', async () => {
886
- const container = document.createElement('div');
887
- const { result } = setup({
888
- ...defaultProps,
889
- direction: 'both',
890
- columnCount: 50,
891
- columnWidth: undefined,
892
- container,
893
- });
894
- // Initialize some column widths
895
- for (let i = 0; i < 20; i++) {
896
- const parent = document.createElement('div');
897
- const child = document.createElement('div');
898
- Object.defineProperty(child, 'offsetWidth', { value: 100 });
899
- child.dataset.colIndex = String(i);
900
- parent.appendChild(child);
901
- result.updateItemSize(0, 100, 50, parent);
902
- }
903
- await nextTick();
904
- expect(result.columnRange.value.end).toBeGreaterThan(result.columnRange.value.start);
905
- });
171
+ // Prepend 2 items (100px)
172
+ props.value.items = [ { id: -1 }, { id: -2 }, ...items ];
906
173
 
907
- it('should handle zero column count', async () => {
908
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 0 });
909
- await nextTick();
910
- expect(result.columnRange.value.end).toBe(0);
911
- });
174
+ await nextTick();
175
+ await nextTick();
176
+ await nextTick();
912
177
 
913
- it('should cover columnRange safeStart clamp', async () => {
914
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 10, columnWidth: 100 });
915
- await nextTick();
916
- expect(result.columnRange.value.start).toBe(0);
917
- });
178
+ // Scroll should be adjusted to 350
179
+ expect(window.scrollY).toBe(350);
918
180
  });
919
181
 
920
- describe('sticky and pushed items', () => {
921
- it('should identify sticky items', async () => {
922
- const { result } = setup({ ...defaultProps, stickyIndices: [ 0, 10 ] });
923
- await nextTick();
924
-
925
- const items = result.renderedItems.value;
926
- const item0 = items.find((i) => i.index === 0);
927
- const item10 = items.find((i) => i.index === 10);
928
- expect(item0?.isSticky).toBe(true);
929
- expect(item10?.isSticky).toBe(true);
182
+ it('triggers correction when viewport dimensions change', async () => {
183
+ const { result } = setup({
184
+ container: window,
185
+ direction: 'vertical',
186
+ itemSize: 50,
187
+ items: mockItems,
930
188
  });
931
189
 
932
- it('should make sticky items active when scrolled past', async () => {
933
- const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ] });
934
- await nextTick();
935
-
936
- result.scrollToOffset(0, 100);
937
- await nextTick();
938
-
939
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
940
- expect(item0?.isStickyActive).toBe(true);
941
- });
942
-
943
- it('should include current sticky item in rendered items even if range is ahead', async () => {
944
- const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ], bufferBefore: 0 });
945
- await nextTick();
946
-
947
- // Scroll to index 20. Range starts at 20.
948
- result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
949
- await nextTick();
950
-
951
- expect(result.scrollDetails.value.range.start).toBe(20);
952
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
953
- expect(item0).toBeDefined();
954
- expect(item0?.isStickyActive).toBe(true);
955
- });
956
-
957
- it('should push sticky item when next sticky item approaches (vertical)', async () => {
958
- const container = document.createElement('div');
959
- Object.defineProperty(container, 'clientHeight', { value: 500 });
960
- Object.defineProperty(container, 'scrollTop', { value: 480, writable: true });
961
- const { result } = setup({ ...defaultProps, container, stickyIndices: [ 0, 10 ], itemSize: 50 });
962
- // We need to trigger scroll to update scrollY
963
- container.dispatchEvent(new Event('scroll'));
964
- await nextTick();
190
+ await nextTick();
191
+ await nextTick();
965
192
 
966
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
967
- expect(item0!.offset.y).toBeLessThanOrEqual(450);
968
- });
193
+ // Scroll to item 50 auto
194
+ result.scrollToIndex(50, null, { align: 'auto', behavior: 'auto' });
195
+ await nextTick();
969
196
 
970
- it('should push sticky item when next sticky item approaches (horizontal)', async () => {
971
- const container = document.createElement('div');
972
- Object.defineProperty(container, 'clientWidth', { value: 500 });
973
- Object.defineProperty(container, 'scrollLeft', { value: 480, writable: true });
974
-
975
- const { result } = setup({
976
- ...defaultProps,
977
- direction: 'horizontal',
978
- container,
979
- stickyIndices: [ 0, 10 ],
980
- itemSize: 50,
981
- columnGap: 0,
982
- });
983
- container.dispatchEvent(new Event('scroll'));
984
- await nextTick();
197
+ const initialScrollY = window.scrollY;
198
+ // item 50 at 2500. viewport 500. item 50 high.
199
+ // targetEnd = 2500 - (500 - 50) = 2050.
200
+ expect(initialScrollY).toBe(2050);
985
201
 
986
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
987
- expect(item0!.offset.x).toBeLessThanOrEqual(450);
988
- });
202
+ // Simulate viewport height decreasing
203
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 485 });
204
+ window.dispatchEvent(new Event('resize'));
989
205
 
990
- it('should handle dynamic sticky item pushing in vertical mode', async () => {
991
- const container = document.createElement('div');
992
- Object.defineProperty(container, 'clientHeight', { value: 500 });
993
- Object.defineProperty(container, 'scrollTop', { value: 460, writable: true });
994
-
995
- const { result } = setup({
996
- ...defaultProps,
997
- container,
998
- itemSize: undefined, // dynamic
999
- stickyIndices: [ 0, 10 ],
1000
- });
1001
- await nextTick();
1002
-
1003
- // Item 0 is sticky. Item 10 is next sticky.
1004
- // Default size = 50.
1005
- // nextStickyY = itemSizesY.query(10) = 500.
1006
- // distance = 500 - 460 = 40.
1007
- // 40 < 50 (item 0 height), so it should be pushed.
1008
- // stickyOffset.y = -(50 - 40) = -10.
1009
- const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1010
- expect(stickyItem?.stickyOffset.y).toBe(-10);
1011
- });
206
+ await nextTick();
207
+ await nextTick();
1012
208
 
1013
- it('should handle dynamic sticky item pushing in horizontal mode', async () => {
1014
- const container = document.createElement('div');
1015
- Object.defineProperty(container, 'clientWidth', { value: 500 });
1016
- Object.defineProperty(container, 'scrollLeft', { value: 460, writable: true });
1017
-
1018
- const { result } = setup({
1019
- ...defaultProps,
1020
- container,
1021
- direction: 'horizontal',
1022
- itemSize: undefined, // dynamic
1023
- stickyIndices: [ 0, 10 ],
1024
- });
1025
- await nextTick();
1026
-
1027
- // nextStickyX = itemSizesX.query(10) = 500.
1028
- // distance = 500 - 460 = 40.
1029
- // 40 < 50, so stickyOffset.x = -10.
1030
- const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1031
- expect(stickyItem?.stickyOffset.x).toBe(-10);
1032
- });
209
+ // It should have corrected to: 2500 - (485 - 50) = 2500 - 435 = 2065.
210
+ expect(window.scrollY).toBe(2065);
1033
211
  });
1034
212
 
1035
- describe('scroll restoration', () => {
1036
- it('should restore scroll position when items are prepended', async () => {
1037
- vi.useFakeTimers();
1038
- const container = document.createElement('div');
1039
- Object.defineProperty(container, 'clientHeight', { value: 500 });
1040
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1041
- container.scrollTo = vi.fn().mockImplementation((options) => {
1042
- container.scrollTop = options.top;
1043
- });
1044
-
1045
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1046
- const { result, props } = setup({
1047
- ...defaultProps,
1048
- items,
1049
- container,
1050
- itemSize: 50,
1051
- restoreScrollOnPrepend: true,
1052
- });
1053
- container.dispatchEvent(new Event('scroll'));
1054
- await nextTick();
1055
-
1056
- expect(result.scrollDetails.value.scrollOffset.y).toBe(100);
1057
-
1058
- // Prepend 2 items
1059
- const newItems = [ { id: -1 }, { id: -2 }, ...items ];
1060
- props.value.items = newItems;
1061
- await nextTick();
1062
- // Trigger initializeSizes
1063
- await nextTick();
1064
-
1065
- // Should have adjusted scroll by 2 * 50 = 100px. New scrollTop should be 200.
1066
- expect(container.scrollTop).toBe(200);
1067
- vi.useRealTimers();
1068
- });
1069
-
1070
- it('should restore scroll position when items are prepended (horizontal)', async () => {
1071
- vi.useFakeTimers();
1072
- const container = document.createElement('div');
1073
- Object.defineProperty(container, 'clientWidth', { value: 500 });
1074
- Object.defineProperty(container, 'scrollLeft', { value: 100, writable: true });
1075
- container.scrollTo = vi.fn().mockImplementation((options) => {
213
+ it('renders sticky indices correctly using optimized search', async () => {
214
+ // Use an isolated container to avoid window pollution
215
+ const container = document.createElement('div');
216
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 200 });
217
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
218
+ // Mock scrollTo on container
219
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
220
+ if (options.left !== undefined) {
1076
221
  container.scrollLeft = options.left;
1077
- });
1078
-
1079
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1080
- const { result, props } = setup({
1081
- ...defaultProps,
1082
- direction: 'horizontal',
1083
- items,
1084
- container,
1085
- itemSize: 50,
1086
- restoreScrollOnPrepend: true,
1087
- });
1088
- container.dispatchEvent(new Event('scroll'));
1089
- await nextTick();
1090
-
1091
- expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
1092
-
1093
- // Prepend 2 items
1094
- const newItems = [ { id: -1 }, { id: -2 }, ...items ];
1095
- props.value.items = newItems;
1096
- await nextTick();
1097
- await nextTick();
1098
-
1099
- expect(container.scrollLeft).toBe(200);
1100
- vi.useRealTimers();
1101
- });
1102
-
1103
- it('should restore scroll position with itemSize as function when prepending', async () => {
1104
- vi.useFakeTimers();
1105
- const container = document.createElement('div');
1106
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1107
- container.scrollTo = vi.fn().mockImplementation((options) => {
222
+ }
223
+ if (options.top !== undefined) {
1108
224
  container.scrollTop = options.top;
1109
- });
1110
-
1111
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1112
- const { props } = setup({
1113
- ...defaultProps,
1114
- items,
1115
- container,
1116
- itemSize: (item: { id: number; }) => (item.id < 0 ? 100 : 50),
1117
- restoreScrollOnPrepend: true,
1118
- });
1119
- await nextTick();
1120
-
1121
- // Prepend 1 item with id -1 (size 100)
1122
- const newItems = [ { id: -1 }, ...items ];
1123
- props.value.items = newItems;
1124
- await nextTick();
1125
- await nextTick();
1126
-
1127
- // Should have adjusted scroll by 100px. New scrollTop should be 200.
1128
- expect(container.scrollTop).toBe(200);
1129
- vi.useRealTimers();
1130
- });
1131
-
1132
- it('should NOT restore scroll position when restoreScrollOnPrepend is false', async () => {
1133
- const container = document.createElement('div');
1134
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1135
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1136
- const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: false });
1137
- await nextTick();
1138
-
1139
- const newItems = [ { id: -1 }, ...items ];
1140
- props.value.items = newItems;
1141
- await nextTick();
1142
- await nextTick();
1143
- expect(container.scrollTop).toBe(100);
1144
- });
1145
-
1146
- it('should NOT restore scroll position when first item does not match', async () => {
1147
- const container = document.createElement('div');
1148
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1149
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1150
- const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: true });
1151
- await nextTick();
1152
-
1153
- const newItems = [ { id: -1 }, { id: 9999 } ]; // completely different
1154
- props.value.items = newItems;
1155
- await nextTick();
1156
- await nextTick();
1157
- expect(container.scrollTop).toBe(100);
225
+ }
226
+ container.dispatchEvent(new Event('scroll'));
1158
227
  });
1159
228
 
1160
- it('should update pendingScroll rowIndex when items are prepended', async () => {
1161
- const container = document.createElement('div');
1162
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1163
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1164
- const { result, props } = setup({ ...defaultProps, container, restoreScrollOnPrepend: true });
1165
- result.scrollToIndex(10, null, { behavior: 'smooth' });
1166
- // pendingScroll should be set because it's not reached yet
1167
-
1168
- props.value.items = [ { id: -1 }, ...props.value.items ];
1169
- await nextTick();
1170
- });
229
+ const { result } = setup({
230
+ container,
231
+ direction: 'vertical',
232
+ itemSize: 50,
233
+ items: Array.from({ length: 20 }, (_, i) => ({ id: i })),
234
+ stickyIndices: [ 0, 10, 19 ],
235
+ bufferBefore: 0,
236
+ bufferAfter: 0,
237
+ });
238
+
239
+ await nextTick();
240
+ await nextTick();
241
+
242
+ // 1. Initial scroll 0. Range [0, 4].
243
+ expect(result.renderedItems.value.map((i) => i.index)).toEqual([ 0, 1, 2, 3 ]);
244
+
245
+ // 2. Scroll to 100 (item 2). Range [2, 6].
246
+ container.scrollTop = 100;
247
+ container.dispatchEvent(new Event('scroll'));
248
+ await nextTick();
249
+ await nextTick();
250
+
251
+ const indices2 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
252
+ expect(indices2).toEqual([ 0, 2, 3, 4, 5 ]);
253
+ expect(result.renderedItems.value.find((i) => i.index === 0)?.isStickyActive).toBe(true);
254
+
255
+ // 3. Scroll to 500 (item 10). Range [10, 14].
256
+ container.scrollTop = 500;
257
+ container.dispatchEvent(new Event('scroll'));
258
+ await nextTick();
259
+ await nextTick();
260
+
261
+ const indices3 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
262
+ expect(indices3).toContain(0);
263
+ expect(indices3).toContain(10);
264
+ expect(indices3).toContain(11);
265
+ expect(indices3).toContain(12);
266
+ expect(indices3).toContain(13);
1171
267
  });
1172
268
 
1173
- describe('advanced logic and edge cases', () => {
1174
- it('should trigger scroll correction when isScrolling becomes false', async () => {
1175
- vi.useFakeTimers();
1176
- const { result } = setup({ ...defaultProps, container: window, itemSize: undefined });
1177
- await nextTick();
1178
- result.scrollToIndex(10, 0, 'start');
1179
- document.dispatchEvent(new Event('scroll'));
1180
- expect(result.scrollDetails.value.isScrolling).toBe(true);
1181
- vi.advanceTimersByTime(250);
1182
- await nextTick();
1183
- expect(result.scrollDetails.value.isScrolling).toBe(false);
1184
- vi.useRealTimers();
269
+ it('updates item sizes and compensates scroll position', async () => {
270
+ const { result } = setup({
271
+ container: window,
272
+ direction: 'vertical',
273
+ itemSize: 0,
274
+ items: mockItems,
1185
275
  });
1186
276
 
1187
- it('should trigger scroll correction when treeUpdateFlag changes', async () => {
1188
- const { result } = setup({ ...defaultProps, itemSize: undefined });
1189
- await nextTick();
1190
- result.scrollToIndex(10, 0, 'start');
1191
- // Trigger tree update
1192
- result.updateItemSize(5, 100, 100);
1193
- await nextTick();
1194
- });
277
+ await nextTick();
278
+ await nextTick();
1195
279
 
1196
- it('should cover updateHostOffset when container is window', async () => {
1197
- const { result, props } = setup({ ...defaultProps, container: window });
1198
- const host = document.createElement('div');
1199
- props.value.hostElement = host;
1200
- await nextTick();
1201
- result.updateHostOffset();
1202
- });
1203
-
1204
- it('should cover updateHostOffset when container is hostElement', async () => {
1205
- const host = document.createElement('div');
1206
- const { result } = setup({ ...defaultProps, container: host, hostElement: host });
1207
- await nextTick();
1208
- result.updateHostOffset();
1209
- });
1210
-
1211
- it('should handle updateHostOffset with window fallback when container is missing', async () => {
1212
- const { result, props } = setup({ ...defaultProps, container: undefined });
1213
- const host = document.createElement('div');
1214
- props.value.hostElement = host;
1215
- await nextTick();
1216
- result.updateHostOffset();
1217
- });
1218
-
1219
- it('should correctly calculate hostOffset when container is an HTMLElement', async () => {
1220
- const container = document.createElement('div');
1221
- const hostElement = document.createElement('div');
1222
-
1223
- container.getBoundingClientRect = vi.fn(() => ({ top: 100, left: 100, bottom: 200, right: 200, width: 100, height: 100, x: 100, y: 100, toJSON: () => '' }));
1224
- hostElement.getBoundingClientRect = vi.fn(() => ({ top: 150, left: 150, bottom: 200, right: 200, width: 50, height: 50, x: 150, y: 150, toJSON: () => '' }));
1225
- Object.defineProperty(container, 'scrollTop', { value: 50, writable: true, configurable: true });
1226
-
1227
- const { result } = setup({ ...defaultProps, container, hostElement });
1228
- await nextTick();
1229
- result.updateHostOffset();
1230
- expect(result.scrollDetails.value.scrollOffset.y).toBeDefined();
1231
- });
1232
-
1233
- it('should cover refresh method', async () => {
1234
- const { result } = setup({ ...defaultProps, itemSize: 0 });
1235
- result.updateItemSize(0, 100, 100);
1236
- await nextTick();
1237
- expect(result.totalHeight.value).toBe(5050);
1238
-
1239
- result.refresh();
1240
- await nextTick();
1241
- expect(result.totalHeight.value).toBe(5000);
1242
- });
1243
-
1244
- it('should trigger scroll correction on tree update with string alignment', async () => {
1245
- const container = document.createElement('div');
1246
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1247
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1248
- const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1249
- // Set a pending scroll with string alignment
1250
- result.scrollToIndex(10, null, 'start');
1251
-
1252
- // Trigger tree update
1253
- result.updateItemSize(0, 100, 100);
1254
- await nextTick();
1255
- });
1256
-
1257
- it('should trigger scroll correction on tree update with pending scroll', async () => {
1258
- const container = document.createElement('div');
1259
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1260
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1261
- const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1262
- // Set a pending scroll
1263
- result.scrollToIndex(10, null, { behavior: 'smooth' });
1264
-
1265
- // Trigger tree update
1266
- result.updateItemSize(0, 100, 100);
1267
- await nextTick();
1268
- });
280
+ // Scroll to item 10 (10 * 40 = 400px)
281
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 400, writable: true });
282
+ document.dispatchEvent(new Event('scroll'));
283
+ await nextTick();
1269
284
 
1270
- it('should trigger scroll correction when scrolling stops with pending scroll', async () => {
1271
- vi.useFakeTimers();
1272
- const container = document.createElement('div');
1273
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1274
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1275
- const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1276
- result.scrollToIndex(10, null, { behavior: 'smooth' });
285
+ // Update item 0 (above viewport) from 40 to 100
286
+ result.updateItemSize(0, 100, 100);
287
+ await nextTick();
1277
288
 
1278
- // Start scrolling
1279
- container.dispatchEvent(new Event('scroll'));
1280
- await nextTick();
1281
- expect(result.scrollDetails.value.isScrolling).toBe(true);
1282
-
1283
- // Wait for scroll timeout
1284
- vi.advanceTimersByTime(250);
1285
- await nextTick();
1286
- expect(result.scrollDetails.value.isScrolling).toBe(false);
1287
- vi.useRealTimers();
1288
- });
289
+ // Scroll position should have been adjusted by 60px
290
+ expect(window.scrollY).toBe(460);
1289
291
  });
1290
292
 
1291
- // eslint-disable-next-line test/prefer-lowercase-title
1292
- describe('SSR support', () => {
1293
- it('should handle colBuffer when ssrRange is present and not scrolling', async () => {
1294
- vi.useFakeTimers();
1295
- const container = document.createElement('div');
1296
- Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
1297
- Object.defineProperty(container, 'scrollLeft', { value: 0, writable: true, configurable: true });
1298
- container.scrollTo = vi.fn().mockImplementation((options) => {
1299
- if (options.left !== undefined) {
1300
- Object.defineProperty(container, 'scrollLeft', { value: options.left, writable: true, configurable: true });
1301
- }
1302
- });
1303
-
1304
- const { result } = setup({
1305
- ...defaultProps,
1306
- container,
1307
- direction: 'both',
1308
- columnCount: 20,
1309
- columnWidth: 100,
1310
- ssrRange: { start: 0, end: 10, colStart: 1, colEnd: 2 }, // SSR values
1311
- initialScrollIndex: 0,
1312
- });
1313
-
1314
- await nextTick(); // onMounted schedules hydration
1315
- await nextTick(); // hydration tick 1
1316
- await nextTick(); // hydration tick 2 (isHydrating = false)
1317
-
1318
- expect(result.isHydrated.value).toBe(true);
1319
-
1320
- // Scroll to col 5 (offset 500)
1321
- result.scrollToIndex(null, 5, { align: 'start', behavior: 'auto' });
1322
- await nextTick();
1323
-
1324
- vi.runAllTimers(); // Clear isScrolling timeout
1325
- await nextTick();
1326
-
1327
- // start = findLowerBound(500) = 5.
1328
- // colBuffer should be 0 because ssrRange is present and isScrolling is false.
1329
- expect(result.columnRange.value.start).toBe(5);
1330
-
1331
- // Now trigger a scroll to make isScrolling true
1332
- container.dispatchEvent(new Event('scroll'));
1333
- await nextTick();
1334
- // isScrolling is now true. colBuffer should be 2.
1335
- expect(result.columnRange.value.start).toBe(3);
1336
- vi.useRealTimers();
1337
- });
1338
-
1339
- it('should handle bufferBefore when ssrRange is present and not scrolling', async () => {
1340
- vi.useFakeTimers();
1341
- const container = document.createElement('div');
1342
- Object.defineProperty(container, 'clientHeight', { value: 500 });
1343
- Object.defineProperty(container, 'scrollTop', { value: 0, writable: true, configurable: true });
1344
- container.scrollTo = vi.fn().mockImplementation((options) => {
1345
- if (options.top !== undefined) {
1346
- Object.defineProperty(container, 'scrollTop', { value: options.top, writable: true, configurable: true });
1347
- }
1348
- });
1349
-
1350
- const { result } = setup({
1351
- ...defaultProps,
1352
- container,
1353
- itemSize: 50,
1354
- bufferBefore: 5,
1355
- ssrRange: { start: 0, end: 10 },
1356
- initialScrollIndex: 10,
1357
- });
1358
-
1359
- await nextTick(); // schedules hydration
1360
- await nextTick(); // hydration tick scrolls to 10
1361
- await nextTick();
1362
-
1363
- vi.runAllTimers(); // Clear isScrolling timeout
1364
- await nextTick();
1365
-
1366
- expect(result.isHydrated.value).toBe(true);
1367
- // start = floor(500 / 50) = 10.
1368
- // Since ssrRange is present and isScrolling is false, bufferBefore should be 0.
1369
- expect(result.renderedItems.value[ 0 ]?.index).toBe(10);
1370
-
1371
- // Now trigger a scroll to make isScrolling true
1372
- container.dispatchEvent(new Event('scroll'));
1373
- await nextTick();
1374
- // isScrolling is now true. bufferBefore should be 5.
1375
- expect(result.renderedItems.value[ 0 ]?.index).toBe(5);
1376
- vi.useRealTimers();
1377
- });
1378
-
1379
- it('should handle SSR range in range calculation', () => {
1380
- const props = ref({
1381
- items: mockItems,
1382
- ssrRange: { start: 0, end: 10 },
1383
- }) as Ref<VirtualScrollProps<unknown>>;
1384
- const result = useVirtualScroll(props);
1385
- expect(result.renderedItems.value.length).toBe(10);
1386
- });
1387
-
1388
- it('should handle SSR range in columnRange calculation', () => {
1389
- const props = ref({
1390
- items: mockItems,
1391
- columnCount: 10,
1392
- ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
1393
- }) as Ref<VirtualScrollProps<unknown>>;
1394
- const result = useVirtualScroll(props);
1395
- expect(result.columnRange.value.end).toBe(5);
1396
- });
1397
-
1398
- it('should handle SSR range with colEnd fallback in columnRange calculation', () => {
1399
- const props = ref({
1400
- items: mockItems,
1401
- columnCount: 10,
1402
- ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 0 },
1403
- }) as Ref<VirtualScrollProps<unknown>>;
1404
- const result = useVirtualScroll(props);
1405
- // colEnd is 0, so it should use columnCount (10)
1406
- expect(result.columnRange.value.end).toBe(10);
1407
- });
1408
-
1409
- it('should handle SSR range with both directions for total sizes', () => {
1410
- const props = ref({
1411
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1412
- direction: 'both',
1413
- columnCount: 10,
1414
- columnWidth: 100,
1415
- itemSize: 50,
1416
- ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1417
- }) as Ref<VirtualScrollProps<unknown>>;
1418
- const result = useVirtualScroll(props);
1419
- expect(result.totalWidth.value).toBe(300); // (5-2) * 100
1420
- expect(result.totalHeight.value).toBe(500); // (20-10) * 50
1421
- });
1422
-
1423
- it('should handle SSR range with horizontal direction for total sizes', () => {
1424
- const props = ref({
1425
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1426
- direction: 'horizontal',
1427
- itemSize: 50,
1428
- ssrRange: { start: 10, end: 20 },
1429
- }) as Ref<VirtualScrollProps<unknown>>;
1430
- const result = useVirtualScroll(props);
1431
- expect(result.totalWidth.value).toBe(500); // (20-10) * 50
1432
- });
1433
-
1434
- it('should handle SSR range with vertical offset in renderedItems', () => {
1435
- const props = ref({
1436
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1437
- direction: 'vertical',
1438
- itemSize: 50,
1439
- ssrRange: { start: 10, end: 20 },
1440
- }) as Ref<VirtualScrollProps<unknown>>;
1441
- const result = useVirtualScroll(props);
1442
- expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1443
- });
1444
-
1445
- it('should handle SSR range with dynamic horizontal offsets in renderedItems', () => {
1446
- const props = ref({
1447
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1448
- direction: 'horizontal',
1449
- itemSize: undefined, // dynamic
1450
- ssrRange: { start: 10, end: 20 },
1451
- }) as Ref<VirtualScrollProps<unknown>>;
1452
- const result = useVirtualScroll(props);
1453
- // ssrOffsetX = itemSizesX.query(10) = 10 * 50 = 500
1454
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(500);
1455
- });
1456
-
1457
- it('should handle SSR range with dynamic sizes for total sizes', () => {
1458
- const props = ref({
1459
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1460
- direction: 'vertical',
1461
- itemSize: 0,
1462
- ssrRange: { start: 10, end: 20 },
1463
- }) as Ref<VirtualScrollProps<unknown>>;
1464
- const result = useVirtualScroll(props);
1465
- expect(result.totalHeight.value).toBe(500);
293
+ it('supports refresh method', async () => {
294
+ const { result } = setup({
295
+ container: window,
296
+ direction: 'vertical',
297
+ itemSize: 50,
298
+ items: mockItems,
1466
299
  });
1467
300
 
1468
- it('should handle SSR range with dynamic horizontal sizes for total sizes', () => {
1469
- const props = ref({
1470
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1471
- direction: 'horizontal',
1472
- itemSize: 0,
1473
- ssrRange: { start: 10, end: 20 },
1474
- }) as Ref<VirtualScrollProps<unknown>>;
1475
- const result = useVirtualScroll(props);
1476
- expect(result.totalWidth.value).toBe(500);
1477
- });
301
+ await nextTick();
302
+ result.refresh();
303
+ await nextTick();
304
+ expect(result.totalHeight.value).toBe(5000);
305
+ });
1478
306
 
1479
- it('should handle SSR range with both directions and dynamic offsets', () => {
1480
- const props = ref({
1481
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1482
- direction: 'both',
1483
- columnCount: 10,
1484
- itemSize: 0,
1485
- ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1486
- }) as Ref<VirtualScrollProps<unknown>>;
1487
- const result = useVirtualScroll(props);
1488
- expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1489
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-300);
307
+ it('supports getColumnWidth with various types', async () => {
308
+ const { result } = setup({
309
+ columnCount: 10,
310
+ columnWidth: [ 100, 200 ],
311
+ direction: 'both',
312
+ items: mockItems,
1490
313
  });
1491
314
 
1492
- it('should scroll to ssrRange on mount', async () => {
1493
- setup({ ...defaultProps, ssrRange: { start: 50, end: 60 } });
1494
- await nextTick();
1495
- expect(window.scrollTo).toHaveBeenCalled();
1496
- });
315
+ await nextTick();
316
+ expect(result.getColumnWidth(0)).toBe(100);
317
+ expect(result.getColumnWidth(1)).toBe(200);
318
+ expect(result.getColumnWidth(2)).toBe(100);
319
+ });
1497
320
 
1498
- it('should handle SSR range with horizontal direction and colStart', () => {
1499
- const props = ref({
1500
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1501
- direction: 'horizontal',
1502
- itemSize: 50,
1503
- ssrRange: { start: 0, end: 10, colStart: 5 },
1504
- }) as Ref<VirtualScrollProps<unknown>>;
1505
- const result = useVirtualScroll(props);
1506
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-250);
321
+ it('updates column sizes from row element', async () => {
322
+ const { result } = setup({
323
+ columnCount: 5,
324
+ columnWidth: 0, // dynamic
325
+ direction: 'both',
326
+ items: mockItems,
1507
327
  });
1508
328
 
1509
- it('should handle SSR range with direction "both" and colStart', () => {
1510
- const props = ref({
1511
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1512
- direction: 'both',
1513
- columnCount: 20,
1514
- columnWidth: 100,
1515
- ssrRange: { start: 0, end: 10, colStart: 5, colEnd: 15 },
1516
- }) as Ref<VirtualScrollProps<unknown>>;
1517
- const result = useVirtualScroll(props);
1518
- // ssrOffsetX = columnSizes.query(5) = 5 * 100 = 500
1519
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-500);
1520
- });
329
+ await nextTick();
1521
330
 
1522
- it('should handle SSR range with direction "both" and colEnd falsy', () => {
1523
- const propsValue = ref({
1524
- columnCount: 10,
1525
- columnWidth: 100,
1526
- direction: 'both' as const,
1527
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1528
- ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
1529
- }) as Ref<VirtualScrollProps<unknown>>;
1530
- const result = useVirtualScroll(propsValue);
1531
- // colEnd is 0, so it should use colCount (10)
1532
- // totalWidth = columnSizes.query(10) - columnSizes.query(5) = 1000 - 500 = 500
1533
- expect(result.totalWidth.value).toBe(500);
331
+ const rowEl = document.createElement('div');
332
+ const cell0 = document.createElement('div');
333
+ cell0.dataset.colIndex = '0';
334
+ Object.defineProperty(cell0, 'getBoundingClientRect', {
335
+ value: () => ({ width: 150 }),
1534
336
  });
337
+ rowEl.appendChild(cell0);
1535
338
 
1536
- it('should handle SSR range with colCount > 0 in totalWidth', () => {
1537
- const props = ref({
1538
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1539
- direction: 'both',
1540
- columnCount: 10,
1541
- columnWidth: 100,
1542
- ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
1543
- }) as Ref<VirtualScrollProps<unknown>>;
1544
- const result = useVirtualScroll(props);
1545
- expect(result.totalWidth.value).toBe(500);
1546
- });
1547
- });
339
+ result.updateItemSizes([ {
340
+ blockSize: 100,
341
+ element: rowEl,
342
+ index: 0,
343
+ inlineSize: 0,
344
+ } ]);
1548
345
 
1549
- describe('helpers', () => {
1550
- it('should cover object padding branches in helpers', () => {
1551
- expect(getPaddingX({ x: 10 }, 'horizontal')).toBe(10);
1552
- expect(getPaddingY({ y: 20 }, 'vertical')).toBe(20);
1553
- });
346
+ await nextTick();
347
+ expect(result.getColumnWidth(0)).toBe(150);
1554
348
  });
1555
349
  });