@pdanpdan/virtual-scroll 0.4.0 → 0.6.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,20 +1,62 @@
1
- /* global ScrollToOptions */
1
+ /* global ScrollToOptions, ResizeObserverCallback */
2
2
  import type { VirtualScrollProps } from '../types';
3
3
  import type { Ref } from 'vue';
4
4
 
5
5
  import { mount } from '@vue/test-utils';
6
- import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { defineComponent, nextTick, ref } from 'vue';
8
8
 
9
9
  import { useVirtualScroll } from './useVirtualScroll';
10
10
 
11
11
  // --- Mocks ---
12
12
 
13
+ interface ResizeObserverMock extends ResizeObserver {
14
+ callback: ResizeObserverCallback;
15
+ targets: Set<Element>;
16
+ }
17
+
18
+ const observers: ResizeObserverMock[] = [];
13
19
  globalThis.ResizeObserver = class ResizeObserver {
14
- observe = vi.fn();
15
- unobserve = vi.fn();
16
- disconnect = vi.fn();
17
- };
20
+ callback: ResizeObserverCallback;
21
+ targets = new Set<Element>();
22
+ constructor(callback: ResizeObserverCallback) {
23
+ this.callback = callback;
24
+ observers.push(this as unknown as ResizeObserverMock);
25
+ }
26
+
27
+ observe(el: Element) {
28
+ this.targets.add(el);
29
+ }
30
+
31
+ unobserve(el: Element) {
32
+ this.targets.delete(el);
33
+ }
34
+
35
+ disconnect() {
36
+ this.targets.clear();
37
+ }
38
+ } as unknown as typeof ResizeObserver;
39
+
40
+ function triggerResize(el: Element, width: number, height: number) {
41
+ const obs = observers.find((o) => o.targets.has(el));
42
+ if (obs) {
43
+ obs.callback([ {
44
+ borderBoxSize: [ { blockSize: height, inlineSize: width } ],
45
+ contentRect: {
46
+ bottom: height,
47
+ height,
48
+ left: 0,
49
+ right: width,
50
+ toJSON: () => '',
51
+ top: 0,
52
+ width,
53
+ x: 0,
54
+ y: 0,
55
+ },
56
+ target: el,
57
+ } as unknown as ResizeObserverEntry ], obs);
58
+ }
59
+ }
18
60
 
19
61
  Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
20
62
  Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
@@ -33,6 +75,10 @@ globalThis.window.scrollTo = vi.fn().mockImplementation((options: ScrollToOption
33
75
  document.dispatchEvent(new Event('scroll'));
34
76
  });
35
77
 
78
+ interface MockItem {
79
+ id: number;
80
+ }
81
+
36
82
  // Helper to test composable
37
83
  function setup<T>(propsValue: VirtualScrollProps<T>) {
38
84
  const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
@@ -49,301 +95,1533 @@ function setup<T>(propsValue: VirtualScrollProps<T>) {
49
95
  }
50
96
 
51
97
  describe('useVirtualScroll', () => {
52
- const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
98
+ const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i }));
53
99
 
54
100
  beforeEach(() => {
55
- vi.clearAllMocks();
56
101
  Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
57
102
  Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
103
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 500 });
104
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 500 });
105
+ });
106
+
107
+ afterEach(() => {
108
+ vi.clearAllMocks();
58
109
  });
59
110
 
