@pdanpdan/virtual-scroll 0.3.0 → 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,980 +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;
56
- refresh: () => void;
85
+ if (options.top !== undefined) {
86
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
57
87
  }
88
+ document.dispatchEvent(new Event('scroll'));
89
+ });
58
90
 
59
- interface TestCompInstance {
60
- mockItems: typeof mockItems;
61
- show: boolean;
62
- showFooter: boolean;
63
- }
91
+ // --- Tests ---
64
92
 
65
- describe('rendering and structure', () => {
66
- it('should render items correctly', () => {
67
- const wrapper = mount(VirtualScroll, {
68
- props: {
69
- items: mockItems,
70
- itemSize: 50,
71
- },
72
- slots: {
73
- item: '<template #item="{ item, index }"><div class="item">{{ index }}: {{ item.label }}</div></template>',
74
- },
75
- });
76
- expect(wrapper.findAll('.item').length).toBeGreaterThan(0);
77
- expect(wrapper.text()).toContain('0: Item 0');
78
- });
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 }` }));
100
+
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
+ });
79
109
 
80
- it('should render header and footer slots', () => {
110
+ describe('basic Rendering', () => {
111
+ it('renders the visible items', async () => {
81
112
  const wrapper = mount(VirtualScroll, {
82
113
  props: {
83
- items: mockItems,
84
114
  itemSize: 50,
115
+ items: mockItems,
85
116
  },
86
117
  slots: {
87
- header: '<div class="header">Header</div>',
88
- 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
+ },
89
122
  },
90
123
  });
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);
95
- });
96
124
 
97
- it('should not render header and footer slots when absent', () => {
98
- const wrapper = mount(VirtualScroll, {
99
- props: {
100
- items: mockItems,
101
- itemSize: 50,
102
- },
103
- });
104
- expect(wrapper.find('.virtual-scroll-header').exists()).toBe(false);
105
- 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');
106
131
  });
107
132
 
108
- it('should render debug information when debug prop is true', async () => {
133
+ it('updates when items change', async () => {
109
134
  const wrapper = mount(VirtualScroll, {
110
135
  props: {
111
- items: mockItems.slice(0, 5),
112
136
  itemSize: 50,
113
- debug: true,
137
+ items: mockItems.slice(0, 5),
114
138
  },
115
139
  });
116
140
  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');
119
- });
141
+ expect(wrapper.findAll('.virtual-scroll-item').length).toBe(5);
120
142
 
121
- it('should not render debug information when debug prop is false', async () => {
122
- const wrapper = mount(VirtualScroll, {
123
- props: {
124
- items: mockItems.slice(0, 5),
125
- itemSize: 50,
126
- debug: false,
127
- },
128
- });
143
+ await wrapper.setProps({ items: mockItems.slice(0, 10) });
129
144
  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');
145
+ expect(wrapper.findAll('.virtual-scroll-item').length).toBe(10);
132
146
  });
133
147
 
134
- it('should handle missing slots gracefully', () => {
148
+ it('supports horizontal direction', async () => {
135
149
  const wrapper = mount(VirtualScroll, {
136
150
  props: {
137
- items: mockItems.slice(0, 1),
138
- itemSize: 50,
151
+ direction: 'horizontal',
152
+ itemSize: 100,
153
+ items: mockItems,
139
154
  },
140
155
  });
141
- 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');
142
160
  });
143
161
 
144
- it('should render table correctly', () => {
162
+ it('supports grid mode (both directions)', async () => {
145
163
  const wrapper = mount(VirtualScroll, {
146
164
  props: {
147
- items: mockItems.slice(0, 10),
165
+ columnCount: 5,
166
+ columnWidth: 100,
167
+ direction: 'both',
148
168
  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>',
169
+ items: mockItems,
155
170
  },
156
171
  });
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);
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');
160
176
  });
177
+ });
161
178
 
162
- it('should render table with header and footer', () => {
179
+ describe('interactions', () => {
180
+ it('scrolls and updates visible items', async () => {
163
181
  const wrapper = mount(VirtualScroll, {
164
182
  props: {
165
- items: mockItems.slice(0, 10),
166
183
  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
- },
175
- });
176
- expect(wrapper.find('thead').exists()).toBe(true);
177
- expect(wrapper.find('tfoot').exists()).toBe(true);
178
- });
179
-
180
- it('should render div header and footer', () => {
181
- const wrapper = mount(VirtualScroll, {
182
- props: {
183
- items: mockItems.slice(0, 10),
184
- containerTag: 'div',
185
- },
186
- slots: {
187
- header: '<div class="header">Header</div>',
188
- footer: '<div class="footer">Footer</div>',
189
- },
190
- });
191
- expect(wrapper.find('div.virtual-scroll-header').exists()).toBe(true);
192
- expect(wrapper.find('div.virtual-scroll-footer').exists()).toBe(true);
193
- });
194
-
195
- it('should apply sticky classes to header and footer', async () => {
196
- const wrapper = mount(VirtualScroll, {
197
- props: {
198
184
  items: mockItems,
199
- stickyHeader: true,
200
- stickyFooter: true,
201
185
  },
202
186
  slots: {
203
- header: '<div>H</div>',
204
- footer: '<div>F</div>',
187
+ item: (props: ItemSlotProps) => {
188
+ const { item } = props as ItemSlotProps<MockItem>;
189
+ return h('div', { class: 'item' }, item.label);
190
+ },
205
191
  },
206
192
  });
207
193
  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
194
 
212
- it('should handle switching containerTag', async () => {
213
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 10), containerTag: 'div' } });
214
- await nextTick();
215
- 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');
216
200
  await nextTick();
217
- expect(wrapper.element.tagName).toBe('TABLE');
218
- await wrapper.setProps({ containerTag: 'div' });
219
201
  await nextTick();
220
- expect(wrapper.element.tagName).toBe('DIV');
221
- });
222
202
 
223
- it('should render table spacer and items', () => {
224
- const wrapper = mount(VirtualScroll, {
225
- 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>',
233
- },
234
- });
235
- expect(wrapper.find('tr.virtual-scroll-spacer').exists()).toBe(true);
236
- expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
203
+ expect(wrapper.text()).toContain('Item 20');
204
+ expect(wrapper.text()).toContain('Item 15');
237
205
  });
238
206
 
239
- it('should handle table rendering without header and footer', async () => {
207
+ it('emits load event when reaching end', async () => {
240
208
  const wrapper = mount(VirtualScroll, {
241
209
  props: {
242
- items: mockItems.slice(0, 5),
243
- containerTag: 'table',
210
+ itemSize: 50,
211
+ items: mockItems.slice(0, 20),
212
+ loadDistance: 100,
244
213
  },
245
214
  });
246
215
  await nextTick();
247
- expect(wrapper.find('thead').exists()).toBe(false);
248
- expect(wrapper.find('tfoot').exists()).toBe(false);
249
- });
216
+ await nextTick();
250
217
 
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
- }
271
- }
272
- }
273
- });
274
- });
218
+ const container = wrapper.find('.virtual-scroll-container');
219
+ const el = container.element as HTMLElement;
275
220
 
276
- describe('styling and dimensions', () => {
277
- it('should render items horizontally when direction is horizontal', async () => {
278
- const wrapper = mount(VirtualScroll, {
279
- props: {
280
- items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
281
- itemSize: 100,
282
- direction: 'horizontal',
283
- },
284
- });
285
- 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)');
290
- });
221
+ expect(wrapper.emitted('load')).toBeUndefined();
291
222
 
292
- it('should handle bidirectional scroll dimensions', async () => {
293
- 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
- },
301
- });
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');
306
- });
223
+ Object.defineProperty(el, 'scrollTop', { value: 450, writable: true });
224
+ await container.trigger('scroll');
225
+ await nextTick();
226
+ await nextTick();
307
227
 
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' } });
313
- });
314
- mount(VirtualScroll, { props: { items: mockItems, container: null } });
228
+ expect(wrapper.emitted('load')).toBeDefined();
315
229
  });
316
230
 
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
- },
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);
324
380
  });
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
381
  });
382
+ });
330
383
 
331
- it('should cover sticky item style branches in getItemStyle', async () => {
332
- const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
384
+ describe('dynamic Sizing', () => {
385
+ it('adjusts total size when items are measured', async () => {
333
386
  const wrapper = mount(VirtualScroll, {
334
387
  props: {
335
- items,
336
- direction: 'horizontal',
337
- stickyIndices: [ 0 ],
338
- scrollPaddingStart: 10,
388
+ itemSize: 0,
389
+ items: mockItems.slice(0, 10),
339
390
  },
340
391
  });
341
392
  await nextTick();
342
393
 
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');
346
- await nextTick();
347
-
348
- expect(stickyItem.style.insetInlineStart).toBe('10px');
394
+ expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('400px');
349
395
 
350
- await wrapper.setProps({ direction: 'vertical', scrollPaddingStart: 20 });
396
+ const firstItem = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
397
+ triggerResize(firstItem, 100, 100);
351
398
  await nextTick();
352
- expect(stickyItem.style.insetBlockStart).toBe('20px');
353
- });
354
-
355
- it('should handle custom container element for header/footer padding', async () => {
356
- const container = document.createElement('div');
357
- const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
358
- mount(VirtualScroll, {
359
- props: {
360
- items,
361
- container,
362
- stickyHeader: true,
363
- },
364
- });
365
399
  await nextTick();
366
- // This covers the branch where container is NOT host element and NOT window
367
- });
368
400
 
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
- });
379
- await nextTick();
401
+ expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('460px');
380
402
  });
381
403
 
382
- it('should cover object padding branches in virtualScrollProps', () => {
383
- mount(VirtualScroll, {
404
+ it('does not allow columns to become 0 width due to 0-size measurements', async () => {
405
+ const wrapper = mount(VirtualScroll, {
384
406
  props: {
385
- items: mockItems.slice(0, 1),
386
- scrollPaddingStart: { x: 10, y: 20 },
387
- 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,
388
414
  },
389
- });
390
- mount(VirtualScroll, {
391
- props: {
392
- items: mockItems.slice(0, 1),
393
- direction: 'horizontal',
394
- scrollPaddingStart: 10,
395
- 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
+ ]),
396
424
  },
397
425
  });
398
- });
399
- });
400
-
401
- describe('keyboard navigation', () => {
402
- let wrapper: VueWrapper<VSInstance>;
403
- let container: DOMWrapper<Element>;
404
- let el: HTMLElement;
405
426
 
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>;
414
- await nextTick();
415
- container = wrapper.find('.virtual-scroll-container');
416
- el = container.element as HTMLElement;
417
-
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 });
423
-
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 } ]));
426
427
  await nextTick();
427
- });
428
-
429
- it('should handle Home key', async () => {
430
- el.scrollTop = 1000;
431
- el.scrollLeft = 500;
432
- await container.trigger('keydown', { key: 'Home' });
433
- await nextTick();
434
- expect(el.scrollTop).toBe(0);
435
- expect(el.scrollLeft).toBe(0);
436
- });
437
-
438
- it('should handle End key (vertical)', async () => {
439
- el.scrollLeft = 0;
440
- await container.trigger('keydown', { key: 'End' });
441
- 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);
448
- });
449
428
 
450
- it('should handle End key (horizontal)', async () => {
451
- await wrapper.setProps({ direction: 'horizontal' });
452
- 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
- await nextTick();
429
+ const initialWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
430
+ expect(initialWidth).toBeGreaterThan(0);
457
431
 
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
- });
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();
464
436
 
465
- it('should handle End key in both mode', async () => {
466
- await wrapper.setProps({ columnCount: 5, columnWidth: 100, direction: 'both' });
467
- await nextTick();
437
+ // Simulate 0-size measurement (e.g. from removal or being hidden)
438
+ triggerResize(cell0, 0, 0);
468
439
 
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
440
  await nextTick();
473
-
474
- await container.trigger('keydown', { key: 'End' });
475
441
  await nextTick();
476
442
 
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);
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);
485
446
  });
486
447
 
487
- it('should handle End key with empty items', async () => {
488
- await wrapper.setProps({ items: [] });
489
- await nextTick();
490
- await container.trigger('keydown', { key: 'End' });
491
- await nextTick();
492
- });
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
+ });
493
470
 
494
- it('should handle ArrowDown / ArrowUp', async () => {
495
- el.scrollLeft = 0;
496
- await container.trigger('keydown', { key: 'ArrowDown' });
497
471
  await nextTick();
498
- expect(el.scrollTop).toBe(40);
499
- expect(el.scrollLeft).toBe(0);
500
472
 
501
- await container.trigger('keydown', { key: 'ArrowUp' });
502
- await nextTick();
503
- expect(el.scrollTop).toBe(0);
504
- expect(el.scrollLeft).toBe(0);
505
- });
473
+ // Initial scroll
474
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
506
475
 
507
- it('should handle ArrowRight / ArrowLeft', async () => {
508
- await wrapper.setProps({ direction: 'horizontal' });
509
- await nextTick();
510
- el.scrollTop = 0;
511
- await container.trigger('keydown', { key: 'ArrowRight' });
512
- await nextTick();
513
- expect(el.scrollLeft).toBe(40);
514
- 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'));
515
479
 
516
- await container.trigger('keydown', { key: 'ArrowLeft' });
517
- await nextTick();
518
- expect(el.scrollLeft).toBe(0);
519
- expect(el.scrollTop).toBe(0);
520
- });
521
-
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);
528
-
529
- await container.trigger('keydown', { key: 'PageUp' });
530
- await nextTick();
531
- expect(el.scrollTop).toBe(0);
532
- expect(el.scrollLeft).toBe(0);
533
- });
480
+ // Measure row 0 and its cells
481
+ triggerResize(row0, 1000, 50);
482
+ for (const cell of cells0) {
483
+ triggerResize(cell, 110, 50);
484
+ }
534
485
 
535
- it('should handle PageDown / PageUp in horizontal mode', async () => {
536
- await wrapper.setProps({ direction: 'horizontal' });
537
486
  await nextTick();
538
- el.scrollTop = 0;
539
- await container.trigger('keydown', { key: 'PageDown' });
540
487
  await nextTick();
541
- expect(el.scrollLeft).toBe(500);
542
- expect(el.scrollTop).toBe(0);
543
488
 
544
- await container.trigger('keydown', { key: 'PageUp' });
545
- await nextTick();
546
- expect(el.scrollLeft).toBe(0);
547
- expect(el.scrollTop).toBe(0);
548
- });
549
-
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);
554
- await nextTick();
555
- await container.trigger('keydown', { key: 'PageDown' });
556
- await nextTick();
557
- expect(el.scrollTop).toBe(4500);
558
- });
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');
559
494
 
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
495
  await nextTick();
564
- await container.trigger('keydown', { key: 'ArrowDown' });
565
496
  await nextTick();
566
- expect(el.scrollTop).toBe(4500);
567
- });
568
497
 
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
- });
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'));
575
501
 
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
- } ]);
502
+ for (const cell of cells20) {
503
+ triggerResize(cell, 110.1, 50);
588
504
  }
589
- await nextTick();
590
- });
591
505
 
592
- it('should handle resize fallback (no borderBoxSize)', async () => {
593
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
594
506
  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
507
  await nextTick();
601
- });
602
508
 
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
- }
611
- 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);
612
511
  });
613
512
 
614
- it('should observe cell resize with data-col-index', async () => {
513
+ it('correctly aligns item 50:50 auto after measurements in dynamic grid', async () => {
615
514
  const wrapper = mount(VirtualScroll, {
616
515
  props: {
617
- items: mockItems.slice(0, 1),
516
+ bufferAfter: 5,
517
+ bufferBefore: 5,
518
+ columnCount: 100,
519
+ defaultColumnWidth: 120,
520
+ defaultItemSize: 120,
618
521
  direction: 'both',
619
- columnCount: 2,
522
+ items: mockItems,
620
523
  },
621
524
  slots: {
622
- 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
+ ]),
623
533
  },
624
534
  });
625
- 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
535
 
630
- if (observer) {
631
- observer.trigger([ {
632
- target: cell,
633
- contentRect: { width: 100, height: 50 } as DOMRectReadOnly,
634
- } ]);
635
- }
636
536
  await nextTick();
637
- });
638
537
 
639
- it('should observe header and footer resize', async () => {
640
- const wrapper = mount(VirtualScroll, {
641
- props: { items: mockItems, itemSize: 50 },
642
- slots: {
643
- header: '<div class="header">H</div>',
644
- footer: '<div class="footer">F</div>',
645
- },
646
- });
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();
647
541
  await nextTick();
648
- const header = wrapper.find('.virtual-scroll-header').element;
649
- const footer = wrapper.find('.virtual-scroll-footer').element;
650
542
 
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
- }
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);
655
547
 
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
- }
660
- await nextTick();
661
- });
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'));
662
551
 
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
- });
668
- 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();
672
- });
552
+ // Simulate measurements for all rendered cells in row 45 as 150px
553
+ for (const cell of cells45) {
554
+ triggerResize(cell, 150, 120);
555
+ }
673
556
 
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 };
681
- },
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
- });
689
- const wrapper = mount(TestComp);
690
557
  await nextTick();
691
-
692
- (wrapper.vm as unknown as TestCompInstance).show = false;
693
- (wrapper.vm as unknown as TestCompInstance).showFooter = false;
694
558
  await nextTick();
695
-
696
- (wrapper.vm as unknown as TestCompInstance).show = true;
697
- (wrapper.vm as unknown as TestCompInstance).showFooter = true;
698
559
  await nextTick();
699
- });
700
560
 
701
- it('should cleanup observers on unmount', async () => {
702
- const wrapper = mount(VirtualScroll, {
703
- props: { items: mockItems, stickyFooter: true, stickyHeader: true },
704
- slots: { footer: '<div>F</div>', header: '<div>H</div>' },
705
- });
706
- await nextTick();
707
- wrapper.unmount();
708
- });
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.
709
567
 
710
- it('should ignore elements with missing or invalid data attributes in itemResizeObserver', async () => {
711
- mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
568
+ // wait for async correction cycle
569
+ await new Promise((resolve) => setTimeout(resolve, 300));
712
570
  await nextTick();
713
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ]!;
714
571
 
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 } ]);
572
+ expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5830);
719
573
 
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 } ]);
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;
723
579
 
724
- await nextTick();
580
+ expect(itemX).toBeGreaterThanOrEqual(offset);
581
+ expect(itemX + itemWidth).toBeLessThanOrEqual(offset + viewportWidth);
725
582
  });
726
583
  });
727
584
 
728
- describe('grid mode logic', () => {
729
- it('should cover firstRenderedIndex watcher for grid', async () => {
585
+ describe('sticky Items', () => {
586
+ it('applies sticky styles to marked items', async () => {
730
587
  const wrapper = mount(VirtualScroll, {
731
588
  props: {
732
- bufferBefore: 2,
733
- columnCount: 5,
734
- direction: 'both',
735
589
  itemSize: 50,
736
590
  items: mockItems,
737
- },
738
- slots: {
739
- item: '<template #item="{ index }"><div class="cell" :data-col-index="0">Item {{ index }}</div></template>',
591
+ stickyIndices: [ 0 ],
740
592
  },
741
593
  });
742
594
  await nextTick();
743
- const vm = wrapper.vm as unknown as VSInstance;
744
595
 
745
- // Scroll to 10
746
- vm.scrollToIndex(10, 0, { align: 'start', behavior: 'auto' });
747
- await nextTick();
748
- 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();
596
+ const container = wrapper.find('.virtual-scroll-container');
597
+ const el = container.element as HTMLElement;
753
598
 
754
- // Scroll to 9
755
- vm.scrollToIndex(9, 0, { align: 'start', behavior: 'auto' });
599
+ Object.defineProperty(el, 'scrollTop', { value: 100, writable: true });
600
+ await container.trigger('scroll');
756
601
  await nextTick();
757
602
  await nextTick();
758
603
 
759
- // Scroll to 50
760
- vm.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
761
- await nextTick();
762
- 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');
763
607
  });
608
+ });
764
609
 
765
- it('should cover firstRenderedIndex watcher when items becomes empty', async () => {
610
+ describe('ssr and Initial State', () => {
611
+ it('renders SSR range if provided', async () => {
766
612
  const wrapper = mount(VirtualScroll, {
767
613
  props: {
768
- columnCount: 5,
769
- direction: 'both',
770
614
  itemSize: 50,
771
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
+ },
772
623
  },
773
624
  });
774
- await nextTick();
775
- await wrapper.setProps({ items: [] });
776
- 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');
777
629
  });
778
- });
779
630
 
780
- describe('infinite scroll and loading', () => {
781
- it('should emit load event when reaching scroll end (vertical)', async () => {
631
+ it('hydrates and scrolls to initial index', async () => {
782
632
  const wrapper = mount(VirtualScroll, {
783
633
  props: {
634
+ initialScrollIndex: 50,
784
635
  itemSize: 50,
785
- items: mockItems.slice(0, 10),
786
- 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
+ },
787
643
  },
788
644
  });
645
+ await nextTick(); // onMounted
646
+ await nextTick(); // hydration + scrollToIndex
789
647
  await nextTick();
790
-
791
- (wrapper.vm as unknown as VSInstance).scrollToOffset(0, 250);
792
648
  await nextTick();
793
649
  await nextTick();
794
650
 
795
- expect(wrapper.emitted('load')).toBeDefined();
796
- expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'vertical' ]);
651
+ expect(wrapper.text()).toContain('Item 50');
797
652
  });
798
653
 
799
- it('should emit load event when reaching scroll end (horizontal)', async () => {
654
+ it('does not gather multiple sticky items at the top', async () => {
800
655
  const wrapper = mount(VirtualScroll, {
801
656
  props: {
802
- direction: 'horizontal',
803
657
  itemSize: 50,
804
- items: mockItems.slice(0, 10),
805
- 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
+ },
806
666
  },
807
667
  });
808
- await nextTick();
809
668
 
810
- (wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
811
669
  await nextTick();
812
670
  await nextTick();
813
671
 
814
- expect(wrapper.emitted('load')).toBeDefined();
815
- expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'horizontal' ]);
816
- });
817
-
818
- it('should not emit load event when loading is true', async () => {
819
- const wrapper = mount(VirtualScroll, {
820
- props: {
821
- itemSize: 50,
822
- items: mockItems.slice(0, 10),
823
- loadDistance: 100,
824
- loading: true,
825
- },
826
- });
827
- await nextTick();
828
672
  const container = wrapper.find('.virtual-scroll-container');
829
673
  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
674
 
833
- el.scrollTop = 250;
834
- 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');
835
678
  await nextTick();
836
679
  await nextTick();
837
680
 
838
- 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');
839
690
  });
691
+ });
840
692
 
841
- it('should render loading slot correctly', async () => {
693
+ describe('slots and Options', () => {
694
+ it('renders header and footer', async () => {
842
695
  const wrapper = mount(VirtualScroll, {
843
- props: {
844
- itemSize: 50,
845
- items: mockItems.slice(0, 10),
846
- loading: true,
847
- },
696
+ props: { items: mockItems.slice(0, 1) },
848
697
  slots: {
849
- loading: '<div class="loading-indicator">Loading...</div>',
698
+ footer: () => h('div', 'FOOTER'),
699
+ header: () => h('div', 'HEADER'),
850
700
  },
851
701
  });
852
- 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');
702
+ expect(wrapper.text()).toContain('HEADER');
703
+ expect(wrapper.text()).toContain('FOOTER');
858
704
  });
859
705
 
860
- it('should toggle loading slot visibility based on loading prop', async () => {
706
+ it('shows loading indicator', async () => {
861
707
  const wrapper = mount(VirtualScroll, {
862
- props: {
863
- items: mockItems.slice(0, 5),
864
- loading: false,
865
- },
708
+ props: { items: [], loading: true },
866
709
  slots: {
867
- loading: '<div class="loader">Loading...</div>',
710
+ loading: () => h('div', 'LOADING...'),
868
711
  },
869
712
  });
870
- await nextTick();
871
-
872
- expect(wrapper.find('.loader').exists()).toBe(false);
873
- expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
874
-
875
- await wrapper.setProps({ loading: true });
876
- await nextTick();
877
- expect(wrapper.find('.loader').exists()).toBe(true);
878
- expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(true);
879
-
880
- await wrapper.setProps({ loading: false });
881
- await nextTick();
882
- expect(wrapper.find('.loader').exists()).toBe(false);
883
- expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
713
+ expect(wrapper.text()).toContain('LOADING...');
884
714
  });
885
- });
886
715
 
887
- describe('internal methods and exports', () => {
888
- it('should handle setItemRef', async () => {
889
- const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
890
- 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
-
897
- it('should handle setItemRef with NaN index', async () => {
898
- mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
899
- 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
-
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');
909
- });
910
-
911
- it('should manually re-measure items on refresh', async () => {
912
- const items = [ { id: 1 } ];
716
+ it('uses correct HTML tags', () => {
913
717
  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>' },
718
+ props: {
719
+ containerTag: 'table',
720
+ itemTag: 'tr',
721
+ items: [],
722
+ wrapperTag: 'tbody',
723
+ },
916
724
  });
917
- await nextTick();
918
-
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 });
922
-
923
- // First measurement via refresh (simulating manual trigger or initial measurement)
924
- const vm = wrapper.vm as unknown as VSInstance;
925
- vm.refresh();
926
- await nextTick();
927
- await nextTick();
928
-
929
- expect(vm.scrollDetails.totalSize.height).toBe(100);
725
+ expect(wrapper.element.tagName).toBe('TABLE');
726
+ expect(wrapper.find('tbody').exists()).toBe(true);
930
727
  });
931
728
 
932
- it('should handle refresh with no rendered items', async () => {
729
+ it('triggers refresh and updates items', async () => {
933
730
  const wrapper = mount(VirtualScroll, {
934
- props: { items: [], itemSize: 0 },
731
+ props: {
732
+ itemSize: 50,
733
+ items: mockItems.slice(0, 10),
734
+ },
935
735
  });
936
736
  await nextTick();
937
- const vm = wrapper.vm as unknown as VSInstance;
938
- vm.refresh();
737
+
738
+ const vs = wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
739
+ vs.refresh();
939
740
  await nextTick();
741
+ // Should not crash
742
+ expect(vs.scrollDetails.items.length).toBeGreaterThan(0);
940
743
  });
941
744
 
942
- it('should emit visibleRangeChange on scroll and hydration', async () => {
943
- const wrapper = mount(VirtualScroll, {
944
- props: { itemSize: 50, items: mockItems },
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
+ },
945
756
  });
946
757
  await nextTick();
947
- await nextTick();
948
- expect(wrapper.emitted('visibleRangeChange')).toBeDefined();
949
-
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'));
953
- await nextTick();
954
- await nextTick();
955
- expect(wrapper.emitted('visibleRangeChange')!.length).toBeGreaterThan(1);
956
758
  });
957
759
 
958
- it('should not emit scroll event before hydration in watch', async () => {
959
- // initialScrollIndex triggers delayed hydration via nextTick in useVirtualScroll
760
+ it('works with window as container', async () => {
960
761
  const wrapper = mount(VirtualScroll, {
961
762
  props: {
962
- initialScrollIndex: 5,
763
+ container: window,
963
764
  itemSize: 50,
964
- items: mockItems.slice(0, 10),
765
+ items: mockItems,
965
766
  },
966
767
  });
967
-
968
- // Before first nextTick, isHydrated is false.
969
- // Changing items will trigger scrollDetails update.
970
- await wrapper.setProps({ items: mockItems.slice(0, 20) });
971
-
972
- // Line 196 in VirtualScroll.vue should be hit here (return if !isHydrated)
973
- expect(wrapper.emitted('scroll')).toBeUndefined();
974
-
975
- await nextTick(); // hydration tick
976
- await nextTick(); // one more for good measure
977
- expect(wrapper.emitted('scroll')).toBeDefined();
768
+ await nextTick();
769
+ expect(wrapper.classes()).toContain('virtual-scroll--window');
978
770
  });
979
771
  });
980
772
  });