@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,2368 +0,0 @@
1
- import type { ItemSlotProps, ScrollbarSlotProps, ScrollDetails, VirtualScrollInstance } from '../types';
2
- import type { VueWrapper } from '@vue/test-utils';
3
- import type { DefineComponent } from 'vue';
4
-
5
- /* global ScrollToOptions, ResizeObserverCallback */
6
- import { mount } from '@vue/test-utils';
7
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
- import { h, nextTick, ref } from 'vue';
9
-
10
- import { displayToVirtual, virtualToDisplay } from '../utils/virtual-scroll-logic';
11
- import VirtualScroll from './VirtualScroll.vue';
12
-
13
- // --- Mocks ---
14
-
15
- Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
16
- Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
17
- Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
18
- Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
19
-
20
- HTMLElement.prototype.scrollTo = function (this: HTMLElement, options?: number | ScrollToOptions, y?: number) {
21
- if (typeof options === 'object') {
22
- if (options.top !== undefined) {
23
- this.scrollTop = options.top;
24
- }
25
- if (options.left !== undefined) {
26
- this.scrollLeft = options.left;
27
- }
28
- } else if (typeof options === 'number' && typeof y === 'number') {
29
- this.scrollLeft = options;
30
- this.scrollTop = y;
31
- }
32
- this.dispatchEvent(new (this.ownerDocument?.defaultView?.Event || Event)('scroll'));
33
- };
34
-
35
- HTMLElement.prototype.setPointerCapture = vi.fn();
36
- HTMLElement.prototype.releasePointerCapture = vi.fn();
37
-
38
- interface ResizeObserverMock extends ResizeObserver {
39
- callback: ResizeObserverCallback;
40
- targets: Set<Element>;
41
- }
42
-
43
- const observers: ResizeObserverMock[] = [];
44
- globalThis.ResizeObserver = class ResizeObserver {
45
- callback: ResizeObserverCallback;
46
- targets = new Set<Element>();
47
- constructor(callback: ResizeObserverCallback) {
48
- this.callback = callback;
49
- observers.push(this as unknown as ResizeObserverMock);
50
- }
51
-
52
- observe(el: Element) {
53
- this.targets.add(el);
54
- }
55
-
56
- unobserve(el: Element) {
57
- this.targets.delete(el);
58
- }
59
-
60
- disconnect() {
61
- this.targets.clear();
62
- }
63
- } as unknown as typeof ResizeObserver;
64
-
65
- function triggerResize(el: Element, width: number, height: number, useBorderBox = true) {
66
- const obs = observers.find((o) => o.targets.has(el));
67
- if (obs) {
68
- obs.callback([ {
69
- ...(useBorderBox ? { borderBoxSize: [ { blockSize: height, inlineSize: width } ] } : {}),
70
- contentRect: {
71
- bottom: height,
72
- height,
73
- left: 0,
74
- right: width,
75
- toJSON: () => '',
76
- top: 0,
77
- width,
78
- x: 0,
79
- y: 0,
80
- },
81
- target: el,
82
- } as unknown as ResizeObserverEntry ], obs);
83
- }
84
- }
85
-
86
- // Mock window.scrollTo
87
- globalThis.window.scrollTo = vi.fn().mockImplementation((options) => {
88
- if (options.left !== undefined) {
89
- Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
90
- }
91
- if (options.top !== undefined) {
92
- Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
93
- }
94
- document.dispatchEvent(new Event('scroll'));
95
- });
96
-
97
- // --- Tests ---
98
-
99
- interface MockItem {
100
- id: number;
101
- label: string;
102
- }
103
-
104
- describe('virtualScroll', () => {
105
- const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
106
-
107
- beforeEach(() => {
108
- Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
109
- Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
110
- Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
111
- Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
112
- vi.useFakeTimers({ toFake: [ 'requestAnimationFrame' ] });
113
- });
114
-
115
- afterEach(() => {
116
- observers.length = 0;
117
- vi.clearAllMocks();
118
- vi.useRealTimers();
119
- });
120
-
121
- describe('core rendering & lifecycle', () => {
122
- it('renders the visible items', async () => {
123
- const wrapper = mount(VirtualScroll, {
124
- props: {
125
- itemSize: 50,
126
- items: mockItems,
127
- },
128
- slots: {
129
- item: (props: ItemSlotProps) => {
130
- const { index, item } = props as ItemSlotProps<MockItem>;
131
- return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
132
- },
133
- },
134
- });
135
-
136
- await nextTick();
137
-
138
- const items = wrapper.findAll('.item');
139
- expect(items.length).toBe(15);
140
- expect(items[ 0 ]?.text()).toBe('0: Item 0');
141
- expect(items[ 14 ]?.text()).toBe('14: Item 14');
142
- });
143
-
144
- it('updates when items change', async () => {
145
- const wrapper = mount(VirtualScroll, {
146
- props: {
147
- itemSize: 50,
148
- items: mockItems.slice(0, 5),
149
- },
150
- });
151
- await nextTick();
152
- expect(wrapper.findAll('.virtual-scroll-item').length).toBe(5);
153
-
154
- await wrapper.setProps({ items: mockItems.slice(0, 10) });
155
- await nextTick();
156
- expect(wrapper.findAll('.virtual-scroll-item').length).toBe(10);
157
- });
158
-
159
- it('supports horizontal direction', async () => {
160
- const wrapper = mount(VirtualScroll, {
161
- props: {
162
- direction: 'horizontal',
163
- itemSize: 100,
164
- items: mockItems,
165
- },
166
- });
167
- await nextTick();
168
- const container = wrapper.find('.virtual-scroll-container');
169
- expect(container.classes()).toContain('virtual-scroll--horizontal');
170
- expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.inlineSize).toBe('10000px');
171
-
172
- // 500px / 100px = 5 visible
173
- // + 5 bufferAfter = 10 total
174
- expect(wrapper.findAll('.virtual-scroll-item').length).toBe(10);
175
- });
176
-
177
- it('supports grid mode (both directions)', async () => {
178
- const wrapper = mount(VirtualScroll, {
179
- props: {
180
- columnCount: 5,
181
- columnWidth: 100,
182
- direction: 'both',
183
- itemSize: 50,
184
- items: mockItems,
185
- },
186
- });
187
- await nextTick();
188
- const style = (wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style;
189
- expect(style.blockSize).toBe('5000px');
190
- expect(style.inlineSize).toBe('500px');
191
-
192
- // 500px / 50px = 10 visible rows
193
- // + 5 bufferAfter = 15 total rows
194
- expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
195
- });
196
-
197
- it('works with window as container', async () => {
198
- const wrapper = mount(VirtualScroll, {
199
- props: {
200
- container: window,
201
- itemSize: 50,
202
- items: mockItems,
203
- },
204
- });
205
- await nextTick();
206
- expect(wrapper.classes()).toContain('virtual-scroll--window');
207
- });
208
-
209
- it('unmounts cleanly', async () => {
210
- const wrapper = mount(VirtualScroll, {
211
- props: {
212
- items: mockItems,
213
- },
214
- });
215
- await nextTick();
216
- wrapper.unmount();
217
- // no errors should be thrown
218
- });
219
-
220
- it('handles hostRef change', async () => {
221
- const wrapper = mount(VirtualScroll, {
222
- props: {
223
- items: mockItems,
224
- containerTag: 'div',
225
- },
226
- });
227
- await nextTick();
228
- await wrapper.setProps({ containerTag: 'section' });
229
- await nextTick();
230
- // should have unobserved old and observed new
231
- });
232
-
233
- it('stops active smooth scroll via stopProgrammaticScroll', async () => {
234
- const wrapper = mount(VirtualScroll, {
235
- props: { itemSize: 50, items: mockItems },
236
- });
237
- await nextTick();
238
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
239
-
240
- vs.scrollToIndex(50, null, { behavior: 'smooth' });
241
- await nextTick();
242
-
243
- const posBefore = vs.scrollDetails.scrollOffset.y;
244
- vs.stopProgrammaticScroll();
245
- await nextTick();
246
-
247
- // Should not have moved significantly or at all from where it was stopped
248
- expect(vs.scrollDetails.scrollOffset.y).toBe(posBefore);
249
- });
250
- });
251
-
252
- describe('scrolling interaction', () => {
253
- it('scrolls and updates visible items', async () => {
254
- const wrapper = mount(VirtualScroll, {
255
- props: {
256
- itemSize: 50,
257
- items: mockItems,
258
- },
259
- slots: {
260
- item: (props: ItemSlotProps) => {
261
- const { item } = props as ItemSlotProps<MockItem>;
262
- return h('div', { class: 'item' }, item.label);
263
- },
264
- },
265
- });
266
- await nextTick();
267
-
268
- const container = wrapper.find('.virtual-scroll-container');
269
- const el = container.element as HTMLElement;
270
-
271
- Object.defineProperty(el, 'scrollTop', { value: 1000, writable: true });
272
- await container.trigger('scroll');
273
- await nextTick();
274
- await nextTick();
275
-
276
- expect(wrapper.text()).toContain('Item 20');
277
- expect(wrapper.text()).toContain('Item 15');
278
-
279
- const items = wrapper.findAll('.item');
280
- expect(items.length).toBeGreaterThanOrEqual(15);
281
- expect(items.length).toBeLessThanOrEqual(25);
282
- });
283
-
284
- it('emits load event when reaching end', async () => {
285
- const wrapper = mount(VirtualScroll, {
286
- props: {
287
- itemSize: 50,
288
- items: mockItems.slice(0, 20),
289
- loadDistance: 100,
290
- },
291
- });
292
- await nextTick();
293
- await nextTick();
294
-
295
- const container = wrapper.find('.virtual-scroll-container');
296
- const el = container.element as HTMLElement;
297
-
298
- expect(wrapper.emitted('load')).toBeUndefined();
299
-
300
- Object.defineProperty(el, 'scrollTop', { value: 450, writable: true });
301
- await container.trigger('scroll');
302
- await nextTick();
303
- await nextTick();
304
-
305
- expect(wrapper.emitted('load')).toBeDefined();
306
- });
307
-
308
- it('handles wheel when virtual scrollbars are inactive', async () => {
309
- const wrapper = mount(VirtualScroll, {
310
- props: {
311
- items: mockItems,
312
- virtualScrollbar: false,
313
- },
314
- });
315
- await nextTick();
316
- await wrapper.find('.virtual-scroll-container').trigger('wheel', { deltaY: 100 });
317
- // should just stop programmatic scroll
318
- });
319
-
320
- it('should not enter a loop when scrolling to end with dynamic items', async () => {
321
- const items = Array.from({ length: 200 }, (_, i) => ({ id: i }));
322
- const wrapper = mount(VirtualScroll, {
323
- props: {
324
- items,
325
- itemSize: 0, // dynamic
326
- defaultItemSize: 40,
327
- },
328
- });
329
-
330
- await nextTick();
331
- await nextTick();
332
-
333
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
334
-
335
- // Press End
336
- await wrapper.trigger('keydown', { key: 'End' });
337
-
338
- // Wait for multiple ticks to let the correction logic work
339
- for (let i = 0; i < 5; i++) {
340
- await nextTick();
341
- }
342
-
343
- // Simulate items being measured differently than estimated
344
- const rendered = wrapper.findAll('.virtual-scroll-item');
345
- for (const item of rendered) {
346
- const idx = Number(item.attributes('data-index'));
347
- if (idx >= 90) {
348
- triggerResize(item.element, 500, 50); // 50 instead of 40
349
- }
350
- }
351
-
352
- // Wait for corrections
353
- for (let i = 0; i < 5; i++) {
354
- await nextTick();
355
- }
356
-
357
- const details = vs.scrollDetails;
358
- // Should be at the end
359
- expect(details.scrollOffset.y).toBeGreaterThanOrEqual(details.totalSize.height - details.viewportSize.height - 1);
360
-
361
- const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
362
-
363
- await nextTick();
364
- await nextTick();
365
-
366
- // Should not be calling scrollToIndex anymore
367
- expect(scrollToIndexSpy).not.toHaveBeenCalled();
368
- });
369
- });
370
-
371
- describe('keyboard navigation', () => {
372
- it('responds to home and end keys in vertical mode', async () => {
373
- const wrapper = mount(VirtualScroll, {
374
- props: { itemSize: 50, items: mockItems },
375
- });
376
- await nextTick();
377
- const container = wrapper.find('.virtual-scroll-container');
378
-
379
- await container.trigger('keydown', { key: 'End' });
380
- await nextTick();
381
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
382
-
383
- await container.trigger('keydown', { key: 'Home' });
384
- await nextTick();
385
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
386
- });
387
-
388
- it('responds to arrows in vertical mode', async () => {
389
- const wrapper = mount(VirtualScroll, {
390
- props: { itemSize: 50, items: mockItems },
391
- });
392
- await nextTick();
393
- const container = wrapper.find('.virtual-scroll-container');
394
-
395
- await container.trigger('keydown', { key: 'ArrowDown' });
396
- await nextTick();
397
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(50);
398
-
399
- await container.trigger('keydown', { key: 'ArrowUp' });
400
- await nextTick();
401
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
402
- });
403
-
404
- it('responds correctly to arrows in rtl mode', async () => {
405
- const container = document.createElement('div');
406
- container.setAttribute('dir', 'rtl');
407
- Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
408
- container.scrollTo = vi.fn().mockImplementation((options) => {
409
- if (options.left !== undefined) {
410
- Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
411
- }
412
- container.dispatchEvent(new Event('scroll'));
413
- });
414
-
415
- const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
416
- if (el === container) {
417
- return { direction: 'rtl' } as CSSStyleDeclaration;
418
- }
419
- return { direction: 'ltr' } as CSSStyleDeclaration;
420
- });
421
-
422
- const wrapper = mount(VirtualScroll, {
423
- props: {
424
- container,
425
- direction: 'horizontal',
426
- itemSize: 100,
427
- items: mockItems,
428
- },
429
- });
430
-
431
- await nextTick();
432
- await nextTick();
433
-
434
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
435
- vs.updateDirection();
436
- await nextTick();
437
- expect(vs.isRtl).toBe(true);
438
-
439
- const vsContainer = wrapper.find('.virtual-scroll-container');
440
-
441
- await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
442
- await nextTick();
443
- await nextTick();
444
- expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
445
-
446
- await vsContainer.trigger('keydown', { key: 'ArrowRight' });
447
- await nextTick();
448
- await nextTick();
449
- expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
450
-
451
- styleSpy.mockRestore();
452
- });
453
-
454
- it('aligns partially visible items correctly with arrows in rtl mode', async () => {
455
- const container = document.createElement('div');
456
- container.setAttribute('dir', 'rtl');
457
- Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
458
- container.scrollTo = vi.fn().mockImplementation((options) => {
459
- if (options.left !== undefined) {
460
- Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
461
- }
462
- container.dispatchEvent(new Event('scroll'));
463
- });
464
-
465
- const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
466
- if (el === container) {
467
- return { direction: 'rtl' } as CSSStyleDeclaration;
468
- }
469
- return { direction: 'ltr' } as CSSStyleDeclaration;
470
- });
471
-
472
- const wrapper = mount(VirtualScroll, {
473
- props: {
474
- container,
475
- direction: 'horizontal',
476
- itemSize: 100,
477
- items: mockItems,
478
- },
479
- });
480
-
481
- await nextTick();
482
- await nextTick();
483
-
484
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
485
- vs.updateDirection();
486
- await nextTick();
487
-
488
- vs.scrollToOffset(50, null);
489
- await nextTick();
490
- await nextTick();
491
-
492
- const vsContainer = wrapper.find('.virtual-scroll-container');
493
-
494
- await vsContainer.trigger('keydown', { key: 'ArrowRight' });
495
- await nextTick();
496
- await nextTick();
497
- expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
498
-
499
- await wrapper.setProps({ itemSize: 150 });
500
- await nextTick();
501
- await nextTick();
502
-
503
- expect(vs.scrollDetails.currentEndIndex).toBe(3);
504
-
505
- await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
506
- await nextTick();
507
- await nextTick();
508
- expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
509
-
510
- styleSpy.mockRestore();
511
- });
512
-
513
- it('scrolls to next item with arrowleft when current item is already at the left edge (rtl)', async () => {
514
- const container = document.createElement('div');
515
- container.setAttribute('dir', 'rtl');
516
- Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
517
- container.scrollTo = vi.fn().mockImplementation((options) => {
518
- if (options.left !== undefined) {
519
- Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
520
- }
521
- container.dispatchEvent(new Event('scroll'));
522
- });
523
-
524
- const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
525
- if (el === container) {
526
- return { direction: 'rtl' } as CSSStyleDeclaration;
527
- }
528
- return { direction: 'ltr' } as CSSStyleDeclaration;
529
- });
530
-
531
- const wrapper = mount(VirtualScroll, {
532
- props: {
533
- container,
534
- direction: 'horizontal',
535
- itemSize: 100,
536
- items: mockItems,
537
- },
538
- });
539
-
540
- await nextTick();
541
- await nextTick();
542
-
543
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
544
- vs.updateDirection();
545
- await nextTick();
546
-
547
- vs.scrollToIndex(null, 4, { align: 'end', behavior: 'auto' });
548
- await nextTick();
549
- await nextTick();
550
-
551
- expect(vs.scrollDetails.currentEndColIndex).toBe(4);
552
- expect(vs.scrollDetails.scrollOffset.x).toBe(0);
553
-
554
- const containerEl = wrapper.find('.virtual-scroll-container');
555
- await containerEl.trigger('keydown', { key: 'ArrowLeft' });
556
- await nextTick();
557
- await nextTick();
558
-
559
- expect(vs.scrollDetails.scrollOffset.x).toBe(100);
560
- styleSpy.mockRestore();
561
- });
562
-
563
- it('scrolls to previous item with arrowup when current item is already at the top', async () => {
564
- const wrapper = mount(VirtualScroll, {
565
- props: { itemSize: 50, items: mockItems },
566
- });
567
- await nextTick();
568
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
569
-
570
- vs.scrollToIndex(2, null, { align: 'start', behavior: 'auto' });
571
- await nextTick();
572
- await nextTick();
573
-
574
- expect(vs.scrollDetails.scrollOffset.y).toBe(100);
575
-
576
- const container = wrapper.find('.virtual-scroll-container');
577
- await container.trigger('keydown', { key: 'ArrowUp' });
578
- await nextTick();
579
- await nextTick();
580
-
581
- expect(vs.scrollDetails.scrollOffset.y).toBe(50);
582
- });
583
-
584
- it('scrolls to next item with arrowright when current item is already at the right edge (ltr)', async () => {
585
- const wrapper = mount(VirtualScroll, {
586
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
587
- });
588
- await nextTick();
589
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
590
-
591
- vs.scrollToIndex(4, null, { align: 'end', behavior: 'auto' });
592
- await nextTick();
593
- await nextTick();
594
-
595
- expect(vs.scrollDetails.currentEndIndex).toBe(4);
596
- expect(vs.scrollDetails.scrollOffset.x).toBe(0);
597
-
598
- const container = wrapper.find('.virtual-scroll-container');
599
- await container.trigger('keydown', { key: 'ArrowRight' });
600
- await nextTick();
601
- await nextTick();
602
-
603
- expect(vs.scrollDetails.scrollOffset.x).toBe(100);
604
- });
605
-
606
- it('scrolls to next item with arrowdown when current item is already at the bottom edge', async () => {
607
- const wrapper = mount(VirtualScroll, {
608
- props: { itemSize: 50, items: mockItems },
609
- });
610
- await nextTick();
611
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
612
-
613
- vs.scrollToIndex(9, null, { align: 'end', behavior: 'auto' });
614
- await nextTick();
615
- await nextTick();
616
-
617
- expect(vs.scrollDetails.currentEndIndex).toBe(9);
618
- expect(vs.scrollDetails.scrollOffset.y).toBe(0);
619
-
620
- const container = wrapper.find('.virtual-scroll-container');
621
- await container.trigger('keydown', { key: 'ArrowDown' });
622
- await nextTick();
623
- await nextTick();
624
-
625
- expect(vs.scrollDetails.scrollOffset.y).toBe(50);
626
- });
627
-
628
- it('does not scroll with arrowdown when already at the very last item', async () => {
629
- const wrapper = mount(VirtualScroll, {
630
- props: { itemSize: 50, items: mockItems },
631
- });
632
- await nextTick();
633
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
634
-
635
- vs.scrollToOffset(null, 4500, { behavior: 'auto' });
636
- await nextTick();
637
- await nextTick();
638
-
639
- expect(vs.scrollDetails.currentEndIndex).toBe(99);
640
- expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
641
-
642
- const container = wrapper.find('.virtual-scroll-container');
643
- await container.trigger('keydown', { key: 'ArrowDown' });
644
- await nextTick();
645
- await nextTick();
646
-
647
- expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
648
- });
649
-
650
- it('does not scroll with arrowright when already at the very last item (horizontal ltr)', async () => {
651
- const wrapper = mount(VirtualScroll, {
652
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
653
- });
654
- await nextTick();
655
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
656
-
657
- vs.scrollToOffset(9500, null, { behavior: 'auto' });
658
- await nextTick();
659
- await nextTick();
660
-
661
- expect(vs.scrollDetails.currentEndColIndex).toBe(99);
662
- expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
663
-
664
- const container = wrapper.find('.virtual-scroll-container');
665
- await container.trigger('keydown', { key: 'ArrowRight' });
666
- await nextTick();
667
- await nextTick();
668
-
669
- expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
670
- });
671
-
672
- it('does not scroll with arrowleft when already at the very last item (horizontal rtl)', async () => {
673
- const styleSpy = vi.spyOn(window, 'getComputedStyle').mockReturnValue({
674
- direction: 'rtl',
675
- } as CSSStyleDeclaration);
676
-
677
- const wrapper = mount(VirtualScroll, {
678
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
679
- });
680
- await nextTick();
681
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
682
-
683
- vs.scrollToOffset(9500, null, { behavior: 'auto' });
684
- await nextTick();
685
- await nextTick();
686
-
687
- expect(vs.scrollDetails.currentEndColIndex).toBe(99);
688
- expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
689
-
690
- const container = wrapper.find('.virtual-scroll-container');
691
- await container.trigger('keydown', { key: 'ArrowLeft' });
692
- await nextTick();
693
- await nextTick();
694
-
695
- expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
696
- styleSpy.mockRestore();
697
- });
698
-
699
- it('responds to pageup and pagedown in vertical mode', async () => {
700
- const wrapper = mount(VirtualScroll, {
701
- props: { itemSize: 50, items: mockItems },
702
- });
703
- await nextTick();
704
- const container = wrapper.find('.virtual-scroll-container');
705
-
706
- await container.trigger('keydown', { key: 'PageDown' });
707
- await nextTick();
708
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(500);
709
-
710
- await container.trigger('keydown', { key: 'PageUp' });
711
- await nextTick();
712
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
713
- });
714
-
715
- it('responds to home and end keys in horizontal mode', async () => {
716
- const wrapper = mount(VirtualScroll, {
717
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
718
- });
719
- await nextTick();
720
- const container = wrapper.find('.virtual-scroll-container');
721
-
722
- await container.trigger('keydown', { key: 'End' });
723
- await nextTick();
724
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(9500);
725
-
726
- await container.trigger('keydown', { key: 'Home' });
727
- await nextTick();
728
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
729
- });
730
-
731
- it('responds to arrows in horizontal mode', async () => {
732
- const wrapper = mount(VirtualScroll, {
733
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
734
- });
735
- await nextTick();
736
- const container = wrapper.find('.virtual-scroll-container');
737
-
738
- await container.trigger('keydown', { key: 'ArrowRight' });
739
- await nextTick();
740
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(100);
741
-
742
- await container.trigger('keydown', { key: 'ArrowLeft' });
743
- await nextTick();
744
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
745
- });
746
-
747
- it('responds to pageup and pagedown in horizontal mode', async () => {
748
- const wrapper = mount(VirtualScroll, {
749
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
750
- });
751
- await nextTick();
752
- const container = wrapper.find('.virtual-scroll-container');
753
-
754
- await container.trigger('keydown', { key: 'PageDown' });
755
- await nextTick();
756
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
757
-
758
- await container.trigger('keydown', { key: 'PageUp' });
759
- await nextTick();
760
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
761
- });
762
-
763
- it('disables smooth scroll for large distances (home/end)', async () => {
764
- const wrapper = mount(VirtualScroll, {
765
- props: {
766
- itemSize: 50,
767
- items: Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `Item ${ i }` })),
768
- container: window,
769
- },
770
- });
771
- await nextTick();
772
- const container = wrapper.find('.virtual-scroll-container');
773
-
774
- const scrollToSpy = vi.spyOn(window, 'scrollTo');
775
-
776
- await container.trigger('keydown', { key: 'End' });
777
- await nextTick();
778
-
779
- expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({
780
- behavior: 'auto',
781
- }));
782
-
783
- scrollToSpy.mockClear();
784
- await container.trigger('keydown', { key: 'Home' });
785
- await nextTick();
786
-
787
- expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({
788
- behavior: 'auto',
789
- }));
790
- });
791
-
792
- it('responds to home and end keys in grid mode', async () => {
793
- const wrapper = mount(VirtualScroll, {
794
- props: {
795
- columnCount: 10,
796
- columnWidth: 100,
797
- direction: 'both',
798
- itemSize: 50,
799
- items: mockItems,
800
- },
801
- });
802
- await nextTick();
803
- const container = wrapper.find('.virtual-scroll-container');
804
-
805
- await container.trigger('keydown', { key: 'End' });
806
- await nextTick();
807
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
808
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
809
-
810
- await container.trigger('keydown', { key: 'Home' });
811
- await nextTick();
812
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
813
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
814
- });
815
-
816
- it('responds to all arrows in grid mode', async () => {
817
- const wrapper = mount(VirtualScroll, {
818
- props: {
819
- columnCount: 10,
820
- columnWidth: 100,
821
- direction: 'both',
822
- itemSize: 50,
823
- items: mockItems,
824
- },
825
- });
826
- await nextTick();
827
- const container = wrapper.find('.virtual-scroll-container');
828
-
829
- await container.trigger('keydown', { key: 'ArrowDown' });
830
- await container.trigger('keydown', { key: 'ArrowRight' });
831
- await nextTick();
832
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(50);
833
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(100);
834
-
835
- await container.trigger('keydown', { key: 'ArrowUp' });
836
- await container.trigger('keydown', { key: 'ArrowLeft' });
837
- await nextTick();
838
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
839
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
840
- });
841
-
842
- it('aligns items precisely with arrow keys', async () => {
843
- const wrapper = mount(VirtualScroll, {
844
- props: {
845
- itemSize: 100,
846
- items: mockItems,
847
- },
848
- });
849
- await nextTick();
850
- const container = wrapper.find('.virtual-scroll-container');
851
-
852
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
853
- vs.scrollToOffset(null, 50, { behavior: 'auto' });
854
- await nextTick();
855
- expect(vs.scrollDetails.scrollOffset.y).toBe(50);
856
-
857
- await container.trigger('keydown', { key: 'ArrowUp' });
858
- await nextTick();
859
- expect(vs.scrollDetails.scrollOffset.y).toBe(0);
860
-
861
- await container.trigger('keydown', { key: 'ArrowDown' });
862
- await nextTick();
863
- expect(vs.scrollDetails.scrollOffset.y).toBe(100);
864
-
865
- await container.trigger('keydown', { key: 'ArrowDown' });
866
- await nextTick();
867
- expect(vs.scrollDetails.scrollOffset.y).toBe(200);
868
- });
869
-
870
- it('aligns partially visible items at the bottom with arrow down', async () => {
871
- const wrapper = mount(VirtualScroll, {
872
- props: { itemSize: 50, items: mockItems },
873
- });
874
- await nextTick();
875
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
876
-
877
- // viewport 500. item 9 ends at 500.
878
- // scroll to 25. item 9 now ends at 525 (partially cut off).
879
- vs.scrollToOffset(null, 25);
880
- await nextTick();
881
- await nextTick();
882
-
883
- expect(vs.scrollDetails.currentEndIndex).toBe(10); // item 10 is at 500-550
884
-
885
- // item 10 is partially visible at bottom. ArrowDown should align it to end.
886
- const container = wrapper.find('.virtual-scroll-container');
887
- await container.trigger('keydown', { key: 'ArrowDown' });
888
- await nextTick();
889
- await nextTick();
890
-
891
- // item 10 ends at 550. viewport 500. targetEnd = 550 - 500 = 50.
892
- expect(vs.scrollDetails.scrollOffset.y).toBe(50);
893
- });
894
-
895
- it('aligns partially visible columns with arrowleft and arrowright in ltr', async () => {
896
- const wrapper = mount(VirtualScroll, {
897
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
898
- });
899
- await nextTick();
900
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
901
-
902
- // Viewport 500. Scroll to 50.
903
- // Item 0 (0-100) is partially visible at start.
904
- // Item 5 (500-600) is partially visible at end.
905
- vs.scrollToOffset(50, null);
906
- await nextTick();
907
- await nextTick();
908
-
909
- const container = wrapper.find('.virtual-scroll-container');
910
-
911
- // 1. ArrowLeft should align item 0 to start
912
- await container.trigger('keydown', { key: 'ArrowLeft' });
913
- await nextTick();
914
- await nextTick();
915
- expect(vs.scrollDetails.scrollOffset.x).toBe(0);
916
-
917
- // Reset
918
- vs.scrollToOffset(50, null);
919
- await nextTick();
920
- await nextTick();
921
-
922
- // 2. ArrowRight should align item 5 to end
923
- await container.trigger('keydown', { key: 'ArrowRight' });
924
- await nextTick();
925
- await nextTick();
926
- // item 5 ends at 600. viewport 500. targetEnd = 600 - 500 = 100.
927
- expect(vs.scrollDetails.scrollOffset.x).toBe(100);
928
- });
929
-
930
- it('aligns partially visible columns with arrowleft and arrowright in rtl', async () => {
931
- const container = document.createElement('div');
932
- container.setAttribute('dir', 'rtl');
933
- Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
934
- container.scrollTo = vi.fn().mockImplementation((options) => {
935
- if (options.left !== undefined) {
936
- Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
937
- }
938
- container.dispatchEvent(new Event('scroll'));
939
- });
940
-
941
- const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
942
- if (el === container) {
943
- return { direction: 'rtl' } as CSSStyleDeclaration;
944
- }
945
- return { direction: 'ltr' } as CSSStyleDeclaration;
946
- });
947
-
948
- const wrapper = mount(VirtualScroll, {
949
- props: {
950
- container,
951
- direction: 'horizontal',
952
- itemSize: 100,
953
- items: mockItems,
954
- },
955
- });
956
-
957
- await nextTick();
958
- await nextTick();
959
-
960
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
961
- vs.updateDirection();
962
- await nextTick();
963
-
964
- // Viewport 500. Logical scroll 50.
965
- // Item 0 (0-100) is partially visible at logical START (Right edge).
966
- // Item 5 (500-600) is partially visible at logical END (Left edge).
967
- vs.scrollToOffset(50, null);
968
- await nextTick();
969
- await nextTick();
970
-
971
- const vsContainer = wrapper.find('.virtual-scroll-container');
972
-
973
- // 1. ArrowRight in RTL should align item 0 to logical START
974
- await vsContainer.trigger('keydown', { key: 'ArrowRight' });
975
- await nextTick();
976
- await nextTick();
977
- expect(vs.scrollDetails.scrollOffset.x).toBe(0);
978
-
979
- // Reset
980
- vs.scrollToOffset(50, null);
981
- await nextTick();
982
- await nextTick();
983
-
984
- // 2. ArrowLeft in RTL should align item 5 to logical END
985
- await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
986
- await nextTick();
987
- await nextTick();
988
- // item 5 ends at 600. viewport 500. targetEnd = 600 - 500 = 100.
989
- expect(vs.scrollDetails.scrollOffset.x).toBe(100);
990
-
991
- styleSpy.mockRestore();
992
- });
993
-
994
- it('ignores vertical arrows in horizontal mode', async () => {
995
- const wrapper = mount(VirtualScroll, {
996
- props: {
997
- items: mockItems,
998
- direction: 'horizontal',
999
- },
1000
- });
1001
- await nextTick();
1002
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
1003
- const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
1004
-
1005
- await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowUp' });
1006
- await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowDown' });
1007
-
1008
- expect(scrollToIndexSpy).not.toHaveBeenCalled();
1009
- });
1010
-
1011
- it('ignores horizontal arrows in vertical mode', async () => {
1012
- const wrapper = mount(VirtualScroll, {
1013
- props: {
1014
- items: mockItems,
1015
- direction: 'vertical',
1016
- },
1017
- });
1018
- await nextTick();
1019
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
1020
- const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
1021
-
1022
- await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowLeft' });
1023
- await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowRight' });
1024
-
1025
- expect(scrollToIndexSpy).not.toHaveBeenCalled();
1026
- });
1027
- });
1028
-
1029
- describe('dynamic sizing & measurements', () => {
1030
- it('adjusts total size when items are measured', async () => {
1031
- const wrapper = mount(VirtualScroll, {
1032
- props: {
1033
- itemSize: 0,
1034
- items: mockItems.slice(0, 10),
1035
- },
1036
- });
1037
- await nextTick();
1038
-
1039
- expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('400px');
1040
-
1041
- const firstItem = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
1042
- triggerResize(firstItem, 100, 100);
1043
- await nextTick();
1044
- await nextTick();
1045
-
1046
- expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('460px');
1047
- });
1048
-
1049
- it('does not allow columns to become 0 width due to 0-size measurements', async () => {
1050
- const wrapper = mount(VirtualScroll, {
1051
- props: {
1052
- bufferAfter: 0,
1053
- bufferBefore: 0,
1054
- columnCount: 10,
1055
- defaultColumnWidth: 100,
1056
- direction: 'both',
1057
- itemSize: 50,
1058
- items: mockItems,
1059
- },
1060
- slots: {
1061
- item: ({ columnRange, index }: ItemSlotProps) => h('div', {
1062
- 'data-index': index,
1063
- }, [
1064
- ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
1065
- class: 'cell',
1066
- 'data-col-index': columnRange.start + i,
1067
- })),
1068
- ]),
1069
- },
1070
- });
1071
-
1072
- await nextTick();
1073
-
1074
- const initialWidth = (wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
1075
- expect(initialWidth).toBeGreaterThan(0);
1076
-
1077
- const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
1078
- const cell0 = row0.querySelector('.cell') as HTMLElement;
1079
- expect(cell0).not.toBeNull();
1080
-
1081
- triggerResize(cell0, 0, 0);
1082
-
1083
- await nextTick();
1084
- await nextTick();
1085
-
1086
- const currentWidth = (wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
1087
- expect(currentWidth).toBe(initialWidth);
1088
- });
1089
-
1090
- it('should not shift horizontally when scrolling vertically even if measurements vary slightly', async () => {
1091
- const wrapper = mount(VirtualScroll, {
1092
- props: {
1093
- bufferAfter: 0,
1094
- bufferBefore: 0,
1095
- columnCount: 10,
1096
- defaultColumnWidth: 100,
1097
- direction: 'both',
1098
- itemSize: 50,
1099
- items: mockItems,
1100
- },
1101
- slots: {
1102
- item: ({ columnRange, index }: ItemSlotProps) => h('div', {
1103
- 'data-index': index,
1104
- }, [
1105
- ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
1106
- class: 'cell',
1107
- 'data-col-index': columnRange.start + i,
1108
- })),
1109
- ]),
1110
- },
1111
- });
1112
-
1113
- await nextTick();
1114
-
1115
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
1116
-
1117
- const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
1118
- const cells0 = Array.from(row0.querySelectorAll('.cell'));
1119
-
1120
- triggerResize(row0, 1000, 50);
1121
- for (const cell of cells0) {
1122
- triggerResize(cell, 110, 50);
1123
- }
1124
-
1125
- await nextTick();
1126
- await nextTick();
1127
-
1128
- const container = wrapper.find('.virtual-scroll-container');
1129
- const el = container.element as HTMLElement;
1130
- Object.defineProperty(el, 'scrollTop', { configurable: true, value: 1000, writable: true });
1131
- await container.trigger('scroll');
1132
-
1133
- await nextTick();
1134
- await nextTick();
1135
-
1136
- const row20 = wrapper.find('.virtual-scroll-item[data-index="20"]').element;
1137
- const cells20 = Array.from(row20.querySelectorAll('.cell'));
1138
-
1139
- for (const cell of cells20) {
1140
- triggerResize(cell, 110.1, 50);
1141
- }
1142
-
1143
- await nextTick();
1144
- await nextTick();
1145
-
1146
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
1147
- });
1148
-
1149
- it('correctly aligns item 50:50 auto after measurements in dynamic grid', async () => {
1150
- const wrapper = mount(VirtualScroll, {
1151
- props: {
1152
- bufferAfter: 5,
1153
- bufferBefore: 5,
1154
- columnCount: 100,
1155
- defaultColumnWidth: 120,
1156
- defaultItemSize: 120,
1157
- direction: 'both',
1158
- items: mockItems,
1159
- },
1160
- slots: {
1161
- item: ({ columnRange, index }: ItemSlotProps) => h('div', {
1162
- 'data-index': index,
1163
- }, [
1164
- ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
1165
- class: 'cell',
1166
- 'data-col-index': columnRange.start + i,
1167
- })),
1168
- ]),
1169
- },
1170
- });
1171
-
1172
- await nextTick();
1173
-
1174
- (wrapper.vm as { scrollToIndex: (r: number, c: number, a: string) => void; }).scrollToIndex(50, 50, 'auto');
1175
- await nextTick();
1176
- await nextTick();
1177
-
1178
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5620);
1179
-
1180
- const row45El = wrapper.find('.virtual-scroll-item[data-index="45"]').element;
1181
- const cells45 = Array.from(row45El.querySelectorAll('.cell'));
1182
-
1183
- for (const cell of cells45) {
1184
- triggerResize(cell, 150, 120);
1185
- }
1186
-
1187
- await nextTick();
1188
- await nextTick();
1189
- await nextTick();
1190
-
1191
- await new Promise((resolve) => setTimeout(resolve, 300));
1192
- await nextTick();
1193
-
1194
- expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5830);
1195
- });
1196
-
1197
- it('handles fallback measurement when borderboxsize is missing', async () => {
1198
- const wrapper = mount(VirtualScroll, {
1199
- props: {
1200
- items: mockItems,
1201
- itemSize: 0, // dynamic
1202
- },
1203
- });
1204
-
1205
- await nextTick();
1206
- const item = wrapper.find('.virtual-scroll-item');
1207
-
1208
- Object.defineProperty(item.element, 'offsetWidth', { value: 500, configurable: true });
1209
- Object.defineProperty(item.element, 'offsetHeight', { value: 60, configurable: true });
1210
-
1211
- triggerResize(item.element, 500, 60, false);
1212
-
1213
- await nextTick();
1214
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
1215
- expect(vs.getRowHeight(0)).toBe(60);
1216
- });
1217
- });
1218
-
1219
- describe('sticky elements', () => {
1220
- it('applies sticky styles to marked items', async () => {
1221
- const wrapper = mount(VirtualScroll, {
1222
- props: {
1223
- itemSize: 50,
1224
- items: mockItems,
1225
- stickyIndices: [ 0 ],
1226
- },
1227
- });
1228
- await nextTick();
1229
-
1230
- const container = wrapper.find('.virtual-scroll-container');
1231
- const el = container.element as HTMLElement;
1232
-
1233
- Object.defineProperty(el, 'scrollTop', { value: 100, writable: true });
1234
- await container.trigger('scroll');
1235
- await nextTick();
1236
- await nextTick();
1237
-
1238
- const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
1239
- expect(item0.classes()).toContain('virtual-scroll--sticky');
1240
- expect((item0.element as HTMLElement).style.insetBlockStart).toBe('0px');
1241
- });
1242
-
1243
- it('does not gather multiple sticky items at the top', async () => {
1244
- const wrapper = mount(VirtualScroll, {
1245
- props: {
1246
- itemSize: 50,
1247
- items: mockItems,
1248
- stickyIndices: [ 0, 1, 2 ],
1249
- },
1250
- slots: {
1251
- item: (props: ItemSlotProps) => {
1252
- const { index, item } = props as ItemSlotProps<MockItem>;
1253
- return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
1254
- },
1255
- },
1256
- });
1257
-
1258
- await nextTick();
1259
- await nextTick();
1260
-
1261
- const container = wrapper.find('.virtual-scroll-container');
1262
- const el = container.element as HTMLElement;
1263
-
1264
- Object.defineProperty(el, 'scrollTop', { configurable: true, value: 150, writable: true });
1265
- await container.trigger('scroll');
1266
- await nextTick();
1267
- await nextTick();
1268
-
1269
- const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
1270
- const item1 = wrapper.find('.virtual-scroll-item[data-index="1"]');
1271
- const item2 = wrapper.find('.virtual-scroll-item[data-index="2"]');
1272
-
1273
- expect(item2.classes()).toContain('virtual-scroll--sticky');
1274
- expect(item1.classes()).not.toContain('virtual-scroll--sticky');
1275
- expect(item0.classes()).not.toContain('virtual-scroll--sticky');
1276
- });
1277
-
1278
- it('scrolls only one item with arrowdown when sticky header is visible', async () => {
1279
- const wrapper = mount(VirtualScroll, {
1280
- props: {
1281
- bufferAfter: 0,
1282
- bufferBefore: 0,
1283
- itemSize: 50,
1284
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1285
- stickyHeader: true,
1286
- },
1287
- slots: {
1288
- header: () => h('div', { class: 'header' }, 'Header'),
1289
- },
1290
- });
1291
- await nextTick();
1292
-
1293
- const header = wrapper.find('.virtual-scroll-header');
1294
- Object.defineProperty(header.element, 'offsetHeight', { configurable: true, value: 100 });
1295
- triggerResize(header.element, 500, 100);
1296
-
1297
- await nextTick();
1298
- await nextTick();
1299
-
1300
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1301
- const container = wrapper.find('.virtual-scroll-container');
1302
-
1303
- expect(vs.scrollDetails.currentEndIndex).toBe(7);
1304
-
1305
- vs.scrollToOffset(null, 200);
1306
- await nextTick();
1307
- await nextTick();
1308
-
1309
- expect(vs.scrollDetails.currentIndex).toBe(4);
1310
-
1311
- await container.trigger('keydown', { key: 'ArrowDown' });
1312
- await nextTick();
1313
- await nextTick();
1314
-
1315
- expect(vs.scrollDetails.scrollOffset.y).toBe(250);
1316
- });
1317
- });
1318
-
1319
- describe('scaling & massive lists', () => {
1320
- it('items should not overlap when scaling is active', async () => {
1321
- const itemSize = 1000;
1322
- const rowCount = 11000;
1323
- const massiveItems = Array.from({ length: rowCount }, (_, i) => ({ id: i, label: `Item ${ i }` }));
1324
-
1325
- const wrapper = mount(VirtualScroll, {
1326
- props: {
1327
- itemSize,
1328
- items: massiveItems,
1329
- },
1330
- slots: {
1331
- item: ({ index }: { index: number; }) => h('div', { class: 'item' }, `Item ${ index }`),
1332
- },
1333
- });
1334
-
1335
- await nextTick();
1336
- await nextTick();
1337
-
1338
- const items = wrapper.findAll('.virtual-scroll-item');
1339
- expect(items.length).toBeGreaterThan(1);
1340
- expect(items.length).toBeLessThan(50);
1341
-
1342
- const item0 = items[ 0 ]!.element as HTMLElement;
1343
- const item1 = items[ 1 ]!.element as HTMLElement;
1344
-
1345
- const style0 = item0.style.transform;
1346
- const style1 = item1.style.transform;
1347
-
1348
- const getY = (style: string) => {
1349
- const match = style.match(/translate\([^,]+, ([^)]+)px\)/);
1350
- return match ? Number.parseFloat(match[ 1 ]!) : 0;
1351
- };
1352
-
1353
- const y0 = getY(style0);
1354
- const y1 = getY(style1);
1355
-
1356
- const diff = Math.abs(y1 - y0);
1357
- expect(diff).toBeCloseTo(itemSize, 0);
1358
- });
1359
-
1360
- it('emulates touch scroll when scaling is active', async () => {
1361
- const itemSize = 1000;
1362
- const rowCount = 11000;
1363
- const massiveItems = Array.from({ length: rowCount }, (_, i) => ({ id: i }));
1364
-
1365
- const wrapper = mount(VirtualScroll, {
1366
- props: {
1367
- itemSize,
1368
- items: massiveItems,
1369
- },
1370
- });
1371
-
1372
- await nextTick();
1373
- await nextTick();
1374
-
1375
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1376
- expect(vs.scaleY).toBeGreaterThan(1);
1377
-
1378
- const container = wrapper.find('.virtual-scroll-container');
1379
- const containerEl = container.element as HTMLElement;
1380
-
1381
- expect(vs.scrollDetails.scrollOffset.y).toBe(0);
1382
-
1383
- containerEl.dispatchEvent(new PointerEvent('pointerdown', {
1384
- clientX: 0,
1385
- clientY: 500,
1386
- pointerId: 1,
1387
- pointerType: 'touch',
1388
- button: 0,
1389
- bubbles: true,
1390
- }));
1391
-
1392
- containerEl.dispatchEvent(new PointerEvent('pointermove', {
1393
- clientX: 0,
1394
- clientY: 400,
1395
- pointerId: 1,
1396
- pointerType: 'touch',
1397
- bubbles: true,
1398
- }));
1399
-
1400
- await vi.advanceTimersToNextFrame();
1401
- expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(100, 0);
1402
-
1403
- containerEl.dispatchEvent(new PointerEvent('pointerup', {
1404
- bubbles: true,
1405
- pointerId: 1,
1406
- pointerType: 'touch',
1407
- }));
1408
- });
1409
-
1410
- it('ignores pointer events when scaling is inactive', async () => {
1411
- const wrapper = mount(VirtualScroll, {
1412
- props: { itemSize: 50, items: mockItems },
1413
- });
1414
- await nextTick();
1415
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
1416
- expect(vs.scaleY).toBe(1);
1417
-
1418
- const container = wrapper.find('.virtual-scroll-container');
1419
- const containerEl = container.element as HTMLElement;
1420
-
1421
- const pointerDownEvent = new PointerEvent('pointerdown', { button: 0, bubbles: true, clientY: 500 });
1422
- containerEl.dispatchEvent(pointerDownEvent);
1423
-
1424
- const scrollOffsetBefore = vs.scrollDetails.scrollOffset.y;
1425
- containerEl.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 400 }));
1426
- expect(vs.scrollDetails.scrollOffset.y).toBe(scrollOffsetBefore);
1427
- });
1428
-
1429
- it('ignores non-primary mouse button pointerdown', async () => {
1430
- const massiveItems = Array.from({ length: 40001 }, (_, i) => ({ id: i }));
1431
- const wrapper = mount(VirtualScroll, {
1432
- props: { itemSize: 250, items: massiveItems },
1433
- });
1434
- await nextTick();
1435
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1436
- expect(vs.scaleY).toBeGreaterThan(1);
1437
-
1438
- const container = wrapper.find('.virtual-scroll-container');
1439
- const containerEl = container.element as HTMLElement;
1440
-
1441
- const pointerDownEvent = new PointerEvent('pointerdown', { button: 1, bubbles: true, clientY: 500, pointerType: 'mouse' });
1442
- containerEl.dispatchEvent(pointerDownEvent);
1443
-
1444
- const scrollOffsetBefore = vs.scrollDetails.scrollOffset.y;
1445
- containerEl.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 400 }));
1446
- expect(vs.scrollDetails.scrollOffset.y).toBe(scrollOffsetBefore);
1447
- });
1448
-
1449
- it('ignores pointermove and pointerup when not dragging', async () => {
1450
- const wrapper = mount(VirtualScroll, {
1451
- props: { itemSize: 50, items: mockItems },
1452
- });
1453
- await nextTick();
1454
- const container = wrapper.find('.virtual-scroll-container');
1455
- const containerEl = container.element as HTMLElement;
1456
-
1457
- const pointerMoveEvent = new PointerEvent('pointermove', { bubbles: true, clientY: 400 });
1458
- containerEl.dispatchEvent(pointerMoveEvent);
1459
-
1460
- const pointerUpEvent = new PointerEvent('pointerup', { bubbles: true });
1461
- containerEl.dispatchEvent(pointerUpEvent);
1462
- });
1463
-
1464
- it('handles pointer-based scrolling when scaling is active', async () => {
1465
- const items = Array.from({ length: 11000 }, (_, i) => ({ id: i }));
1466
- const wrapper = mount(VirtualScroll, {
1467
- props: {
1468
- itemSize: 1000, // 11M VU
1469
- items,
1470
- },
1471
- });
1472
-
1473
- await nextTick();
1474
- await nextTick();
1475
-
1476
- const vs = wrapper.vm as unknown as VirtualScrollInstance<unknown>;
1477
- expect(vs.scaleY).toBeGreaterThan(1);
1478
-
1479
- const container = wrapper.find('.virtual-scroll-container');
1480
-
1481
- container.element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 100, button: 0, pointerId: 1, bubbles: true }));
1482
- container.element.dispatchEvent(new PointerEvent('pointermove', { clientX: 0, clientY: 50, pointerId: 1, bubbles: true }));
1483
- await nextTick();
1484
- vi.runAllTimers(); // process requestAnimationFrame
1485
- await nextTick();
1486
-
1487
- // Dragged 50px up.
1488
- expect(vs.scrollDetails.scrollOffset.y).toBe(50);
1489
-
1490
- // pointerup
1491
- container.element.dispatchEvent(new PointerEvent('pointerup', { clientX: 0, clientY: 50, pointerId: 1, bubbles: true }));
1492
- await nextTick();
1493
- });
1494
-
1495
- it('implements inertia scrolling with friction and cancellation', async () => {
1496
- const items = Array.from({ length: 11000 }, (_, i) => ({ id: i }));
1497
- const wrapper = mount(VirtualScroll, {
1498
- props: {
1499
- itemSize: 1000,
1500
- items,
1501
- },
1502
- });
1503
-
1504
- await nextTick();
1505
- await nextTick();
1506
-
1507
- const vs = wrapper.vm as unknown as VirtualScrollInstance<unknown>;
1508
- const container = wrapper.find('.virtual-scroll-container');
1509
-
1510
- // 1. Start inertia by swiping quickly
1511
- container.element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 400, button: 0, pointerId: 1, bubbles: true }));
1512
- container.element.dispatchEvent(new PointerEvent('pointermove', { clientX: 0, clientY: 300, pointerId: 1, bubbles: true }));
1513
- await nextTick();
1514
-
1515
- // Swipe fast
1516
- container.element.dispatchEvent(new PointerEvent('pointerup', { clientX: 0, clientY: 200, pointerId: 1, bubbles: true }));
1517
- await nextTick();
1518
-
1519
- // 2. Verify it continues to scroll
1520
- vi.advanceTimersByTime(16); // step 1
1521
- await nextTick();
1522
- const pos1 = vs.scrollDetails.scrollOffset.y;
1523
- expect(pos1).toBeGreaterThan(200);
1524
-
1525
- vi.advanceTimersByTime(16); // step 2
1526
- await nextTick();
1527
- const pos2 = vs.scrollDetails.scrollOffset.y;
1528
- expect(pos2).toBeGreaterThan(pos1);
1529
-
1530
- // 3. Stop inertia via stopProgrammaticScroll
1531
- vs.stopProgrammaticScroll();
1532
- vi.advanceTimersByTime(16);
1533
- await nextTick();
1534
- expect(vs.scrollDetails.scrollOffset.y).toBe(pos2);
1535
- });
1536
-
1537
- it('prevents cross-axis drift during inertia', async () => {
1538
- const items = Array.from({ length: 11000 }, (_, i) => ({ id: i }));
1539
- const wrapper = mount(VirtualScroll, {
1540
- props: {
1541
- itemSize: 1000,
1542
- columnCount: 11000,
1543
- columnWidth: 1000,
1544
- direction: 'both',
1545
- items,
1546
- },
1547
- });
1548
-
1549
- await nextTick();
1550
- await nextTick();
1551
-
1552
- const vs = wrapper.vm as unknown as VirtualScrollInstance<unknown>;
1553
- const container = wrapper.find('.virtual-scroll-container');
1554
-
1555
- // Swipe horizontally with very small vertical component
1556
- container.element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 400, clientY: 100, button: 0, pointerId: 1, bubbles: true }));
1557
- container.element.dispatchEvent(new PointerEvent('pointermove', { clientX: 300, clientY: 98, pointerId: 1, bubbles: true }));
1558
- await nextTick();
1559
- vi.runAllTimers();
1560
- await nextTick();
1561
-
1562
- container.element.dispatchEvent(new PointerEvent('pointerup', { clientX: 200, clientY: 98, pointerId: 1, bubbles: true }));
1563
- await nextTick();
1564
-
1565
- // Velocity Y should have been zeroed because X velocity is much higher
1566
- vi.advanceTimersByTime(16);
1567
- await nextTick();
1568
-
1569
- expect(vs.scrollDetails.scrollOffset.x).toBeGreaterThan(200);
1570
- expect(vs.scrollDetails.scrollOffset.y).toBe(2); // Initial deltaY was 2 (100 -> 98). No more movement.
1571
- });
1572
-
1573
- describe('large scale rendering boundaries', () => {
1574
- const rowHeight = 1000;
1575
- const rowCount = 11000; // 11,000,000px
1576
- const massiveItems = Array.from({ length: rowCount }, (_, i) => ({ id: i }));
1577
- const viewportHeight = 500;
1578
-
1579
- const variants = [
1580
- { name: 'plain', props: {} },
1581
- { name: 'sticky header', props: { stickyHeader: true }, hasHeader: true },
1582
- { name: 'sticky footer', props: { stickyFooter: true }, hasFooter: true },
1583
- { name: 'both sticky', props: { stickyHeader: true, stickyFooter: true }, hasHeader: true, hasFooter: true },
1584
- { name: 'plain with gap', props: {} },
1585
- { name: 'sticky header with gap', props: { stickyHeader: true, gap: 50 }, hasHeader: true },
1586
- { name: 'sticky footer with gap', props: { stickyFooter: true, gap: 50 }, hasFooter: true },
1587
- { name: 'both sticky with gap', props: { stickyHeader: true, stickyFooter: true, gap: 50 }, hasHeader: true, hasFooter: true },
1588
- ];
1589
-
1590
- for (const variant of variants) {
1591
- describe(variant.name, () => {
1592
- it('renders last items when scrolled to end manually', async () => {
1593
- const wrapper = mount(VirtualScroll, {
1594
- props: {
1595
- items: massiveItems,
1596
- itemSize: rowHeight,
1597
- ...variant.props,
1598
- },
1599
- slots: {
1600
- ...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
1601
- ...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
1602
- },
1603
- });
1604
-
1605
- await nextTick();
1606
- await nextTick();
1607
-
1608
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1609
- const container = wrapper.find('.virtual-scroll-container');
1610
-
1611
- const totalRUHeight = vs.scrollDetails.totalSize.height;
1612
- const maxRUOffset = totalRUHeight - vs.scrollDetails.viewportSize.height;
1613
- const maxScroll = virtualToDisplay(maxRUOffset, vs.componentOffset.y, vs.scaleY);
1614
- Object.defineProperty(container.element, 'scrollTop', { configurable: true, value: maxScroll });
1615
- await container.trigger('scroll');
1616
-
1617
- await nextTick();
1618
- await nextTick();
1619
-
1620
- const renderedIndices = vs.scrollDetails.items.map((i) => i.index);
1621
- expect(renderedIndices).toContain(rowCount - 1);
1622
- expect(renderedIndices).toContain(rowCount - 2);
1623
-
1624
- const lastItem = vs.scrollDetails.items.find((i) => i.index === rowCount - 1)!;
1625
- expect(lastItem.originalY + lastItem.size.height).toBeCloseTo(vs.scrollDetails.scrollOffset.y + viewportHeight, 0);
1626
- expect(vs.scrollDetails.scrollOffset.y + viewportHeight).toBeCloseTo(totalRUHeight, 0);
1627
-
1628
- wrapper.unmount();
1629
- });
1630
-
1631
- it('renders last items when end key is pressed', async () => {
1632
- const wrapper = mount(VirtualScroll, {
1633
- props: {
1634
- items: massiveItems,
1635
- itemSize: rowHeight,
1636
- ...variant.props,
1637
- },
1638
- slots: {
1639
- ...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
1640
- ...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
1641
- },
1642
- });
1643
-
1644
- await nextTick();
1645
- await nextTick();
1646
-
1647
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1648
- const container = wrapper.find('.virtual-scroll-container');
1649
-
1650
- await container.trigger('keydown', { key: 'End' });
1651
-
1652
- await nextTick();
1653
- await nextTick();
1654
-
1655
- const renderedIndices = vs.scrollDetails.items.map((i) => i.index);
1656
- expect(renderedIndices).toContain(rowCount - 1);
1657
-
1658
- const lastItem = vs.scrollDetails.items.find((i) => i.index === rowCount - 1)!;
1659
- expect(lastItem.originalY + lastItem.size.height).toBeCloseTo(vs.scrollDetails.scrollOffset.y + viewportHeight, 0);
1660
-
1661
- wrapper.unmount();
1662
- });
1663
-
1664
- it('renders first items when home key is pressed after being at end', async () => {
1665
- const wrapper = mount(VirtualScroll, {
1666
- props: {
1667
- items: massiveItems,
1668
- itemSize: rowHeight,
1669
- ...variant.props,
1670
- },
1671
- slots: {
1672
- ...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
1673
- ...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
1674
- },
1675
- });
1676
-
1677
- await nextTick();
1678
- await nextTick();
1679
-
1680
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1681
- const container = wrapper.find('.virtual-scroll-container');
1682
-
1683
- vs.scrollToIndex(rowCount - 1, null, { align: 'end', behavior: 'auto' });
1684
- await nextTick();
1685
- await nextTick();
1686
-
1687
- await container.trigger('keydown', { key: 'Home' });
1688
- await nextTick();
1689
- await nextTick();
1690
-
1691
- const renderedIndices = vs.scrollDetails.items.map((i) => i.index);
1692
- expect(renderedIndices).toContain(0);
1693
- expect(renderedIndices).toContain(1);
1694
- expect(vs.scrollDetails.scrollOffset.y).toBe(0);
1695
-
1696
- wrapper.unmount();
1697
- });
1698
-
1699
- it('renders correct items when scrollbar is at boundaries', async () => {
1700
- const wrapper = mount(VirtualScroll, {
1701
- props: {
1702
- items: massiveItems,
1703
- itemSize: rowHeight,
1704
- virtualScrollbar: true,
1705
- ...variant.props,
1706
- },
1707
- slots: {
1708
- ...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
1709
- ...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
1710
- },
1711
- });
1712
-
1713
- await nextTick();
1714
- await nextTick();
1715
-
1716
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1717
-
1718
- const maxDisplayOffset = vs.renderedHeight - vs.scrollDetails.displayViewportSize.height;
1719
- vs.scrollToOffset(null, displayToVirtual(maxDisplayOffset, vs.componentOffset.y, vs.scaleY));
1720
-
1721
- await nextTick();
1722
- await nextTick();
1723
-
1724
- expect(vs.scrollDetails.items.map((i) => i.index)).toContain(rowCount - 1);
1725
-
1726
- vs.scrollToOffset(null, 0);
1727
- await nextTick();
1728
- await nextTick();
1729
-
1730
- expect(vs.scrollDetails.items.map((i) => i.index)).toContain(0);
1731
- expect(vs.scrollDetails.scrollOffset.y).toBe(0);
1732
-
1733
- wrapper.unmount();
1734
- });
1735
- });
1736
- }
1737
- });
1738
- });
1739
-
1740
- describe('virtual scrollbars', () => {
1741
- it('scrolls horizontally with shift + mousewheel when scaling is active', async () => {
1742
- const massiveColCount = 200000;
1743
- const massiveItems = Array.from({ length: 10 }, (_, i) => ({ id: i }));
1744
- const wrapper = mount(VirtualScroll, {
1745
- props: {
1746
- columnCount: massiveColCount,
1747
- columnWidth: 100,
1748
- direction: 'both',
1749
- itemSize: 50,
1750
- items: massiveItems,
1751
- },
1752
- });
1753
-
1754
- await nextTick();
1755
- await nextTick();
1756
-
1757
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1758
- expect(vs.scaleX).toBeGreaterThan(1);
1759
-
1760
- expect(vs.scrollDetails.scrollOffset.x).toBe(0);
1761
- expect(vs.scrollDetails.scrollOffset.y).toBe(0);
1762
-
1763
- const wheelEvent = new WheelEvent('wheel', {
1764
- bubbles: true,
1765
- cancelable: true,
1766
- deltaX: 0,
1767
- deltaY: 100,
1768
- shiftKey: true,
1769
- });
1770
- wrapper.find('.virtual-scroll-container').element.dispatchEvent(wheelEvent);
1771
-
1772
- await nextTick();
1773
-
1774
- expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
1775
- expect(vs.scrollDetails.scrollOffset.y).toBe(0);
1776
- });
1777
-
1778
- it('updates thumb size when total size changes', async () => {
1779
- const wrapper = mount(VirtualScroll, {
1780
- props: {
1781
- itemSize: 50,
1782
- items: Array.from({ length: 20 }, (_, i) => ({ id: i })),
1783
- virtualScrollbar: true,
1784
- },
1785
- });
1786
-
1787
- await nextTick();
1788
- await nextTick();
1789
-
1790
- const verticalThumb = wrapper.find('.virtual-scroll-scrollbar-container .virtual-scrollbar-thumb--vertical');
1791
- expect(verticalThumb.exists()).toBe(true);
1792
- expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('50%');
1793
-
1794
- await wrapper.setProps({
1795
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1796
- });
1797
-
1798
- await nextTick();
1799
- await nextTick();
1800
- await nextTick();
1801
-
1802
- expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('10%');
1803
-
1804
- await wrapper.setProps({
1805
- items: Array.from({ length: 1000 }, (_, i) => ({ id: i })),
1806
- });
1807
-
1808
- await nextTick();
1809
- await nextTick();
1810
- await nextTick();
1811
-
1812
- expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('6.4%');
1813
- });
1814
-
1815
- it('scrolls when clicking on vertical scrollbar track', async () => {
1816
- const wrapper = mount(VirtualScroll, {
1817
- props: {
1818
- itemSize: 50,
1819
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1820
- virtualScrollbar: true,
1821
- },
1822
- });
1823
-
1824
- await nextTick();
1825
- await nextTick();
1826
-
1827
- const track = wrapper.find('.virtual-scrollbar-track--vertical');
1828
- expect(track.exists()).toBe(true);
1829
-
1830
- vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
1831
- top: 0,
1832
- left: 490,
1833
- width: 10,
1834
- height: 500,
1835
- bottom: 500,
1836
- right: 500,
1837
- } as DOMRect);
1838
-
1839
- await track.trigger('mousedown', {
1840
- clientY: 250,
1841
- });
1842
-
1843
- await nextTick();
1844
-
1845
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1846
- expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(2250, 0);
1847
- });
1848
-
1849
- it('scrolls to absolute end when clicking near the end of the vertical track', async () => {
1850
- const wrapper = mount(VirtualScroll, {
1851
- props: { itemSize: 50, items: mockItems, virtualScrollbar: true },
1852
- });
1853
- await nextTick();
1854
- await nextTick();
1855
-
1856
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1857
- const track = wrapper.find('.virtual-scrollbar-track--vertical');
1858
-
1859
- vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
1860
- bottom: 500,
1861
- height: 500,
1862
- left: 490,
1863
- right: 500,
1864
- top: 0,
1865
- width: 10,
1866
- x: 490,
1867
- y: 0,
1868
- } as DOMRect);
1869
-
1870
- await track.trigger('mousedown', { clientY: 500 });
1871
- await nextTick();
1872
-
1873
- expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
1874
- });
1875
-
1876
- it('scrolls to absolute end when clicking near the end of the horizontal track', async () => {
1877
- const wrapper = mount(VirtualScroll, {
1878
- props: { direction: 'horizontal', itemSize: 50, items: mockItems, virtualScrollbar: true },
1879
- });
1880
- await nextTick();
1881
- await nextTick();
1882
-
1883
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1884
- const track = wrapper.find('.virtual-scrollbar-track--horizontal');
1885
-
1886
- vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
1887
- bottom: 500,
1888
- height: 10,
1889
- left: 0,
1890
- right: 500,
1891
- top: 490,
1892
- width: 500,
1893
- x: 0,
1894
- y: 490,
1895
- } as DOMRect);
1896
-
1897
- await track.trigger('mousedown', { clientX: 500 });
1898
- await nextTick();
1899
-
1900
- expect(vs.scrollDetails.scrollOffset.x).toBe(4500);
1901
- });
1902
-
1903
- it('scrolls when clicking on horizontal scrollbar track', async () => {
1904
- const wrapper = mount(VirtualScroll, {
1905
- props: {
1906
- direction: 'horizontal',
1907
- itemSize: 100,
1908
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1909
- virtualScrollbar: true,
1910
- },
1911
- });
1912
-
1913
- await nextTick();
1914
- await nextTick();
1915
-
1916
- const track = wrapper.find('.virtual-scrollbar-track--horizontal');
1917
- expect(track.exists()).toBe(true);
1918
-
1919
- vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
1920
- top: 490,
1921
- left: 0,
1922
- width: 500,
1923
- height: 10,
1924
- bottom: 500,
1925
- right: 500,
1926
- } as DOMRect);
1927
-
1928
- await track.trigger('mousedown', {
1929
- clientX: 250,
1930
- });
1931
-
1932
- await nextTick();
1933
-
1934
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1935
- expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(4750, 0);
1936
- });
1937
-
1938
- it('calls internal scrolltooffset with infinity when scrollbar reaches the end', async () => {
1939
- let capturedCallback: ((offset: number) => void) | undefined;
1940
- const wrapper = mount(VirtualScroll, {
1941
- props: { itemSize: 50, items: mockItems, virtualScrollbar: true },
1942
- slots: {
1943
- scrollbar: (slotProps: ScrollbarSlotProps) => {
1944
- if (slotProps.scrollbarProps.axis === 'vertical') {
1945
- capturedCallback = slotProps.scrollbarProps.scrollToOffset;
1946
- }
1947
- return h('div', { class: 'captured-scrollbar' });
1948
- },
1949
- },
1950
- });
1951
-
1952
- await nextTick();
1953
- await nextTick();
1954
-
1955
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
1956
-
1957
- triggerResize(wrapper.element as HTMLElement, 500, 500);
1958
- await nextTick();
1959
- await nextTick();
1960
-
1961
- expect(vs.isHydrated).toBe(true);
1962
- expect(wrapper.find('.captured-scrollbar').exists()).toBe(true);
1963
- expect(typeof capturedCallback).toBe('function');
1964
-
1965
- capturedCallback!(4500);
1966
- await nextTick();
1967
- await nextTick();
1968
-
1969
- expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
1970
- });
1971
-
1972
- it('does not show horizontal scrollbar if items fit', async () => {
1973
- const wrapper = mount(VirtualScroll, {
1974
- props: {
1975
- direction: 'horizontal',
1976
- itemSize: 100,
1977
- items: Array.from({ length: 2 }, (_, i) => ({ id: i })),
1978
- virtualScrollbar: true,
1979
- },
1980
- });
1981
-
1982
- await nextTick();
1983
- await nextTick();
1984
-
1985
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
1986
- expect(vs.scrollbarPropsHorizontal).toBeNull();
1987
- });
1988
-
1989
- it('forces virtual scrollbars when virtualscrollbar prop is true', async () => {
1990
- const wrapper = mount(VirtualScroll, {
1991
- props: {
1992
- items: [ { id: 1 } ],
1993
- itemSize: 50,
1994
- virtualScrollbar: true,
1995
- },
1996
- });
1997
- await nextTick();
1998
- expect(wrapper.find('.virtual-scroll-scrollbar-container').exists()).toBe(true);
1999
- });
2000
- });
2001
-
2002
- describe('ssr & hydration', () => {
2003
- it('renders ssr range if provided', async () => {
2004
- const wrapper = mount(VirtualScroll, {
2005
- props: {
2006
- itemSize: 50,
2007
- items: mockItems,
2008
- ssrRange: { end: 20, start: 10 },
2009
- },
2010
- slots: {
2011
- item: (props: ItemSlotProps) => {
2012
- const { item } = props as ItemSlotProps<MockItem>;
2013
- return h('div', item.label);
2014
- },
2015
- },
2016
- });
2017
- const items = wrapper.findAll('.virtual-scroll-item');
2018
- expect(items.length).toBe(10);
2019
- expect(items[ 0 ]?.attributes('data-index')).toBe('10');
2020
- expect(wrapper.text()).toContain('Item 10');
2021
- });
2022
-
2023
- it('hydrates and scrolls to initial index', async () => {
2024
- const wrapper = mount(VirtualScroll, {
2025
- props: {
2026
- initialScrollIndex: 50,
2027
- itemSize: 50,
2028
- items: mockItems,
2029
- },
2030
- slots: {
2031
- item: (props: ItemSlotProps) => {
2032
- const { item } = props as ItemSlotProps<MockItem>;
2033
- return h('div', item.label);
2034
- },
2035
- },
2036
- });
2037
- await nextTick();
2038
- await nextTick();
2039
- await nextTick();
2040
- await nextTick();
2041
- await nextTick();
2042
-
2043
- expect(wrapper.text()).toContain('Item 50');
2044
- });
2045
-
2046
- it('renders gaps correctly during initial mount/ssr', async () => {
2047
- const wrapper = mount(VirtualScroll, {
2048
- props: {
2049
- direction: 'both',
2050
- items: mockItems.slice(0, 10),
2051
- itemSize: 50,
2052
- columnCount: 5,
2053
- columnWidth: 100,
2054
- gap: 10,
2055
- columnGap: 20,
2056
- },
2057
- });
2058
-
2059
- // Check styles immediately after mount (before hydration)
2060
- const vsWrapper = wrapper.find('.virtual-scroll-wrapper');
2061
- const vsWrapperStyle = (vsWrapper.element as HTMLElement).style;
2062
- expect(vsWrapperStyle.rowGap).toBe('10px');
2063
- expect(vsWrapperStyle.columnGap).toBe('20px');
2064
-
2065
- const vsItem = wrapper.find('.virtual-scroll-item');
2066
- const vsItemStyle = (vsItem.element as HTMLElement).style;
2067
- expect(vsItemStyle.columnGap).toBe('20px');
2068
- });
2069
- });
2070
-
2071
- describe('slots & custom content', () => {
2072
- it('renders header and footer', async () => {
2073
- const wrapper = mount(VirtualScroll, {
2074
- props: { items: mockItems.slice(0, 1) },
2075
- slots: {
2076
- footer: () => h('div', 'FOOTER'),
2077
- header: () => h('div', 'HEADER'),
2078
- },
2079
- });
2080
- expect(wrapper.text()).toContain('HEADER');
2081
- expect(wrapper.text()).toContain('FOOTER');
2082
- });
2083
-
2084
- it('shows loading indicator', async () => {
2085
- const wrapper = mount(VirtualScroll, {
2086
- props: { items: [], loading: true },
2087
- slots: {
2088
- loading: () => h('div', 'LOADING...'),
2089
- },
2090
- });
2091
- expect(wrapper.text()).toContain('LOADING...');
2092
- });
2093
-
2094
- it('uses correct html tags', () => {
2095
- const wrapper = mount(VirtualScroll, {
2096
- props: {
2097
- containerTag: 'table',
2098
- itemTag: 'tr',
2099
- items: [],
2100
- wrapperTag: 'tbody',
2101
- },
2102
- });
2103
- expect(wrapper.element.tagName).toBe('TABLE');
2104
- expect(wrapper.find('tbody').exists()).toBe(true);
2105
- });
2106
-
2107
- it('triggers refresh and updates items', async () => {
2108
- const wrapper = mount(VirtualScroll, {
2109
- props: {
2110
- itemSize: 50,
2111
- items: mockItems.slice(0, 10),
2112
- },
2113
- });
2114
- await nextTick();
2115
-
2116
- const vs = wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
2117
- vs.refresh();
2118
- await nextTick();
2119
- expect(vs.scrollDetails.items.length).toBeGreaterThan(0);
2120
- expect(vs.scrollDetails.items.length).toBeLessThan(50);
2121
- });
2122
-
2123
- it('handles sticky header and footer measurements', async () => {
2124
- mount(VirtualScroll, {
2125
- props: {
2126
- items: mockItems.slice(0, 10),
2127
- stickyFooter: true,
2128
- stickyHeader: true,
2129
- },
2130
- slots: {
2131
- footer: () => h('div', { class: 'footer', style: 'height: 30px' }, 'FOOTER'),
2132
- header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
2133
- },
2134
- });
2135
- await nextTick();
2136
- });
2137
-
2138
- it('accounts for sticky header and footer in scroll padding', async () => {
2139
- const wrapper = mount(VirtualScroll, {
2140
- props: {
2141
- items: mockItems,
2142
- itemSize: 50,
2143
- stickyHeader: true,
2144
- },
2145
- slots: {
2146
- header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
2147
- },
2148
- });
2149
-
2150
- await nextTick();
2151
- await nextTick();
2152
-
2153
- const vs = wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; };
2154
- expect(vs.scrollDetails.totalSize.height).toBeGreaterThan(0);
2155
- });
2156
-
2157
- it('resets measured padding when header/footer is removed', async () => {
2158
- const TestComponent = {
2159
- components: { VirtualScroll },
2160
- props: [ 'showHeader', 'showFooter' ],
2161
- template: `
2162
- <VirtualScroll :itemSize="50" :items="items">
2163
- <template v-if="showHeader" #header>
2164
- <div class="header" style="height: 100px">HEADER</div>
2165
- </template>
2166
- <template v-if="showFooter" #footer>
2167
- <div class="footer" style="height: 100px">FOOTER</div>
2168
- </template>
2169
- </VirtualScroll>
2170
- `,
2171
- data() {
2172
- return { items: Array.from({ length: 10 }, (_, i) => ({ id: i })) };
2173
- },
2174
- };
2175
-
2176
- const wrapper = mount(TestComponent, {
2177
- props: {
2178
- showHeader: true,
2179
- showFooter: true,
2180
- },
2181
- });
2182
-
2183
- await nextTick();
2184
-
2185
- const vs = wrapper.findComponent(VirtualScroll as unknown as DefineComponent).vm as unknown as VirtualScrollInstance<MockItem>;
2186
-
2187
- const headerEl = wrapper.find('.virtual-scroll-header').element as HTMLElement;
2188
- const footerEl = wrapper.find('.virtual-scroll-footer').element as HTMLElement;
2189
-
2190
- Object.defineProperty(headerEl, 'offsetHeight', { configurable: true, value: 100 });
2191
- Object.defineProperty(footerEl, 'offsetHeight', { configurable: true, value: 100 });
2192
-
2193
- triggerResize(headerEl, 500, 100);
2194
- triggerResize(footerEl, 500, 100);
2195
-
2196
- await nextTick();
2197
- await nextTick();
2198
-
2199
- expect(vs.scrollDetails.totalSize.height).toBe(700);
2200
-
2201
- await wrapper.setProps({ showHeader: false });
2202
- await nextTick();
2203
- await nextTick();
2204
-
2205
- expect(vs.scrollDetails.totalSize.height).toBe(600);
2206
-
2207
- await wrapper.setProps({ showFooter: false });
2208
- await nextTick();
2209
- await nextTick();
2210
-
2211
- expect(vs.scrollDetails.totalSize.height).toBe(500);
2212
- });
2213
- });
2214
-
2215
- describe('dynamic list changes', () => {
2216
- it('clamps scroll position when items count decreases (with scaling)', async () => {
2217
- const items = ref(Array.from({ length: 11000 }, (_, i) => ({ id: i })));
2218
- const wrapper = mount({
2219
- components: { VirtualScroll },
2220
- setup() {
2221
- return { items };
2222
- },
2223
- template: '<VirtualScroll :items="items" :item-size="1000" style="height: 500px" />',
2224
- });
2225
- await nextTick();
2226
- await nextTick();
2227
- const vs = wrapper.findComponent(VirtualScroll as unknown as VueWrapper).vm as VirtualScrollInstance<{ id: number; }>;
2228
-
2229
- expect(vs.scaleY).toBeGreaterThan(1);
2230
-
2231
- vs.scrollToIndex(10500, null, { align: 'start', behavior: 'auto' });
2232
- await nextTick();
2233
- await nextTick();
2234
-
2235
- expect(vs.scrollDetails.scrollOffset.y).toBe(10500000);
2236
-
2237
- items.value = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
2238
- await nextTick();
2239
- await nextTick();
2240
-
2241
- expect(vs.scrollDetails.scrollOffset.y).toBeLessThanOrEqual(1000000 - 500);
2242
- });
2243
-
2244
- it('syncs display scroll position when total height changes (with scaling)', async () => {
2245
- const items = ref(Array.from({ length: 30000 }, (_, i) => ({ id: i })));
2246
- const wrapper = mount({
2247
- components: { VirtualScroll },
2248
- setup() {
2249
- return { items };
2250
- },
2251
- template: '<VirtualScroll :items="items" :item-size="1000" style="height: 500px" />',
2252
- });
2253
- await nextTick();
2254
- await nextTick();
2255
- const vs = wrapper.findComponent(VirtualScroll as unknown as VueWrapper).vm as unknown as VirtualScrollInstance<{ id: number; }>;
2256
-
2257
- vs.scrollToOffset(null, 10000000);
2258
- await nextTick();
2259
- await nextTick();
2260
-
2261
- const initialDisplayScroll = (wrapper.find('.virtual-scroll-container').element as HTMLElement).scrollTop;
2262
-
2263
- items.value = Array.from({ length: 40000 }, (_, i) => ({ id: i }));
2264
- await nextTick();
2265
- await nextTick();
2266
-
2267
- const newDisplayScroll = (wrapper.find('.virtual-scroll-container').element as HTMLElement).scrollTop;
2268
- expect(newDisplayScroll).not.toBe(initialDisplayScroll);
2269
- expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(10000000, 0);
2270
- });
2271
-
2272
- it('updates pending scroll index when items are prepended in a dynamic list', async () => {
2273
- const items = ref(Array.from({ length: 50 }, (_, i) => ({ id: i })));
2274
- const wrapper = mount({
2275
- components: { VirtualScroll },
2276
- setup() {
2277
- return { items };
2278
- },
2279
- template: '<VirtualScroll :items="items" :item-size="50" restore-scroll-on-prepend style="height: 200px" />',
2280
- });
2281
-
2282
- await nextTick();
2283
- await nextTick();
2284
- await nextTick();
2285
-
2286
- const vs = wrapper.findComponent(VirtualScroll as unknown as VueWrapper).vm as unknown as VirtualScrollInstance<{ id: number; }>;
2287
-
2288
- expect(vs.isHydrated).toBe(true);
2289
-
2290
- vs.scrollToIndex(10, null, { behavior: 'smooth', align: 'start' });
2291
- await nextTick();
2292
- await nextTick();
2293
-
2294
- items.value = [ { id: -2 }, { id: -1 }, ...items.value ];
2295
-
2296
- for (let i = 0; i < 15; i++) {
2297
- await nextTick();
2298
- }
2299
-
2300
- expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(600, 0);
2301
- });
2302
-
2303
- it('recycles items and maintains a small rendered item count', async () => {
2304
- const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
2305
- const wrapper = mount(VirtualScroll, {
2306
- props: {
2307
- items,
2308
- itemSize: 50,
2309
- },
2310
- });
2311
-
2312
- await nextTick();
2313
- await nextTick();
2314
-
2315
- expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
2316
-
2317
- const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
2318
-
2319
- vs.scrollToOffset(null, 5000, { behavior: 'auto' });
2320
- await nextTick();
2321
- await nextTick();
2322
-
2323
- expect(wrapper.findAll('.virtual-scroll-item').length).toBe(20);
2324
-
2325
- vs.scrollToOffset(null, 49500, { behavior: 'auto' });
2326
- await nextTick();
2327
- await nextTick();
2328
-
2329
- expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
2330
- });
2331
-
2332
- describe('table virtualization', () => {
2333
- it('correctly virtualizes when using table tags and constrained height', async () => {
2334
- const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
2335
- const wrapper = mount(VirtualScroll, {
2336
- props: {
2337
- items,
2338
- itemSize: 40,
2339
- containerTag: 'table',
2340
- wrapperTag: 'tbody',
2341
- itemTag: 'tr',
2342
- style: { height: '400px', display: 'block' },
2343
- },
2344
- slots: {
2345
- item: '<td class="item">{{ index }}</td>',
2346
- },
2347
- });
2348
-
2349
- await nextTick();
2350
- // Since it's mounted in JSDOM, we need to mock clientHeight/clientWidth if they are 0
2351
- const el = wrapper.element as HTMLElement;
2352
- Object.defineProperty(el, 'clientHeight', { value: 400, configurable: true });
2353
- Object.defineProperty(el, 'clientWidth', { value: 800, configurable: true });
2354
-
2355
- // Trigger resize observation
2356
- const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
2357
- vs.refresh();
2358
- await nextTick();
2359
- await nextTick();
2360
-
2361
- // 400px height / 40px itemSize = 10 items + buffer
2362
- const renderedCount = wrapper.findAll('tr.virtual-scroll-item').length;
2363
- expect(renderedCount).toBeLessThan(30);
2364
- expect(renderedCount).toBeGreaterThan(10);
2365
- });
2366
- });
2367
- });
2368
- });