60
- it('calculates total dimensions correctly', async () => {
61
- const { result } = setup({
62
- container: window,
63
- direction: 'vertical',
64
- itemSize: 50,
65
- items: mockItems,
111
+ describe('core rendering & dimensions', () => {
112
+ it('calculates total dimensions correctly', async () => {
113
+ const { result, wrapper } = setup({
114
+ container: window,
115
+ direction: 'vertical',
116
+ itemSize: 50,
117
+ items: mockItems,
118
+ });
119
+
120
+ expect(result.totalHeight.value).toBe(5000);
121
+ expect(result.totalWidth.value).toBe(500);
122
+ wrapper.unmount();
66
123
  });
67
124
 
68
- expect(result.totalHeight.value).toBe(5000);
69
- expect(result.totalWidth.value).toBe(500);
70
- });
125
+ it('provides rendered items for the visible range', async () => {
126
+ const { result, wrapper } = setup({
127
+ container: window,
128
+ direction: 'vertical',
129
+ itemSize: 50,
130
+ items: mockItems,
131
+ });
132
+
133
+ await nextTick();
134
+ await nextTick();
135
+
136
+ // viewport 500, item 50 => 10 items + buffer 5 = 15 items
137
+ expect(result.renderedItems.value.length).toBe(15);
138
+ expect(result.renderedItems.value[ 0 ]!.index).toBe(0);
139
+ wrapper.unmount();
140
+ });
71
141
 
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,
142
+ it('provides getRowHeight and getColumnWidth helpers', async () => {
143
+ const items: MockItem[] = [ { id: 1 }, { id: 2 } ];
144
+ const { result, wrapper } = setup({
145
+ direction: 'both',
146
+ itemSize: (item: MockItem) => (item.id === 1 ? 60 : 40),
147
+ columnWidth: [ 100, 200 ],
148
+ items,
149
+ gap: 10,
150
+ columnGap: 20,
151
+ });
152
+
153
+ await nextTick();
154
+
155
+ // getRowHeight returns item size WITHOUT gap
156
+ expect(result.getRowHeight(0)).toBe(60);
157
+ expect(result.getRowHeight(1)).toBe(40);
158
+
159
+ // getColumnWidth returns col width WITHOUT gap
160
+ expect(result.getColumnWidth(0)).toBe(100);
161
+ expect(result.getColumnWidth(1)).toBe(200);
162
+ expect(result.getColumnWidth(2)).toBe(100); // cyclic for arrays
163
+ wrapper.unmount();
164
+
165
+ // Dynamic sizes
166
+ const { result: result2, wrapper: wrapper2 } = setup({
167
+ direction: 'vertical',
168
+ itemSize: 0,
169
+ items: [ { id: 1 } ],
170
+ defaultItemSize: 45,
171
+ });
172
+
173
+ await nextTick();
174
+ expect(result2.getRowHeight(0)).toBe(45); // default before measurement
175
+
176
+ result2.updateItemSize(0, 100, 100);
177
+ await nextTick();
178
+ expect(result2.getRowHeight(0)).toBe(100);
179
+ wrapper2.unmount();
78
180
  });
79
181
 
80
- await nextTick();
81
- await nextTick();
182
+ it('provides getItemSize helper with various direction and type branches', async () => {
183
+ const { result, wrapper } = setup({
184
+ direction: 'horizontal',
185
+ itemSize: 50,
186
+ items: mockItems,
187
+ columnGap: 10,
188
+ });
189
+ await nextTick();
190
+ expect(result.getItemSize(0)).toBe(50);
191
+
192
+ await wrapper.unmount();
193
+
194
+ const { result: res2, wrapper: w2 } = setup({
195
+ direction: 'vertical',
196
+ itemSize: (item: MockItem) => (item.id === 0 ? 100 : 40),
197
+ items: mockItems,
198
+ });
199
+ await nextTick();
200
+ expect(res2.getItemSize(0)).toBe(100);
201
+ expect(res2.getItemSize(1)).toBe(40);
202
+ w2.unmount();
203
+ });
82
204
 
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);
86
- });
205
+ it('getItemSize returns correct size based on direction and measurements', async () => {
206
+ const { result, wrapper } = setup({
207
+ direction: 'horizontal',
208
+ itemSize: 0,
209
+ items: mockItems,
210
+ columnGap: 10,
211
+ });
212
+ await nextTick();
213
+ result.updateItemSize(0, 100, 100);
214
+ await nextTick();
215
+ // In horizontal, getItemSize uses itemSizesX - columnGap
216
+ expect(result.getItemSize(0)).toBe(100);
217
+
218
+ // Test fallback to default when not measured yet
219
+ expect(result.getItemSize(1)).toBe(40);
220
+
221
+ wrapper.unmount();
222
+
223
+ const { result: res2, wrapper: w2 } = setup({
224
+ direction: 'vertical',
225
+ itemSize: 0,
226
+ items: mockItems,
227
+ gap: 10,
228
+ });
229
+ await nextTick();
230
+ res2.updateItemSize(0, 100, 100);
231
+ await nextTick();
232
+ // In vertical, fallback uses itemSizesY - gap
233
+ expect(res2.getItemSize(0)).toBe(100);
234
+ w2.unmount();
235
+ });
87
236
 
88
- it('updates when scroll position changes', async () => {
89
- const { result } = setup({
90
- container: window,
91
- direction: 'vertical',
92
- itemSize: 50,
93
- items: mockItems,
237
+ it('supports getColumnWidth with various types', async () => {
238
+ const { result, wrapper } = setup({
239
+ columnCount: 10,
240
+ columnWidth: [ 100, 200 ],
241
+ direction: 'both',
242
+ items: mockItems,
243
+ });
244
+
245
+ await nextTick();
246
+ expect(result.getColumnWidth(0)).toBe(100);
247
+ expect(result.getColumnWidth(1)).toBe(200);
248
+ expect(result.getColumnWidth(2)).toBe(100);
249
+ wrapper.unmount();
94
250
  });
95
251
 
96
- await nextTick();
97
- await nextTick();
252
+ it('skips invalid items in renderedItems', async () => {
253
+ const { result, props, wrapper } = setup({
254
+ direction: 'vertical',
255
+ itemSize: 50,
256
+ items: [ { id: 1 }, { id: 2 } ],
257
+ });
258
+
259
+ await nextTick();
260
+ await nextTick();
261
+
262
+ // Manually force an out-of-bounds range or inconsistent state
263
+ props.value.items = [ { id: 1 } ];
264
+ // renderedItems will filter out index 1 if it's still in range.end
265
+ expect(result.renderedItems.value.length).toBe(1);
266
+ wrapper.unmount();
267
+ });
98
268
 
99
- Object.defineProperty(window, 'scrollY', { configurable: true, value: 500, writable: true });
100
- document.dispatchEvent(new Event('scroll'));
269
+ it('handles sparse items array', async () => {
270
+ const sparseItems: unknown[] = [];
271
+ sparseItems[ 0 ] = { id: 0 };
272
+ sparseItems[ 10 ] = { id: 10 }; // hole between 1 and 9
101
273
 
102
- await nextTick();
103
- await nextTick();
274
+ const { result, wrapper } = setup({
275
+ direction: 'vertical',
276
+ itemSize: 50,
277
+ items: sparseItems,
278
+ });
104
279
 
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);
108
- });
280
+ await nextTick();
281
+ await nextTick();
109
282
 
110
- it('supports programmatic scrolling', async () => {
111
- const { result } = setup({
112
- container: window,
113
- direction: 'vertical',
114
- itemSize: 50,
115
- items: mockItems,
283
+ // Should only render the defined items
284
+ expect(result.renderedItems.value.length).toBeGreaterThan(0);
285
+ expect(result.renderedItems.value.every((i) => i.item !== undefined)).toBe(true);
286
+ wrapper.unmount();
116
287
  });
117
288
 
118
- await nextTick();
119
- await nextTick();
289
+ it('uses sequential query optimization in renderedItems', async () => {
290
+ const { result, wrapper } = setup({
291
+ direction: 'vertical',
292
+ itemSize: 50,
293
+ items: mockItems,
294
+ bufferBefore: 0,
295
+ bufferAfter: 10,
296
+ });
297
+
298
+ await nextTick();
299
+ await nextTick();
300
+
301
+ // accessing renderedItems will trigger queryYCached sequentially
302
+ const items = result.renderedItems.value;
303
+ expect(items.length).toBeGreaterThan(5);
304
+ expect(items[ 0 ]!.index).toBe(0);
305
+ expect(items[ 1 ]!.index).toBe(1);
306
+
307
+ wrapper.unmount();
308
+ });
309
+
310
+ it('handles non-sequential prefix sum queries and sticky item queries', async () => {
311
+ const { result, wrapper } = setup({
312
+ direction: 'vertical',
313
+ items: mockItems,
314
+ itemSize: 50,
315
+ stickyIndices: [ 0, 50, 99 ],
316
+ });
317
+
318
+ await nextTick();
319
+ await nextTick();
320
+
321
+ // Scroll to end (4500)
322
+ result.scrollToOffset(null, 4500);
323
+ await nextTick();
324
+ await nextTick();
325
+
326
+ // renderedItems will query indices around 99.
327
+ // Sticky logic will query index 50 non-sequentially.
328
+ expect(result.renderedItems.value.length).toBeGreaterThan(0);
329
+ wrapper.unmount();
330
+ });
120
331
 
121
- result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
332
+ it('calculates column range and offsets correctly during ssr', async () => {
333
+ const { result, wrapper } = setup({
334
+ direction: 'both',
335
+ items: mockItems,
336
+ columnCount: 10,
337
+ columnWidth: 100,
338
+ itemSize: 50,
339
+ ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
340
+ });
341
+
342
+ // isHydrated is false initially
343
+ expect(result.isHydrated.value).toBe(false);
344
+
345
+ // columnRange during SSR
346
+ expect(result.columnRange.value.start).toBe(2);
347
+ expect(result.columnRange.value.end).toBe(5);
348
+ expect(result.columnRange.value.padStart).toBe(200);
349
+
350
+ // renderedItems offsets during SSR
351
+ // ssrOffsetY = 10 * 50 = 500. ssrOffsetX = columnSizes.query(2) = 200.
352
+ // Item (10, 2) is at VU(200, 500).
353
+ // offsetX = (originalX - ssrOffsetX) = (200 - 200) = 0
354
+ const item = result.renderedItems.value.find((i) => i.index === 10);
355
+ expect(item).toBeDefined();
356
+ expect(item?.offset.x).toBe(0);
357
+ expect(item?.offset.y).toBe(0);
358
+
359
+ wrapper.unmount();
360
+ });
122
361
 
123
- await nextTick();
124
- await nextTick();
362
+ it('supports refresh method', async () => {
363
+ const { result, wrapper } = setup({
364
+ container: window,
365
+ direction: 'vertical',
366
+ itemSize: 50,
367
+ items: mockItems,
368
+ });
369
+
370
+ await nextTick();
371
+ result.refresh();
372
+ await nextTick();
373
+ expect(result.totalHeight.value).toBe(5000);
374
+ wrapper.unmount();
375
+ });
125
376
 
126
- expect(window.scrollTo).toHaveBeenCalled();
127
- expect(result.scrollDetails.value.currentIndex).toBe(20);
377
+ it('cleans up observers on unmount', async () => {
378
+ const disconnectSpy = vi.fn();
379
+ const oldMutationObserver = globalThis.MutationObserver;
380
+ globalThis.MutationObserver = (class MutationObserver {
381
+ observe = vi.fn();
382
+ disconnect = disconnectSpy;
383
+ } as unknown) as typeof MutationObserver;
384
+
385
+ const { wrapper } = setup({
386
+ container: document.createElement('div'),
387
+ items: mockItems,
388
+ });
389
+
390
+ await nextTick();
391
+ wrapper.unmount();
392
+ expect(disconnectSpy).toHaveBeenCalled();
393
+ globalThis.MutationObserver = oldMutationObserver;
394
+ });
128
395
  });
129
396
 
130
- it('handles dynamic item sizes', async () => {
131
- const { result } = setup({
132
- container: window,
133
- direction: 'vertical',
134
- itemSize: 0, // dynamic
135
- items: mockItems,
397
+ describe('scroll management', () => {
398
+ it('updates when scroll position changes', async () => {
399
+ const { result, wrapper } = setup({
400
+ container: window,
401
+ direction: 'vertical',
402
+ itemSize: 50,
403
+ items: mockItems,
404
+ });
405
+
406
+ await nextTick();
407
+ await nextTick();
408
+
409
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 500, writable: true });
410
+ document.dispatchEvent(new Event('scroll'));
411
+
412
+ await nextTick();
413
+ await nextTick();
414
+
415
+ // At 500px, start index is 500/50 = 10. With buffer 5, start is 5.
416
+ expect(result.scrollDetails.value.currentIndex).toBe(10);
417
+ expect(result.renderedItems.value[ 0 ]!.index).toBe(5);
418
+ wrapper.unmount();
136
419
  });
137
420
 
138
- await nextTick();
139
- await nextTick();
421
+ it('supports programmatic scrolling', async () => {
422
+ const { result, wrapper } = setup({
423
+ container: window,
424
+ direction: 'vertical',
425
+ itemSize: 50,
426
+ items: mockItems,
427
+ });
140
428
 
141
- // Initial estimate 100 * 40 = 4000
142
- expect(result.totalHeight.value).toBe(4000);
429
+ await nextTick();
430
+ await nextTick();
143
431
 
144
- result.updateItemSize(0, 100, 100);
145
- await nextTick();
432
+ result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
146
433
 
147
- // Now 1*100 + 99*40 = 100 + 3960 = 4060
148
- expect(result.totalHeight.value).toBe(4060);
149
- });
434
+ await nextTick();
435
+ await nextTick();
436
+
437
+ expect(window.scrollTo).toHaveBeenCalled();
438
+ expect(result.scrollDetails.value.currentIndex).toBe(20);
439
+ wrapper.unmount();
440
+ });
441
+
442
+ it('clears pendingScroll when scrollToOffset is called', async () => {
443
+ const { result, wrapper } = setup({
444
+ container: window,
445
+ direction: 'vertical',
446
+ itemSize: 50,
447
+ items: mockItems,
448
+ });
449
+
450
+ await nextTick();
451
+ await nextTick();
452
+
453
+ // Set a pending scroll
454
+ result.scrollToIndex(50, null, { align: 'start', behavior: 'smooth' });
455
+ await nextTick();
150
456
 
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,
457
+ // Call scrollToOffset
458
+ result.scrollToOffset(null, 100);
459
+ await nextTick();
460
+
461
+ // Wait for scroll timeout (250ms)
462
+ await new Promise((resolve) => setTimeout(resolve, 300));
463
+ await nextTick();
464
+
465
+ // The index 50 should NOT be corrected back because pendingScroll was cleared
466
+ expect(window.scrollY).toBe(100);
467
+ wrapper.unmount();
159
468
  });
160
469
 
161
- await nextTick();
162
- await nextTick();
470
+ it('triggers correction when viewport dimensions change', async () => {
471
+ const { result, wrapper } = setup({
472
+ container: window,
473
+ direction: 'vertical',
474
+ itemSize: 50,
475
+ items: mockItems,
476
+ });
477
+
478
+ await nextTick();
479
+ await nextTick();
480
+
481
+ // Scroll to item 50 auto
482
+ result.scrollToIndex(50, null, { align: 'auto', behavior: 'auto' });
483
+ await nextTick();
163
484
 
164
- // Scroll to index 5 (250px)
165
- result.scrollToOffset(0, 250, { behavior: 'auto' });
166
- await nextTick();
167
- await nextTick();
485
+ const initialScrollY = window.scrollY;
486
+ expect(initialScrollY).toBe(2050);
487
+
488
+ // Simulate viewport height decreasing
489
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 485 });
490
+ window.dispatchEvent(new Event('resize'));
491
+
492
+ await nextTick();
493
+ await nextTick();
494
+
495
+ // It should have corrected to: 2500 - (485 - 50) = 2065.
496
+ expect(window.scrollY).toBe(2065);
497
+ wrapper.unmount();
498
+ });
168
499
 
169
- expect(window.scrollY).toBe(250);
500
+ it('triggers correction when container dimensions change', async () => {
501
+ const container = document.createElement('div');
502
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500, writable: true });
503
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500, writable: true });
170
504
 
171
- // Prepend 2 items (100px)
172
- props.value.items = [ { id: -1 }, { id: -2 }, ...items ];
505
+ const { result, wrapper } = setup({
506
+ container,
507
+ itemSize: 50,
508
+ items: mockItems,
509
+ });
173
510
 
174
- await nextTick();
175
- await nextTick();
176
- await nextTick();
511
+ await nextTick();
512
+ await nextTick();
177
513
 
178
- // Scroll should be adjusted to 350
179
- expect(window.scrollY).toBe(350);
514
+ // Change dimensions
515
+ Object.defineProperty(container, 'clientHeight', { value: 800 });
516
+ Object.defineProperty(container, 'clientWidth', { value: 800 });
517
+ triggerResize(container, 800, 800);
518
+
519
+ await nextTick();
520
+ await nextTick();
521
+
522
+ expect(result.scrollDetails.value.displayViewportSize.height).toBe(800);
523
+ wrapper.unmount();
524
+ });
180
525
  });
