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