@pdanpdan/virtual-scroll 0.2.1 → 0.4.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,933 +1,772 @@
1
- import type { ScrollAlignment, ScrollAlignmentOptions, ScrollDetails, ScrollToIndexOptions } from '../composables/useVirtualScroll';
2
- import type { DOMWrapper, VueWrapper } from '@vue/test-utils';
1
+ import type { ItemSlotProps, ScrollDetails } from '../types';
3
2
 
3
+ /* global ScrollToOptions, ResizeObserverCallback */
4
4
  import { mount } from '@vue/test-utils';
5
- import { beforeEach, describe, expect, it } from 'vitest';
6
- import { defineComponent, nextTick, ref } from 'vue';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { h, nextTick } from 'vue';
7
7
 
8
8
  import VirtualScroll from './VirtualScroll.vue';
9
9
 
10
- type ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
10
+ // --- Mocks ---
11
+
12
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
13
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
14
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
15
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
16
+
17
+ HTMLElement.prototype.scrollTo = function (this: HTMLElement, options?: number | ScrollToOptions, y?: number) {
18
+ if (typeof options === 'object') {
19
+ if (options.top !== undefined) {
20
+ this.scrollTop = options.top;
21
+ }
22
+ if (options.left !== undefined) {
23
+ this.scrollLeft = options.left;
24
+ }
25
+ } else if (typeof options === 'number' && typeof y === 'number') {
26
+ this.scrollLeft = options;
27
+ this.scrollTop = y;
28
+ }
29
+ this.dispatchEvent(new Event('scroll'));
30
+ };
11
31
 