181
526
 
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,
527
+ describe('dynamic sizing & prepending', () => {
528
+ it('handles dynamic item sizes', async () => {
529
+ const { result, wrapper } = setup({
530
+ container: window,
531
+ direction: 'vertical',
532
+ itemSize: 0, // dynamic
533
+ items: mockItems,
534
+ });
535
+
536
+ await nextTick();
537
+ await nextTick();
538
+
539
+ // Initial estimate 100 * 40 = 4000
540
+ expect(result.totalHeight.value).toBe(4000);
541
+
542
+ result.updateItemSize(0, 100, 100);
543
+ await nextTick();
544
+
545
+ // Now 1*100 + 99*40 = 4060
546
+ expect(result.totalHeight.value).toBe(4060);
547
+ wrapper.unmount();
548
+ });
549
+
550
+ it('updates item sizes and compensates scroll position', async () => {
551
+ const { result, wrapper } = setup({
552
+ container: window,
553
+ direction: 'vertical',
554
+ itemSize: 0,
555
+ items: mockItems,
556
+ });
557
+
558
+ await nextTick();
559
+ await nextTick();
560
+
561
+ // Scroll to item 10 (10 * 40 = 400px)
562
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 400, writable: true });
563
+ document.dispatchEvent(new Event('scroll'));
564
+ await nextTick();
565
+
566
+ // Update item 0 (above viewport) from 40 to 100
567
+ result.updateItemSize(0, 100, 100);
568
+ await nextTick();
569
+
570
+ // Scroll position should have been adjusted by 60px
571
+ expect(window.scrollY).toBe(460);
572
+ wrapper.unmount();
573
+ });
574
+
575
+ it('supports batched updateItemSizes', async () => {
576
+ const { result, wrapper } = setup({
577
+ itemSize: 0,
578
+ items: mockItems,
579
+ });
580
+ await nextTick();
581
+ result.updateItemSizes([
582
+ { index: 0, inlineSize: 100, blockSize: 100 },
583
+ { index: 1, inlineSize: 100, blockSize: 100 },
584
+ ]);
585
+ await nextTick();
586
+ expect(result.getRowHeight(0)).toBe(100);
587
+ expect(result.getRowHeight(1)).toBe(100);
588
+ wrapper.unmount();
589
+ });
590
+
591
+ it('ignores out of bounds updates in updateItemSize', async () => {
592
+ const { result, wrapper } = setup({
593
+ itemSize: 0,
594
+ items: mockItems,
595
+ });
596
+ await nextTick();
597
+ const initialHeight = result.scrollDetails.value.totalSize.height;
598
+ result.updateItemSize(1000, 100, 100); // Out of bounds
599
+ await nextTick();
600
+ expect(result.scrollDetails.value.totalSize.height).toBe(initialHeight);
601
+ wrapper.unmount();
602
+ });
603
+
604
+ it('updates column sizes from row element children', async () => {
605
+ const { result, wrapper } = setup({
606
+ columnCount: 5,
607
+ columnWidth: 0,
608
+ direction: 'both',
609
+ items: mockItems,
610
+ });
611
+
612
+ await nextTick();
613
+
614
+ const rowEl = document.createElement('div');
615
+ const cell0 = document.createElement('div');
616
+ cell0.dataset.colIndex = '0';
617
+ Object.defineProperty(cell0, 'getBoundingClientRect', {
618
+ value: () => ({ width: 120 }),
619
+ });
620
+ const cell1 = document.createElement('div');
621
+ cell1.dataset.colIndex = '1';
622
+ Object.defineProperty(cell1, 'getBoundingClientRect', {
623
+ value: () => ({ width: 180 }),
624
+ });
625
+ rowEl.appendChild(cell0);
626
+ rowEl.appendChild(cell1);
627
+
628
+ result.updateItemSizes([ {
629
+ blockSize: 50,
630
+ element: rowEl,
631
+ index: 0,
632
+ inlineSize: 0,
633
+ } ]);
634
+
635
+ await nextTick();
636
+ expect(result.getColumnWidth(0)).toBe(120);
637
+ expect(result.getColumnWidth(1)).toBe(180);
638
+ wrapper.unmount();
188
639
  });
