@pdanpdan/virtual-scroll 0.1.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.
- package/README.md +292 -0
- package/dist/index.css +1 -0
- package/dist/index.js +961 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/components/VirtualScroll.test.ts +912 -0
- package/src/components/VirtualScroll.vue +748 -0
- package/src/composables/useVirtualScroll.test.ts +1214 -0
- package/src/composables/useVirtualScroll.ts +1407 -0
- package/src/index.ts +4 -0
- package/src/utils/fenwick-tree.test.ts +119 -0
- package/src/utils/fenwick-tree.ts +155 -0
- package/src/utils/scroll.ts +59 -0
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
import type { VirtualScrollProps } from './useVirtualScroll';
|
|
2
|
+
import type { Ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { mount } from '@vue/test-utils';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { defineComponent, nextTick, ref } from 'vue';
|
|
7
|
+
|
|
8
|
+
import { getPaddingX, getPaddingY } from '../utils/scroll';
|
|
9
|
+
import { useVirtualScroll } from './useVirtualScroll';
|
|
10
|
+
|
|
11
|
+
type ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
|
|
12
|
+
|
|
13
|
+
// Mock ResizeObserver
|
|
14
|
+
interface ResizeObserverMock {
|
|
15
|
+
callback: ResizeObserverCallback;
|
|
16
|
+
targets: Set<Element>;
|
|
17
|
+
trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
globalThis.ResizeObserver = class {
|
|
21
|
+
callback: ResizeObserverCallback;
|
|
22
|
+
static instances: ResizeObserverMock[] = [];
|
|
23
|
+
targets: Set<Element> = new Set();
|
|
24
|
+
|
|
25
|
+
constructor(callback: ResizeObserverCallback) {
|
|
26
|
+
this.callback = callback;
|
|
27
|
+
(this.constructor as unknown as { instances: ResizeObserverMock[]; }).instances.push(this as unknown as ResizeObserverMock);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
observe(target: Element) {
|
|
31
|
+
this.targets.add(target);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
unobserve(target: Element) {
|
|
35
|
+
this.targets.delete(target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
disconnect() {
|
|
39
|
+
this.targets.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
trigger(entries: Partial<ResizeObserverEntry>[]) {
|
|
43
|
+
this.callback(entries as ResizeObserverEntry[], this as unknown as ResizeObserver);
|
|
44
|
+
}
|
|
45
|
+
} as unknown as typeof ResizeObserver & { instances: ResizeObserverMock[]; };
|
|
46
|
+
|
|
47
|
+
globalThis.window.scrollTo = vi.fn();
|
|
48
|
+
|
|
49
|
+
// Helper to test composable within a component context
|
|
50
|
+
function setup<T>(propsValue: VirtualScrollProps<T>) {
|
|
51
|
+
const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
|
|
52
|
+
let result: ReturnType<typeof useVirtualScroll<T>>;
|
|
53
|
+
|
|
54
|
+
const TestComponent = defineComponent({
|
|
55
|
+
setup() {
|
|
56
|
+
result = useVirtualScroll(props);
|
|
57
|
+
return () => null;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const wrapper = mount(TestComponent);
|
|
61
|
+
return { result: result!, props, wrapper };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
|
|
65
|
+
const defaultProps: VirtualScrollProps<{ id: number; }> = {
|
|
66
|
+
items: mockItems,
|
|
67
|
+
itemSize: 50,
|
|
68
|
+
direction: 'vertical' as const,
|
|
69
|
+
bufferBefore: 2,
|
|
70
|
+
bufferAfter: 2,
|
|
71
|
+
container: window,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe('useVirtualScroll', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
window.scrollX = 0;
|
|
77
|
+
window.scrollY = 0;
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
vi.useRealTimers();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('initialization and total size', () => {
|
|
83
|
+
it('should initialize with correct total height', async () => {
|
|
84
|
+
const { result } = setup({ ...defaultProps });
|
|
85
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should update total size when items length changes', async () => {
|
|
89
|
+
const { result, props } = setup({ ...defaultProps });
|
|
90
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
91
|
+
|
|
92
|
+
props.value.items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
|
93
|
+
await nextTick();
|
|
94
|
+
expect(result.totalHeight.value).toBe(2500);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should recalculate totalHeight when itemSize changes', async () => {
|
|
98
|
+
const { result, props } = setup({ ...defaultProps });
|
|
99
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
100
|
+
|
|
101
|
+
props.value.itemSize = 100;
|
|
102
|
+
await nextTick();
|
|
103
|
+
expect(result.totalHeight.value).toBe(10000);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should recalculate when gaps change', async () => {
|
|
107
|
+
const { result, props } = setup({ ...defaultProps, gap: 10 });
|
|
108
|
+
expect(result.totalHeight.value).toBe(6000); // 100 * (50 + 10)
|
|
109
|
+
|
|
110
|
+
props.value.gap = 20;
|
|
111
|
+
await nextTick();
|
|
112
|
+
expect(result.totalHeight.value).toBe(7000); // 100 * (50 + 20)
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should handle itemSize as a function', async () => {
|
|
116
|
+
const { result } = setup({
|
|
117
|
+
...defaultProps,
|
|
118
|
+
itemSize: (_item: { id: number; }, index: number) => 50 + index,
|
|
119
|
+
});
|
|
120
|
+
// 50*100 + (0+99)*100/2 = 5000 + 4950 = 9950
|
|
121
|
+
expect(result.totalHeight.value).toBe(9950);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should handle direction both (grid mode)', async () => {
|
|
125
|
+
const { result } = setup({
|
|
126
|
+
...defaultProps,
|
|
127
|
+
direction: 'both',
|
|
128
|
+
columnCount: 10,
|
|
129
|
+
columnWidth: 100,
|
|
130
|
+
});
|
|
131
|
+
expect(result.totalWidth.value).toBe(1000);
|
|
132
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle horizontal direction', async () => {
|
|
136
|
+
const { result } = setup({ ...defaultProps, direction: 'horizontal' });
|
|
137
|
+
expect(result.totalWidth.value).toBe(5000);
|
|
138
|
+
expect(result.totalHeight.value).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should cover default values for buffer and gap', async () => {
|
|
142
|
+
const { result } = setup({
|
|
143
|
+
items: mockItems,
|
|
144
|
+
itemSize: 50,
|
|
145
|
+
} as unknown as VirtualScrollProps<{ id: number; }>);
|
|
146
|
+
expect(result.renderedItems.value.length).toBeGreaterThan(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('range and rendered items', () => {
|
|
151
|
+
it('should calculate rendered items based on scroll position', async () => {
|
|
152
|
+
const { result } = setup({ ...defaultProps });
|
|
153
|
+
expect(result.renderedItems.value.length).toBeGreaterThan(0);
|
|
154
|
+
expect(result.scrollDetails.value.currentIndex).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should handle horizontal direction in range/renderedItems', async () => {
|
|
158
|
+
const { result } = setup({ ...defaultProps, direction: 'horizontal' });
|
|
159
|
+
expect(result.renderedItems.value.length).toBeGreaterThan(0);
|
|
160
|
+
expect(result.scrollDetails.value.currentIndex).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should handle horizontal non-fixed size range', async () => {
|
|
164
|
+
const container = document.createElement('div');
|
|
165
|
+
Object.defineProperty(container, 'clientWidth', { value: 500 });
|
|
166
|
+
Object.defineProperty(container, 'clientHeight', { value: 500 });
|
|
167
|
+
const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined, container });
|
|
168
|
+
for (let i = 0; i < 20; i++) {
|
|
169
|
+
result.updateItemSize(i, 50, 50);
|
|
170
|
+
}
|
|
171
|
+
await nextTick();
|
|
172
|
+
|
|
173
|
+
container.scrollLeft = 100;
|
|
174
|
+
container.dispatchEvent(new Event('scroll'));
|
|
175
|
+
await nextTick();
|
|
176
|
+
expect(result.scrollDetails.value.currentIndex).toBeGreaterThan(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should handle undefined items in renderedItems (out of bounds)', async () => {
|
|
180
|
+
const { result } = setup({ ...defaultProps, stickyIndices: [ 200 ] });
|
|
181
|
+
expect(result.renderedItems.value.find((i) => i.index === 200)).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should include sticky items in renderedItems only when relevant', async () => {
|
|
185
|
+
const { result } = setup({ ...defaultProps, stickyIndices: [ 50 ] });
|
|
186
|
+
// Initially at top, item 50 is far away and should NOT be in renderedItems
|
|
187
|
+
expect(result.renderedItems.value.find((i) => i.index === 50)).toBeUndefined();
|
|
188
|
+
|
|
189
|
+
// Scroll near item 50
|
|
190
|
+
result.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
|
|
191
|
+
await nextTick();
|
|
192
|
+
|
|
193
|
+
const item50 = result.renderedItems.value.find((i) => i.index === 50);
|
|
194
|
+
expect(item50).toBeDefined();
|
|
195
|
+
expect(item50!.isSticky).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('dynamic sizing and updateItemSize', () => {
|
|
200
|
+
it('should update item size and trigger reactivity', async () => {
|
|
201
|
+
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
202
|
+
expect(result.totalHeight.value).toBe(5000); // Default estimate
|
|
203
|
+
|
|
204
|
+
result.updateItemSize(0, 100, 100);
|
|
205
|
+
await nextTick();
|
|
206
|
+
expect(result.totalHeight.value).toBe(5050);
|
|
207
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should treat 0, null, undefined as dynamic itemSize', async () => {
|
|
211
|
+
for (const val of [ 0, null, undefined ]) {
|
|
212
|
+
const { result } = setup({ ...defaultProps, itemSize: val as unknown as undefined });
|
|
213
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
214
|
+
result.updateItemSize(0, 100, 100);
|
|
215
|
+
await nextTick();
|
|
216
|
+
expect(result.totalHeight.value).toBe(5050);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should treat 0, null, undefined as dynamic columnWidth', async () => {
|
|
221
|
+
for (const val of [ 0, null, undefined ]) {
|
|
222
|
+
const { result } = setup({
|
|
223
|
+
...defaultProps,
|
|
224
|
+
direction: 'both',
|
|
225
|
+
columnCount: 2,
|
|
226
|
+
columnWidth: val as unknown as undefined,
|
|
227
|
+
});
|
|
228
|
+
expect(result.getColumnWidth(0)).toBe(150);
|
|
229
|
+
const parent = document.createElement('div');
|
|
230
|
+
const col0 = document.createElement('div');
|
|
231
|
+
Object.defineProperty(col0, 'offsetWidth', { value: 200, configurable: true });
|
|
232
|
+
col0.dataset.colIndex = '0';
|
|
233
|
+
parent.appendChild(col0);
|
|
234
|
+
result.updateItemSize(0, 200, 50, parent);
|
|
235
|
+
await nextTick();
|
|
236
|
+
expect(result.totalWidth.value).toBe(350);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should handle dynamic column width with data-col-index', async () => {
|
|
241
|
+
const { result } = setup({
|
|
242
|
+
...defaultProps,
|
|
243
|
+
direction: 'both',
|
|
244
|
+
columnCount: 2,
|
|
245
|
+
columnWidth: undefined,
|
|
246
|
+
});
|
|
247
|
+
const parent = document.createElement('div');
|
|
248
|
+
const child1 = document.createElement('div');
|
|
249
|
+
Object.defineProperty(child1, 'offsetWidth', { value: 200 });
|
|
250
|
+
child1.dataset.colIndex = '0';
|
|
251
|
+
const child2 = document.createElement('div');
|
|
252
|
+
Object.defineProperty(child2, 'offsetWidth', { value: 300 });
|
|
253
|
+
child2.dataset.colIndex = '1';
|
|
254
|
+
parent.appendChild(child1);
|
|
255
|
+
parent.appendChild(child2);
|
|
256
|
+
|
|
257
|
+
result.updateItemSize(0, 500, 50, parent);
|
|
258
|
+
await nextTick();
|
|
259
|
+
expect(result.getColumnWidth(0)).toBe(200);
|
|
260
|
+
expect(result.getColumnWidth(1)).toBe(300);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should return early in updateItemSize if itemSize is fixed', async () => {
|
|
264
|
+
const { result } = setup({ ...defaultProps, itemSize: 50 });
|
|
265
|
+
result.updateItemSize(0, 100, 100);
|
|
266
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should use defaultItemSize and defaultColumnWidth when provided', () => {
|
|
270
|
+
const { result } = setup({
|
|
271
|
+
...defaultProps,
|
|
272
|
+
itemSize: undefined,
|
|
273
|
+
columnWidth: undefined,
|
|
274
|
+
defaultItemSize: 100,
|
|
275
|
+
defaultColumnWidth: 200,
|
|
276
|
+
direction: 'both',
|
|
277
|
+
columnCount: 10,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(result.totalHeight.value).toBe(100 * 100); // 100 items * 100 defaultItemSize
|
|
281
|
+
expect(result.totalWidth.value).toBe(10 * 200); // 10 columns * 200 defaultColumnWidth
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should ignore small delta updates in updateItemSize', async () => {
|
|
285
|
+
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
286
|
+
result.updateItemSize(0, 50.1, 50.1);
|
|
287
|
+
await nextTick();
|
|
288
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should not shrink item height in both mode encountered so far', async () => {
|
|
292
|
+
const { result } = setup({ ...defaultProps, direction: 'both', itemSize: undefined, columnCount: 2 });
|
|
293
|
+
result.updateItemSize(0, 100, 100);
|
|
294
|
+
await nextTick();
|
|
295
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
|
|
296
|
+
|
|
297
|
+
result.updateItemSize(0, 100, 80);
|
|
298
|
+
await nextTick();
|
|
299
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should update item height in vertical mode', async () => {
|
|
303
|
+
const { result } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
|
|
304
|
+
result.updateItemSize(0, 100, 100);
|
|
305
|
+
await nextTick();
|
|
306
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should handle updateItemSize for horizontal direction', async () => {
|
|
310
|
+
const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
|
|
311
|
+
result.updateItemSize(0, 100, 50);
|
|
312
|
+
await nextTick();
|
|
313
|
+
expect(result.totalWidth.value).toBe(5050);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should preserve measurements in initializeSizes when dynamic', async () => {
|
|
317
|
+
const { result, props } = setup({ ...defaultProps, itemSize: undefined });
|
|
318
|
+
result.updateItemSize(0, 100, 100);
|
|
319
|
+
await nextTick();
|
|
320
|
+
expect(result.totalHeight.value).toBe(5050);
|
|
321
|
+
|
|
322
|
+
// Trigger initializeSizes by changing length
|
|
323
|
+
props.value.items = Array.from({ length: 101 }, (_, i) => ({ id: i }));
|
|
324
|
+
await nextTick();
|
|
325
|
+
// Should still be 100 for index 0, not reset to default 50
|
|
326
|
+
expect(result.totalHeight.value).toBe(5050 + 50);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should track max dimensions in updateItemSize', async () => {
|
|
330
|
+
const { result } = setup({ ...defaultProps, direction: 'both', itemSize: undefined, columnCount: 2 });
|
|
331
|
+
// Initial maxWidth is 0 (since vertical direction didn't set it for X)
|
|
332
|
+
// Wait, in 'both' mode, initializeSizes sets it.
|
|
333
|
+
|
|
334
|
+
result.updateItemSize(0, 5000, 6000);
|
|
335
|
+
await nextTick();
|
|
336
|
+
// Should have hit maxWidth.value = width
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should cover spacer skip heuristic in updateItemSize', async () => {
|
|
340
|
+
const container = document.createElement('div');
|
|
341
|
+
Object.defineProperty(container, 'clientWidth', { value: 500 });
|
|
342
|
+
const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 2, itemSize: 0, columnWidth: 0, container });
|
|
343
|
+
await nextTick();
|
|
344
|
+
const parent = document.createElement('div');
|
|
345
|
+
const spacer = document.createElement('div');
|
|
346
|
+
Object.defineProperty(spacer, 'offsetWidth', { value: 1000 });
|
|
347
|
+
parent.appendChild(spacer);
|
|
348
|
+
result.updateItemSize(0, 100, 50, parent);
|
|
349
|
+
await nextTick();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should allow columns to shrink on first measurement', async () => {
|
|
353
|
+
const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 2, columnWidth: undefined });
|
|
354
|
+
// Default estimate is 150
|
|
355
|
+
expect(result.getColumnWidth(0)).toBe(150);
|
|
356
|
+
|
|
357
|
+
const parent = document.createElement('div');
|
|
358
|
+
const child = document.createElement('div');
|
|
359
|
+
Object.defineProperty(child, 'offsetWidth', { value: 100 });
|
|
360
|
+
child.dataset.colIndex = '0';
|
|
361
|
+
parent.appendChild(child);
|
|
362
|
+
|
|
363
|
+
// First measurement is 100
|
|
364
|
+
result.updateItemSize(0, 100, 50, parent);
|
|
365
|
+
await nextTick();
|
|
366
|
+
expect(result.getColumnWidth(0)).toBe(100);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should allow shrinking on first measurement', async () => {
|
|
370
|
+
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
371
|
+
// Default estimate is 50
|
|
372
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(50);
|
|
373
|
+
|
|
374
|
+
// First measurement is 20 (smaller than 50)
|
|
375
|
+
result.updateItemSize(0, 50, 20);
|
|
376
|
+
await nextTick();
|
|
377
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(20);
|
|
378
|
+
|
|
379
|
+
// Second measurement is 10 (smaller than 20) - should NOT shrink
|
|
380
|
+
result.updateItemSize(0, 50, 10);
|
|
381
|
+
await nextTick();
|
|
382
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(20);
|
|
383
|
+
|
|
384
|
+
// Third measurement is 30 (larger than 20) - SHOULD grow
|
|
385
|
+
result.updateItemSize(0, 50, 30);
|
|
386
|
+
await nextTick();
|
|
387
|
+
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(30);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should handle cells querySelector in updateItemSizes', async () => {
|
|
391
|
+
const { result } = setup({
|
|
392
|
+
...defaultProps,
|
|
393
|
+
direction: 'both',
|
|
394
|
+
columnCount: 2,
|
|
395
|
+
columnWidth: undefined,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const parent = document.createElement('div');
|
|
399
|
+
const child1 = document.createElement('div');
|
|
400
|
+
Object.defineProperty(child1, 'offsetWidth', { value: 200 });
|
|
401
|
+
child1.dataset.colIndex = '0';
|
|
402
|
+
const child2 = document.createElement('div');
|
|
403
|
+
Object.defineProperty(child2, 'offsetWidth', { value: 300 });
|
|
404
|
+
child2.dataset.colIndex = '1';
|
|
405
|
+
|
|
406
|
+
parent.appendChild(child1);
|
|
407
|
+
parent.appendChild(child2);
|
|
408
|
+
|
|
409
|
+
result.updateItemSizes([ { index: 0, inlineSize: 500, blockSize: 50, element: parent } ]);
|
|
410
|
+
await nextTick();
|
|
411
|
+
expect(result.getColumnWidth(0)).toBe(200);
|
|
412
|
+
expect(result.getColumnWidth(1)).toBe(300);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('scroll and offsets', () => {
|
|
417
|
+
it('should handle scrollToIndex out of bounds', async () => {
|
|
418
|
+
const { result } = setup({ ...defaultProps });
|
|
419
|
+
// Row past end
|
|
420
|
+
result.scrollToIndex(mockItems.length + 10, 0);
|
|
421
|
+
await nextTick();
|
|
422
|
+
expect(window.scrollTo).toHaveBeenCalled();
|
|
423
|
+
|
|
424
|
+
// Col past end (in grid mode)
|
|
425
|
+
const { result: r_grid } = setup({ ...defaultProps, direction: 'both', columnCount: 5, columnWidth: 100 });
|
|
426
|
+
r_grid.scrollToIndex(0, 10);
|
|
427
|
+
await nextTick();
|
|
428
|
+
expect(window.scrollTo).toHaveBeenCalled();
|
|
429
|
+
|
|
430
|
+
// Column past end in horizontal mode
|
|
431
|
+
const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal' });
|
|
432
|
+
r_horiz.scrollToIndex(0, 200);
|
|
433
|
+
await nextTick();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('should handle scrollToIndex auto alignment with padding', async () => {
|
|
437
|
+
const container = document.createElement('div');
|
|
438
|
+
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
439
|
+
Object.defineProperty(container, 'scrollTop', { value: 200, writable: true, configurable: true });
|
|
440
|
+
|
|
441
|
+
const { result } = setup({ ...defaultProps, container, itemSize: 50, scrollPaddingStart: 100 });
|
|
442
|
+
await nextTick();
|
|
443
|
+
|
|
444
|
+
// Current visible range: [scrollTop + paddingStart, scrollTop + viewport - paddingEnd] = [300, 700]
|
|
445
|
+
// Scroll to item at y=250. 250 < 300, so not visible.
|
|
446
|
+
// targetY < relativeScrollY + paddingStart (250 < 200 + 100) -> hit line 729
|
|
447
|
+
result.scrollToIndex(5, null, 'auto');
|
|
448
|
+
await nextTick();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should hit scrollToIndex X calculation branches', async () => {
|
|
452
|
+
const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', itemSize: 100 });
|
|
453
|
+
await nextTick();
|
|
454
|
+
// colIndex null
|
|
455
|
+
r_horiz.scrollToIndex(0, null);
|
|
456
|
+
// rowIndex null
|
|
457
|
+
r_horiz.scrollToIndex(null, 5);
|
|
458
|
+
await nextTick();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('should handle scrollToOffset with element container and scrollTo method', async () => {
|
|
462
|
+
const container = document.createElement('div');
|
|
463
|
+
container.scrollTo = vi.fn();
|
|
464
|
+
const { result } = setup({ ...defaultProps, container });
|
|
465
|
+
result.scrollToOffset(100, 200);
|
|
466
|
+
expect(container.scrollTo).toHaveBeenCalled();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should handle scrollToIndex with null indices', async () => {
|
|
470
|
+
const { result } = setup({ ...defaultProps });
|
|
471
|
+
result.scrollToIndex(null, null);
|
|
472
|
+
await nextTick();
|
|
473
|
+
result.scrollToIndex(10, null);
|
|
474
|
+
await nextTick();
|
|
475
|
+
result.scrollToIndex(null, 10);
|
|
476
|
+
await nextTick();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should handle scrollToIndex auto alignment', async () => {
|
|
480
|
+
const container = document.createElement('div');
|
|
481
|
+
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
482
|
+
Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
|
|
483
|
+
|
|
484
|
+
const { result } = setup({ ...defaultProps, container, itemSize: 50 });
|
|
485
|
+
await nextTick();
|
|
486
|
+
|
|
487
|
+
// Scroll down so some items are above
|
|
488
|
+
result.scrollToIndex(20, 0, 'start');
|
|
489
|
+
await nextTick();
|
|
490
|
+
|
|
491
|
+
// Auto align: already visible
|
|
492
|
+
result.scrollToIndex(20, null, 'auto');
|
|
493
|
+
await nextTick();
|
|
494
|
+
|
|
495
|
+
// Auto align: above viewport (scroll up)
|
|
496
|
+
result.scrollToIndex(5, null, 'auto');
|
|
497
|
+
await nextTick();
|
|
498
|
+
|
|
499
|
+
// Auto align: below viewport (scroll down)
|
|
500
|
+
result.scrollToIndex(50, null, 'auto');
|
|
501
|
+
await nextTick();
|
|
502
|
+
|
|
503
|
+
// Horizontal auto align
|
|
504
|
+
const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', container, itemSize: 100 });
|
|
505
|
+
await nextTick();
|
|
506
|
+
|
|
507
|
+
r_horiz.scrollToIndex(0, 20, 'start');
|
|
508
|
+
await nextTick();
|
|
509
|
+
|
|
510
|
+
r_horiz.scrollToIndex(null, 5, 'auto');
|
|
511
|
+
await nextTick();
|
|
512
|
+
|
|
513
|
+
r_horiz.scrollToIndex(null, 50, 'auto');
|
|
514
|
+
await nextTick();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should handle scrollToIndex with various alignments', async () => {
|
|
518
|
+
const { result } = setup({ ...defaultProps });
|
|
519
|
+
result.scrollToIndex(50, 0, 'center');
|
|
520
|
+
await nextTick();
|
|
521
|
+
result.scrollToIndex(50, 0, 'end');
|
|
522
|
+
await nextTick();
|
|
523
|
+
result.scrollToIndex(50, 0, { x: 'center', y: 'end' });
|
|
524
|
+
await nextTick();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('should handle scrollToOffset with window container', async () => {
|
|
528
|
+
const { result } = setup({ ...defaultProps, container: window });
|
|
529
|
+
result.scrollToOffset(null, 100);
|
|
530
|
+
expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 100 }));
|
|
531
|
+
|
|
532
|
+
result.scrollToOffset(null, 200, { behavior: 'smooth' });
|
|
533
|
+
expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200, behavior: 'smooth' }));
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should handle scrollToIndex with auto alignment and axis preservation', async () => {
|
|
537
|
+
const { result } = setup({ ...defaultProps });
|
|
538
|
+
// Axis preservation (null index)
|
|
539
|
+
result.scrollToIndex(10, null, 'auto');
|
|
540
|
+
await nextTick();
|
|
541
|
+
result.scrollToIndex(null, 5, 'auto');
|
|
542
|
+
await nextTick();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('should handle scrollToOffset with nulls to keep current position', async () => {
|
|
546
|
+
const { result } = setup({ ...defaultProps, container: window });
|
|
547
|
+
window.scrollX = 50;
|
|
548
|
+
window.scrollY = 60;
|
|
549
|
+
|
|
550
|
+
// Pass null to keep current Y while updating X
|
|
551
|
+
result.scrollToOffset(100, null);
|
|
552
|
+
expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 }));
|
|
553
|
+
|
|
554
|
+
// Pass null to keep current X while updating Y
|
|
555
|
+
result.scrollToOffset(null, 200);
|
|
556
|
+
expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 }));
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should handle scrollToOffset with both axes', async () => {
|
|
560
|
+
const { result } = setup({ ...defaultProps });
|
|
561
|
+
result.scrollToOffset(100, 200);
|
|
562
|
+
expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100, top: 200 }));
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('should handle scrollToOffset fallback when scrollTo is missing', async () => {
|
|
566
|
+
const container = document.createElement('div');
|
|
567
|
+
(container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
|
|
568
|
+
const { result } = setup({ ...defaultProps, container });
|
|
569
|
+
|
|
570
|
+
result.scrollToOffset(100, 200);
|
|
571
|
+
expect(container.scrollTop).toBe(200);
|
|
572
|
+
expect(container.scrollLeft).toBe(100);
|
|
573
|
+
|
|
574
|
+
// X only
|
|
575
|
+
result.scrollToOffset(300, null);
|
|
576
|
+
expect(container.scrollLeft).toBe(300);
|
|
577
|
+
|
|
578
|
+
// Y only
|
|
579
|
+
result.scrollToOffset(null, 400);
|
|
580
|
+
expect(container.scrollTop).toBe(400);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('should clear pendingScroll when reached', async () => {
|
|
584
|
+
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
585
|
+
result.scrollToIndex(10, 0, { isCorrection: true });
|
|
586
|
+
await nextTick();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('should cover scrollToIndex row >= length branch', async () => {
|
|
590
|
+
const { result } = setup({ ...defaultProps });
|
|
591
|
+
result.scrollToIndex(200, null);
|
|
592
|
+
await nextTick();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('should handle scrollToIndex horizontal alignment branches', async () => {
|
|
596
|
+
const container = document.createElement('div');
|
|
597
|
+
Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
|
|
598
|
+
Object.defineProperty(container, 'scrollLeft', { value: 1000, writable: true, configurable: true });
|
|
599
|
+
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
600
|
+
container.scrollLeft = options.left;
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const { result } = setup({ ...defaultProps, direction: 'horizontal', container, itemSize: 50, scrollPaddingStart: 100 });
|
|
604
|
+
await nextTick();
|
|
605
|
+
|
|
606
|
+
// targetX = 5 * 50 = 250. relativeScrollX = 1000. paddingStart = 100.
|
|
607
|
+
// targetX < relativeScrollX + paddingStart (250 < 1100)
|
|
608
|
+
result.scrollToIndex(null, 5, 'auto');
|
|
609
|
+
await nextTick();
|
|
610
|
+
expect(container.scrollLeft).toBeLessThan(1000);
|
|
611
|
+
|
|
612
|
+
// End alignment
|
|
613
|
+
result.scrollToIndex(null, 5, 'end');
|
|
614
|
+
await nextTick();
|
|
615
|
+
|
|
616
|
+
// Center alignment
|
|
617
|
+
result.scrollToIndex(null, 5, 'center');
|
|
618
|
+
await nextTick();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('should only apply scrollPaddingStart to Y axis in "both" mode if it is a number', async () => {
|
|
622
|
+
setup({ ...defaultProps, direction: 'both', scrollPaddingStart: 10 });
|
|
623
|
+
await nextTick();
|
|
624
|
+
// Y padding should be 10, X padding should be 0
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('should stop programmatic scroll', async () => {
|
|
628
|
+
const { result } = setup(defaultProps);
|
|
629
|
+
result.scrollToIndex(10, null, { behavior: 'smooth' });
|
|
630
|
+
expect(result.scrollDetails.value.isProgrammaticScroll).toBe(true);
|
|
631
|
+
|
|
632
|
+
result.stopProgrammaticScroll();
|
|
633
|
+
expect(result.scrollDetails.value.isProgrammaticScroll).toBe(false);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
describe('event handling and viewport', () => {
|
|
638
|
+
it('should handle window scroll events', async () => {
|
|
639
|
+
setup({ ...defaultProps });
|
|
640
|
+
window.scrollX = 150;
|
|
641
|
+
window.scrollY = 250;
|
|
642
|
+
window.dispatchEvent(new Event('scroll'));
|
|
643
|
+
await nextTick();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('should handle document scroll events', async () => {
|
|
647
|
+
setup({ ...defaultProps });
|
|
648
|
+
document.dispatchEvent(new Event('scroll'));
|
|
649
|
+
await nextTick();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should handle scroll events on container element', async () => {
|
|
653
|
+
const container = document.createElement('div');
|
|
654
|
+
setup({ ...defaultProps, container });
|
|
655
|
+
Object.defineProperty(container, 'scrollTop', { value: 100 });
|
|
656
|
+
container.dispatchEvent(new Event('scroll'));
|
|
657
|
+
await nextTick();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('should update viewport size on container resize', async () => {
|
|
661
|
+
const container = document.createElement('div');
|
|
662
|
+
Object.defineProperty(container, 'clientWidth', { value: 500, writable: true });
|
|
663
|
+
Object.defineProperty(container, 'clientHeight', { value: 500, writable: true });
|
|
664
|
+
const { result } = setup({ ...defaultProps, container });
|
|
665
|
+
await nextTick();
|
|
666
|
+
expect(result.scrollDetails.value.viewportSize.width).toBe(500);
|
|
667
|
+
|
|
668
|
+
Object.defineProperty(container, 'clientWidth', { value: 800 });
|
|
669
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(container));
|
|
670
|
+
if (observer) {
|
|
671
|
+
observer.trigger([ { target: container } ]);
|
|
672
|
+
}
|
|
673
|
+
await nextTick();
|
|
674
|
+
expect(result.scrollDetails.value.viewportSize.width).toBe(800);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('should handle isScrolling timeout', async () => {
|
|
678
|
+
vi.useFakeTimers();
|
|
679
|
+
const container = document.createElement('div');
|
|
680
|
+
const { result } = setup({ ...defaultProps, container });
|
|
681
|
+
container.dispatchEvent(new Event('scroll'));
|
|
682
|
+
await nextTick();
|
|
683
|
+
expect(result.scrollDetails.value.isScrolling).toBe(true);
|
|
684
|
+
vi.advanceTimersByTime(250);
|
|
685
|
+
await nextTick();
|
|
686
|
+
expect(result.scrollDetails.value.isScrolling).toBe(false);
|
|
687
|
+
vi.useRealTimers();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('should handle container change in mount watcher', async () => {
|
|
691
|
+
const { props } = setup({ ...defaultProps });
|
|
692
|
+
await nextTick();
|
|
693
|
+
props.value.container = null;
|
|
694
|
+
await nextTick();
|
|
695
|
+
props.value.container = window;
|
|
696
|
+
await nextTick();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('should handle window resize events', async () => {
|
|
700
|
+
setup({ ...defaultProps, container: window });
|
|
701
|
+
window.innerWidth = 1200;
|
|
702
|
+
window.dispatchEvent(new Event('resize'));
|
|
703
|
+
await nextTick();
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('should cover handleScroll with document target', async () => {
|
|
707
|
+
setup({ ...defaultProps, container: window });
|
|
708
|
+
document.dispatchEvent(new Event('scroll'));
|
|
709
|
+
await nextTick();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('should handle undefined window in handleScroll', async () => {
|
|
713
|
+
const originalWindow = globalThis.window;
|
|
714
|
+
const container = document.createElement('div');
|
|
715
|
+
setup({ ...defaultProps, container });
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
(globalThis as unknown as { window: unknown; }).window = undefined;
|
|
719
|
+
container.dispatchEvent(new Event('scroll'));
|
|
720
|
+
await nextTick();
|
|
721
|
+
} finally {
|
|
722
|
+
globalThis.window = originalWindow;
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
describe('column widths and ranges', () => {
|
|
728
|
+
it('should handle columnWidth as an array', async () => {
|
|
729
|
+
const { result } = setup({
|
|
730
|
+
...defaultProps,
|
|
731
|
+
direction: 'both',
|
|
732
|
+
columnCount: 4,
|
|
733
|
+
columnWidth: [ 100, 200 ],
|
|
734
|
+
});
|
|
735
|
+
expect(result.getColumnWidth(0)).toBe(100);
|
|
736
|
+
expect(result.getColumnWidth(1)).toBe(200);
|
|
737
|
+
expect(result.totalWidth.value).toBe(600);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('should handle columnWidth array fallback for falsy values', async () => {
|
|
741
|
+
const { result } = setup({
|
|
742
|
+
...defaultProps,
|
|
743
|
+
direction: 'both',
|
|
744
|
+
columnCount: 2,
|
|
745
|
+
columnWidth: [ 0 ] as unknown as number[],
|
|
746
|
+
});
|
|
747
|
+
expect(result.getColumnWidth(0)).toBe(150); // DEFAULT_COLUMN_WIDTH
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('should handle columnWidth as a function', async () => {
|
|
751
|
+
const { result } = setup({
|
|
752
|
+
...defaultProps,
|
|
753
|
+
direction: 'both',
|
|
754
|
+
columnCount: 10,
|
|
755
|
+
columnWidth: (index: number) => (index % 2 === 0 ? 100 : 200),
|
|
756
|
+
});
|
|
757
|
+
expect(result.getColumnWidth(0)).toBe(100);
|
|
758
|
+
expect(result.totalWidth.value).toBe(1500);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('should handle getColumnWidth fallback when dynamic', async () => {
|
|
762
|
+
const { result } = setup({
|
|
763
|
+
...defaultProps,
|
|
764
|
+
direction: 'both',
|
|
765
|
+
columnCount: 2,
|
|
766
|
+
columnWidth: undefined,
|
|
767
|
+
});
|
|
768
|
+
expect(result.getColumnWidth(0)).toBe(150);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('should handle columnRange while loop coverage', async () => {
|
|
772
|
+
const container = document.createElement('div');
|
|
773
|
+
const { result } = setup({
|
|
774
|
+
...defaultProps,
|
|
775
|
+
direction: 'both',
|
|
776
|
+
columnCount: 50,
|
|
777
|
+
columnWidth: undefined,
|
|
778
|
+
container,
|
|
779
|
+
});
|
|
780
|
+
// Initialize some column widths
|
|
781
|
+
for (let i = 0; i < 20; i++) {
|
|
782
|
+
const parent = document.createElement('div');
|
|
783
|
+
const child = document.createElement('div');
|
|
784
|
+
Object.defineProperty(child, 'offsetWidth', { value: 100 });
|
|
785
|
+
child.dataset.colIndex = String(i);
|
|
786
|
+
parent.appendChild(child);
|
|
787
|
+
result.updateItemSize(0, 100, 50, parent);
|
|
788
|
+
}
|
|
789
|
+
await nextTick();
|
|
790
|
+
expect(result.columnRange.value.end).toBeGreaterThan(result.columnRange.value.start);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('should handle zero column count', async () => {
|
|
794
|
+
const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 0 });
|
|
795
|
+
await nextTick();
|
|
796
|
+
expect(result.columnRange.value.end).toBe(0);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('should cover columnRange safeStart clamp', async () => {
|
|
800
|
+
const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 10, columnWidth: 100 });
|
|
801
|
+
await nextTick();
|
|
802
|
+
expect(result.columnRange.value.start).toBe(0);
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
describe('lifecycle and logic branches', () => {
|
|
807
|
+
it('should trigger scroll correction when isScrolling becomes false', async () => {
|
|
808
|
+
vi.useFakeTimers();
|
|
809
|
+
const { result } = setup({ ...defaultProps, container: window, itemSize: undefined });
|
|
810
|
+
await nextTick();
|
|
811
|
+
result.scrollToIndex(10, 0, 'start');
|
|
812
|
+
document.dispatchEvent(new Event('scroll'));
|
|
813
|
+
expect(result.scrollDetails.value.isScrolling).toBe(true);
|
|
814
|
+
vi.advanceTimersByTime(250);
|
|
815
|
+
await nextTick();
|
|
816
|
+
expect(result.scrollDetails.value.isScrolling).toBe(false);
|
|
817
|
+
vi.useRealTimers();
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('should trigger scroll correction when treeUpdateFlag changes', async () => {
|
|
821
|
+
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
822
|
+
await nextTick();
|
|
823
|
+
result.scrollToIndex(10, 0, 'start');
|
|
824
|
+
// Trigger tree update
|
|
825
|
+
result.updateItemSize(5, 100, 100);
|
|
826
|
+
await nextTick();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('should cover updateHostOffset when container is window', async () => {
|
|
830
|
+
const { result, props } = setup({ ...defaultProps, container: window });
|
|
831
|
+
const host = document.createElement('div');
|
|
832
|
+
props.value.hostElement = host;
|
|
833
|
+
await nextTick();
|
|
834
|
+
result.updateHostOffset();
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('should cover updateHostOffset when container is hostElement', async () => {
|
|
838
|
+
const host = document.createElement('div');
|
|
839
|
+
const { result } = setup({ ...defaultProps, container: host, hostElement: host });
|
|
840
|
+
await nextTick();
|
|
841
|
+
result.updateHostOffset();
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('should correctly calculate hostOffset when container is an HTMLElement', async () => {
|
|
845
|
+
const container = document.createElement('div');
|
|
846
|
+
const hostElement = document.createElement('div');
|
|
847
|
+
|
|
848
|
+
container.getBoundingClientRect = vi.fn(() => ({ top: 100, left: 100, bottom: 200, right: 200, width: 100, height: 100, x: 100, y: 100, toJSON: () => '' }));
|
|
849
|
+
hostElement.getBoundingClientRect = vi.fn(() => ({ top: 150, left: 150, bottom: 200, right: 200, width: 50, height: 50, x: 150, y: 150, toJSON: () => '' }));
|
|
850
|
+
Object.defineProperty(container, 'scrollTop', { value: 50, writable: true, configurable: true });
|
|
851
|
+
|
|
852
|
+
const { result } = setup({ ...defaultProps, container, hostElement });
|
|
853
|
+
await nextTick();
|
|
854
|
+
result.updateHostOffset();
|
|
855
|
+
expect(result.scrollDetails.value.scrollOffset.y).toBeDefined();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('should cover refresh method', async () => {
|
|
859
|
+
const { result } = setup({ ...defaultProps, itemSize: 0 });
|
|
860
|
+
result.updateItemSize(0, 100, 100);
|
|
861
|
+
await nextTick();
|
|
862
|
+
expect(result.totalHeight.value).toBe(5050);
|
|
863
|
+
|
|
864
|
+
result.refresh();
|
|
865
|
+
await nextTick();
|
|
866
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
describe('sticky header pushing', () => {
|
|
871
|
+
it('should push sticky item when next sticky item approaches (vertical)', async () => {
|
|
872
|
+
const container = document.createElement('div');
|
|
873
|
+
Object.defineProperty(container, 'clientHeight', { value: 500 });
|
|
874
|
+
Object.defineProperty(container, 'scrollTop', { value: 480, writable: true });
|
|
875
|
+
const { result } = setup({ ...defaultProps, container, stickyIndices: [ 0, 10 ], itemSize: 50 });
|
|
876
|
+
// We need to trigger scroll to update scrollY
|
|
877
|
+
container.dispatchEvent(new Event('scroll'));
|
|
878
|
+
await nextTick();
|
|
879
|
+
|
|
880
|
+
const item0 = result.renderedItems.value.find((i) => i.index === 0);
|
|
881
|
+
expect(item0!.offset.y).toBeLessThanOrEqual(450);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should push sticky item when next sticky item approaches (horizontal)', async () => {
|
|
885
|
+
const container = document.createElement('div');
|
|
886
|
+
Object.defineProperty(container, 'clientWidth', { value: 500 });
|
|
887
|
+
Object.defineProperty(container, 'scrollLeft', { value: 480, writable: true });
|
|
888
|
+
|
|
889
|
+
const { result } = setup({
|
|
890
|
+
...defaultProps,
|
|
891
|
+
direction: 'horizontal',
|
|
892
|
+
container,
|
|
893
|
+
stickyIndices: [ 0, 10 ],
|
|
894
|
+
itemSize: 50,
|
|
895
|
+
columnGap: 0,
|
|
896
|
+
});
|
|
897
|
+
container.dispatchEvent(new Event('scroll'));
|
|
898
|
+
await nextTick();
|
|
899
|
+
|
|
900
|
+
const item0 = result.renderedItems.value.find((i) => i.index === 0);
|
|
901
|
+
expect(item0!.offset.x).toBeLessThanOrEqual(450);
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
describe('scroll restoration', () => {
|
|
906
|
+
it('should restore scroll position when items are prepended', async () => {
|
|
907
|
+
vi.useFakeTimers();
|
|
908
|
+
const container = document.createElement('div');
|
|
909
|
+
Object.defineProperty(container, 'clientHeight', { value: 500 });
|
|
910
|
+
Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
|
|
911
|
+
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
912
|
+
container.scrollTop = options.top;
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
|
916
|
+
const { result, props } = setup({
|
|
917
|
+
...defaultProps,
|
|
918
|
+
items,
|
|
919
|
+
container,
|
|
920
|
+
itemSize: 50,
|
|
921
|
+
restoreScrollOnPrepend: true,
|
|
922
|
+
});
|
|
923
|
+
container.dispatchEvent(new Event('scroll'));
|
|
924
|
+
await nextTick();
|
|
925
|
+
|
|
926
|
+
expect(result.scrollDetails.value.scrollOffset.y).toBe(100);
|
|
927
|
+
|
|
928
|
+
// Prepend 2 items
|
|
929
|
+
const newItems = [ { id: -1 }, { id: -2 }, ...items ];
|
|
930
|
+
props.value.items = newItems;
|
|
931
|
+
await nextTick();
|
|
932
|
+
// Trigger initializeSizes
|
|
933
|
+
await nextTick();
|
|
934
|
+
|
|
935
|
+
// Should have adjusted scroll by 2 * 50 = 100px. New scrollTop should be 200.
|
|
936
|
+
expect(container.scrollTop).toBe(200);
|
|
937
|
+
vi.useRealTimers();
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('should restore scroll position when items are prepended (horizontal)', async () => {
|
|
941
|
+
vi.useFakeTimers();
|
|
942
|
+
const container = document.createElement('div');
|
|
943
|
+
Object.defineProperty(container, 'clientWidth', { value: 500 });
|
|
944
|
+
Object.defineProperty(container, 'scrollLeft', { value: 100, writable: true });
|
|
945
|
+
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
946
|
+
container.scrollLeft = options.left;
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
|
950
|
+
const { result, props } = setup({
|
|
951
|
+
...defaultProps,
|
|
952
|
+
direction: 'horizontal',
|
|
953
|
+
items,
|
|
954
|
+
container,
|
|
955
|
+
itemSize: 50,
|
|
956
|
+
restoreScrollOnPrepend: true,
|
|
957
|
+
});
|
|
958
|
+
container.dispatchEvent(new Event('scroll'));
|
|
959
|
+
await nextTick();
|
|
960
|
+
|
|
961
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
|
|
962
|
+
|
|
963
|
+
// Prepend 2 items
|
|
964
|
+
const newItems = [ { id: -1 }, { id: -2 }, ...items ];
|
|
965
|
+
props.value.items = newItems;
|
|
966
|
+
await nextTick();
|
|
967
|
+
await nextTick();
|
|
968
|
+
|
|
969
|
+
expect(container.scrollLeft).toBe(200);
|
|
970
|
+
vi.useRealTimers();
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('should NOT restore scroll position when restoreScrollOnPrepend is false', async () => {
|
|
974
|
+
const container = document.createElement('div');
|
|
975
|
+
Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
|
|
976
|
+
const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
|
977
|
+
const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: false });
|
|
978
|
+
await nextTick();
|
|
979
|
+
|
|
980
|
+
const newItems = [ { id: -1 }, ...items ];
|
|
981
|
+
props.value.items = newItems;
|
|
982
|
+
await nextTick();
|
|
983
|
+
await nextTick();
|
|
984
|
+
expect(container.scrollTop).toBe(100);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('should NOT restore scroll position when first item does not match', async () => {
|
|
988
|
+
const container = document.createElement('div');
|
|
989
|
+
Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
|
|
990
|
+
const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
|
991
|
+
const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: true });
|
|
992
|
+
await nextTick();
|
|
993
|
+
|
|
994
|
+
const newItems = [ { id: -1 }, { id: 9999 } ]; // completely different
|
|
995
|
+
props.value.items = newItems;
|
|
996
|
+
await nextTick();
|
|
997
|
+
await nextTick();
|
|
998
|
+
expect(container.scrollTop).toBe(100);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('should update pendingScroll rowIndex when items are prepended', async () => {
|
|
1002
|
+
const container = document.createElement('div');
|
|
1003
|
+
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
1004
|
+
Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
|
|
1005
|
+
const { result, props } = setup({ ...defaultProps, container, restoreScrollOnPrepend: true });
|
|
1006
|
+
result.scrollToIndex(10, null, { behavior: 'smooth' });
|
|
1007
|
+
// pendingScroll should be set because it's not reached yet
|
|
1008
|
+
|
|
1009
|
+
props.value.items = [ { id: -1 }, ...props.value.items ];
|
|
1010
|
+
await nextTick();
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it('should handle updateItemSizes for horizontal direction', async () => {
|
|
1014
|
+
const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
|
|
1015
|
+
result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 50 } ]);
|
|
1016
|
+
await nextTick();
|
|
1017
|
+
expect(result.totalWidth.value).toBe(5050);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it('should trigger scroll correction on tree update with pending scroll', async () => {
|
|
1021
|
+
const container = document.createElement('div');
|
|
1022
|
+
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
1023
|
+
Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
|
|
1024
|
+
const { result } = setup({ ...defaultProps, container, itemSize: undefined });
|
|
1025
|
+
// Set a pending scroll
|
|
1026
|
+
result.scrollToIndex(10, null, { behavior: 'smooth' });
|
|
1027
|
+
|
|
1028
|
+
// Trigger tree update
|
|
1029
|
+
result.updateItemSize(0, 100, 100);
|
|
1030
|
+
await nextTick();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it('should trigger scroll correction when scrolling stops with pending scroll', async () => {
|
|
1034
|
+
vi.useFakeTimers();
|
|
1035
|
+
const container = document.createElement('div');
|
|
1036
|
+
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
1037
|
+
Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
|
|
1038
|
+
const { result } = setup({ ...defaultProps, container, itemSize: undefined });
|
|
1039
|
+
result.scrollToIndex(10, null, { behavior: 'smooth' });
|
|
1040
|
+
|
|
1041
|
+
// Start scrolling
|
|
1042
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1043
|
+
await nextTick();
|
|
1044
|
+
expect(result.scrollDetails.value.isScrolling).toBe(true);
|
|
1045
|
+
|
|
1046
|
+
// Wait for scroll timeout
|
|
1047
|
+
vi.advanceTimersByTime(250);
|
|
1048
|
+
await nextTick();
|
|
1049
|
+
expect(result.scrollDetails.value.isScrolling).toBe(false);
|
|
1050
|
+
vi.useRealTimers();
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// eslint-disable-next-line test/prefer-lowercase-title
|
|
1055
|
+
describe('SSR support', () => {
|
|
1056
|
+
it('should handle SSR range in range calculation', () => {
|
|
1057
|
+
const props = ref({
|
|
1058
|
+
items: mockItems,
|
|
1059
|
+
ssrRange: { start: 0, end: 10 },
|
|
1060
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1061
|
+
const result = useVirtualScroll(props);
|
|
1062
|
+
expect(result.renderedItems.value.length).toBe(10);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('should handle SSR range in columnRange calculation', () => {
|
|
1066
|
+
const props = ref({
|
|
1067
|
+
items: mockItems,
|
|
1068
|
+
columnCount: 10,
|
|
1069
|
+
ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
|
|
1070
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1071
|
+
const result = useVirtualScroll(props);
|
|
1072
|
+
expect(result.columnRange.value.end).toBe(5);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it('should handle SSR range with both directions for total sizes', () => {
|
|
1076
|
+
const props = ref({
|
|
1077
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1078
|
+
direction: 'both',
|
|
1079
|
+
columnCount: 10,
|
|
1080
|
+
columnWidth: 100,
|
|
1081
|
+
itemSize: 50,
|
|
1082
|
+
ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
|
|
1083
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1084
|
+
const result = useVirtualScroll(props);
|
|
1085
|
+
expect(result.totalWidth.value).toBe(300); // (5-2) * 100
|
|
1086
|
+
expect(result.totalHeight.value).toBe(500); // (20-10) * 50
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('should handle SSR range with horizontal direction for total sizes', () => {
|
|
1090
|
+
const props = ref({
|
|
1091
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1092
|
+
direction: 'horizontal',
|
|
1093
|
+
itemSize: 50,
|
|
1094
|
+
ssrRange: { start: 10, end: 20 },
|
|
1095
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1096
|
+
const result = useVirtualScroll(props);
|
|
1097
|
+
expect(result.totalWidth.value).toBe(500); // (20-10) * 50
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('should handle SSR range with fixed size horizontal for total sizes', () => {
|
|
1101
|
+
const props = ref({
|
|
1102
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1103
|
+
direction: 'horizontal',
|
|
1104
|
+
itemSize: 50,
|
|
1105
|
+
ssrRange: { start: 10, end: 20 },
|
|
1106
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1107
|
+
const result = useVirtualScroll(props);
|
|
1108
|
+
expect(result.totalWidth.value).toBe(500); // (20-10) * 50
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it('should handle SSR range with vertical offset in renderedItems', () => {
|
|
1112
|
+
const props = ref({
|
|
1113
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1114
|
+
direction: 'vertical',
|
|
1115
|
+
itemSize: 50,
|
|
1116
|
+
ssrRange: { start: 10, end: 20 },
|
|
1117
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1118
|
+
const result = useVirtualScroll(props);
|
|
1119
|
+
expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it('should handle SSR range with dynamic sizes for total sizes', () => {
|
|
1123
|
+
const props = ref({
|
|
1124
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1125
|
+
direction: 'vertical',
|
|
1126
|
+
itemSize: 0,
|
|
1127
|
+
ssrRange: { start: 10, end: 20 },
|
|
1128
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1129
|
+
const result = useVirtualScroll(props);
|
|
1130
|
+
expect(result.totalHeight.value).toBe(500);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('should handle SSR range with dynamic horizontal sizes for total sizes', () => {
|
|
1134
|
+
const props = ref({
|
|
1135
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1136
|
+
direction: 'horizontal',
|
|
1137
|
+
itemSize: 0,
|
|
1138
|
+
ssrRange: { start: 10, end: 20 },
|
|
1139
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1140
|
+
const result = useVirtualScroll(props);
|
|
1141
|
+
expect(result.totalWidth.value).toBe(500);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it('should handle SSR range with both directions and dynamic offsets', () => {
|
|
1145
|
+
const props = ref({
|
|
1146
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1147
|
+
direction: 'both',
|
|
1148
|
+
columnCount: 10,
|
|
1149
|
+
itemSize: 0,
|
|
1150
|
+
ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
|
|
1151
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1152
|
+
const result = useVirtualScroll(props);
|
|
1153
|
+
expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
|
|
1154
|
+
expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-300);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it('should scroll to ssrRange on mount', async () => {
|
|
1158
|
+
setup({ ...defaultProps, ssrRange: { start: 50, end: 60 } });
|
|
1159
|
+
await nextTick();
|
|
1160
|
+
expect(window.scrollTo).toHaveBeenCalled();
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
it('should handle SSR range with horizontal direction and colStart', () => {
|
|
1164
|
+
const props = ref({
|
|
1165
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1166
|
+
direction: 'horizontal',
|
|
1167
|
+
itemSize: 50,
|
|
1168
|
+
ssrRange: { start: 0, end: 10, colStart: 5 },
|
|
1169
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1170
|
+
const result = useVirtualScroll(props);
|
|
1171
|
+
expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-250);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it('should handle SSR range with direction "both" and colStart', () => {
|
|
1175
|
+
const props = ref({
|
|
1176
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1177
|
+
direction: 'both',
|
|
1178
|
+
columnCount: 20,
|
|
1179
|
+
columnWidth: 100,
|
|
1180
|
+
ssrRange: { start: 0, end: 10, colStart: 5, colEnd: 15 },
|
|
1181
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1182
|
+
const result = useVirtualScroll(props);
|
|
1183
|
+
// ssrOffsetX = columnSizes.query(5) = 5 * 100 = 500
|
|
1184
|
+
expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-500);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it('should handle SSR range with colCount > 0 in totalWidth', () => {
|
|
1188
|
+
const props = ref({
|
|
1189
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1190
|
+
direction: 'both',
|
|
1191
|
+
columnCount: 10,
|
|
1192
|
+
columnWidth: 100,
|
|
1193
|
+
ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
|
|
1194
|
+
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1195
|
+
const result = useVirtualScroll(props);
|
|
1196
|
+
expect(result.totalWidth.value).toBe(500);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
it('should skip undefined items in renderedItems', async () => {
|
|
1200
|
+
// items array is mockItems (length 100)
|
|
1201
|
+
const { result } = setup({ ...defaultProps, stickyIndices: [ 1000 ] });
|
|
1202
|
+
// Scroll way past the end
|
|
1203
|
+
result.scrollToOffset(0, 100000);
|
|
1204
|
+
await nextTick();
|
|
1205
|
+
// prevStickyIdx will be 1000, which is out of bounds
|
|
1206
|
+
expect(result.renderedItems.value.length).toBe(0);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it('should cover object padding branches in helpers', () => {
|
|
1210
|
+
expect(getPaddingX({ x: 10 }, 'horizontal')).toBe(10);
|
|
1211
|
+
expect(getPaddingY({ y: 20 }, 'vertical')).toBe(20);
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
});
|