@pdanpdan/virtual-scroll 0.6.0 → 0.7.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,1627 +0,0 @@
1
- /* global ScrollToOptions, ResizeObserverCallback */
2
- import type { VirtualScrollProps } from '../types';
3
- import type { Ref } from 'vue';
4
-
5
- import { mount } from '@vue/test-utils';
6
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
- import { defineComponent, nextTick, ref } from 'vue';
8
-
9
- import { useVirtualScroll } from './useVirtualScroll';
10
-
11
- // --- Mocks ---
12
-
13
- interface ResizeObserverMock extends ResizeObserver {
14
- callback: ResizeObserverCallback;
15
- targets: Set<Element>;
16
- }
17
-
18
- const observers: ResizeObserverMock[] = [];
19
- globalThis.ResizeObserver = class ResizeObserver {
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
- }
60
-
61
- Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
62
- Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
63
- Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 500 });
64
- Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 500 });
65
- Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
66
- Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
67
-
68
- globalThis.window.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
69
- if (options.left !== undefined) {
70
- Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
71
- }
72
- if (options.top !== undefined) {
73
- Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
74
- }
75
- document.dispatchEvent(new Event('scroll'));
76
- });
77
-
78
- interface MockItem {
79
- id: number;
80
- }
81
-
82
- // Helper to test composable
83
- function setup<T>(propsValue: VirtualScrollProps<T>) {
84
- const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
85
- let result: ReturnType<typeof useVirtualScroll<T>>;
86
-
87
- const TestComponent = defineComponent({
88
- setup() {
89
- result = useVirtualScroll(props);
90
- return () => null;
91
- },
92
- });
93
- const wrapper = mount(TestComponent);
94
- return { props, result: result!, wrapper };
95
- }
96
-
97
- describe('useVirtualScroll', () => {
98
- const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i }));
99
-
100
- beforeEach(() => {
101
- Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
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();
109
- });
110
-
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();
123
- });
124
-
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
- });
141
-
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();
180
- });
181
-
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
- });
204
-
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
- });
236
-
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();
250
- });
251
-
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
- });
268
-
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
273
-
274
- const { result, wrapper } = setup({
275
- direction: 'vertical',
276
- itemSize: 50,
277
- items: sparseItems,
278
- });
279
-
280
- await nextTick();
281
- await nextTick();
282
-
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();
287
- });
288
-
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
- });
331
-
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
- });
361
-
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
- });
376
-
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
- });
395
- });
396
-
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();
419
- });
420
-
421
- it('supports programmatic scrolling', async () => {
422
- const { result, wrapper } = setup({
423
- container: window,
424
- direction: 'vertical',
425
- itemSize: 50,
426
- items: mockItems,
427
- });
428
-
429
- await nextTick();
430
- await nextTick();
431
-
432
- result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
433
-
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();
456
-
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();
468
- });
469
-
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();
484
-
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
- });
499
-
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 });
504
-
505
- const { result, wrapper } = setup({
506
- container,
507
- itemSize: 50,
508
- items: mockItems,
509
- });
510
-
511
- await nextTick();
512
- await nextTick();
513
-
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
- });
525
- });
526
-
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();
639
- });
640
-
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
- });
730
-
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
- });
738
-
739
- await nextTick();
740
-
741
- // Initially estimated
742
- expect(result.getItemSize(0)).toBe(100);
743
-
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);
749
-
750
- wrapper.unmount();
751
- });
752
- });
753
-
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
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();
789
- });
790
-
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
- });
914
- });
915
-
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();
990
- });
991
-
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
- });
999
-
1000
- await nextTick();
1001
-
1002
- result.scrollToOffset(200, null);
1003
- await nextTick();
1004
-
1005
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
1006
- expect(item0?.isStickyActive).toBe(true);
1007
-
1008
- wrapper.unmount();
1009
- });
1010
-
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
- });
1083
- });
1084
-
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();
1134
- });
1135
-
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();
1171
-
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();
1229
- });
1230
-
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
- });
1280
-
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();
1357
- });
1358
-
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);
1387
-
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
- });
1626
- });
1627
- });