189
640
 
190
- await nextTick();
191
- await nextTick();
641
+ it('updates column sizes from row element', async () => {
642
+ const { result, wrapper } = setup({
643
+ columnCount: 5,
644
+ columnWidth: 0, // dynamic
645
+ direction: 'both',
646
+ items: mockItems,
647
+ });
648
+
649
+ await nextTick();
650
+
651
+ const rowEl = document.createElement('div');
652
+ const cell0 = document.createElement('div');
653
+ cell0.dataset.colIndex = '0';
654
+ Object.defineProperty(cell0, 'getBoundingClientRect', {
655
+ value: () => ({ width: 150 }),
656
+ });
657
+ rowEl.appendChild(cell0);
658
+
659
+ result.updateItemSizes([ {
660
+ blockSize: 100,
661
+ element: rowEl,
662
+ index: 0,
663
+ inlineSize: 0,
664
+ } ]);
665
+
666
+ await nextTick();
667
+ expect(result.getColumnWidth(0)).toBe(150);
668
+ wrapper.unmount();
669
+ });
670
+
671
+ it('restores scroll position when items are prepended', async () => {
672
+ const items = Array.from({ length: 20 }, (_, i) => ({ id: i }));
673
+ const { props, result, wrapper } = setup({
674
+ container: window,
675
+ direction: 'vertical',
676
+ itemSize: 50,
677
+ items,
678
+ restoreScrollOnPrepend: true,
679
+ });
680
+
681
+ await nextTick();
682
+ await nextTick();
683
+
684
+ // Scroll to index 5 (250px)
685
+ result.scrollToOffset(0, 250, { behavior: 'auto' });
686
+ await nextTick();
687
+ await nextTick();
688
+
689
+ expect(window.scrollY).toBe(250);
690
+
691
+ // Prepend 2 items (100px)
692
+ props.value.items = [ { id: -1 }, { id: -2 }, ...items ];
693
+
694
+ await nextTick();
695
+ await nextTick();
696
+ await nextTick();
697
+
698
+ // Scroll should be adjusted to 350
699
+ expect(window.scrollY).toBe(350);
700
+ wrapper.unmount();
701
+ });
702
+
703
+ it('restores horizontal scroll position when items are prepended', async () => {
704
+ const initialItems = Array.from({ length: 10 }, (_, i) => ({ id: i + 5 }));
705
+ const { result, props, wrapper } = setup({
706
+ direction: 'horizontal',
707
+ itemSize: 100,
708
+ items: initialItems,
709
+ restoreScrollOnPrepend: true,
710
+ });
711
+
712
+ await nextTick();
713
+ result.scrollToOffset(200, null);
714
+ await nextTick();
715
+
716
+ // Prepend 5 items
717
+ const newItems = [
718
+ ...Array.from({ length: 5 }, (_, i) => ({ id: i })),
719
+ ...initialItems,
720
+ ];
721
+ props.value.items = newItems;
722
+
723
+ await nextTick();
724
+ await nextTick();
725
+
726
+ // Should have added 5 * 100 = 500px to scroll position
727
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(700);
728
+ wrapper.unmount();
729
+ });
192
730
 
193
- // Scroll to item 50 auto
194
- result.scrollToIndex(50, null, { align: 'auto', behavior: 'auto' });
195
- await nextTick();
731
+ it('handles horizontal dynamic item sizes', async () => {
732
+ const { result, wrapper } = setup({
733
+ direction: 'horizontal',
734
+ itemSize: 0, // dynamic
735
+ defaultItemSize: 100,
736
+ items: mockItems,
737
+ });
196
738
 
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);
739
+ await nextTick();
201
740
 
202
- // Simulate viewport height decreasing
203
- Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 485 });
204
- window.dispatchEvent(new Event('resize'));
741
+ // Initially estimated
742
+ expect(result.getItemSize(0)).toBe(100);
205
743
 
206
- await nextTick();
207
- await nextTick();
744
+ // Update size
745
+ result.updateItemSize(0, 150, 500);
746
+ await nextTick();
747
+ expect(result.getItemSize(0)).toBe(150);
748
+ expect(result.scrollDetails.value.totalSize.width).toBe(150 + 99 * 100);
208
749
 
209
- // It should have corrected to: 2500 - (485 - 50) = 2500 - 435 = 2065.
210
- expect(window.scrollY).toBe(2065);
750
+ wrapper.unmount();
751
+ });
211
752
  });
