@pdanpdan/virtual-scroll 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,13 @@
1
- import type { ItemSlotProps, ScrollDetails } from '../types';
1
+ import type { ItemSlotProps, ScrollbarSlotProps, ScrollDetails, VirtualScrollInstance } from '../types';
2
+ import type { VueWrapper } from '@vue/test-utils';
3
+ import type { DefineComponent } from 'vue';
2
4
 
3
5
  /* global ScrollToOptions, ResizeObserverCallback */
4
6
  import { mount } from '@vue/test-utils';
5
- import { beforeEach, describe, expect, it, vi } from 'vitest';
6
- import { h, nextTick } from 'vue';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { h, nextTick, ref } from 'vue';
7
9
 
10
+ import { displayToVirtual, virtualToDisplay } from '../utils/virtual-scroll-logic';
8
11
  import VirtualScroll from './VirtualScroll.vue';
9
12
 
10
13
  // --- Mocks ---
@@ -26,9 +29,12 @@ HTMLElement.prototype.scrollTo = function (this: HTMLElement, options?: number |
26
29
  this.scrollLeft = options;
27
30
  this.scrollTop = y;
28
31
  }
29
- this.dispatchEvent(new Event('scroll'));
32
+ this.dispatchEvent(new (this.ownerDocument?.defaultView?.Event || Event)('scroll'));
30
33
  };
31
34
 
35
+ HTMLElement.prototype.setPointerCapture = vi.fn();
36
+ HTMLElement.prototype.releasePointerCapture = vi.fn();
37
+
32
38
  interface ResizeObserverMock extends ResizeObserver {
33
39
  callback: ResizeObserverCallback;
34
40
  targets: Set<Element>;
@@ -56,11 +62,11 @@ globalThis.ResizeObserver = class ResizeObserver {
56
62
  }
57
63
  } as unknown as typeof ResizeObserver;
58
64
 