12
- // Mock ResizeObserver
13
- interface ResizeObserverMock {
32
+ interface ResizeObserverMock extends ResizeObserver {
14
33
  callback: ResizeObserverCallback;
15
34
  targets: Set<Element>;
16
- trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
17
35
  }
18
36
 
19
- globalThis.ResizeObserver = class {
37
+ const observers: ResizeObserverMock[] = [];
38
+ globalThis.ResizeObserver = class ResizeObserver {
20
39
  callback: ResizeObserverCallback;
21
- static instances: ResizeObserverMock[] = [];
22
- targets: Set<Element> = new Set();
23
-
40
+ targets = new Set<Element>();
24
41
  constructor(callback: ResizeObserverCallback) {
25
42
  this.callback = callback;
26
- (this.constructor as unknown as { instances: ResizeObserverMock[]; }).instances.push(this as unknown as ResizeObserverMock);
43
+ observers.push(this as unknown as ResizeObserverMock);
27
44
  }
28
45
 
29
- observe(target: Element) {
30
- this.targets.add(target);
46
+ observe(el: Element) {
47
+ this.targets.add(el);
31
48
  }
32
49
 
33
- unobserve(target: Element) {
34
- this.targets.delete(target);
50
+ unobserve(el: Element) {
51
+ this.targets.delete(el);
35
52
  }
36
53
 
37
54
  disconnect() {
38
55
  this.targets.clear();
39
56
  }
57
+ } as unknown as typeof ResizeObserver;
58
+
59
+ function triggerResize(el: Element, width: number, height: number) {
60
+ const obs = observers.find((o) => o.targets.has(el));
61
+ if (obs) {
62
+ obs.callback([ {
63
+ borderBoxSize: [ { blockSize: height, inlineSize: width } ],
64
+ contentRect: {
65
+ bottom: height,
66
+ height,
67
+ left: 0,
68
+ right: width,
69
+ toJSON: () => '',
70
+ top: 0,
71
+ width,
72
+ x: 0,
73
+ y: 0,
74
+ },
75
+ target: el,
76
+ } as unknown as ResizeObserverEntry ], obs);
77
+ }
78
+ }
40
79
 
41
- trigger(entries: Partial<ResizeObserverEntry>[]) {
42
- this.callback(entries as ResizeObserverEntry[], this as unknown as ResizeObserver);
80
+ // Mock window.scrollTo
81
+ globalThis.window.scrollTo = vi.fn().mockImplementation((options) => {
82
+ if (options.left !== undefined) {
83
+ Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
43
84
  }
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;
85
+ if (options.top !== undefined) {
86
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
56
87
  }
88
+ document.dispatchEvent(new Event('scroll'));
89
+ });
57
90
 
58
- interface TestCompInstance {
59
- mockItems: typeof mockItems;
60
- show: boolean;
61
- showFooter: boolean;
62
- }
91
+ // --- Tests ---
63
92
 
64
- describe('rendering and structure', () => {
65
- it('should render items correctly', () => {
66
- const wrapper = mount(VirtualScroll, {
67
- props: {
68
- items: mockItems,
69
- itemSize: 50,
70
- },
71
- slots: {
72
- item: '<template #item="{ item, index }"><div class="item">{{ index }}: {{ item.label }}</div></template>',
73
- },
74
- });
75
- expect(wrapper.findAll('.item').length).toBeGreaterThan(0);
76
- expect(wrapper.text()).toContain('0: Item 0');
77
- });
93
+ interface MockItem {
94
+ id: number;
95
+ label: string;
96
+ }
97
+
98
+ describe('virtualScroll', () => {
99
+ const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
78
100
 
79
- it('should render header and footer slots', () => {
101
+ beforeEach(() => {
102
+ vi.clearAllMocks();
103
+ observers.length = 0;
104
+ Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
105
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
106
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
107
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
108
+ });
109
+
110
+ describe('basic Rendering', () => {
111
+ it('renders the visible items', async () => {
80
112
  const wrapper = mount(VirtualScroll, {
81
113
  props: {
82
- items: mockItems,
83
114
  itemSize: 50,
115
+ items: mockItems,
84
116
  },
85
117
  slots: {
86
- header: '<div class="header">Header</div>',
87
- footer: '<div class="footer">Footer</div>',
118
+ item: (props: ItemSlotProps) => {
119
+ const { index, item } = props as ItemSlotProps<MockItem>;
120
+ return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
121
+ },
88
122
  },
89
123
  });
90
- expect(wrapper.find('.virtual-scroll-header').exists()).toBe(true);
91
- expect(wrapper.find('.header').exists()).toBe(true);
92
- expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(true);
93
- expect(wrapper.find('.footer').exists()).toBe(true);
94
- });
95
124
 
96
- it('should not render header and footer slots when absent', () => {
97
- const wrapper = mount(VirtualScroll, {
98
- props: {
99
- items: mockItems,
100
- itemSize: 50,
101
- },
102
- });
103
- expect(wrapper.find('.virtual-scroll-header').exists()).toBe(false);
104
- expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(false);
125
+ await nextTick();
126
+
127
+ const items = wrapper.findAll('.item');
128
+ expect(items.length).toBe(15);
129
+ expect(items[ 0 ]?.text()).toBe('0: Item 0');
130
+ expect(items[ 14 ]?.text()).toBe('14: Item 14');
105
131
  });
106
132
 
107
- it('should render debug information when debug prop is true', async () => {
133
+ it('updates when items change', async () => {
108
134
  const wrapper = mount(VirtualScroll, {
109
135
  props: {
110
- items: mockItems.slice(0, 5),
111
136
  itemSize: 50,
112
- debug: true,
137
+ items: mockItems.slice(0, 5),
113
138
  },
114
139
  });
115
140
  await nextTick();
116
- expect(wrapper.find('.virtual-scroll-debug-info').exists()).toBe(true);
117
- expect(wrapper.find('.virtual-scroll-item').classes()).toContain('virtual-scroll--debug');
118
- });
141
+ expect(wrapper.findAll('.virtual-scroll-item').length).toBe(5);
119
142
 
120
- it('should not render debug information when debug prop is false', async () => {
121
- const wrapper = mount(VirtualScroll, {
122
- props: {
123
- items: mockItems.slice(0, 5),
124
- itemSize: 50,
125
- debug: false,
126
- },
127
- });
143
+ await wrapper.setProps({ items: mockItems.slice(0, 10) });
128
144
  await nextTick();
129
- expect(wrapper.find('.virtual-scroll-debug-info').exists()).toBe(false);
130
- expect(wrapper.find('.virtual-scroll-item').classes()).not.toContain('virtual-scroll--debug');
145
+ expect(wrapper.findAll('.virtual-scroll-item').length).toBe(10);
131
146
  });
132
147
 
133
- it('should handle missing slots gracefully', () => {
148
+ it('supports horizontal direction', async () => {
134
149
  const wrapper = mount(VirtualScroll, {
135
150
  props: {
136
- items: mockItems.slice(0, 1),
137
- itemSize: 50,
151
+ direction: 'horizontal',
152
+ itemSize: 100,
153
+ items: mockItems,
138
154
  },
139
155
  });
140
- expect(wrapper.exists()).toBe(true);
156
+ await nextTick();
157
+ const container = wrapper.find('.virtual-scroll-container');
158
+ expect(container.classes()).toContain('virtual-scroll--horizontal');
159
+ expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.inlineSize).toBe('10000px');
141
160
  });
142
161
 
143
- it('should render table correctly', () => {
162
+ it('supports grid mode (both directions)', async () => {
144
163
  const wrapper = mount(VirtualScroll, {
145
164
  props: {
146
- items: mockItems.slice(0, 10),
165
+ columnCount: 5,
166
+ columnWidth: 100,
167
+ direction: 'both',
147
168
  itemSize: 50,
148
- containerTag: 'table',
149
- wrapperTag: 'tbody',
150
- itemTag: 'tr',
151
- },
152
- slots: {
153
- item: '<template #item="{ item, index }"><td>{{ index }}</td><td>{{ item.label }}</td></template>',
169
+ items: mockItems,
154
170
  },
155
171
  });
156
- expect(wrapper.element.tagName).toBe('TABLE');
157
- expect(wrapper.find('tbody').exists()).toBe(true);
158
- expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
172
+ await nextTick();
173
+ const style = (wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style;
174
+ expect(style.blockSize).toBe('5000px');
175
+ expect(style.inlineSize).toBe('500px');
159
176
  });
177
+ });
160
178
 
161
- it('should render table with header and footer', () => {
179
+ describe('interactions', () => {
180
+ it('scrolls and updates visible items', async () => {
162
181
  const wrapper = mount(VirtualScroll, {
163
182
  props: {
164
- items: mockItems.slice(0, 10),
165
183
  itemSize: 50,
166
- containerTag: 'table',
167
- wrapperTag: 'tbody',
168
- itemTag: 'tr',
169
- },
170
- slots: {
171
- header: '<tr><th>ID</th></tr>',
172
- footer: '<tr><td>Footer</td></tr>',
173
- },
174
- });
175
- expect(wrapper.find('thead').exists()).toBe(true);
176
- expect(wrapper.find('tfoot').exists()).toBe(true);
177
- });
178
-
179
- it('should render div header and footer', () => {
180
- const wrapper = mount(VirtualScroll, {
181
- props: {
182
- items: mockItems.slice(0, 10),
183
- containerTag: 'div',
184
- },
185
- slots: {
186
- header: '<div class="header">Header</div>',
187
- footer: '<div class="footer">Footer</div>',
188
- },
189
- });
190
- expect(wrapper.find('div.virtual-scroll-header').exists()).toBe(true);
191
- expect(wrapper.find('div.virtual-scroll-footer').exists()).toBe(true);
192
- });
193
-
194
- it('should apply sticky classes to header and footer', async () => {
195
- const wrapper = mount(VirtualScroll, {
196
- props: {
197
184
  items: mockItems,
198
- stickyHeader: true,
199
- stickyFooter: true,
200
185
  },
201
186
  slots: {
202
- header: '<div>H</div>',
203
- footer: '<div>F</div>',
187
+ item: (props: ItemSlotProps) => {
188
+ const { item } = props as ItemSlotProps<MockItem>;
189
+ return h('div', { class: 'item' }, item.label);
190
+ },
204
191
  },
205
192
  });
206
193
  await nextTick();
207
- expect(wrapper.find('.virtual-scroll-header').classes()).toContain('virtual-scroll--sticky');
208
- expect(wrapper.find('.virtual-scroll-footer').classes()).toContain('virtual-scroll--sticky');
209
- });
210
194
 
211
- it('should handle switching containerTag', async () => {
212
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 10), containerTag: 'div' } });
213
- await nextTick();
214
- await wrapper.setProps({ containerTag: 'table' });
195
+ const container = wrapper.find('.virtual-scroll-container');
196
+ const el = container.element as HTMLElement;
197
+
198
+ Object.defineProperty(el, 'scrollTop', { value: 1000, writable: true });
199
+ await container.trigger('scroll');
215
200
  await nextTick();
216
- expect(wrapper.element.tagName).toBe('TABLE');
217
- await wrapper.setProps({ containerTag: 'div' });
218
201
  await nextTick();
219
- expect(wrapper.element.tagName).toBe('DIV');
220
- });
221
202
 
222
- it('should render table spacer and items', () => {
223
- const wrapper = mount(VirtualScroll, {
224
- props: {
225
- items: mockItems.slice(0, 5),
226
- containerTag: 'table',
227
- wrapperTag: 'tbody',
228
- itemTag: 'tr',
229
- },
230
- slots: {
231
- item: '<template #item="{ item }"><td>{{ item.label }}</td></template>',
232
- },
233
- });
234
- expect(wrapper.find('tr.virtual-scroll-spacer').exists()).toBe(true);
235
- expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
203
+ expect(wrapper.text()).toContain('Item 20');
204
+ expect(wrapper.text()).toContain('Item 15');
236
205
  });
237
206
 
238
- it('should handle table rendering without header and footer', async () => {
207
+ it('emits load event when reaching end', async () => {
239
208
  const wrapper = mount(VirtualScroll, {
240
209
  props: {
241
- items: mockItems.slice(0, 5),
242
- containerTag: 'table',
210
+ itemSize: 50,
211
+ items: mockItems.slice(0, 20),
212
+ loadDistance: 100,
243
213
  },
244
214
  });
245
215
  await nextTick();
246
- expect(wrapper.find('thead').exists()).toBe(false);
247
- expect(wrapper.find('tfoot').exists()).toBe(false);
248
- });
216
+ await nextTick();
249
217
 
250
- it('should cover all template branches for slots and tags', async () => {
251
- for (const tag of [ 'div', 'table' ] as const) {
252
- for (const loading of [ true, false ]) {
253
- for (const withSlots of [ true, false ]) {
254
- const slots = withSlots
255
- ? {
256
- header: tag === 'table' ? '<tr><td>H</td></tr>' : '<div>H</div>',
257
- footer: tag === 'table' ? '<tr><td>F</td></tr>' : '<div>F</div>',
258
- loading: tag === 'table' ? '<tr><td>L</td></tr>' : '<div>L</div>',
259
- }
260
- : {};
261
- const wrapper = mount(VirtualScroll, {
262
- props: { items: mockItems.slice(0, 1), containerTag: tag, loading },
263
- slots,
264
- });
265
- await nextTick();
266
- wrapper.unmount();
267
- }
268
- }
269
- }
270
- });
271
- });
218
+ const container = wrapper.find('.virtual-scroll-container');
219
+ const el = container.element as HTMLElement;
272
220
 
273
- describe('styling and dimensions', () => {
274
- it('should render items horizontally when direction is horizontal', async () => {
275
- const wrapper = mount(VirtualScroll, {
276
- props: {
277
- items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
278
- itemSize: 100,
279
- direction: 'horizontal',
280
- },
281
- });
221
+ expect(wrapper.emitted('load')).toBeUndefined();
222
+
223
+ Object.defineProperty(el, 'scrollTop', { value: 450, writable: true });
224
+ await container.trigger('scroll');
225
+ await nextTick();
282
226
  await nextTick();
283
- const items = wrapper.findAll('.virtual-scroll-item');
284
- expect(items.length).toBeGreaterThan(0);
285
- const firstItem = items[ 0 ]?.element as HTMLElement;
286
- expect(firstItem.style.transform).toBe('translate(0px, 0px)');
287
- });
288
227
 
289
- it('should handle bidirectional scroll dimensions', async () => {
290
- const wrapper = mount(VirtualScroll, {
291
- props: {
292
- items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
293
- itemSize: 100,
294
- direction: 'both',
295
- columnCount: 5,
296
- columnWidth: 150,
297
- },
298
- });
299
- const VS_wrapper = wrapper.find('.virtual-scroll-wrapper');
300
- const style = (VS_wrapper.element as HTMLElement).style;
301
- expect(style.blockSize).toBe('1000px');
302
- expect(style.inlineSize).toBe('750px');
228
+ expect(wrapper.emitted('load')).toBeDefined();
303
229
  });
304
230
 
305
- it('should cover all containerStyle branches', () => {
306
- [ 'vertical', 'horizontal', 'both' ].forEach((direction) => {
307
- mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both' } });
308
- mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both', container: document.body } });
309
- mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both', containerTag: 'table' } });
231
+ describe('keyboard Navigation', () => {
232
+ it('responds to Home and End keys in vertical mode', async () => {
233
+ const wrapper = mount(VirtualScroll, {
234
+ props: { itemSize: 50, items: mockItems },
235
+ });
236
+ await nextTick();
237
+ const container = wrapper.find('.virtual-scroll-container');
238
+
239
+ await container.trigger('keydown', { key: 'End' });
240
+ await nextTick();
241
+ // item 99 at 4950. end align -> 4950 - (500 - 50) = 4500.
242
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
243
+
244
+ await container.trigger('keydown', { key: 'Home' });
245
+ await nextTick();
246
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
247
+ });
248
+
249
+ it('responds to Arrows in vertical mode', async () => {
250
+ const wrapper = mount(VirtualScroll, {
251
+ props: { itemSize: 50, items: mockItems },
252
+ });
253
+ await nextTick();
254
+ const container = wrapper.find('.virtual-scroll-container');
255
+
256
+ await container.trigger('keydown', { key: 'ArrowDown' });
257
+ await nextTick();
258
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(40); // DEFAULT_ITEM_SIZE
259
+
260
+ await container.trigger('keydown', { key: 'ArrowUp' });
261
+ await nextTick();
262
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
263
+ });
264
+
265
+ it('responds to PageUp and PageDown in vertical mode', async () => {
266
+ const wrapper = mount(VirtualScroll, {
267
+ props: { itemSize: 50, items: mockItems },
268
+ });
269
+ await nextTick();
270
+ const container = wrapper.find('.virtual-scroll-container');
271
+
272
+ await container.trigger('keydown', { key: 'PageDown' });
273
+ await nextTick();
274
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(500);
275
+
276
+ await container.trigger('keydown', { key: 'PageUp' });
277
+ await nextTick();
278
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
279
+ });
280
+
281
+ it('responds to Home and End keys in horizontal mode', async () => {
282
+ const wrapper = mount(VirtualScroll, {
283
+ props: { direction: 'horizontal', itemSize: 100, items: mockItems },
284
+ });
285
+ await nextTick();
286
+ const container = wrapper.find('.virtual-scroll-container');
287
+
288
+ await container.trigger('keydown', { key: 'End' });
289
+ await nextTick();
290
+ // last item 99 at 9900. end align -> 9900 - (500 - 100) = 9500.
291
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(9500);
292
+
293
+ await container.trigger('keydown', { key: 'Home' });
294
+ await nextTick();
295
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
296
+ });
297
+
298
+ it('responds to Arrows in horizontal mode', async () => {
299
+ const wrapper = mount(VirtualScroll, {
300
+ props: { direction: 'horizontal', itemSize: 100, items: mockItems },
301
+ });
302
+ await nextTick();
303
+ const container = wrapper.find('.virtual-scroll-container');
304
+
305
+ await container.trigger('keydown', { key: 'ArrowRight' });
306
+ await nextTick();
307
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(40);
308
+
309
+ await container.trigger('keydown', { key: 'ArrowLeft' });
310
+ await nextTick();
311
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
312
+ });
313
+
314
+ it('responds to PageUp and PageDown in horizontal mode', async () => {
315
+ const wrapper = mount(VirtualScroll, {
316
+ props: { direction: 'horizontal', itemSize: 100, items: mockItems },
317
+ });
318
+ await nextTick();
319
+ const container = wrapper.find('.virtual-scroll-container');
320
+
321
+ await container.trigger('keydown', { key: 'PageDown' });
322
+ await nextTick();
323
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
324
+
325
+ await container.trigger('keydown', { key: 'PageUp' });
326
+ await nextTick();
327
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
328
+ });
329
+
330
+ it('responds to Home and End keys in grid mode', async () => {
331
+ const wrapper = mount(VirtualScroll, {
332
+ props: {
333
+ columnCount: 10,
334
+ columnWidth: 100,
335
+ direction: 'both',
336
+ itemSize: 50,
337
+ items: mockItems,
338
+ },
339
+ });
340
+ await nextTick();
341
+ const container = wrapper.find('.virtual-scroll-container');
342
+
343
+ await container.trigger('keydown', { key: 'End' });
344
+ await nextTick();
345
+ // last row 99 at 4950. end align -> 4500.
346
+ // last col 9 at 900. end align -> 900 - (500 - 100) = 500.
347
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
348
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
349
+
350
+ await container.trigger('keydown', { key: 'Home' });
351
+ await nextTick();
352
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
353
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
354
+ });
355
+
356
+ it('responds to all Arrows in grid mode', async () => {
357
+ const wrapper = mount(VirtualScroll, {
358
+ props: {
359
+ columnCount: 10,
360
+ columnWidth: 100,
361
+ direction: 'both',
362
+ itemSize: 50,
363
+ items: mockItems,
364
+ },
365
+ });
366
+ await nextTick();
367
+ const container = wrapper.find('.virtual-scroll-container');
368
+
369
+ await container.trigger('keydown', { key: 'ArrowDown' });
370
+ await container.trigger('keydown', { key: 'ArrowRight' });
371
+ await nextTick();
372
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(40);
373
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(40);
374
+
375
+ await container.trigger('keydown', { key: 'ArrowUp' });
376
+ await container.trigger('keydown', { key: 'ArrowLeft' });
377
+ await nextTick();
378
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
379
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
310
380
  });
311
- mount(VirtualScroll, { props: { items: mockItems, container: null } });
312
381
  });
382
+ });
313
383
 
314
- it('should cover getItemStyle branches', async () => {
384
+ describe('dynamic Sizing', () => {
385
+ it('adjusts total size when items are measured', async () => {
315
386
  const wrapper = mount(VirtualScroll, {
316
387
  props: {
317
- items: mockItems.slice(0, 5),
318
- itemSize: 50,
319
- direction: 'horizontal',
388
+ itemSize: 0,
389
+ items: mockItems.slice(0, 10),
320
390
  },
321
391
  });
322
392
  await nextTick();
323
- const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
324
- expect(item.style.inlineSize).toBe('50px');
325
- expect(item.style.blockSize).toBe('100%');
326
- });
327
393
 
328
- it('should cover sticky item style branches in getItemStyle', async () => {
329
- const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
330
- const wrapper = mount(VirtualScroll, {
331
- props: {
332
- items,
333
- direction: 'horizontal',
334
- stickyIndices: [ 0 ],
335
- scrollPaddingStart: 10,
336
- },
337
- });
338
- await nextTick();
394
+ expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('400px');
339
395
 
340
- const stickyItem = wrapper.find('.virtual-scroll-item').element as HTMLElement;
341
- // It should be sticky active if we scroll
342
- await wrapper.trigger('scroll');
396
+ const firstItem = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
397
+ triggerResize(firstItem, 100, 100);
343
398
  await nextTick();
344
-
345
- expect(stickyItem.style.insetInlineStart).toBe('10px');
346
-
347
- await wrapper.setProps({ direction: 'vertical', scrollPaddingStart: 20 });
348
399
  await nextTick();
349
- expect(stickyItem.style.insetBlockStart).toBe('20px');
350
- });
351
400
 
352
- it('should handle custom container element for header/footer padding', async () => {
353
- const container = document.createElement('div');
354
- const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
355
- mount(VirtualScroll, {
356
- props: {
357
- items,
358
- container,
359
- stickyHeader: true,
360
- },
361
- });
362
- await nextTick();
363
- // This covers the branch where container is NOT host element and NOT window
401
+ expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('460px');
364
402
  });
365
403
 
366
- it('should cover object padding branches in virtualScrollProps', () => {
367
- mount(VirtualScroll, {
404
+ it('does not allow columns to become 0 width due to 0-size measurements', async () => {
405
+ const wrapper = mount(VirtualScroll, {
368
406
  props: {
369
- items: mockItems.slice(0, 1),
370
- scrollPaddingStart: { x: 10, y: 20 },
371
- scrollPaddingEnd: { x: 30, y: 40 },
407
+ bufferAfter: 0,
408
+ bufferBefore: 0,
409
+ columnCount: 10,
410
+ defaultColumnWidth: 100,
411
+ direction: 'both',
412
+ itemSize: 50,
413
+ items: mockItems,
372
414
  },
373
- });
374
- mount(VirtualScroll, {
375
- props: {
376
- items: mockItems.slice(0, 1),
377
- direction: 'horizontal',
378
- scrollPaddingStart: 10,
379
- scrollPaddingEnd: 20,
415
+ slots: {
416
+ item: ({ columnRange, index }: ItemSlotProps) => h('div', {
417
+ 'data-index': index,
418
+ }, [
419
+ ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
420
+ class: 'cell',
421
+ 'data-col-index': columnRange.start + i,
422
+ })),
423
+ ]),
380
424
  },
381
425
  });
382
- });
383
- });
384
-
385
- describe('keyboard navigation', () => {
386
- let wrapper: VueWrapper<VSInstance>;
387
- let container: DOMWrapper<Element>;
388
- let el: HTMLElement;
389
-
390
- beforeEach(async () => {
391
- wrapper = mount(VirtualScroll, {
392
- props: {
393
- items: mockItems,
394
- itemSize: 50,
395
- direction: 'vertical',
396
- },
397
- }) as unknown as VueWrapper<VSInstance>;
398
- await nextTick();
399
- container = wrapper.find('.virtual-scroll-container');
400
- el = container.element as HTMLElement;
401
426
 
402
- // Mock dimensions
403
- Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
404
- Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
405
- Object.defineProperty(el, 'offsetHeight', { value: 500, configurable: true });
406
- Object.defineProperty(el, 'offsetWidth', { value: 500, configurable: true });
407
-
408
- const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
409
- observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
410
427
  await nextTick();
411
- });
412
-
413
- it('should handle Home key', async () => {
414
- el.scrollTop = 1000;
415
- el.scrollLeft = 500;
416
- await container.trigger('keydown', { key: 'Home' });
417
- await nextTick();
418
- expect(el.scrollTop).toBe(0);
419
- expect(el.scrollLeft).toBe(0);
420
- });
421
-
422
- it('should handle End key (vertical)', async () => {
423
- el.scrollLeft = 0;
424
- await container.trigger('keydown', { key: 'End' });
425
- await nextTick();
426
- // totalHeight = 100 items * 50px = 5000px
427
- // viewportHeight = 500px
428
- // scrollToIndex(99, 0, 'end') -> targetY = 99 * 50 = 4950
429
- // alignment 'end' -> targetY = 4950 - (500 - 50) = 4500
430
- expect(el.scrollTop).toBe(4500);
431
- expect(el.scrollLeft).toBe(0);
432
- });
433
428
 
434
- it('should handle End key (horizontal)', async () => {
435
- await wrapper.setProps({ direction: 'horizontal' });
436
- await nextTick();
437
- // Trigger resize again for horizontal
438
- const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
439
- observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
440
- await nextTick();
429
+ const initialWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
430
+ expect(initialWidth).toBeGreaterThan(0);
441
431
 
442
- el.scrollTop = 0;
443
- await container.trigger('keydown', { key: 'End' });
444
- await nextTick();
445
- expect(el.scrollLeft).toBe(4500);
446
- expect(el.scrollTop).toBe(0);
447
- });
432
+ // Find a cell from the first row
433
+ const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
434
+ const cell0 = row0.querySelector('.cell') as HTMLElement;
435
+ expect(cell0).not.toBeNull();
448
436
 
449
- it('should handle End key in both mode', async () => {
450
- await wrapper.setProps({ columnCount: 5, columnWidth: 100, direction: 'both' });
451
- await nextTick();
437
+ // Simulate 0-size measurement (e.g. from removal or being hidden)
438
+ triggerResize(cell0, 0, 0);
452
439
 
453
- // Trigger a resize
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
440
  await nextTick();
457
-
458
- await container.trigger('keydown', { key: 'End' });
459
441
  await nextTick();
460
442
 
461
- // items: 100 (rows), height: 50 -> totalHeight: 5000
462
- // columns: 5, width: 100 -> totalWidth: 500
463
- // viewport: 500x500
464
- // scrollToIndex(99, 4, 'end')
465
- // targetY = 99 * 50 = 4950. end alignment: 4950 - (500 - 50) = 4500
466
- // targetX = 4 * 100 = 400. end alignment: 400 - (500 - 100) = 0
467
- expect(el.scrollTop).toBe(4500);
468
- expect(el.scrollLeft).toBe(0);
443
+ // totalWidth should NOT have decreased if we ignore 0 measurements
444
+ const currentWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
445
+ expect(currentWidth).toBe(initialWidth);
469
446
  });
470
447
 
471
- it('should handle End key with empty items', async () => {
472
- await wrapper.setProps({ items: [] });
473
- await nextTick();
474
- await container.trigger('keydown', { key: 'End' });
475
- await nextTick();
476
- });
448
+ it('should not shift horizontally when scrolling vertically even if measurements vary slightly', async () => {
449
+ const wrapper = mount(VirtualScroll, {
450
+ props: {
451
+ bufferAfter: 0,
452
+ bufferBefore: 0,
453
+ columnCount: 10,
454
+ defaultColumnWidth: 100,
455
+ direction: 'both',
456
+ itemSize: 50,
457
+ items: mockItems,
458
+ },
459
+ slots: {
460
+ item: ({ columnRange, index }: ItemSlotProps) => h('div', {
461
+ 'data-index': index,
462
+ }, [
463
+ ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
464
+ class: 'cell',
465
+ 'data-col-index': columnRange.start + i,
466
+ })),
467
+ ]),
468
+ },
469
+ });
477
470
 
478
- it('should handle ArrowDown / ArrowUp', async () => {
479
- el.scrollLeft = 0;
480
- await container.trigger('keydown', { key: 'ArrowDown' });
481
471
  await nextTick();
482
- expect(el.scrollTop).toBe(40);
483
- expect(el.scrollLeft).toBe(0);
484
472
 
485
- await container.trigger('keydown', { key: 'ArrowUp' });
486
- await nextTick();
487
- expect(el.scrollTop).toBe(0);
488
- expect(el.scrollLeft).toBe(0);
489
- });
473
+ // Initial scroll
474
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
490
475
 
491
- it('should handle ArrowRight / ArrowLeft', async () => {
492
- await wrapper.setProps({ direction: 'horizontal' });
493
- await nextTick();
494
- el.scrollTop = 0;
495
- await container.trigger('keydown', { key: 'ArrowRight' });
496
- await nextTick();
497
- expect(el.scrollLeft).toBe(40);
498
- expect(el.scrollTop).toBe(0);
476
+ // Measure some columns of row 0
477
+ const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
478
+ const cells0 = Array.from(row0.querySelectorAll('.cell'));
499
479
 
500
- await container.trigger('keydown', { key: 'ArrowLeft' });
501
- await nextTick();
502
- expect(el.scrollLeft).toBe(0);
503
- expect(el.scrollTop).toBe(0);
504
- });
505
-
506
- it('should handle PageDown / PageUp', async () => {
507
- el.scrollLeft = 0;
508
- await container.trigger('keydown', { key: 'PageDown' });
509
- await nextTick();
510
- expect(el.scrollTop).toBe(500);
511
- expect(el.scrollLeft).toBe(0);
512
-
513
- await container.trigger('keydown', { key: 'PageUp' });
514
- await nextTick();
515
- expect(el.scrollTop).toBe(0);
516
- expect(el.scrollLeft).toBe(0);
517
- });
480
+ // Measure row 0 and its cells
481
+ triggerResize(row0, 1000, 50);
482
+ for (const cell of cells0) {
483
+ triggerResize(cell, 110, 50);
484
+ }
518
485
 
519
- it('should handle PageDown / PageUp in horizontal mode', async () => {
520
- await wrapper.setProps({ direction: 'horizontal' });
521
486
  await nextTick();
522
- el.scrollTop = 0;
523
- await container.trigger('keydown', { key: 'PageDown' });
524
487
  await nextTick();
525
- expect(el.scrollLeft).toBe(500);
526
- expect(el.scrollTop).toBe(0);
527
488
 
528
- await container.trigger('keydown', { key: 'PageUp' });
529
- await nextTick();
530
- expect(el.scrollLeft).toBe(0);
531
- expect(el.scrollTop).toBe(0);
532
- });
533
-
534
- it('should not scroll past the end using PageDown', async () => {
535
- // items: 100, itemSize: 50 -> totalHeight = 5000
536
- // viewportHeight: 500 -> maxScroll = 4500
537
- (wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4400);
538
- await nextTick();
539
- await container.trigger('keydown', { key: 'PageDown' });
540
- await nextTick();
541
- expect(el.scrollTop).toBe(4500);
542
- });
489
+ // Scroll down to row 20
490
+ const container = wrapper.find('.virtual-scroll-container');
491
+ const el = container.element as HTMLElement;
492
+ Object.defineProperty(el, 'scrollTop', { configurable: true, value: 1000, writable: true });
493
+ await container.trigger('scroll');
543
494
 
544
- it('should not scroll past the end using ArrowDown', async () => {
545
- // maxScroll = 4500
546
- (wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4480);
547
495
  await nextTick();
548
- await container.trigger('keydown', { key: 'ArrowDown' });
549
496
  await nextTick();
550
- expect(el.scrollTop).toBe(4500);
551
- });
552
497
 
553
- it('should handle unhandled keys', async () => {
554
- const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true });
555
- el.dispatchEvent(event);
556
- expect(event.defaultPrevented).toBe(false);
557
- });
558
- });
498
+ // Now row 20 is at the top. Measure its cells with slightly different width.
499
+ const row20 = wrapper.find('.virtual-scroll-item[data-index="20"]').element;
500
+ const cells20 = Array.from(row20.querySelectorAll('.cell'));
559
501
 
560
- describe('resize and observers', () => {
561
- it('should update item size on resize', async () => {
562
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
563
- await nextTick();
564
- const firstItem = wrapper.find('.virtual-scroll-item').element;
565
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
566
- if (observer) {
567
- observer.trigger([ {
568
- target: firstItem,
569
- contentRect: { width: 100, height: 100 } as DOMRectReadOnly,
570
- borderBoxSize: [ { inlineSize: 110, blockSize: 110 } ],
571
- } ]);
502
+ for (const cell of cells20) {
503
+ triggerResize(cell, 110.1, 50);
572
504
  }
573
- await nextTick();
574
- });
575
505
 
576
- it('should handle resize fallback (no borderBoxSize)', async () => {
577
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
578
506
  await nextTick();
579
- const firstItem = wrapper.find('.virtual-scroll-item').element;
580
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
581
- if (observer) {
582
- observer.trigger([ { target: firstItem, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
583
- }
584
507
  await nextTick();
585
- });
586
508
 
587
- it('should observe host resize and update offset', async () => {
588
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
589
- await nextTick();
590
- const host = wrapper.find('.virtual-scroll-container').element;
591
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(host));
592
- if (observer) {
593
- observer.trigger([ { target: host } ]);
594
- }
595
- await nextTick();
509
+ // ScrollOffset.x should STILL BE 0. It should not have shifted because of d = 0.1
510
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
596
511
  });
597
512
 
598
- it('should observe cell resize with data-col-index', async () => {
513
+ it('correctly aligns item 50:50 auto after measurements in dynamic grid', async () => {
599
514
  const wrapper = mount(VirtualScroll, {
600
515
  props: {
601
- items: mockItems.slice(0, 1),
516
+ bufferAfter: 5,
517
+ bufferBefore: 5,
518
+ columnCount: 100,
519
+ defaultColumnWidth: 120,
520
+ defaultItemSize: 120,
602
521
  direction: 'both',
603
- columnCount: 2,
522
+ items: mockItems,
604
523
  },
605
524
  slots: {
606
- item: '<template #item="{ index }"><div class="row"><div class="cell" data-col-index="0">Cell {{ index }}</div></div></template>',
525
+ item: ({ columnRange, index }: ItemSlotProps) => h('div', {
526
+ 'data-index': index,
527
+ }, [
528
+ ...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
529
+ class: 'cell',
530
+ 'data-col-index': columnRange.start + i,
531
+ })),
532
+ ]),
607
533
  },
608
534
  });
609
- await nextTick();
610
- const cell = wrapper.find('.cell').element;
611
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(cell));
612
- expect(observer).toBeDefined();
613
535
 
614
- if (observer) {
615
- observer.trigger([ {
616
- target: cell,
617
- contentRect: { width: 100, height: 50 } as DOMRectReadOnly,
618
- } ]);
619
- }
620
536
  await nextTick();
621
- });
622
537
 
623
- it('should observe header and footer resize', async () => {
624
- const wrapper = mount(VirtualScroll, {
625
- props: { items: mockItems, itemSize: 50 },
626
- slots: {
627
- header: '<div class="header">H</div>',
628
- footer: '<div class="footer">F</div>',
629
- },
630
- });
538
+ // Jump to 50:50 auto
539
+ (wrapper.vm as unknown as { scrollToIndex: (r: number, c: number, a: string) => void; }).scrollToIndex(50, 50, 'auto');
540
+ await nextTick();
631
541
  await nextTick();
632
- const header = wrapper.find('.virtual-scroll-header').element;
633
- const footer = wrapper.find('.virtual-scroll-footer').element;
634
542
 
635
- const headerObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(header));
636
- if (headerObserver) {
637
- headerObserver.trigger([ { target: header } ]);
638
- }
543
+ // Initial scroll position (estimates)
544
+ // itemX = 50 * 120 = 6000. itemWidth = 120. viewport = 500.
545
+ // targetEnd = 6000 + 120 - 500 = 5620.
546
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5620);
639
547
 
640
- const footerObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(footer));
641
- if (footerObserver) {
642
- footerObserver.trigger([ { target: footer } ]);
643
- }
644
- await nextTick();
645
- });
548
+ // Row 50 should be rendered. Row 45 should be the first rendered row.
549
+ const row45El = wrapper.find('.virtual-scroll-item[data-index="45"]').element;
550
+ const cells45 = Array.from(row45El.querySelectorAll('.cell'));
646
551
 
647
- it('should observe footer on mount if slot exists', async () => {
648
- const wrapper = mount(VirtualScroll, {
649
- props: { items: mockItems, itemSize: 50 },
650
- slots: { footer: '<div class="footer">F</div>' },
651
- });
652
- await nextTick();
653
- const footer = wrapper.find('.virtual-scroll-footer').element;
654
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(footer));
655
- expect(observer).toBeDefined();
656
- });
552
+ // Simulate measurements for all rendered cells in row 45 as 150px
553
+ for (const cell of cells45) {
554
+ triggerResize(cell, 150, 120);
555
+ }
657
556
 
658
- it('should cover header/footer unobserve when removed/replaced', async () => {
659
- const TestComp = defineComponent({
660
- components: { VirtualScroll },
661
- setup() {
662
- const show = ref(true);
663
- const showFooter = ref(true);
664
- return { mockItems, show, showFooter };
665
- },
666
- template: `
667
- <VirtualScroll :items="mockItems">
668
- <template v-if="show" #header><div>H</div></template>
669
- <template v-if="showFooter" #footer><div>F</div></template>
670
- </VirtualScroll>
671
- `,
672
- });
673
- const wrapper = mount(TestComp);
674
557
  await nextTick();
675
-
676
- (wrapper.vm as unknown as TestCompInstance).show = false;
677
- (wrapper.vm as unknown as TestCompInstance).showFooter = false;
678
558
  await nextTick();
679
-
680
- (wrapper.vm as unknown as TestCompInstance).show = true;
681
- (wrapper.vm as unknown as TestCompInstance).showFooter = true;
682
559
  await nextTick();
683
- });
684
560
 
685
- it('should cleanup observers on unmount', async () => {
686
- const wrapper = mount(VirtualScroll, {
687
- props: { items: mockItems, stickyFooter: true, stickyHeader: true },
688
- slots: { footer: '<div>F</div>', header: '<div>H</div>' },
689
- });
690
- await nextTick();
691
- wrapper.unmount();
692
- });
561
+ // Correction should have triggered.
562
+ // At x=5620, rendered columns are 44..52 (inclusive).
563
+ // If columns 44..52 are all 150px:
564
+ // New itemX for col 50: 44 * 120 + 6 * 150 = 5280 + 900 = 6180.
565
+ // itemWidth = 150. viewport = 500.
566
+ // targetEnd = 6180 + 150 - 500 = 5830.
693
567
 
694
- it('should ignore elements with missing or invalid data attributes in itemResizeObserver', async () => {
695
- mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
568
+ // wait for async correction cycle
569
+ await new Promise((resolve) => setTimeout(resolve, 300));
696
570
  await nextTick();
697
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ]!;
698
571
 
699
- // 1. Invalid index string
700
- const div1 = document.createElement('div');
701
- div1.dataset.index = 'invalid';
702
- observer.trigger([ { target: div1, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
572
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5830);
703
573
 
704
- // 2. Missing index and colIndex
705
- const div2 = document.createElement('div');
706
- observer.trigger([ { target: div2, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
574
+ // Check if it's fully visible
575
+ const offset = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x;
576
+ const viewportWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.viewportSize.width;
577
+ const itemX = 6180;
578
+ const itemWidth = 150;
707
579
 
708
- await nextTick();
580
+ expect(itemX).toBeGreaterThanOrEqual(offset);
581
+ expect(itemX + itemWidth).toBeLessThanOrEqual(offset + viewportWidth);
709
582
  });
710
583
  });
711
584
 
712
- describe('grid mode logic', () => {
713
- it('should cover firstRenderedIndex watcher for grid', async () => {
585
+ describe('sticky Items', () => {
586
+ it('applies sticky styles to marked items', async () => {
714
587
  const wrapper = mount(VirtualScroll, {
715
588
  props: {
716
- bufferBefore: 2,
717
- columnCount: 5,
718
- direction: 'both',
719
589
  itemSize: 50,
720
590
  items: mockItems,
721
- },
722
- slots: {
723
- item: '<template #item="{ index }"><div class="cell" :data-col-index="0">Item {{ index }}</div></template>',
591
+ stickyIndices: [ 0 ],
724
592
  },
725
593
  });
726
594
  await nextTick();
727
- const vm = wrapper.vm as unknown as VSInstance;
728
-
729
- // Scroll to 10
730
- vm.scrollToIndex(10, 0, { align: 'start', behavior: 'auto' });
731
- await nextTick();
732
- await nextTick();
733
595
 
734
- const item8 = wrapper.find('.virtual-scroll-item[data-index="8"]').element;
735
- const itemResizeObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item8));
736
- expect(itemResizeObserver).toBeDefined();
596
+ const container = wrapper.find('.virtual-scroll-container');
597
+ const el = container.element as HTMLElement;
737
598
 
738
- // Scroll to 9
739
- vm.scrollToIndex(9, 0, { align: 'start', behavior: 'auto' });
599
+ Object.defineProperty(el, 'scrollTop', { value: 100, writable: true });
600
+ await container.trigger('scroll');
740
601
  await nextTick();
741
602
  await nextTick();
742
603
 
743
- // Scroll to 50
744
- vm.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
745
- await nextTick();
746
- await nextTick();
604
+ const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
605
+ expect(item0.classes()).toContain('virtual-scroll--sticky');
606
+ expect((item0.element as HTMLElement).style.insetBlockStart).toBe('0px');
747
607
  });
608
+ });
748
609
 
749
- it('should cover firstRenderedIndex watcher when items becomes empty', async () => {
610
+ describe('ssr and Initial State', () => {
611
+ it('renders SSR range if provided', async () => {
750
612
  const wrapper = mount(VirtualScroll, {
751
613
  props: {
752
- columnCount: 5,
753
- direction: 'both',
754
614
  itemSize: 50,
755
615
  items: mockItems,
616
+ ssrRange: { end: 20, start: 10 },
617
+ },
618
+ slots: {
619
+ item: (props: ItemSlotProps) => {
620
+ const { item } = props as ItemSlotProps<MockItem>;
621
+ return h('div', item.label);
622
+ },
756
623
  },
757
624
  });
758
- await nextTick();
759
- await wrapper.setProps({ items: [] });
760
- await nextTick();
625
+ const items = wrapper.findAll('.virtual-scroll-item');
626
+ expect(items.length).toBe(10);
627
+ expect(items[ 0 ]?.attributes('data-index')).toBe('10');
628
+ expect(wrapper.text()).toContain('Item 10');
761
629
  });
762
- });
763
630
 
764
- describe('infinite scroll and loading', () => {
765
- it('should emit load event when reaching scroll end (vertical)', async () => {
631
+ it('hydrates and scrolls to initial index', async () => {
766
632
  const wrapper = mount(VirtualScroll, {
767
633
  props: {
634
+ initialScrollIndex: 50,
768
635
  itemSize: 50,
769
- items: mockItems.slice(0, 10),
770
- loadDistance: 400,
636
+ items: mockItems,
637
+ },
638
+ slots: {
639
+ item: (props: ItemSlotProps) => {
640
+ const { item } = props as ItemSlotProps<MockItem>;
641
+ return h('div', item.label);
642
+ },
771
643
  },
772
644
  });
645
+ await nextTick(); // onMounted
646
+ await nextTick(); // hydration + scrollToIndex
773
647
  await nextTick();
774
-
775
- (wrapper.vm as unknown as VSInstance).scrollToOffset(0, 250);
776
648
  await nextTick();
777
649
  await nextTick();
778
650
 
779
- expect(wrapper.emitted('load')).toBeDefined();
780
- expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'vertical' ]);
651
+ expect(wrapper.text()).toContain('Item 50');
781
652
  });
782
653
 
783
- it('should emit load event when reaching scroll end (horizontal)', async () => {
654
+ it('does not gather multiple sticky items at the top', async () => {
784
655
  const wrapper = mount(VirtualScroll, {
785
656
  props: {
786
- direction: 'horizontal',
787
657
  itemSize: 50,
788
- items: mockItems.slice(0, 10),
789
- loadDistance: 400,
658
+ items: mockItems,
659
+ stickyIndices: [ 0, 1, 2 ],
660
+ },
661
+ slots: {
662
+ item: (props: ItemSlotProps) => {
663
+ const { index, item } = props as ItemSlotProps<MockItem>;
664
+ return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
665
+ },
790
666
  },
791
667
  });
792
- await nextTick();
793
668
 
794
- (wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
795
669
  await nextTick();
796
670
  await nextTick();
797
671
 
798
- expect(wrapper.emitted('load')).toBeDefined();
799
- expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'horizontal' ]);
800
- });
801
-
802
- it('should not emit load event when loading is true', async () => {
803
- const wrapper = mount(VirtualScroll, {
804
- props: {
805
- itemSize: 50,
806
- items: mockItems.slice(0, 10),
807
- loadDistance: 100,
808
- loading: true,
809
- },
810
- });
811
- await nextTick();
812
672
  const container = wrapper.find('.virtual-scroll-container');
813
673
  const el = container.element as HTMLElement;
814
- Object.defineProperty(el, 'clientHeight', { value: 200, configurable: true });
815
- Object.defineProperty(el, 'scrollHeight', { value: 500, configurable: true });
816
674
 
817
- el.scrollTop = 250;
818
- container.element.dispatchEvent(new Event('scroll'));
675
+ // Scroll past item 2 (originalY = 100). relativeScrollY = 150.
676
+ Object.defineProperty(el, 'scrollTop', { configurable: true, value: 150, writable: true });
677
+ await container.trigger('scroll');
819
678
  await nextTick();
820
679
  await nextTick();
821
680
 
822
- expect(wrapper.emitted('load')).toBeUndefined();
681
+ // Only item 2 should be active sticky.
682
+ // Item 0 and 1 should have isStickyActive = false.
683
+ const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
684
+ const item1 = wrapper.find('.virtual-scroll-item[data-index="1"]');
685
+ const item2 = wrapper.find('.virtual-scroll-item[data-index="2"]');
686
+
687
+ expect(item2.classes()).toContain('virtual-scroll--sticky');
688
+ expect(item1.classes()).not.toContain('virtual-scroll--sticky');
689
+ expect(item0.classes()).not.toContain('virtual-scroll--sticky');
823
690
  });
691
+ });
824
692
 
825
- it('should render loading slot correctly', async () => {
693
+ describe('slots and Options', () => {
694
+ it('renders header and footer', async () => {
826
695
  const wrapper = mount(VirtualScroll, {
827
- props: {
828
- itemSize: 50,
829
- items: mockItems.slice(0, 10),
830
- loading: true,
831
- },
696
+ props: { items: mockItems.slice(0, 1) },
832
697
  slots: {
833
- loading: '<div class="loading-indicator">Loading...</div>',
698
+ footer: () => h('div', 'FOOTER'),
699
+ header: () => h('div', 'HEADER'),
834
700
  },
835
701
  });
836
- await nextTick();
837
- expect(wrapper.find('.loading-indicator').exists()).toBe(true);
838
-
839
- await wrapper.setProps({ direction: 'horizontal' });
840
- await nextTick();
841
- expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
702
+ expect(wrapper.text()).toContain('HEADER');
703
+ expect(wrapper.text()).toContain('FOOTER');
842
704
  });
843
705
 
844
- it('should toggle loading slot visibility based on loading prop', async () => {
706
+ it('shows loading indicator', async () => {
845
707
  const wrapper = mount(VirtualScroll, {
846
- props: {
847
- items: mockItems.slice(0, 5),
848
- loading: false,
849
- },
708
+ props: { items: [], loading: true },
850
709
  slots: {
851
- loading: '<div class="loader">Loading...</div>',
710
+ loading: () => h('div', 'LOADING...'),
852
711
  },
853
712
  });
854
- await nextTick();
855
-
856
- expect(wrapper.find('.loader').exists()).toBe(false);
857
- expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
858
-
859
- await wrapper.setProps({ loading: true });
860
- await nextTick();
861
- expect(wrapper.find('.loader').exists()).toBe(true);
862
- expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(true);
863
-
864
- await wrapper.setProps({ loading: false });
865
- await nextTick();
866
- expect(wrapper.find('.loader').exists()).toBe(false);
867
- expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
868
- });
869
- });
870
-
871
- describe('internal methods and exports', () => {
872
- it('should handle setItemRef', async () => {
873
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
874
- await nextTick();
875
- const vm = wrapper.vm as unknown as VSInstance;
876
- const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
877
- vm.setItemRef(item, 0);
878
- vm.setItemRef(null, 0);
879
- });
880
-
881
- it('should handle setItemRef with NaN index', async () => {
882
- mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
883
- await nextTick();
884
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ];
885
- const div = document.createElement('div');
886
- observer?.trigger([ { target: div, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
713
+ expect(wrapper.text()).toContain('LOADING...');
887
714
  });
888
715
 
889
- it('should expose methods', () => {
890
- const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
891
- expect(typeof (wrapper.vm as unknown as VSInstance).scrollToIndex).toBe('function');
892
- expect(typeof (wrapper.vm as unknown as VSInstance).scrollToOffset).toBe('function');
716
+ it('uses correct HTML tags', () => {
717
+ const wrapper = mount(VirtualScroll, {
718
+ props: {
719
+ containerTag: 'table',
720
+ itemTag: 'tr',
721
+ items: [],
722
+ wrapperTag: 'tbody',
723
+ },
724
+ });
725
+ expect(wrapper.element.tagName).toBe('TABLE');
726
+ expect(wrapper.find('tbody').exists()).toBe(true);
893
727
  });
894
728
 
895
- it('should emit visibleRangeChange on scroll and hydration', async () => {
729
+ it('triggers refresh and updates items', async () => {
896
730
  const wrapper = mount(VirtualScroll, {
897
- props: { itemSize: 50, items: mockItems },
731
+ props: {
732
+ itemSize: 50,
733
+ items: mockItems.slice(0, 10),
734
+ },
898
735
  });
899
736
  await nextTick();
900
- await nextTick();
901
- expect(wrapper.emitted('visibleRangeChange')).toBeDefined();
902
737
 
903
- const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
904
- Object.defineProperty(container, 'scrollTop', { value: 500, writable: true });
905
- await container.dispatchEvent(new Event('scroll'));
738
+ const vs = wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
739
+ vs.refresh();
906
740
  await nextTick();
741
+ // Should not crash
742
+ expect(vs.scrollDetails.items.length).toBeGreaterThan(0);
743
+ });
744
+
745
+ it('handles sticky header and footer measurements', async () => {
746
+ mount(VirtualScroll, {
747
+ props: {
748
+ items: mockItems.slice(0, 10),
749
+ stickyFooter: true,
750
+ stickyHeader: true,
751
+ },
752
+ slots: {
753
+ footer: () => h('div', { class: 'footer', style: 'height: 30px' }, 'FOOTER'),
754
+ header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
755
+ },
756
+ });
907
757
  await nextTick();
908
- expect(wrapper.emitted('visibleRangeChange')!.length).toBeGreaterThan(1);
909
758
  });
910
759
 
911
- it('should not emit scroll event before hydration in watch', async () => {
912
- // initialScrollIndex triggers delayed hydration via nextTick in useVirtualScroll
760
+ it('works with window as container', async () => {
913
761
  const wrapper = mount(VirtualScroll, {
914
762
  props: {
915
- initialScrollIndex: 5,
763
+ container: window,
916
764
  itemSize: 50,
917
- items: mockItems.slice(0, 10),
765
+ items: mockItems,
918
766
  },
919
767
  });
920
-
921
- // Before first nextTick, isHydrated is false.
922
- // Changing items will trigger scrollDetails update.
923
- await wrapper.setProps({ items: mockItems.slice(0, 20) });
924
-
925
- // Line 196 in VirtualScroll.vue should be hit here (return if !isHydrated)
926
- expect(wrapper.emitted('scroll')).toBeUndefined();
927
-
928
- await nextTick(); // hydration tick
929
- await nextTick(); // one more for good measure
930
- expect(wrapper.emitted('scroll')).toBeDefined();
768
+ await nextTick();
769
+ expect(wrapper.classes()).toContain('virtual-scroll--window');
931
770
  });
932
771
  });
933
772
  });