212
753
 
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) {
221
- container.scrollLeft = options.left;
222
- }
223
- if (options.top !== undefined) {
224
- container.scrollTop = options.top;
225
- }
754
+ describe('scroll correction & smooth scrolling', () => {
755
+ it('handles smooth scroll and waits for it to finish before correcting', async () => {
756
+ const container = document.createElement('div');
757
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
758
+ container.scrollTo = vi.fn();
759
+
760
+ const { result, wrapper } = setup({
761
+ container,
762
+ direction: 'vertical',
763
+ itemSize: 0,
764
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
765
+ });
766
+
767
+ await nextTick();
768
+ await nextTick();
769
+
770
+ // Start a smooth scroll
771
+ result.scrollToIndex(50, null, { behavior: 'smooth' });
772
+ // Simulate scroll event to set isScrolling = true
226
773
  container.dispatchEvent(new Event('scroll'));
774
+ await nextTick();
775
+
776
+ // Simulate measurement update while scrolling
777
+ result.updateItemSize(0, 100, 100);
778
+ await nextTick();
779
+
780
+ expect(container.scrollTo).toHaveBeenCalledTimes(1);
781
+
782
+ // End scroll by waiting for timeout (default 250ms)
783
+ await new Promise((resolve) => setTimeout(resolve, 300));
784
+ await nextTick();
785
+
786
+ // Now correction should trigger
787
+ expect(container.scrollTo).toHaveBeenCalledTimes(2);
788
+ wrapper.unmount();
227
789
  });
228
790
 
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);
791
+ it('performs scroll correction when item sizes change during/after scrollToIndex', async () => {
792
+ const container = document.createElement('div');
793
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
794
+ let scrollTop = 0;
795
+ Object.defineProperty(container, 'scrollTop', {
796
+ configurable: true,
797
+ get: () => scrollTop,
798
+ set: (val) => { scrollTop = val; },
799
+ });
800
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
801
+ if (options.top !== undefined) {
802
+ scrollTop = options.top;
803
+ }
804
+ container.dispatchEvent(new Event('scroll'));
805
+ });
806
+
807
+ const { result, wrapper } = setup({
808
+ container,
809
+ direction: 'vertical',
810
+ itemSize: 0,
811
+ items: mockItems,
812
+ defaultItemSize: 40,
813
+ });
814
+
815
+ await nextTick();
816
+ await nextTick();
817
+
818
+ result.scrollToIndex(50, null, { align: 'start', behavior: 'auto' });
819
+ await nextTick();
820
+ await nextTick();
821
+
822
+ expect(scrollTop).toBe(2000);
823
+
824
+ // Update item 10 from 40 to 100. Shift item 50 down by 60px.
825
+ result.updateItemSize(10, 100, 100);
826
+ await nextTick();
827
+ await nextTick();
828
+
829
+ expect(scrollTop).toBe(2060);
830
+ wrapper.unmount();
831
+ });
832
+
833
+ it('performs scroll correction for "end" alignment when item sizes change', async () => {
834
+ const container = document.createElement('div');
835
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
836
+ let scrollTop = 0;
837
+ Object.defineProperty(container, 'scrollTop', {
838
+ configurable: true,
839
+ get: () => scrollTop,
840
+ set: (val) => { scrollTop = val; },
841
+ });
842
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
843
+ if (options.top !== undefined) {
844
+ scrollTop = options.top;
845
+ }
846
+ container.dispatchEvent(new Event('scroll'));
847
+ });
848
+
849
+ const { result, wrapper } = setup({
850
+ container,
851
+ direction: 'vertical',
852
+ itemSize: 0,
853
+ items: mockItems,
854
+ defaultItemSize: 40,
855
+ });
856
+
857
+ await nextTick();
858
+ await nextTick();
859
+
860
+ result.scrollToIndex(50, null, { align: 'end', behavior: 'auto' });
861
+ await nextTick();
862
+ await nextTick();
863
+
864
+ expect(scrollTop).toBe(1540);
865
+
866
+ result.updateItemSize(50, 100, 100);
867
+ await nextTick();
868
+ await nextTick();
869
+
870
+ expect(scrollTop).toBe(1600);
871
+ wrapper.unmount();
872
+ });
873
+
874
+ it('correctly scrolls to the end of a dynamic list with corrections', async () => {
875
+ const container = document.createElement('div');
876
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
877
+ let scrollTop = 0;
878
+ Object.defineProperty(container, 'scrollTop', {
879
+ configurable: true,
880
+ get: () => scrollTop,
881
+ set: (val) => { scrollTop = val; },
882
+ });
883
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
884
+ if (options.top !== undefined) {
885
+ scrollTop = options.top;
886
+ }
887
+ container.dispatchEvent(new Event('scroll'));
888
+ });
889
+
890
+ const { result, wrapper } = setup({
891
+ container,
892
+ direction: 'vertical',
893
+ itemSize: 0,
894
+ items: mockItems,
895
+ defaultItemSize: 40,
896
+ });
897
+
898
+ await nextTick();
899
+ await nextTick();
900
+
901
+ result.scrollToIndex(99, null, { align: 'end', behavior: 'auto' });
902
+ await nextTick();
903
+ await nextTick();
904
+ expect(scrollTop).toBe(3500);
905
+
906
+ const updates = Array.from({ length: 90 }, (_, i) => ({ index: i, inlineSize: 100, blockSize: 50 }));
907
+ result.updateItemSizes(updates);
908
+ await nextTick();
909
+ await nextTick();
910
+
911
+ expect(scrollTop).toBe(4400);
912
+ wrapper.unmount();
913
+ });
267
914
  });
268
915
 
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,
916
+ describe('sticky elements', () => {
917
+ it('renders sticky indices correctly using optimized search', async () => {
918
+ const container = document.createElement('div');
919
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 200 });
920
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
921
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
922
+ if (options.left !== undefined) {
923
+ container.scrollLeft = options.left;
924
+ }
925
+ if (options.top !== undefined) {
926
+ container.scrollTop = options.top;
927
+ }
928
+ container.dispatchEvent(new Event('scroll'));
929
+ });
930
+
931
+ const { result, wrapper } = setup({
932
+ container,
933
+ direction: 'vertical',
934
+ itemSize: 50,
935
+ items: Array.from({ length: 20 }, (_, i) => ({ id: i })),
936
+ stickyIndices: [ 0, 10, 19 ],
937
+ bufferBefore: 0,
938
+ bufferAfter: 0,
939
+ });
940
+
941
+ await nextTick();
942
+ await nextTick();
943
+
944
+ expect(result.renderedItems.value.map((i) => i.index)).toEqual([ 0, 1, 2, 3 ]);
945
+
946
+ container.scrollTop = 100;
947
+ container.dispatchEvent(new Event('scroll'));
948
+ await nextTick();
949
+ await nextTick();
950
+
951
+ const indices2 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
952
+ expect(indices2).toEqual([ 0, 2, 3, 4, 5 ]);
953
+ expect(result.renderedItems.value.find((i) => i.index === 0)?.isStickyActive).toBe(true);
954
+
955
+ container.scrollTop = 500;
956
+ container.dispatchEvent(new Event('scroll'));
957
+ await nextTick();
958
+ await nextTick();
959
+
960
+ const indices3 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
961
+ expect(indices3).toContain(0);
962
+ expect(indices3).toContain(10);
963
+ expect(indices3).toContain(11);
964
+ expect(indices3).toContain(12);
965
+ expect(indices3).toContain(13);
966
+ wrapper.unmount();
967
+ });
968
+
969
+ it('renders sticky items that are before the visible range', async () => {
970
+ const { result, wrapper } = setup({
971
+ direction: 'vertical',
972
+ itemSize: 100,
973
+ items: Array.from({ length: 50 }, (_, i) => ({ id: i })),
974
+ stickyIndices: [ 0 ],
975
+ bufferBefore: 0,
976
+ bufferAfter: 0,
977
+ });
978
+
979
+ await nextTick();
980
+ await nextTick();
981
+
982
+ result.scrollToOffset(null, 1000, { behavior: 'auto' });
983
+ await nextTick();
984
+ await nextTick();
985
+
986
+ const renderedIndices = result.renderedItems.value.map((i) => i.index);
987
+ expect(renderedIndices).toContain(0);
988
+ expect(result.renderedItems.value.find((i) => i.index === 0)?.isStickyActive).toBe(true);
989
+ wrapper.unmount();
275
990
  });