59
- function triggerResize(el: Element, width: number, height: number) {
65
+ function triggerResize(el: Element, width: number, height: number, useBorderBox = true) {
60
66
  const obs = observers.find((o) => o.targets.has(el));
61
67
  if (obs) {
62
68
  obs.callback([ {
63
- borderBoxSize: [ { blockSize: height, inlineSize: width } ],
69
+ ...(useBorderBox ? { borderBoxSize: [ { blockSize: height, inlineSize: width } ] } : {}),
64
70
  contentRect: {
65
71
  bottom: height,
66
72
  height,
@@ -99,15 +105,20 @@ describe('virtualScroll', () => {
99
105
  const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
100
106
 
101
107
  beforeEach(() => {
102
- vi.clearAllMocks();
103
- observers.length = 0;
104
108
  Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
105
109
  Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
106
110
  Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
107
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();
108
119
  });
109
120
 
110
- describe('basic Rendering', () => {
121
+ describe('core rendering & lifecycle', () => {
111
122
  it('renders the visible items', async () => {
112
123
  const wrapper = mount(VirtualScroll, {
113
124
  props: {
@@ -157,6 +168,10 @@ describe('virtualScroll', () => {
157
168
  const container = wrapper.find('.virtual-scroll-container');
158
169
  expect(container.classes()).toContain('virtual-scroll--horizontal');
159
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);
160
175
  });
161
176
 
162
177
  it('supports grid mode (both directions)', async () => {
@@ -173,10 +188,68 @@ describe('virtualScroll', () => {
173
188
  const style = (wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style;
174
189
  expect(style.blockSize).toBe('5000px');
175
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);
176
249
  });
177
250
  });
178
251
 
179
- describe('interactions', () => {
252
+ describe('scrolling interaction', () => {
180
253
  it('scrolls and updates visible items', async () => {
181
254
  const wrapper = mount(VirtualScroll, {
182
255
  props: {
@@ -202,6 +275,10 @@ describe('virtualScroll', () => {
202
275
 
203
276
  expect(wrapper.text()).toContain('Item 20');
204
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);
205
282
  });
206
283
 
207
284
  it('emits load event when reaching end', async () => {
@@ -228,387 +305,1702 @@ describe('virtualScroll', () => {
228
305
  expect(wrapper.emitted('load')).toBeDefined();
229
306
  });
230
307
 
231
- describe('keyboard Navigation', () => {
232
- it('responds to Home and End keys in vertical mode', async () => {
233
- const wrapper = mount(VirtualScroll, {
234
- props: { itemSize: 50, items: mockItems },
235
- });
236
- await nextTick();
237
- const container = wrapper.find('.virtual-scroll-container');
238
-
239
- await container.trigger('keydown', { key: 'End' });
240
- await nextTick();
241
- // item 99 at 4950. end align -> 4950 - (500 - 50) = 4500.
242
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
243
-
244
- await container.trigger('keydown', { key: 'Home' });
245
- await nextTick();
246
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
247
- });
248
-
249
- it('responds to Arrows in vertical mode', async () => {
250
- const wrapper = mount(VirtualScroll, {
251
- props: { itemSize: 50, items: mockItems },
252
- });
253
- await nextTick();
254
- const container = wrapper.find('.virtual-scroll-container');
255
-
256
- await container.trigger('keydown', { key: 'ArrowDown' });
257
- await nextTick();
258
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(40); // DEFAULT_ITEM_SIZE
259
-
260
- await container.trigger('keydown', { key: 'ArrowUp' });
261
- await nextTick();
262
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
308
+ it('handles wheel when virtual scrollbars are inactive', async () => {
309
+ const wrapper = mount(VirtualScroll, {
310
+ props: {
311
+ items: mockItems,
312
+ virtualScrollbar: false,
313
+ },
263
314
  });
315
+ await nextTick();
316
+ await wrapper.find('.virtual-scroll-container').trigger('wheel', { deltaY: 100 });
317
+ // should just stop programmatic scroll
318
+ });
264
319
 
265
- it('responds to PageUp and PageDown in vertical mode', async () => {
266
- const wrapper = mount(VirtualScroll, {
267
- props: { itemSize: 50, items: mockItems },
268
- });
269
- await nextTick();
270
- const container = wrapper.find('.virtual-scroll-container');
271
-
272
- await container.trigger('keydown', { key: 'PageDown' });
273
- await nextTick();
274
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(500);
275
-
276
- await container.trigger('keydown', { key: 'PageUp' });
277
- await nextTick();
278
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
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
+ },
279
328
  });
280
329
 
281
- it('responds to Home and End keys in horizontal mode', async () => {
282
- const wrapper = mount(VirtualScroll, {
283
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
284
- });
285
- await nextTick();
286
- const container = wrapper.find('.virtual-scroll-container');
287
-
288
- await container.trigger('keydown', { key: 'End' });
289
- await nextTick();
290
- // last item 99 at 9900. end align -> 9900 - (500 - 100) = 9500.
291
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(9500);
330
+ await nextTick();
331
+ await nextTick();
292
332
 
293
- await container.trigger('keydown', { key: 'Home' });
294
- await nextTick();
295
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
296
- });
333
+ const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
297
334
 
298
- it('responds to Arrows in horizontal mode', async () => {
299
- const wrapper = mount(VirtualScroll, {
300
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
301
- });
302
- await nextTick();
303
- const container = wrapper.find('.virtual-scroll-container');
335
+ // Press End
336
+ await wrapper.trigger('keydown', { key: 'End' });
304
337
 
305
- await container.trigger('keydown', { key: 'ArrowRight' });
338
+ // Wait for multiple ticks to let the correction logic work
339
+ for (let i = 0; i < 5; i++) {
306
340
  await nextTick();
307
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(40);
341
+ }
308
342
 
309
- await container.trigger('keydown', { key: 'ArrowLeft' });
310
- await nextTick();
311
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
312
- });
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
+ }
313
351
 
314
- it('responds to PageUp and PageDown in horizontal mode', async () => {
315
- const wrapper = mount(VirtualScroll, {
316
- props: { direction: 'horizontal', itemSize: 100, items: mockItems },
317
- });
352
+ // Wait for corrections
353
+ for (let i = 0; i < 5; i++) {
318
354
  await nextTick();
319
- const container = wrapper.find('.virtual-scroll-container');
355
+ }
320
356
 
321
- await container.trigger('keydown', { key: 'PageDown' });
322
- await nextTick();
323
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
357
+ const details = vs.scrollDetails;
358
+ // Should be at the end
359
+ expect(details.scrollOffset.y).toBeGreaterThanOrEqual(details.totalSize.height - details.viewportSize.height - 1);
324
360
 
325
- await container.trigger('keydown', { key: 'PageUp' });
326
- await nextTick();
327
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
328
- });
361
+ const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
329
362
 
330
- it('responds to Home and End keys in grid mode', async () => {
331
- const wrapper = mount(VirtualScroll, {
332
- props: {
333
- columnCount: 10,
334
- columnWidth: 100,
335
- direction: 'both',
336
- itemSize: 50,
337
- items: mockItems,
338
- },
339
- });
340
- await nextTick();
341
- const container = wrapper.find('.virtual-scroll-container');
363
+ await nextTick();
364
+ await nextTick();
342
365
 
343
- await container.trigger('keydown', { key: 'End' });
344
- await nextTick();
345
- // last row 99 at 4950. end align -> 4500.
346
- // last col 9 at 900. end align -> 900 - (500 - 100) = 500.
347
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
348
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
366
+ // Should not be calling scrollToIndex anymore
367
+ expect(scrollToIndexSpy).not.toHaveBeenCalled();
368
+ });
369
+ });
349
370
 
350
- await container.trigger('keydown', { key: 'Home' });
351
- await nextTick();
352
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
353
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
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 },
354
375
  });
376
+ await nextTick();
377
+ const container = wrapper.find('.virtual-scroll-container');
355
378
 
356
- it('responds to all Arrows in grid mode', async () => {
357
- const wrapper = mount(VirtualScroll, {
358
- props: {
359
- columnCount: 10,
360
- columnWidth: 100,
361
- direction: 'both',
362
- itemSize: 50,
363
- items: mockItems,
364
- },
365
- });
366
- await nextTick();
367
- const container = wrapper.find('.virtual-scroll-container');
368
-
369
- await container.trigger('keydown', { key: 'ArrowDown' });
370
- await container.trigger('keydown', { key: 'ArrowRight' });
371
- await nextTick();
372
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(40);
373
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(40);
379
+ await container.trigger('keydown', { key: 'End' });
380
+ await nextTick();
381
+ expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
374
382
 
375
- await container.trigger('keydown', { key: 'ArrowUp' });
376
- await container.trigger('keydown', { key: 'ArrowLeft' });
377
- await nextTick();
378
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
379
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
380
- });
383
+ await container.trigger('keydown', { key: 'Home' });
384
+ await nextTick();
385
+ expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
381
386
  });
382
- });
383
387
 
384
- describe('dynamic Sizing', () => {
385
- it('adjusts total size when items are measured', async () => {
388
+ it('responds to arrows in vertical mode', async () => {
386
389
  const wrapper = mount(VirtualScroll, {
387
- props: {
388
- itemSize: 0,
389
- items: mockItems.slice(0, 10),
390
- },
390
+ props: { itemSize: 50, items: mockItems },
391
391
  });
392
392
  await nextTick();
393
+ const container = wrapper.find('.virtual-scroll-container');
393
394
 
394
- expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('400px');
395
-
396
- const firstItem = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
397
- triggerResize(firstItem, 100, 100);
398
- await nextTick();
395
+ await container.trigger('keydown', { key: 'ArrowDown' });
399
396
  await nextTick();
397
+ expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(50);
400
398
 
401
- expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('460px');
399
+ await container.trigger('keydown', { key: 'ArrowUp' });
400
+ await nextTick();
401
+ expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
402
402
  });
403
403
 
404
- it('does not allow columns to become 0 width due to 0-size measurements', async () => {
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
+
405
422
  const wrapper = mount(VirtualScroll, {
406
423
  props: {
407
- bufferAfter: 0,
408
- bufferBefore: 0,
409
- columnCount: 10,
410
- defaultColumnWidth: 100,
411
- direction: 'both',
412
- itemSize: 50,
424
+ container,
425
+ direction: 'horizontal',
426
+ itemSize: 100,
413
427
  items: mockItems,
414
428
  },
415
- slots: {
416
- item: ({ columnRange, index }: ItemSlotProps) => h('div', {
417
- 'data-index': index,
418
- }, [
419
- ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
420
- class: 'cell',
421
- 'data-col-index': columnRange.start + i,
422
- })),
423
- ]),
424
- },
425
429
  });
426
430
 
431
+ await nextTick();
427
432
  await nextTick();
428
433
 
429
- const initialWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
430
- expect(initialWidth).toBeGreaterThan(0);
434
+ const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
435
+ vs.updateDirection();
436
+ await nextTick();
437
+ expect(vs.isRtl).toBe(true);
431
438
 
432
- // Find a cell from the first row
433
- const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
434
- const cell0 = row0.querySelector('.cell') as HTMLElement;
435
- expect(cell0).not.toBeNull();
439
+ const vsContainer = wrapper.find('.virtual-scroll-container');
436
440
 
437
- // Simulate 0-size measurement (e.g. from removal or being hidden)
438
- triggerResize(cell0, 0, 0);
441
+ await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
442
+ await nextTick();
443
+ await nextTick();
444
+ expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
439
445
 
446
+ await vsContainer.trigger('keydown', { key: 'ArrowRight' });
440
447
  await nextTick();
441
448
  await nextTick();
449
+ expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
442
450
 
443
- // totalWidth should NOT have decreased if we ignore 0 measurements
444
- const currentWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
445
- expect(currentWidth).toBe(initialWidth);
451
+ styleSpy.mockRestore();
446
452
  });
447
453
 
448
- it('should not shift horizontally when scrolling vertically even if measurements vary slightly', async () => {
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
+
449
472
  const wrapper = mount(VirtualScroll, {
450
473
  props: {
451
- bufferAfter: 0,
452
- bufferBefore: 0,
453
- columnCount: 10,
454
- defaultColumnWidth: 100,
455
- direction: 'both',
456
- itemSize: 50,
474
+ container,
475
+ direction: 'horizontal',
476
+ itemSize: 100,
457
477
  items: mockItems,
458
478
  },
459
- slots: {
460
- item: ({ columnRange, index }: ItemSlotProps) => h('div', {
461
- 'data-index': index,
462
- }, [
463
- ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
464
- class: 'cell',
465
- 'data-col-index': columnRange.start + i,
466
- })),
467
- ]),
468
- },
469
479
  });
470
480
 
481
+ await nextTick();
471
482
  await nextTick();
472
483
 
473
- // Initial scroll
474
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
475
-
476
- // Measure some columns of row 0
477
- const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
478
- const cells0 = Array.from(row0.querySelectorAll('.cell'));
479
-
480
- // Measure row 0 and its cells
481
- triggerResize(row0, 1000, 50);
482
- for (const cell of cells0) {
483
- triggerResize(cell, 110, 50);
484
- }
484
+ const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
485
+ vs.updateDirection();
486
+ await nextTick();
485
487
 
488
+ vs.scrollToOffset(50, null);
486
489
  await nextTick();
487
490
  await nextTick();
488
491
 
489
- // Scroll down to row 20
490
- const container = wrapper.find('.virtual-scroll-container');
491
- const el = container.element as HTMLElement;
492
- Object.defineProperty(el, 'scrollTop', { configurable: true, value: 1000, writable: true });
493
- await container.trigger('scroll');
492
+ const vsContainer = wrapper.find('.virtual-scroll-container');
494
493
 
494
+ await vsContainer.trigger('keydown', { key: 'ArrowRight' });
495
495
  await nextTick();
496
496
  await nextTick();
497
+ expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
497
498
 
498
- // Now row 20 is at the top. Measure its cells with slightly different width.
499
- const row20 = wrapper.find('.virtual-scroll-item[data-index="20"]').element;
500
- const cells20 = Array.from(row20.querySelectorAll('.cell'));
499
+ await wrapper.setProps({ itemSize: 150 });
500
+ await nextTick();
501
+ await nextTick();
501
502
 
502
- for (const cell of cells20) {
503
- triggerResize(cell, 110.1, 50);
504
- }
503
+ expect(vs.scrollDetails.currentEndIndex).toBe(3);
505
504
 
505
+ await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
506
506
  await nextTick();
507
507
  await nextTick();
508
+ expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
508
509
 
509
- // ScrollOffset.x should STILL BE 0. It should not have shifted because of d = 0.1
510
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
510
+ styleSpy.mockRestore();
511
511
  });
512
512
 
513
- it('correctly aligns item 50:50 auto after measurements in dynamic grid', async () => {
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
+
514
531
  const wrapper = mount(VirtualScroll, {
515
532
  props: {
516
- bufferAfter: 5,
517
- bufferBefore: 5,
518
- columnCount: 100,
519
- defaultColumnWidth: 120,
520
- defaultItemSize: 120,
521
- direction: 'both',
533
+ container,
534
+ direction: 'horizontal',
535
+ itemSize: 100,
522
536
  items: mockItems,
523
537
  },
524
- slots: {
525
- item: ({ columnRange, index }: ItemSlotProps) => h('div', {
526
- 'data-index': index,
527
- }, [
528
- ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
529
- class: 'cell',
530
- 'data-col-index': columnRange.start + i,
531
- })),
532
- ]),
533
- },
534
538
  });
535
539
 
536
- await nextTick();
537
-
538
- // Jump to 50:50 auto
539
- (wrapper.vm as unknown as { scrollToIndex: (r: number, c: number, a: string) => void; }).scrollToIndex(50, 50, 'auto');
540
540
  await nextTick();
541
541
  await nextTick();
542
542
 
543
- // Initial scroll position (estimates)
544
- // itemX = 50 * 120 = 6000. itemWidth = 120. viewport = 500.
545
- // targetEnd = 6000 + 120 - 500 = 5620.
546
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5620);
543
+ const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
544
+ vs.updateDirection();
545
+ await nextTick();
547
546
 
548
- // Row 50 should be rendered. Row 45 should be the first rendered row.
549
- const row45El = wrapper.find('.virtual-scroll-item[data-index="45"]').element;
550
- const cells45 = Array.from(row45El.querySelectorAll('.cell'));
547
+ vs.scrollToIndex(null, 4, { align: 'end', behavior: 'auto' });
548
+ await nextTick();
549
+ await nextTick();
551
550
 
552
- // Simulate measurements for all rendered cells in row 45 as 150px
553
- for (const cell of cells45) {
554
- triggerResize(cell, 150, 120);
555
- }
551
+ expect(vs.scrollDetails.currentEndColIndex).toBe(4);
552
+ expect(vs.scrollDetails.scrollOffset.x).toBe(0);
556
553
 
557
- await nextTick();
554
+ const containerEl = wrapper.find('.virtual-scroll-container');
555
+ await containerEl.trigger('keydown', { key: 'ArrowLeft' });
558
556
  await nextTick();
559
557
  await nextTick();
560
558
 
561
- // Correction should have triggered.
562
- // At x=5620, rendered columns are 44..52 (inclusive).
563
- // If columns 44..52 are all 150px:
564
- // New itemX for col 50: 44 * 120 + 6 * 150 = 5280 + 900 = 6180.
565
- // itemWidth = 150. viewport = 500.
566
- // targetEnd = 6180 + 150 - 500 = 5830.
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();
567
1190
 
568
- // wait for async correction cycle
569
1191
  await new Promise((resolve) => setTimeout(resolve, 300));
570
1192
  await nextTick();
571
1193
 
572
- expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5830);
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
+ });
573
1807
 
574
- // Check if it's fully visible
575
- const offset = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x;
576
- const viewportWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.viewportSize.width;
577
- const itemX = 6180;
578
- const itemWidth = 150;
1808
+ await nextTick();
1809
+ await nextTick();
1810
+ await nextTick();
579
1811
 
580
- expect(itemX).toBeGreaterThanOrEqual(offset);
581
- expect(itemX + itemWidth).toBeLessThanOrEqual(offset + viewportWidth);
1812
+ expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('6.4%');
582
1813
  });
583
- });
584
1814
 
585
- describe('sticky Items', () => {
586
- it('applies sticky styles to marked items', async () => {
1815
+ it('scrolls when clicking on vertical scrollbar track', async () => {
587
1816
  const wrapper = mount(VirtualScroll, {
588
1817
  props: {
589
1818
  itemSize: 50,
590
- items: mockItems,
591
- stickyIndices: [ 0 ],
1819
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1820
+ virtualScrollbar: true,
592
1821
  },
593
1822
  });
1823
+
1824
+ await nextTick();
594
1825
  await nextTick();
595
1826
 
596
- const container = wrapper.find('.virtual-scroll-container');
597
- const el = container.element as HTMLElement;
1827
+ const track = wrapper.find('.virtual-scrollbar-track--vertical');
1828
+ expect(track.exists()).toBe(true);
598
1829
 
599
- Object.defineProperty(el, 'scrollTop', { value: 100, writable: true });
600
- await container.trigger('scroll');
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
+ });
601
1853
  await nextTick();
602
1854
  await nextTick();
603
1855
 
604
- const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
605
- expect(item0.classes()).toContain('virtual-scroll--sticky');
606
- expect((item0.element as HTMLElement).style.insetBlockStart).toBe('0px');
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);
607
1999
  });
608
2000
  });
609
2001
 
610
- describe('ssr and Initial State', () => {
611
- it('renders SSR range if provided', async () => {
2002
+ describe('ssr & hydration', () => {
2003
+ it('renders ssr range if provided', async () => {
612
2004
  const wrapper = mount(VirtualScroll, {
613
2005
  props: {
614
2006
  itemSize: 50,
@@ -642,8 +2034,8 @@ describe('virtualScroll', () => {
642
2034
  },
643
2035
  },
644
2036
  });
645
- await nextTick(); // onMounted
646
- await nextTick(); // hydration + scrollToIndex
2037
+ await nextTick();
2038
+ await nextTick();
647
2039
  await nextTick();
648
2040
  await nextTick();
649
2041
  await nextTick();
@@ -651,46 +2043,32 @@ describe('virtualScroll', () => {
651
2043
  expect(wrapper.text()).toContain('Item 50');
652
2044
  });
653
2045
 
654
- it('does not gather multiple sticky items at the top', async () => {
2046
+ it('renders gaps correctly during initial mount/ssr', async () => {
655
2047
  const wrapper = mount(VirtualScroll, {
656
2048
  props: {
2049
+ direction: 'both',
2050
+ items: mockItems.slice(0, 10),
657
2051
  itemSize: 50,
658
- items: mockItems,
659
- stickyIndices: [ 0, 1, 2 ],
660
- },
661
- slots: {
662
- item: (props: ItemSlotProps) => {
663
- const { index, item } = props as ItemSlotProps<MockItem>;
664
- return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
665
- },
2052
+ columnCount: 5,
2053
+ columnWidth: 100,
2054
+ gap: 10,
2055
+ columnGap: 20,
666
2056
  },
667
2057
  });
668
2058
 
669
- await nextTick();
670
- await nextTick();
671
-
672
- const container = wrapper.find('.virtual-scroll-container');
673
- const el = container.element as HTMLElement;
674
-
675
- // Scroll past item 2 (originalY = 100). relativeScrollY = 150.
676
- Object.defineProperty(el, 'scrollTop', { configurable: true, value: 150, writable: true });
677
- await container.trigger('scroll');
678
- await nextTick();
679
- await nextTick();
680
-
681
- // Only item 2 should be active sticky.
682
- // Item 0 and 1 should have isStickyActive = false.
683
- const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
684
- const item1 = wrapper.find('.virtual-scroll-item[data-index="1"]');
685
- const item2 = wrapper.find('.virtual-scroll-item[data-index="2"]');
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');
686
2064
 
687
- expect(item2.classes()).toContain('virtual-scroll--sticky');
688
- expect(item1.classes()).not.toContain('virtual-scroll--sticky');
689
- expect(item0.classes()).not.toContain('virtual-scroll--sticky');
2065
+ const vsItem = wrapper.find('.virtual-scroll-item');
2066
+ const vsItemStyle = (vsItem.element as HTMLElement).style;
2067
+ expect(vsItemStyle.columnGap).toBe('20px');
690
2068
  });
691
2069
  });
692
2070
 
693
- describe('slots and Options', () => {
2071
+ describe('slots & custom content', () => {
694
2072
  it('renders header and footer', async () => {
695
2073
  const wrapper = mount(VirtualScroll, {
696
2074
  props: { items: mockItems.slice(0, 1) },
@@ -713,7 +2091,7 @@ describe('virtualScroll', () => {
713
2091
  expect(wrapper.text()).toContain('LOADING...');
714
2092
  });
715
2093
 
716
- it('uses correct HTML tags', () => {
2094
+ it('uses correct html tags', () => {
717
2095
  const wrapper = mount(VirtualScroll, {
718
2096
  props: {
719
2097
  containerTag: 'table',
@@ -735,11 +2113,11 @@ describe('virtualScroll', () => {
735
2113
  });
736
2114
  await nextTick();
737
2115
 
738
- const vs = wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
2116
+ const vs = wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
739
2117
  vs.refresh();
740
2118
  await nextTick();
741
- // Should not crash
742
2119
  expect(vs.scrollDetails.items.length).toBeGreaterThan(0);
2120
+ expect(vs.scrollDetails.items.length).toBeLessThan(50);
743
2121
  });
744
2122
 
745
2123
  it('handles sticky header and footer measurements', async () => {
@@ -757,16 +2135,234 @@ describe('virtualScroll', () => {
757
2135
  await nextTick();
758
2136
  });
759
2137
 
760
- it('works with window as container', async () => {
2138
+ it('accounts for sticky header and footer in scroll padding', async () => {
761
2139
  const wrapper = mount(VirtualScroll, {
762
2140
  props: {
763
- container: window,
764
- itemSize: 50,
765
2141
  items: mockItems,
2142
+ itemSize: 50,
2143
+ stickyHeader: true,
2144
+ },
2145
+ slots: {
2146
+ header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
766
2147
  },
767
2148
  });
2149
+
768
2150
  await nextTick();
769
- expect(wrapper.classes()).toContain('virtual-scroll--window');
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
+ });
770
2366
  });
771
2367
  });
772
2368
  });