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