276
991
 
277
- await nextTick();
278
- await nextTick();
992
+ it('handles horizontal sticky items', async () => {
993
+ const { result, wrapper } = setup({
994
+ direction: 'horizontal',
995
+ itemSize: 100,
996
+ stickyIndices: [ 0 ],
997
+ items: mockItems,
998
+ });
279
999
 
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();
1000
+ await nextTick();
284
1001
 
285
- // Update item 0 (above viewport) from 40 to 100
286
- result.updateItemSize(0, 100, 100);
287
- await nextTick();
1002
+ result.scrollToOffset(200, null);
1003
+ await nextTick();
288
1004
 
289
- // Scroll position should have been adjusted by 60px
290
- expect(window.scrollY).toBe(460);
291
- });
1005
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
1006
+ expect(item0?.isStickyActive).toBe(true);
292
1007
 
293
- it('supports refresh method', async () => {
294
- const { result } = setup({
295
- container: window,
296
- direction: 'vertical',
297
- itemSize: 50,
298
- items: mockItems,
1008
+ wrapper.unmount();
299
1009
  });
300
1010
 
301
- await nextTick();
302
- result.refresh();
303
- await nextTick();
304
- expect(result.totalHeight.value).toBe(5000);
1011
+ describe('sticky footer & header scrollToIndex', () => {
1012
+ const stickyMockItems = Array.from({ length: 10 }, (_, i) => ({ id: i }));
1013
+
1014
+ it('scrolls to the last item correctly with sticky footer and hostOffset', async () => {
1015
+ const hostRef = document.createElement('div');
1016
+ const hostElement = document.createElement('div');
1017
+ vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
1018
+ top: 0,
1019
+ left: 0,
1020
+ bottom: 500,
1021
+ right: 500,
1022
+ width: 500,
1023
+ height: 500,
1024
+ } as DOMRect);
1025
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1026
+ top: 50,
1027
+ left: 0,
1028
+ bottom: 550,
1029
+ right: 500,
1030
+ width: 500,
1031
+ height: 500,
1032
+ } as DOMRect);
1033
+
1034
+ const { result, wrapper } = setup({
1035
+ container: hostRef,
1036
+ hostRef,
1037
+ hostElement,
1038
+ direction: 'vertical',
1039
+ itemSize: 50,
1040
+ items: stickyMockItems,
1041
+ stickyStart: { y: 50 }, // 50px sticky header
1042
+ stickyEnd: { y: 50 }, // 50px sticky footer
1043
+ });
1044
+
1045
+ await nextTick();
1046
+ await nextTick();
1047
+ result.updateHostOffset();
1048
+
1049
+ expect(result.totalHeight.value).toBe(600);
1050
+
1051
+ result.scrollToIndex(9, 0, { align: 'end', behavior: 'auto' });
1052
+
1053
+ await nextTick();
1054
+ await nextTick();
1055
+
1056
+ expect(hostRef.scrollTop).toBe(100);
1057
+ expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
1058
+
1059
+ wrapper.unmount();
1060
+ });
1061
+
1062
+ it('renders the last item when scrolled to the end with sticky footer', async () => {
1063
+ const { result, wrapper } = setup({
1064
+ container: window,
1065
+ direction: 'vertical',
1066
+ itemSize: 50,
1067
+ items: stickyMockItems,
1068
+ stickyEnd: { y: 50 },
1069
+ });
1070
+
1071
+ await nextTick();
1072
+ await nextTick();
1073
+
1074
+ window.scrollTo({ top: 50 });
1075
+ await nextTick();
1076
+ await nextTick();
1077
+
1078
+ expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
1079
+
1080
+ wrapper.unmount();
1081
+ });
1082
+ });
305
1083
  });
306
1084
 
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,
1085
+ describe('rtl support', () => {
1086
+ it('detects RTL mode and handles scroll position accordingly', async () => {
1087
+ const container = document.createElement('div');
1088
+ container.setAttribute('dir', 'rtl');
1089
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1090
+ let scrollLeft = 0;
1091
+ Object.defineProperty(container, 'scrollLeft', {
1092
+ configurable: true,
1093
+ get: () => scrollLeft,
1094
+ set: (val) => { scrollLeft = val; },
1095
+ });
1096
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1097
+ if (options.left !== undefined) {
1098
+ scrollLeft = options.left;
1099
+ }
1100
+ if (options.top !== undefined) {
1101
+ container.scrollTop = options.top;
1102
+ }
1103
+ container.dispatchEvent(new Event('scroll'));
1104
+ });
1105
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
1106
+ const dir = el === container ? 'rtl' : 'ltr';
1107
+ return {
1108
+ get direction() { return dir; },
1109
+ } as unknown as CSSStyleDeclaration;
1110
+ });
1111
+
1112
+ const { result, wrapper } = setup({
1113
+ container,
1114
+ direction: 'horizontal',
1115
+ itemSize: 100,
1116
+ items: mockItems,
1117
+ });
1118
+
1119
+ await nextTick();
1120
+ await nextTick();
1121
+
1122
+ expect(result.isRtl.value).toBe(true);
1123
+
1124
+ container.scrollLeft = -100;
1125
+ container.dispatchEvent(new Event('scroll'));
1126
+ await nextTick();
1127
+
1128
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
1129
+
1130
+ result.scrollToIndex(null, 2, { align: 'start', behavior: 'auto' });
1131
+ expect(container.scrollLeft).toBe(-200);
1132
+ wrapper.unmount();
1133
+ styleSpy.mockRestore();
313
1134
  });
314
1135
 
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
- });
1136
+ it('detects RTL mode change on a parent element', async () => {
1137
+ const parent = document.createElement('div');
1138
+ const container = document.createElement('div');
1139
+ parent.appendChild(container);
1140
+
1141
+ vi.useFakeTimers();
1142
+
1143
+ const spy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => ({
1144
+ get direction() {
1145
+ let current: HTMLElement | null = el as HTMLElement;
1146
+ while (current) {
1147
+ if (current.getAttribute('dir') === 'rtl') {
1148
+ return 'rtl';
1149
+ }
1150
+ current = current.parentElement;
1151
+ }
1152
+ return 'ltr';
1153
+ },
1154
+ } as unknown as CSSStyleDeclaration));
1155
+
1156
+ const { result, wrapper } = setup({
1157
+ container,
1158
+ direction: 'horizontal',
1159
+ items: mockItems,
1160
+ });
1161
+
1162
+ await nextTick();
1163
+ await nextTick();
1164
+
1165
+ expect(result.isRtl.value).toBe(false);
1166
+
1167
+ parent.setAttribute('dir', 'rtl');
1168
+
1169
+ vi.advanceTimersByTime(1000);
1170
+ await nextTick();
320
1171
 
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,
1172
+ expect(result.isRtl.value).toBe(true);
1173
+
1174
+ wrapper.unmount();
1175
+ spy.mockRestore();
1176
+ vi.useRealTimers();
1177
+ });
1178
+
1179
+ it('updates host offset and direction reactively', async () => {
1180
+ const container = document.createElement('div');
1181
+ const hostRef = document.createElement('div');
1182
+ const hostElement = document.createElement('div');
1183
+
1184
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
1185
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1186
+
1187
+ let currentDir = 'ltr';
1188
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1189
+ get direction() { return currentDir; },
1190
+ } as unknown as CSSStyleDeclaration));
1191
+
1192
+ const { result, wrapper } = setup({
1193
+ container,
1194
+ hostRef,
1195
+ hostElement,
1196
+ items: mockItems,
1197
+ itemSize: 50,
1198
+ });
1199
+
1200
+ await nextTick();
1201
+ await nextTick();
1202
+
1203
+ vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
1204
+ left: 10,
1205
+ top: 20,
1206
+ toJSON: () => {},
1207
+ } as DOMRect);
1208
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1209
+ left: 15,
1210
+ top: 25,
1211
+ toJSON: () => {},
1212
+ } as DOMRect);
1213
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
1214
+ left: 0,
1215
+ top: 0,
1216
+ toJSON: () => {},
1217
+ } as DOMRect);
1218
+
1219
+ result.updateHostOffset();
1220
+ await nextTick();
1221
+
1222
+ expect(result.scrollDetails.value.displayScrollOffset.x).toBe(0);
1223
+
1224
+ currentDir = 'rtl';
1225
+ result.updateDirection();
1226
+ expect(result.isRtl.value).toBe(true);
1227
+ wrapper.unmount();
1228
+ styleSpy.mockRestore();
327
1229
  });
328
1230
 
329
- await nextTick();
1231
+ it('updates host offset but not scroll logical position when RTL changes in vertical mode', async () => {
1232
+ const container = document.createElement('div');
1233
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
1234
+
1235
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
1236
+ left: 0,
1237
+ right: 1000,
1238
+ top: 0,
1239
+ bottom: 500,
1240
+ width: 1000,
1241
+ height: 500,
1242
+ } as DOMRect);
1243
+
1244
+ const hostElement = document.createElement('div');
1245
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1246
+ left: 100,
1247
+ right: 200,
1248
+ top: 0,
1249
+ bottom: 50,
1250
+ width: 100,
1251
+ height: 50,
1252
+ } as DOMRect);
1253
+
1254
+ let currentDir = 'ltr';
1255
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1256
+ get direction() { return currentDir; },
1257
+ } as unknown as CSSStyleDeclaration));
1258
+
1259
+ const { result, wrapper } = setup({
1260
+ container,
1261
+ hostElement,
1262
+ direction: 'vertical',
1263
+ items: mockItems,
1264
+ itemSize: 50,
1265
+ });
1266
+
1267
+ await nextTick();
1268
+ expect(result.componentOffset.x).toBe(100);
1269
+
1270
+ currentDir = 'rtl';
1271
+ result.updateDirection();
1272
+ await nextTick();
1273
+
1274
+ expect(result.isRtl.value).toBe(true);
1275
+ expect(result.componentOffset.x).toBe(800);
1276
+
1277
+ wrapper.unmount();
1278
+ styleSpy.mockRestore();
1279
+ });
330
1280
 
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 }),
1281
+ it('calculates host offset correctly in RTL mode', async () => {
1282
+ const container = document.createElement('div');
1283
+ container.setAttribute('dir', 'rtl');
1284
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1285
+ if (options.left !== undefined) {
1286
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
1287
+ }
1288
+ container.dispatchEvent(new Event('scroll'));
1289
+ });
1290
+ const hostElement = document.createElement('div');
1291
+ container.appendChild(hostElement);
1292
+
1293
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
1294
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
1295
+
1296
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
1297
+ const dir = el === container ? 'rtl' : 'ltr';
1298
+ return {
1299
+ get direction() { return dir; },
1300
+ } as unknown as CSSStyleDeclaration;
1301
+ });
1302
+
1303
+ const { result, wrapper } = setup({
1304
+ container,
1305
+ hostElement,
1306
+ items: mockItems,
1307
+ itemSize: 50,
1308
+ direction: 'horizontal',
1309
+ });
1310
+
1311
+ await nextTick();
1312
+ await nextTick();
1313
+
1314
+ expect(result.isRtl.value).toBe(true);
1315
+
1316
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
1317
+ left: 0,
1318
+ right: 1000,
1319
+ width: 1000,
1320
+ toJSON: () => {},
1321
+ } as DOMRect);
1322
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1323
+ left: 200,
1324
+ right: 700,
1325
+ width: 500,
1326
+ toJSON: () => {},
1327
+ } as DOMRect);
1328
+
1329
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: 0, writable: true });
1330
+
1331
+ result.updateHostOffset();
1332
+ await nextTick();
1333
+
1334
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(0);
1335
+
1336
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: -400, writable: true });
1337
+ container.dispatchEvent(new Event('scroll'));
1338
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1339
+ bottom: 500,
1340
+ left: 600,
1341
+ right: 1100,
1342
+ toJSON: () => {},
1343
+ top: 0,
1344
+ width: 500,
1345
+ } as DOMRect);
1346
+
1347
+ result.updateHostOffset();
1348
+ await nextTick();
1349
+ await nextTick();
1350
+
1351
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
1352
+
1353
+ result.scrollToIndex(null, 4, { align: 'start', behavior: 'auto' });
1354
+ expect(container.scrollLeft).toBe(-500);
1355
+ wrapper.unmount();
1356
+ styleSpy.mockRestore();
336
1357
  });
337
- rowEl.appendChild(cell0);
338
1358
 
339
- result.updateItemSizes([ {
340
- blockSize: 100,
341
- element: rowEl,
342
- index: 0,
343
- inlineSize: 0,
344
- } ]);
1359
+ it('calculates rendered item offsets correctly in RTL mode when scrolled', async () => {
1360
+ const container = document.createElement('div');
1361
+ container.setAttribute('dir', 'rtl');
1362
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1363
+
1364
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
1365
+ const dir = el === container ? 'rtl' : 'ltr';
1366
+ return {
1367
+ get direction() { return dir; },
1368
+ } as unknown as CSSStyleDeclaration;
1369
+ });
1370
+
1371
+ const { result, wrapper } = setup({
1372
+ container,
1373
+ direction: 'horizontal',
1374
+ itemSize: 100,
1375
+ items: mockItems,
1376
+ });
1377
+
1378
+ await nextTick();
1379
+ await nextTick();
1380
+
1381
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: -200, writable: true });
1382
+ container.dispatchEvent(new Event('scroll'));
1383
+ await nextTick();
1384
+ await nextTick();
1385
+
1386
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
345
1387
 
346
- await nextTick();
347
- expect(result.getColumnWidth(0)).toBe(150);
1388
+ const item2 = result.renderedItems.value.find((i) => i.index === 2);
1389
+ expect(item2).toBeDefined();
1390
+ expect(item2?.offset.x).toBe(200);
1391
+ wrapper.unmount();
1392
+ styleSpy.mockRestore();
1393
+ });
1394
+
1395
+ it('maintains horizontal scroll position when switching between RTL and LTR', async () => {
1396
+ const container = document.createElement('div');
1397
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1398
+ let scrollLeft = 0;
1399
+ Object.defineProperty(container, 'scrollLeft', {
1400
+ configurable: true,
1401
+ get: () => scrollLeft,
1402
+ set: (val) => { scrollLeft = val; },
1403
+ });
1404
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1405
+ if (options.left !== undefined) {
1406
+ scrollLeft = options.left;
1407
+ }
1408
+ container.dispatchEvent(new Event('scroll'));
1409
+ });
1410
+
1411
+ let currentDir = 'ltr';
1412
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1413
+ get direction() { return currentDir; },
1414
+ } as unknown as CSSStyleDeclaration));
1415
+
1416
+ const { result, wrapper } = setup({
1417
+ container,
1418
+ direction: 'horizontal',
1419
+ itemSize: 100,
1420
+ items: mockItems,
1421
+ });
1422
+
1423
+ await nextTick();
1424
+ await nextTick();
1425
+
1426
+ result.scrollToOffset(200, null, { behavior: 'auto' });
1427
+ await nextTick();
1428
+ expect(scrollLeft).toBe(200);
1429
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
1430
+
1431
+ currentDir = 'rtl';
1432
+ result.updateDirection();
1433
+ await nextTick();
1434
+ await nextTick();
1435
+
1436
+ expect(result.isRtl.value).toBe(true);
1437
+ expect(scrollLeft).toBe(-200);
1438
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
1439
+
1440
+ wrapper.unmount();
1441
+ styleSpy.mockRestore();
1442
+ });
1443
+
1444
+ it('maintains horizontal scroll position when switching between RTL and LTR with padding', async () => {
1445
+ const container = document.createElement('div');
1446
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1447
+ let scrollLeft = 0;
1448
+ Object.defineProperty(container, 'scrollLeft', {
1449
+ configurable: true,
1450
+ get: () => scrollLeft,
1451
+ set: (val) => { scrollLeft = val; },
1452
+ });
1453
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1454
+ if (options.left !== undefined) {
1455
+ scrollLeft = options.left;
1456
+ }
1457
+ container.dispatchEvent(new Event('scroll'));
1458
+ });
1459
+
1460
+ let currentDir = 'ltr';
1461
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1462
+ get direction() { return currentDir; },
1463
+ } as unknown as CSSStyleDeclaration));
1464
+
1465
+ const { result, wrapper } = setup({
1466
+ container,
1467
+ direction: 'horizontal',
1468
+ itemSize: 100,
1469
+ items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
1470
+ scrollPaddingStart: 50,
1471
+ });
1472
+
1473
+ await nextTick();
1474
+ await nextTick();
1475
+
1476
+ result.scrollToOffset(150, null, { behavior: 'auto' });
1477
+ await nextTick();
1478
+ expect(scrollLeft).toBe(150);
1479
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
1480
+
1481
+ currentDir = 'rtl';
1482
+ result.updateDirection();
1483
+ await nextTick();
1484
+ await nextTick();
1485
+
1486
+ expect(result.isRtl.value).toBe(true);
1487
+ expect(scrollLeft).toBe(-150);
1488
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
1489
+
1490
+ currentDir = 'ltr';
1491
+ result.updateDirection();
1492
+ await nextTick();
1493
+ await nextTick();
1494
+
1495
+ expect(result.isRtl.value).toBe(false);
1496
+ expect(scrollLeft).toBe(150);
1497
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
1498
+
1499
+ wrapper.unmount();
1500
+ styleSpy.mockRestore();
1501
+ });
1502
+ });
1503
+
1504
+ describe('scaling & large lists', () => {
1505
+ it('syncs display scroll when items count changes in a scaled list', async () => {
1506
+ const props = ref<VirtualScrollProps<MockItem>>({
1507
+ itemSize: 1000,
1508
+ items: Array.from({ length: 30000 }, (_, i) => ({ id: i })), // 30M VU
1509
+ });
1510
+ const result = useVirtualScroll(props);
1511
+
1512
+ result.scrollToOffset(null, 10000000);
1513
+ await nextTick();
1514
+ await nextTick();
1515
+
1516
+ props.value.items = Array.from({ length: 40000 }, (_, i) => ({ id: i }));
1517
+ await nextTick();
1518
+ await nextTick();
1519
+
1520
+ expect(result.scrollDetails.value.scrollOffset.y).toBeCloseTo(10000000, 0);
1521
+ });
1522
+
1523
+ describe('coordinate scaling & bounds', () => {
1524
+ it('rendered item offsets do not grow excessively under scaling', async () => {
1525
+ const itemCount = 11000;
1526
+ const itemSize = 1000;
1527
+ const viewportHeight = 500;
1528
+ const items = Array.from({ length: itemCount }, (_, i) => ({ id: i }));
1529
+
1530
+ const { result, wrapper } = setup({
1531
+ container: window,
1532
+ direction: 'vertical',
1533
+ itemSize,
1534
+ items,
1535
+ });
1536
+
1537
+ await nextTick();
1538
+ await nextTick();
1539
+
1540
+ // Viewport 500
1541
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: viewportHeight });
1542
+ window.dispatchEvent(new Event('resize'));
1543
+ await nextTick();
1544
+
1545
+ // Scroll to item 100 (virtual 100000)
1546
+ result.scrollToIndex(100, null, { align: 'start', behavior: 'auto' });
1547
+ await nextTick();
1548
+ await nextTick();
1549
+
1550
+ const scaleY = result.scaleY.value;
1551
+ const expectedDisplayScroll = 100000 / scaleY;
1552
+ expect(window.scrollY).toBeCloseTo(expectedDisplayScroll, 0);
1553
+
1554
+ const item100 = result.renderedItems.value.find((i) => i.index === 100);
1555
+ expect(item100).toBeDefined();
1556
+
1557
+ // item100.offset.y should be (100 * 1000) / scaleY = 100000 / scaleY
1558
+ expect(item100?.offset.y).toBeCloseTo(expectedDisplayScroll, 0);
1559
+
1560
+ wrapper.unmount();
1561
+ });
1562
+
1563
+ it('does not allow scrolling below the last item when sticky elements are present', async () => {
1564
+ const itemCount = 1000;
1565
+ const itemSize = 50;
1566
+ const headerHeight = 50;
1567
+ const footerHeight = 50;
1568
+ const viewportHeight = 500;
1569
+ const items = Array.from({ length: itemCount }, (_, i) => ({ id: i }));
1570
+
1571
+ const { result, wrapper } = setup({
1572
+ container: window,
1573
+ direction: 'vertical',
1574
+ itemSize,
1575
+ items,
1576
+ stickyStart: { y: headerHeight },
1577
+ stickyEnd: { y: footerHeight },
1578
+ });
1579
+
1580
+ await nextTick();
1581
+ await nextTick();
1582
+
1583
+ expect(result.totalHeight.value).toBe(50100);
1584
+
1585
+ result.scrollToIndex(999, null, { align: 'end', behavior: 'auto' });
1586
+ await nextTick();
1587
+ await nextTick();
1588
+
1589
+ expect(window.scrollY).toBe(49600);
1590
+
1591
+ const lastItem = result.renderedItems.value.find((i) => i.index === 999);
1592
+ expect(lastItem).toBeDefined();
1593
+
1594
+ const itemBottomDisplay = lastItem!.offset.y + headerHeight + itemSize;
1595
+ expect(itemBottomDisplay - (window.scrollY + viewportHeight)).toBe(-footerHeight);
1596
+
1597
+ wrapper.unmount();
1598
+ });
1599
+ });
1600
+ });
1601
+
1602
+ describe('host element & layout', () => {
1603
+ it('calculates hostRefOffset correctly', async () => {
1604
+ const container = document.createElement('div');
1605
+ const hostRef = document.createElement('div');
1606
+ vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
1607
+ left: 100,
1608
+ top: 100,
1609
+ width: 100,
1610
+ height: 50,
1611
+ } as DOMRect);
1612
+
1613
+ const { result, wrapper } = setup({
1614
+ container,
1615
+ hostRef,
1616
+ items: mockItems,
1617
+ itemSize: 50,
1618
+ });
1619
+
1620
+ await nextTick();
1621
+ result.updateHostOffset();
1622
+
1623
+ expect(result.scrollDetails.value.displayScrollOffset.y).toBe(0);
1624
+ wrapper.unmount();
1625
+ });
348
1626
  });
349
1627
  });