@pdanpdan/virtual-scroll 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -324
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +836 -376
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1334 -741
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +8 -2
- package/src/components/VirtualScroll.test.ts +1921 -325
- package/src/components/VirtualScroll.vue +829 -386
- package/src/components/VirtualScrollbar.test.ts +174 -0
- package/src/components/VirtualScrollbar.vue +102 -0
- package/src/composables/useVirtualScroll.test.ts +1506 -228
- package/src/composables/useVirtualScroll.ts +869 -517
- package/src/composables/useVirtualScrollbar.test.ts +526 -0
- package/src/composables/useVirtualScrollbar.ts +244 -0
- package/src/index.ts +9 -0
- package/src/types.ts +353 -110
- package/src/utils/fenwick-tree.test.ts +39 -39
- package/src/utils/scroll.test.ts +181 -101
- package/src/utils/scroll.ts +43 -5
- package/src/utils/virtual-scroll-logic.test.ts +673 -323
- package/src/utils/virtual-scroll-logic.ts +759 -430
|
@@ -1,20 +1,62 @@
|
|
|
1
|
-
/* global ScrollToOptions */
|
|
1
|
+
/* global ScrollToOptions, ResizeObserverCallback */
|
|
2
2
|
import type { VirtualScrollProps } from '../types';
|
|
3
3
|
import type { Ref } from 'vue';
|
|
4
4
|
|
|
5
5
|
import { mount } from '@vue/test-utils';
|
|
6
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
7
|
import { defineComponent, nextTick, ref } from 'vue';
|
|
8
8
|
|
|
9
9
|
import { useVirtualScroll } from './useVirtualScroll';
|
|
10
10
|
|
|
11
11
|
// --- Mocks ---
|
|
12
12
|
|
|
13
|
+
interface ResizeObserverMock extends ResizeObserver {
|
|
14
|
+
callback: ResizeObserverCallback;
|
|
15
|
+
targets: Set<Element>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const observers: ResizeObserverMock[] = [];
|
|
13
19
|
globalThis.ResizeObserver = class ResizeObserver {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
callback: ResizeObserverCallback;
|
|
21
|
+
targets = new Set<Element>();
|
|
22
|
+
constructor(callback: ResizeObserverCallback) {
|
|
23
|
+
this.callback = callback;
|
|
24
|
+
observers.push(this as unknown as ResizeObserverMock);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
observe(el: Element) {
|
|
28
|
+
this.targets.add(el);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
unobserve(el: Element) {
|
|
32
|
+
this.targets.delete(el);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
disconnect() {
|
|
36
|
+
this.targets.clear();
|
|
37
|
+
}
|
|
38
|
+
} as unknown as typeof ResizeObserver;
|
|
39
|
+
|
|
40
|
+
function triggerResize(el: Element, width: number, height: number) {
|
|
41
|
+
const obs = observers.find((o) => o.targets.has(el));
|
|
42
|
+
if (obs) {
|
|
43
|
+
obs.callback([ {
|
|
44
|
+
borderBoxSize: [ { blockSize: height, inlineSize: width } ],
|
|
45
|
+
contentRect: {
|
|
46
|
+
bottom: height,
|
|
47
|
+
height,
|
|
48
|
+
left: 0,
|
|
49
|
+
right: width,
|
|
50
|
+
toJSON: () => '',
|
|
51
|
+
top: 0,
|
|
52
|
+
width,
|
|
53
|
+
x: 0,
|
|
54
|
+
y: 0,
|
|
55
|
+
},
|
|
56
|
+
target: el,
|
|
57
|
+
} as unknown as ResizeObserverEntry ], obs);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
18
60
|
|
|
19
61
|
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
|
|
20
62
|
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
|
|
@@ -33,6 +75,10 @@ globalThis.window.scrollTo = vi.fn().mockImplementation((options: ScrollToOption
|
|
|
33
75
|
document.dispatchEvent(new Event('scroll'));
|
|
34
76
|
});
|
|
35
77
|
|
|
78
|
+
interface MockItem {
|
|
79
|
+
id: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
// Helper to test composable
|
|
37
83
|
function setup<T>(propsValue: VirtualScrollProps<T>) {
|
|
38
84
|
const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
|
|
@@ -49,301 +95,1533 @@ function setup<T>(propsValue: VirtualScrollProps<T>) {
|
|
|
49
95
|
}
|
|
50
96
|
|
|
51
97
|
describe('useVirtualScroll', () => {
|
|
52
|
-
const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
|
|
98
|
+
const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i }));
|
|
53
99
|
|
|
54
100
|
beforeEach(() => {
|
|
55
|
-
vi.clearAllMocks();
|
|
56
101
|
Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
|
|
57
102
|
Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
|
|
103
|
+
Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 500 });
|
|
104
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 500 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
58
109
|
});
|
|
59
110
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
describe('core rendering & dimensions', () => {
|
|
112
|
+
it('calculates total dimensions correctly', async () => {
|
|
113
|
+
const { result, wrapper } = setup({
|
|
114
|
+
container: window,
|
|
115
|
+
direction: 'vertical',
|
|
116
|
+
itemSize: 50,
|
|
117
|
+
items: mockItems,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
121
|
+
expect(result.totalWidth.value).toBe(500);
|
|
122
|
+
wrapper.unmount();
|
|
66
123
|
});
|
|
67
124
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
125
|
+
it('provides rendered items for the visible range', async () => {
|
|
126
|
+
const { result, wrapper } = setup({
|
|
127
|
+
container: window,
|
|
128
|
+
direction: 'vertical',
|
|
129
|
+
itemSize: 50,
|
|
130
|
+
items: mockItems,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await nextTick();
|
|
134
|
+
await nextTick();
|
|
135
|
+
|
|
136
|
+
// viewport 500, item 50 => 10 items + buffer 5 = 15 items
|
|
137
|
+
expect(result.renderedItems.value.length).toBe(15);
|
|
138
|
+
expect(result.renderedItems.value[ 0 ]!.index).toBe(0);
|
|
139
|
+
wrapper.unmount();
|
|
140
|
+
});
|
|
71
141
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
142
|
+
it('provides getRowHeight and getColumnWidth helpers', async () => {
|
|
143
|
+
const items: MockItem[] = [ { id: 1 }, { id: 2 } ];
|
|
144
|
+
const { result, wrapper } = setup({
|
|
145
|
+
direction: 'both',
|
|
146
|
+
itemSize: (item: MockItem) => (item.id === 1 ? 60 : 40),
|
|
147
|
+
columnWidth: [ 100, 200 ],
|
|
148
|
+
items,
|
|
149
|
+
gap: 10,
|
|
150
|
+
columnGap: 20,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await nextTick();
|
|
154
|
+
|
|
155
|
+
// getRowHeight returns item size WITHOUT gap
|
|
156
|
+
expect(result.getRowHeight(0)).toBe(60);
|
|
157
|
+
expect(result.getRowHeight(1)).toBe(40);
|
|
158
|
+
|
|
159
|
+
// getColumnWidth returns col width WITHOUT gap
|
|
160
|
+
expect(result.getColumnWidth(0)).toBe(100);
|
|
161
|
+
expect(result.getColumnWidth(1)).toBe(200);
|
|
162
|
+
expect(result.getColumnWidth(2)).toBe(100); // cyclic for arrays
|
|
163
|
+
wrapper.unmount();
|
|
164
|
+
|
|
165
|
+
// Dynamic sizes
|
|
166
|
+
const { result: result2, wrapper: wrapper2 } = setup({
|
|
167
|
+
direction: 'vertical',
|
|
168
|
+
itemSize: 0,
|
|
169
|
+
items: [ { id: 1 } ],
|
|
170
|
+
defaultItemSize: 45,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await nextTick();
|
|
174
|
+
expect(result2.getRowHeight(0)).toBe(45); // default before measurement
|
|
175
|
+
|
|
176
|
+
result2.updateItemSize(0, 100, 100);
|
|
177
|
+
await nextTick();
|
|
178
|
+
expect(result2.getRowHeight(0)).toBe(100);
|
|
179
|
+
wrapper2.unmount();
|
|
78
180
|
});
|
|
79
181
|
|
|
80
|
-
|
|
81
|
-
|
|
182
|
+
it('provides getItemSize helper with various direction and type branches', async () => {
|
|
183
|
+
const { result, wrapper } = setup({
|
|
184
|
+
direction: 'horizontal',
|
|
185
|
+
itemSize: 50,
|
|
186
|
+
items: mockItems,
|
|
187
|
+
columnGap: 10,
|
|
188
|
+
});
|
|
189
|
+
await nextTick();
|
|
190
|
+
expect(result.getItemSize(0)).toBe(50);
|
|
191
|
+
|
|
192
|
+
await wrapper.unmount();
|
|
193
|
+
|
|
194
|
+
const { result: res2, wrapper: w2 } = setup({
|
|
195
|
+
direction: 'vertical',
|
|
196
|
+
itemSize: (item: MockItem) => (item.id === 0 ? 100 : 40),
|
|
197
|
+
items: mockItems,
|
|
198
|
+
});
|
|
199
|
+
await nextTick();
|
|
200
|
+
expect(res2.getItemSize(0)).toBe(100);
|
|
201
|
+
expect(res2.getItemSize(1)).toBe(40);
|
|
202
|
+
w2.unmount();
|
|
203
|
+
});
|
|
82
204
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
205
|
+
it('getItemSize returns correct size based on direction and measurements', async () => {
|
|
206
|
+
const { result, wrapper } = setup({
|
|
207
|
+
direction: 'horizontal',
|
|
208
|
+
itemSize: 0,
|
|
209
|
+
items: mockItems,
|
|
210
|
+
columnGap: 10,
|
|
211
|
+
});
|
|
212
|
+
await nextTick();
|
|
213
|
+
result.updateItemSize(0, 100, 100);
|
|
214
|
+
await nextTick();
|
|
215
|
+
// In horizontal, getItemSize uses itemSizesX - columnGap
|
|
216
|
+
expect(result.getItemSize(0)).toBe(100);
|
|
217
|
+
|
|
218
|
+
// Test fallback to default when not measured yet
|
|
219
|
+
expect(result.getItemSize(1)).toBe(40);
|
|
220
|
+
|
|
221
|
+
wrapper.unmount();
|
|
222
|
+
|
|
223
|
+
const { result: res2, wrapper: w2 } = setup({
|
|
224
|
+
direction: 'vertical',
|
|
225
|
+
itemSize: 0,
|
|
226
|
+
items: mockItems,
|
|
227
|
+
gap: 10,
|
|
228
|
+
});
|
|
229
|
+
await nextTick();
|
|
230
|
+
res2.updateItemSize(0, 100, 100);
|
|
231
|
+
await nextTick();
|
|
232
|
+
// In vertical, fallback uses itemSizesY - gap
|
|
233
|
+
expect(res2.getItemSize(0)).toBe(100);
|
|
234
|
+
w2.unmount();
|
|
235
|
+
});
|
|
87
236
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
237
|
+
it('supports getColumnWidth with various types', async () => {
|
|
238
|
+
const { result, wrapper } = setup({
|
|
239
|
+
columnCount: 10,
|
|
240
|
+
columnWidth: [ 100, 200 ],
|
|
241
|
+
direction: 'both',
|
|
242
|
+
items: mockItems,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await nextTick();
|
|
246
|
+
expect(result.getColumnWidth(0)).toBe(100);
|
|
247
|
+
expect(result.getColumnWidth(1)).toBe(200);
|
|
248
|
+
expect(result.getColumnWidth(2)).toBe(100);
|
|
249
|
+
wrapper.unmount();
|
|
94
250
|
});
|
|
95
251
|
|
|
96
|
-
|
|
97
|
-
|
|
252
|
+
it('skips invalid items in renderedItems', async () => {
|
|
253
|
+
const { result, props, wrapper } = setup({
|
|
254
|
+
direction: 'vertical',
|
|
255
|
+
itemSize: 50,
|
|
256
|
+
items: [ { id: 1 }, { id: 2 } ],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await nextTick();
|
|
260
|
+
await nextTick();
|
|
261
|
+
|
|
262
|
+
// Manually force an out-of-bounds range or inconsistent state
|
|
263
|
+
props.value.items = [ { id: 1 } ];
|
|
264
|
+
// renderedItems will filter out index 1 if it's still in range.end
|
|
265
|
+
expect(result.renderedItems.value.length).toBe(1);
|
|
266
|
+
wrapper.unmount();
|
|
267
|
+
});
|
|
98
268
|
|
|
99
|
-
|
|
100
|
-
|
|
269
|
+
it('handles sparse items array', async () => {
|
|
270
|
+
const sparseItems: unknown[] = [];
|
|
271
|
+
sparseItems[ 0 ] = { id: 0 };
|
|
272
|
+
sparseItems[ 10 ] = { id: 10 }; // hole between 1 and 9
|
|
101
273
|
|
|
102
|
-
|
|
103
|
-
|
|
274
|
+
const { result, wrapper } = setup({
|
|
275
|
+
direction: 'vertical',
|
|
276
|
+
itemSize: 50,
|
|
277
|
+
items: sparseItems,
|
|
278
|
+
});
|
|
104
279
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(result.renderedItems.value[ 0 ]!.index).toBe(5);
|
|
108
|
-
});
|
|
280
|
+
await nextTick();
|
|
281
|
+
await nextTick();
|
|
109
282
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
itemSize: 50,
|
|
115
|
-
items: mockItems,
|
|
283
|
+
// Should only render the defined items
|
|
284
|
+
expect(result.renderedItems.value.length).toBeGreaterThan(0);
|
|
285
|
+
expect(result.renderedItems.value.every((i) => i.item !== undefined)).toBe(true);
|
|
286
|
+
wrapper.unmount();
|
|
116
287
|
});
|
|
117
288
|
|
|
118
|
-
|
|
119
|
-
|
|
289
|
+
it('uses sequential query optimization in renderedItems', async () => {
|
|
290
|
+
const { result, wrapper } = setup({
|
|
291
|
+
direction: 'vertical',
|
|
292
|
+
itemSize: 50,
|
|
293
|
+
items: mockItems,
|
|
294
|
+
bufferBefore: 0,
|
|
295
|
+
bufferAfter: 10,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await nextTick();
|
|
299
|
+
await nextTick();
|
|
300
|
+
|
|
301
|
+
// accessing renderedItems will trigger queryYCached sequentially
|
|
302
|
+
const items = result.renderedItems.value;
|
|
303
|
+
expect(items.length).toBeGreaterThan(5);
|
|
304
|
+
expect(items[ 0 ]!.index).toBe(0);
|
|
305
|
+
expect(items[ 1 ]!.index).toBe(1);
|
|
306
|
+
|
|
307
|
+
wrapper.unmount();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('handles non-sequential prefix sum queries and sticky item queries', async () => {
|
|
311
|
+
const { result, wrapper } = setup({
|
|
312
|
+
direction: 'vertical',
|
|
313
|
+
items: mockItems,
|
|
314
|
+
itemSize: 50,
|
|
315
|
+
stickyIndices: [ 0, 50, 99 ],
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await nextTick();
|
|
319
|
+
await nextTick();
|
|
320
|
+
|
|
321
|
+
// Scroll to end (4500)
|
|
322
|
+
result.scrollToOffset(null, 4500);
|
|
323
|
+
await nextTick();
|
|
324
|
+
await nextTick();
|
|
325
|
+
|
|
326
|
+
// renderedItems will query indices around 99.
|
|
327
|
+
// Sticky logic will query index 50 non-sequentially.
|
|
328
|
+
expect(result.renderedItems.value.length).toBeGreaterThan(0);
|
|
329
|
+
wrapper.unmount();
|
|
330
|
+
});
|
|
120
331
|
|
|
121
|
-
|
|
332
|
+
it('calculates column range and offsets correctly during ssr', async () => {
|
|
333
|
+
const { result, wrapper } = setup({
|
|
334
|
+
direction: 'both',
|
|
335
|
+
items: mockItems,
|
|
336
|
+
columnCount: 10,
|
|
337
|
+
columnWidth: 100,
|
|
338
|
+
itemSize: 50,
|
|
339
|
+
ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// isHydrated is false initially
|
|
343
|
+
expect(result.isHydrated.value).toBe(false);
|
|
344
|
+
|
|
345
|
+
// columnRange during SSR
|
|
346
|
+
expect(result.columnRange.value.start).toBe(2);
|
|
347
|
+
expect(result.columnRange.value.end).toBe(5);
|
|
348
|
+
expect(result.columnRange.value.padStart).toBe(200);
|
|
349
|
+
|
|
350
|
+
// renderedItems offsets during SSR
|
|
351
|
+
// ssrOffsetY = 10 * 50 = 500. ssrOffsetX = columnSizes.query(2) = 200.
|
|
352
|
+
// Item (10, 2) is at VU(200, 500).
|
|
353
|
+
// offsetX = (originalX - ssrOffsetX) = (200 - 200) = 0
|
|
354
|
+
const item = result.renderedItems.value.find((i) => i.index === 10);
|
|
355
|
+
expect(item).toBeDefined();
|
|
356
|
+
expect(item?.offset.x).toBe(0);
|
|
357
|
+
expect(item?.offset.y).toBe(0);
|
|
358
|
+
|
|
359
|
+
wrapper.unmount();
|
|
360
|
+
});
|
|
122
361
|
|
|
123
|
-
|
|
124
|
-
|
|
362
|
+
it('supports refresh method', async () => {
|
|
363
|
+
const { result, wrapper } = setup({
|
|
364
|
+
container: window,
|
|
365
|
+
direction: 'vertical',
|
|
366
|
+
itemSize: 50,
|
|
367
|
+
items: mockItems,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await nextTick();
|
|
371
|
+
result.refresh();
|
|
372
|
+
await nextTick();
|
|
373
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
374
|
+
wrapper.unmount();
|
|
375
|
+
});
|
|
125
376
|
|
|
126
|
-
|
|
127
|
-
|
|
377
|
+
it('cleans up observers on unmount', async () => {
|
|
378
|
+
const disconnectSpy = vi.fn();
|
|
379
|
+
const oldMutationObserver = globalThis.MutationObserver;
|
|
380
|
+
globalThis.MutationObserver = (class MutationObserver {
|
|
381
|
+
observe = vi.fn();
|
|
382
|
+
disconnect = disconnectSpy;
|
|
383
|
+
} as unknown) as typeof MutationObserver;
|
|
384
|
+
|
|
385
|
+
const { wrapper } = setup({
|
|
386
|
+
container: document.createElement('div'),
|
|
387
|
+
items: mockItems,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await nextTick();
|
|
391
|
+
wrapper.unmount();
|
|
392
|
+
expect(disconnectSpy).toHaveBeenCalled();
|
|
393
|
+
globalThis.MutationObserver = oldMutationObserver;
|
|
394
|
+
});
|
|
128
395
|
});
|
|
129
396
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
397
|
+
describe('scroll management', () => {
|
|
398
|
+
it('updates when scroll position changes', async () => {
|
|
399
|
+
const { result, wrapper } = setup({
|
|
400
|
+
container: window,
|
|
401
|
+
direction: 'vertical',
|
|
402
|
+
itemSize: 50,
|
|
403
|
+
items: mockItems,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await nextTick();
|
|
407
|
+
await nextTick();
|
|
408
|
+
|
|
409
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: 500, writable: true });
|
|
410
|
+
document.dispatchEvent(new Event('scroll'));
|
|
411
|
+
|
|
412
|
+
await nextTick();
|
|
413
|
+
await nextTick();
|
|
414
|
+
|
|
415
|
+
// At 500px, start index is 500/50 = 10. With buffer 5, start is 5.
|
|
416
|
+
expect(result.scrollDetails.value.currentIndex).toBe(10);
|
|
417
|
+
expect(result.renderedItems.value[ 0 ]!.index).toBe(5);
|
|
418
|
+
wrapper.unmount();
|
|
136
419
|
});
|
|
137
420
|
|
|
138
|
-
|
|
139
|
-
|
|
421
|
+
it('supports programmatic scrolling', async () => {
|
|
422
|
+
const { result, wrapper } = setup({
|
|
423
|
+
container: window,
|
|
424
|
+
direction: 'vertical',
|
|
425
|
+
itemSize: 50,
|
|
426
|
+
items: mockItems,
|
|
427
|
+
});
|
|
140
428
|
|
|
141
|
-
|
|
142
|
-
|
|
429
|
+
await nextTick();
|
|
430
|
+
await nextTick();
|
|
143
431
|
|
|
144
|
-
|
|
145
|
-
await nextTick();
|
|
432
|
+
result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
|
|
146
433
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
434
|
+
await nextTick();
|
|
435
|
+
await nextTick();
|
|
436
|
+
|
|
437
|
+
expect(window.scrollTo).toHaveBeenCalled();
|
|
438
|
+
expect(result.scrollDetails.value.currentIndex).toBe(20);
|
|
439
|
+
wrapper.unmount();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('clears pendingScroll when scrollToOffset is called', async () => {
|
|
443
|
+
const { result, wrapper } = setup({
|
|
444
|
+
container: window,
|
|
445
|
+
direction: 'vertical',
|
|
446
|
+
itemSize: 50,
|
|
447
|
+
items: mockItems,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await nextTick();
|
|
451
|
+
await nextTick();
|
|
452
|
+
|
|
453
|
+
// Set a pending scroll
|
|
454
|
+
result.scrollToIndex(50, null, { align: 'start', behavior: 'smooth' });
|
|
455
|
+
await nextTick();
|
|
150
456
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
457
|
+
// Call scrollToOffset
|
|
458
|
+
result.scrollToOffset(null, 100);
|
|
459
|
+
await nextTick();
|
|
460
|
+
|
|
461
|
+
// Wait for scroll timeout (250ms)
|
|
462
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
463
|
+
await nextTick();
|
|
464
|
+
|
|
465
|
+
// The index 50 should NOT be corrected back because pendingScroll was cleared
|
|
466
|
+
expect(window.scrollY).toBe(100);
|
|
467
|
+
wrapper.unmount();
|
|
159
468
|
});
|
|
160
469
|
|
|
161
|
-
|
|
162
|
-
|
|
470
|
+
it('triggers correction when viewport dimensions change', async () => {
|
|
471
|
+
const { result, wrapper } = setup({
|
|
472
|
+
container: window,
|
|
473
|
+
direction: 'vertical',
|
|
474
|
+
itemSize: 50,
|
|
475
|
+
items: mockItems,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await nextTick();
|
|
479
|
+
await nextTick();
|
|
480
|
+
|
|
481
|
+
// Scroll to item 50 auto
|
|
482
|
+
result.scrollToIndex(50, null, { align: 'auto', behavior: 'auto' });
|
|
483
|
+
await nextTick();
|
|
163
484
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
485
|
+
const initialScrollY = window.scrollY;
|
|
486
|
+
expect(initialScrollY).toBe(2050);
|
|
487
|
+
|
|
488
|
+
// Simulate viewport height decreasing
|
|
489
|
+
Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 485 });
|
|
490
|
+
window.dispatchEvent(new Event('resize'));
|
|
491
|
+
|
|
492
|
+
await nextTick();
|
|
493
|
+
await nextTick();
|
|
494
|
+
|
|
495
|
+
// It should have corrected to: 2500 - (485 - 50) = 2065.
|
|
496
|
+
expect(window.scrollY).toBe(2065);
|
|
497
|
+
wrapper.unmount();
|
|
498
|
+
});
|
|
168
499
|
|
|
169
|
-
|
|
500
|
+
it('triggers correction when container dimensions change', async () => {
|
|
501
|
+
const container = document.createElement('div');
|
|
502
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500, writable: true });
|
|
503
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500, writable: true });
|
|
170
504
|
|
|
171
|
-
|
|
172
|
-
|
|
505
|
+
const { result, wrapper } = setup({
|
|
506
|
+
container,
|
|
507
|
+
itemSize: 50,
|
|
508
|
+
items: mockItems,
|
|
509
|
+
});
|
|
173
510
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
await nextTick();
|
|
511
|
+
await nextTick();
|
|
512
|
+
await nextTick();
|
|
177
513
|
|
|
178
|
-
|
|
179
|
-
|
|
514
|
+
// Change dimensions
|
|
515
|
+
Object.defineProperty(container, 'clientHeight', { value: 800 });
|
|
516
|
+
Object.defineProperty(container, 'clientWidth', { value: 800 });
|
|
517
|
+
triggerResize(container, 800, 800);
|
|
518
|
+
|
|
519
|
+
await nextTick();
|
|
520
|
+
await nextTick();
|
|
521
|
+
|
|
522
|
+
expect(result.scrollDetails.value.displayViewportSize.height).toBe(800);
|
|
523
|
+
wrapper.unmount();
|
|
524
|
+
});
|
|
180
525
|
});
|
|
181
526
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
527
|
+
describe('dynamic sizing & prepending', () => {
|
|
528
|
+
it('handles dynamic item sizes', async () => {
|
|
529
|
+
const { result, wrapper } = setup({
|
|
530
|
+
container: window,
|
|
531
|
+
direction: 'vertical',
|
|
532
|
+
itemSize: 0, // dynamic
|
|
533
|
+
items: mockItems,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
await nextTick();
|
|
537
|
+
await nextTick();
|
|
538
|
+
|
|
539
|
+
// Initial estimate 100 * 40 = 4000
|
|
540
|
+
expect(result.totalHeight.value).toBe(4000);
|
|
541
|
+
|
|
542
|
+
result.updateItemSize(0, 100, 100);
|
|
543
|
+
await nextTick();
|
|
544
|
+
|
|
545
|
+
// Now 1*100 + 99*40 = 4060
|
|
546
|
+
expect(result.totalHeight.value).toBe(4060);
|
|
547
|
+
wrapper.unmount();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('updates item sizes and compensates scroll position', async () => {
|
|
551
|
+
const { result, wrapper } = setup({
|
|
552
|
+
container: window,
|
|
553
|
+
direction: 'vertical',
|
|
554
|
+
itemSize: 0,
|
|
555
|
+
items: mockItems,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await nextTick();
|
|
559
|
+
await nextTick();
|
|
560
|
+
|
|
561
|
+
// Scroll to item 10 (10 * 40 = 400px)
|
|
562
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: 400, writable: true });
|
|
563
|
+
document.dispatchEvent(new Event('scroll'));
|
|
564
|
+
await nextTick();
|
|
565
|
+
|
|
566
|
+
// Update item 0 (above viewport) from 40 to 100
|
|
567
|
+
result.updateItemSize(0, 100, 100);
|
|
568
|
+
await nextTick();
|
|
569
|
+
|
|
570
|
+
// Scroll position should have been adjusted by 60px
|
|
571
|
+
expect(window.scrollY).toBe(460);
|
|
572
|
+
wrapper.unmount();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('supports batched updateItemSizes', async () => {
|
|
576
|
+
const { result, wrapper } = setup({
|
|
577
|
+
itemSize: 0,
|
|
578
|
+
items: mockItems,
|
|
579
|
+
});
|
|
580
|
+
await nextTick();
|
|
581
|
+
result.updateItemSizes([
|
|
582
|
+
{ index: 0, inlineSize: 100, blockSize: 100 },
|
|
583
|
+
{ index: 1, inlineSize: 100, blockSize: 100 },
|
|
584
|
+
]);
|
|
585
|
+
await nextTick();
|
|
586
|
+
expect(result.getRowHeight(0)).toBe(100);
|
|
587
|
+
expect(result.getRowHeight(1)).toBe(100);
|
|
588
|
+
wrapper.unmount();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('ignores out of bounds updates in updateItemSize', async () => {
|
|
592
|
+
const { result, wrapper } = setup({
|
|
593
|
+
itemSize: 0,
|
|
594
|
+
items: mockItems,
|
|
595
|
+
});
|
|
596
|
+
await nextTick();
|
|
597
|
+
const initialHeight = result.scrollDetails.value.totalSize.height;
|
|
598
|
+
result.updateItemSize(1000, 100, 100); // Out of bounds
|
|
599
|
+
await nextTick();
|
|
600
|
+
expect(result.scrollDetails.value.totalSize.height).toBe(initialHeight);
|
|
601
|
+
wrapper.unmount();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('updates column sizes from row element children', async () => {
|
|
605
|
+
const { result, wrapper } = setup({
|
|
606
|
+
columnCount: 5,
|
|
607
|
+
columnWidth: 0,
|
|
608
|
+
direction: 'both',
|
|
609
|
+
items: mockItems,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
await nextTick();
|
|
613
|
+
|
|
614
|
+
const rowEl = document.createElement('div');
|
|
615
|
+
const cell0 = document.createElement('div');
|
|
616
|
+
cell0.dataset.colIndex = '0';
|
|
617
|
+
Object.defineProperty(cell0, 'getBoundingClientRect', {
|
|
618
|
+
value: () => ({ width: 120 }),
|
|
619
|
+
});
|
|
620
|
+
const cell1 = document.createElement('div');
|
|
621
|
+
cell1.dataset.colIndex = '1';
|
|
622
|
+
Object.defineProperty(cell1, 'getBoundingClientRect', {
|
|
623
|
+
value: () => ({ width: 180 }),
|
|
624
|
+
});
|
|
625
|
+
rowEl.appendChild(cell0);
|
|
626
|
+
rowEl.appendChild(cell1);
|
|
627
|
+
|
|
628
|
+
result.updateItemSizes([ {
|
|
629
|
+
blockSize: 50,
|
|
630
|
+
element: rowEl,
|
|
631
|
+
index: 0,
|
|
632
|
+
inlineSize: 0,
|
|
633
|
+
} ]);
|
|
634
|
+
|
|
635
|
+
await nextTick();
|
|
636
|
+
expect(result.getColumnWidth(0)).toBe(120);
|
|
637
|
+
expect(result.getColumnWidth(1)).toBe(180);
|
|
638
|
+
wrapper.unmount();
|
|
188
639
|
});
|
|
189
640
|
|
|
190
|
-
|
|
191
|
-
|
|
641
|
+
it('updates column sizes from row element', async () => {
|
|
642
|
+
const { result, wrapper } = setup({
|
|
643
|
+
columnCount: 5,
|
|
644
|
+
columnWidth: 0, // dynamic
|
|
645
|
+
direction: 'both',
|
|
646
|
+
items: mockItems,
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
await nextTick();
|
|
650
|
+
|
|
651
|
+
const rowEl = document.createElement('div');
|
|
652
|
+
const cell0 = document.createElement('div');
|
|
653
|
+
cell0.dataset.colIndex = '0';
|
|
654
|
+
Object.defineProperty(cell0, 'getBoundingClientRect', {
|
|
655
|
+
value: () => ({ width: 150 }),
|
|
656
|
+
});
|
|
657
|
+
rowEl.appendChild(cell0);
|
|
658
|
+
|
|
659
|
+
result.updateItemSizes([ {
|
|
660
|
+
blockSize: 100,
|
|
661
|
+
element: rowEl,
|
|
662
|
+
index: 0,
|
|
663
|
+
inlineSize: 0,
|
|
664
|
+
} ]);
|
|
665
|
+
|
|
666
|
+
await nextTick();
|
|
667
|
+
expect(result.getColumnWidth(0)).toBe(150);
|
|
668
|
+
wrapper.unmount();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('restores scroll position when items are prepended', async () => {
|
|
672
|
+
const items = Array.from({ length: 20 }, (_, i) => ({ id: i }));
|
|
673
|
+
const { props, result, wrapper } = setup({
|
|
674
|
+
container: window,
|
|
675
|
+
direction: 'vertical',
|
|
676
|
+
itemSize: 50,
|
|
677
|
+
items,
|
|
678
|
+
restoreScrollOnPrepend: true,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
await nextTick();
|
|
682
|
+
await nextTick();
|
|
683
|
+
|
|
684
|
+
// Scroll to index 5 (250px)
|
|
685
|
+
result.scrollToOffset(0, 250, { behavior: 'auto' });
|
|
686
|
+
await nextTick();
|
|
687
|
+
await nextTick();
|
|
688
|
+
|
|
689
|
+
expect(window.scrollY).toBe(250);
|
|
690
|
+
|
|
691
|
+
// Prepend 2 items (100px)
|
|
692
|
+
props.value.items = [ { id: -1 }, { id: -2 }, ...items ];
|
|
693
|
+
|
|
694
|
+
await nextTick();
|
|
695
|
+
await nextTick();
|
|
696
|
+
await nextTick();
|
|
697
|
+
|
|
698
|
+
// Scroll should be adjusted to 350
|
|
699
|
+
expect(window.scrollY).toBe(350);
|
|
700
|
+
wrapper.unmount();
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('restores horizontal scroll position when items are prepended', async () => {
|
|
704
|
+
const initialItems = Array.from({ length: 10 }, (_, i) => ({ id: i + 5 }));
|
|
705
|
+
const { result, props, wrapper } = setup({
|
|
706
|
+
direction: 'horizontal',
|
|
707
|
+
itemSize: 100,
|
|
708
|
+
items: initialItems,
|
|
709
|
+
restoreScrollOnPrepend: true,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
await nextTick();
|
|
713
|
+
result.scrollToOffset(200, null);
|
|
714
|
+
await nextTick();
|
|
715
|
+
|
|
716
|
+
// Prepend 5 items
|
|
717
|
+
const newItems = [
|
|
718
|
+
...Array.from({ length: 5 }, (_, i) => ({ id: i })),
|
|
719
|
+
...initialItems,
|
|
720
|
+
];
|
|
721
|
+
props.value.items = newItems;
|
|
722
|
+
|
|
723
|
+
await nextTick();
|
|
724
|
+
await nextTick();
|
|
725
|
+
|
|
726
|
+
// Should have added 5 * 100 = 500px to scroll position
|
|
727
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(700);
|
|
728
|
+
wrapper.unmount();
|
|
729
|
+
});
|
|
192
730
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
731
|
+
it('handles horizontal dynamic item sizes', async () => {
|
|
732
|
+
const { result, wrapper } = setup({
|
|
733
|
+
direction: 'horizontal',
|
|
734
|
+
itemSize: 0, // dynamic
|
|
735
|
+
defaultItemSize: 100,
|
|
736
|
+
items: mockItems,
|
|
737
|
+
});
|
|
196
738
|
|
|
197
|
-
|
|
198
|
-
// item 50 at 2500. viewport 500. item 50 high.
|
|
199
|
-
// targetEnd = 2500 - (500 - 50) = 2050.
|
|
200
|
-
expect(initialScrollY).toBe(2050);
|
|
739
|
+
await nextTick();
|
|
201
740
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
window.dispatchEvent(new Event('resize'));
|
|
741
|
+
// Initially estimated
|
|
742
|
+
expect(result.getItemSize(0)).toBe(100);
|
|
205
743
|
|
|
206
|
-
|
|
207
|
-
|
|
744
|
+
// Update size
|
|
745
|
+
result.updateItemSize(0, 150, 500);
|
|
746
|
+
await nextTick();
|
|
747
|
+
expect(result.getItemSize(0)).toBe(150);
|
|
748
|
+
expect(result.scrollDetails.value.totalSize.width).toBe(150 + 99 * 100);
|
|
208
749
|
|
|
209
|
-
|
|
210
|
-
|
|
750
|
+
wrapper.unmount();
|
|
751
|
+
});
|
|
211
752
|
});
|
|
212
753
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
754
|
+
describe('scroll correction & smooth scrolling', () => {
|
|
755
|
+
it('handles smooth scroll and waits for it to finish before correcting', async () => {
|
|
756
|
+
const container = document.createElement('div');
|
|
757
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
758
|
+
container.scrollTo = vi.fn();
|
|
759
|
+
|
|
760
|
+
const { result, wrapper } = setup({
|
|
761
|
+
container,
|
|
762
|
+
direction: 'vertical',
|
|
763
|
+
itemSize: 0,
|
|
764
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
await nextTick();
|
|
768
|
+
await nextTick();
|
|
769
|
+
|
|
770
|
+
// Start a smooth scroll
|
|
771
|
+
result.scrollToIndex(50, null, { behavior: 'smooth' });
|
|
772
|
+
// Simulate scroll event to set isScrolling = true
|
|
226
773
|
container.dispatchEvent(new Event('scroll'));
|
|
774
|
+
await nextTick();
|
|
775
|
+
|
|
776
|
+
// Simulate measurement update while scrolling
|
|
777
|
+
result.updateItemSize(0, 100, 100);
|
|
778
|
+
await nextTick();
|
|
779
|
+
|
|
780
|
+
expect(container.scrollTo).toHaveBeenCalledTimes(1);
|
|
781
|
+
|
|
782
|
+
// End scroll by waiting for timeout (default 250ms)
|
|
783
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
784
|
+
await nextTick();
|
|
785
|
+
|
|
786
|
+
// Now correction should trigger
|
|
787
|
+
expect(container.scrollTo).toHaveBeenCalledTimes(2);
|
|
788
|
+
wrapper.unmount();
|
|
227
789
|
});
|
|
228
790
|
|
|
229
|
-
|
|
230
|
-
container
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
791
|
+
it('performs scroll correction when item sizes change during/after scrollToIndex', async () => {
|
|
792
|
+
const container = document.createElement('div');
|
|
793
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
794
|
+
let scrollTop = 0;
|
|
795
|
+
Object.defineProperty(container, 'scrollTop', {
|
|
796
|
+
configurable: true,
|
|
797
|
+
get: () => scrollTop,
|
|
798
|
+
set: (val) => { scrollTop = val; },
|
|
799
|
+
});
|
|
800
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
801
|
+
if (options.top !== undefined) {
|
|
802
|
+
scrollTop = options.top;
|
|
803
|
+
}
|
|
804
|
+
container.dispatchEvent(new Event('scroll'));
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const { result, wrapper } = setup({
|
|
808
|
+
container,
|
|
809
|
+
direction: 'vertical',
|
|
810
|
+
itemSize: 0,
|
|
811
|
+
items: mockItems,
|
|
812
|
+
defaultItemSize: 40,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
await nextTick();
|
|
816
|
+
await nextTick();
|
|
817
|
+
|
|
818
|
+
result.scrollToIndex(50, null, { align: 'start', behavior: 'auto' });
|
|
819
|
+
await nextTick();
|
|
820
|
+
await nextTick();
|
|
821
|
+
|
|
822
|
+
expect(scrollTop).toBe(2000);
|
|
823
|
+
|
|
824
|
+
// Update item 10 from 40 to 100. Shift item 50 down by 60px.
|
|
825
|
+
result.updateItemSize(10, 100, 100);
|
|
826
|
+
await nextTick();
|
|
827
|
+
await nextTick();
|
|
828
|
+
|
|
829
|
+
expect(scrollTop).toBe(2060);
|
|
830
|
+
wrapper.unmount();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('performs scroll correction for "end" alignment when item sizes change', async () => {
|
|
834
|
+
const container = document.createElement('div');
|
|
835
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
836
|
+
let scrollTop = 0;
|
|
837
|
+
Object.defineProperty(container, 'scrollTop', {
|
|
838
|
+
configurable: true,
|
|
839
|
+
get: () => scrollTop,
|
|
840
|
+
set: (val) => { scrollTop = val; },
|
|
841
|
+
});
|
|
842
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
843
|
+
if (options.top !== undefined) {
|
|
844
|
+
scrollTop = options.top;
|
|
845
|
+
}
|
|
846
|
+
container.dispatchEvent(new Event('scroll'));
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const { result, wrapper } = setup({
|
|
850
|
+
container,
|
|
851
|
+
direction: 'vertical',
|
|
852
|
+
itemSize: 0,
|
|
853
|
+
items: mockItems,
|
|
854
|
+
defaultItemSize: 40,
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
await nextTick();
|
|
858
|
+
await nextTick();
|
|
859
|
+
|
|
860
|
+
result.scrollToIndex(50, null, { align: 'end', behavior: 'auto' });
|
|
861
|
+
await nextTick();
|
|
862
|
+
await nextTick();
|
|
863
|
+
|
|
864
|
+
expect(scrollTop).toBe(1540);
|
|
865
|
+
|
|
866
|
+
result.updateItemSize(50, 100, 100);
|
|
867
|
+
await nextTick();
|
|
868
|
+
await nextTick();
|
|
869
|
+
|
|
870
|
+
expect(scrollTop).toBe(1600);
|
|
871
|
+
wrapper.unmount();
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('correctly scrolls to the end of a dynamic list with corrections', async () => {
|
|
875
|
+
const container = document.createElement('div');
|
|
876
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
877
|
+
let scrollTop = 0;
|
|
878
|
+
Object.defineProperty(container, 'scrollTop', {
|
|
879
|
+
configurable: true,
|
|
880
|
+
get: () => scrollTop,
|
|
881
|
+
set: (val) => { scrollTop = val; },
|
|
882
|
+
});
|
|
883
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
884
|
+
if (options.top !== undefined) {
|
|
885
|
+
scrollTop = options.top;
|
|
886
|
+
}
|
|
887
|
+
container.dispatchEvent(new Event('scroll'));
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
const { result, wrapper } = setup({
|
|
891
|
+
container,
|
|
892
|
+
direction: 'vertical',
|
|
893
|
+
itemSize: 0,
|
|
894
|
+
items: mockItems,
|
|
895
|
+
defaultItemSize: 40,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
await nextTick();
|
|
899
|
+
await nextTick();
|
|
900
|
+
|
|
901
|
+
result.scrollToIndex(99, null, { align: 'end', behavior: 'auto' });
|
|
902
|
+
await nextTick();
|
|
903
|
+
await nextTick();
|
|
904
|
+
expect(scrollTop).toBe(3500);
|
|
905
|
+
|
|
906
|
+
const updates = Array.from({ length: 90 }, (_, i) => ({ index: i, inlineSize: 100, blockSize: 50 }));
|
|
907
|
+
result.updateItemSizes(updates);
|
|
908
|
+
await nextTick();
|
|
909
|
+
await nextTick();
|
|
910
|
+
|
|
911
|
+
expect(scrollTop).toBe(4400);
|
|
912
|
+
wrapper.unmount();
|
|
913
|
+
});
|
|
267
914
|
});
|
|
268
915
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
container
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
916
|
+
describe('sticky elements', () => {
|
|
917
|
+
it('renders sticky indices correctly using optimized search', async () => {
|
|
918
|
+
const container = document.createElement('div');
|
|
919
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 200 });
|
|
920
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
921
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
922
|
+
if (options.left !== undefined) {
|
|
923
|
+
container.scrollLeft = options.left;
|
|
924
|
+
}
|
|
925
|
+
if (options.top !== undefined) {
|
|
926
|
+
container.scrollTop = options.top;
|
|
927
|
+
}
|
|
928
|
+
container.dispatchEvent(new Event('scroll'));
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
const { result, wrapper } = setup({
|
|
932
|
+
container,
|
|
933
|
+
direction: 'vertical',
|
|
934
|
+
itemSize: 50,
|
|
935
|
+
items: Array.from({ length: 20 }, (_, i) => ({ id: i })),
|
|
936
|
+
stickyIndices: [ 0, 10, 19 ],
|
|
937
|
+
bufferBefore: 0,
|
|
938
|
+
bufferAfter: 0,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
await nextTick();
|
|
942
|
+
await nextTick();
|
|
943
|
+
|
|
944
|
+
expect(result.renderedItems.value.map((i) => i.index)).toEqual([ 0, 1, 2, 3 ]);
|
|
945
|
+
|
|
946
|
+
container.scrollTop = 100;
|
|
947
|
+
container.dispatchEvent(new Event('scroll'));
|
|
948
|
+
await nextTick();
|
|
949
|
+
await nextTick();
|
|
950
|
+
|
|
951
|
+
const indices2 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
|
|
952
|
+
expect(indices2).toEqual([ 0, 2, 3, 4, 5 ]);
|
|
953
|
+
expect(result.renderedItems.value.find((i) => i.index === 0)?.isStickyActive).toBe(true);
|
|
954
|
+
|
|
955
|
+
container.scrollTop = 500;
|
|
956
|
+
container.dispatchEvent(new Event('scroll'));
|
|
957
|
+
await nextTick();
|
|
958
|
+
await nextTick();
|
|
959
|
+
|
|
960
|
+
const indices3 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
|
|
961
|
+
expect(indices3).toContain(0);
|
|
962
|
+
expect(indices3).toContain(10);
|
|
963
|
+
expect(indices3).toContain(11);
|
|
964
|
+
expect(indices3).toContain(12);
|
|
965
|
+
expect(indices3).toContain(13);
|
|
966
|
+
wrapper.unmount();
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('renders sticky items that are before the visible range', async () => {
|
|
970
|
+
const { result, wrapper } = setup({
|
|
971
|
+
direction: 'vertical',
|
|
972
|
+
itemSize: 100,
|
|
973
|
+
items: Array.from({ length: 50 }, (_, i) => ({ id: i })),
|
|
974
|
+
stickyIndices: [ 0 ],
|
|
975
|
+
bufferBefore: 0,
|
|
976
|
+
bufferAfter: 0,
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
await nextTick();
|
|
980
|
+
await nextTick();
|
|
981
|
+
|
|
982
|
+
result.scrollToOffset(null, 1000, { behavior: 'auto' });
|
|
983
|
+
await nextTick();
|
|
984
|
+
await nextTick();
|
|
985
|
+
|
|
986
|
+
const renderedIndices = result.renderedItems.value.map((i) => i.index);
|
|
987
|
+
expect(renderedIndices).toContain(0);
|
|
988
|
+
expect(result.renderedItems.value.find((i) => i.index === 0)?.isStickyActive).toBe(true);
|
|
989
|
+
wrapper.unmount();
|
|
275
990
|
});
|
|
276
991
|
|
|
277
|
-
|
|
278
|
-
|
|
992
|
+
it('handles horizontal sticky items', async () => {
|
|
993
|
+
const { result, wrapper } = setup({
|
|
994
|
+
direction: 'horizontal',
|
|
995
|
+
itemSize: 100,
|
|
996
|
+
stickyIndices: [ 0 ],
|
|
997
|
+
items: mockItems,
|
|
998
|
+
});
|
|
279
999
|
|
|
280
|
-
|
|
281
|
-
Object.defineProperty(window, 'scrollY', { configurable: true, value: 400, writable: true });
|
|
282
|
-
document.dispatchEvent(new Event('scroll'));
|
|
283
|
-
await nextTick();
|
|
1000
|
+
await nextTick();
|
|
284
1001
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
await nextTick();
|
|
1002
|
+
result.scrollToOffset(200, null);
|
|
1003
|
+
await nextTick();
|
|
288
1004
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
});
|
|
1005
|
+
const item0 = result.renderedItems.value.find((i) => i.index === 0);
|
|
1006
|
+
expect(item0?.isStickyActive).toBe(true);
|
|
292
1007
|
|
|
293
|
-
|
|
294
|
-
const { result } = setup({
|
|
295
|
-
container: window,
|
|
296
|
-
direction: 'vertical',
|
|
297
|
-
itemSize: 50,
|
|
298
|
-
items: mockItems,
|
|
1008
|
+
wrapper.unmount();
|
|
299
1009
|
});
|
|
300
1010
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
1011
|
+
describe('sticky footer & header scrollToIndex', () => {
|
|
1012
|
+
const stickyMockItems = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
1013
|
+
|
|
1014
|
+
it('scrolls to the last item correctly with sticky footer and hostOffset', async () => {
|
|
1015
|
+
const hostRef = document.createElement('div');
|
|
1016
|
+
const hostElement = document.createElement('div');
|
|
1017
|
+
vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
|
|
1018
|
+
top: 0,
|
|
1019
|
+
left: 0,
|
|
1020
|
+
bottom: 500,
|
|
1021
|
+
right: 500,
|
|
1022
|
+
width: 500,
|
|
1023
|
+
height: 500,
|
|
1024
|
+
} as DOMRect);
|
|
1025
|
+
vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
|
|
1026
|
+
top: 50,
|
|
1027
|
+
left: 0,
|
|
1028
|
+
bottom: 550,
|
|
1029
|
+
right: 500,
|
|
1030
|
+
width: 500,
|
|
1031
|
+
height: 500,
|
|
1032
|
+
} as DOMRect);
|
|
1033
|
+
|
|
1034
|
+
const { result, wrapper } = setup({
|
|
1035
|
+
container: hostRef,
|
|
1036
|
+
hostRef,
|
|
1037
|
+
hostElement,
|
|
1038
|
+
direction: 'vertical',
|
|
1039
|
+
itemSize: 50,
|
|
1040
|
+
items: stickyMockItems,
|
|
1041
|
+
stickyStart: { y: 50 }, // 50px sticky header
|
|
1042
|
+
stickyEnd: { y: 50 }, // 50px sticky footer
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
await nextTick();
|
|
1046
|
+
await nextTick();
|
|
1047
|
+
result.updateHostOffset();
|
|
1048
|
+
|
|
1049
|
+
expect(result.totalHeight.value).toBe(600);
|
|
1050
|
+
|
|
1051
|
+
result.scrollToIndex(9, 0, { align: 'end', behavior: 'auto' });
|
|
1052
|
+
|
|
1053
|
+
await nextTick();
|
|
1054
|
+
await nextTick();
|
|
1055
|
+
|
|
1056
|
+
expect(hostRef.scrollTop).toBe(100);
|
|
1057
|
+
expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
|
|
1058
|
+
|
|
1059
|
+
wrapper.unmount();
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('renders the last item when scrolled to the end with sticky footer', async () => {
|
|
1063
|
+
const { result, wrapper } = setup({
|
|
1064
|
+
container: window,
|
|
1065
|
+
direction: 'vertical',
|
|
1066
|
+
itemSize: 50,
|
|
1067
|
+
items: stickyMockItems,
|
|
1068
|
+
stickyEnd: { y: 50 },
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
await nextTick();
|
|
1072
|
+
await nextTick();
|
|
1073
|
+
|
|
1074
|
+
window.scrollTo({ top: 50 });
|
|
1075
|
+
await nextTick();
|
|
1076
|
+
await nextTick();
|
|
1077
|
+
|
|
1078
|
+
expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
|
|
1079
|
+
|
|
1080
|
+
wrapper.unmount();
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
305
1083
|
});
|
|
306
1084
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
1085
|
+
describe('rtl support', () => {
|
|
1086
|
+
it('detects RTL mode and handles scroll position accordingly', async () => {
|
|
1087
|
+
const container = document.createElement('div');
|
|
1088
|
+
container.setAttribute('dir', 'rtl');
|
|
1089
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
1090
|
+
let scrollLeft = 0;
|
|
1091
|
+
Object.defineProperty(container, 'scrollLeft', {
|
|
1092
|
+
configurable: true,
|
|
1093
|
+
get: () => scrollLeft,
|
|
1094
|
+
set: (val) => { scrollLeft = val; },
|
|
1095
|
+
});
|
|
1096
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
1097
|
+
if (options.left !== undefined) {
|
|
1098
|
+
scrollLeft = options.left;
|
|
1099
|
+
}
|
|
1100
|
+
if (options.top !== undefined) {
|
|
1101
|
+
container.scrollTop = options.top;
|
|
1102
|
+
}
|
|
1103
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1104
|
+
});
|
|
1105
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
1106
|
+
const dir = el === container ? 'rtl' : 'ltr';
|
|
1107
|
+
return {
|
|
1108
|
+
get direction() { return dir; },
|
|
1109
|
+
} as unknown as CSSStyleDeclaration;
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
const { result, wrapper } = setup({
|
|
1113
|
+
container,
|
|
1114
|
+
direction: 'horizontal',
|
|
1115
|
+
itemSize: 100,
|
|
1116
|
+
items: mockItems,
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
await nextTick();
|
|
1120
|
+
await nextTick();
|
|
1121
|
+
|
|
1122
|
+
expect(result.isRtl.value).toBe(true);
|
|
1123
|
+
|
|
1124
|
+
container.scrollLeft = -100;
|
|
1125
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1126
|
+
await nextTick();
|
|
1127
|
+
|
|
1128
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
|
|
1129
|
+
|
|
1130
|
+
result.scrollToIndex(null, 2, { align: 'start', behavior: 'auto' });
|
|
1131
|
+
expect(container.scrollLeft).toBe(-200);
|
|
1132
|
+
wrapper.unmount();
|
|
1133
|
+
styleSpy.mockRestore();
|
|
313
1134
|
});
|
|
314
1135
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
1136
|
+
it('detects RTL mode change on a parent element', async () => {
|
|
1137
|
+
const parent = document.createElement('div');
|
|
1138
|
+
const container = document.createElement('div');
|
|
1139
|
+
parent.appendChild(container);
|
|
1140
|
+
|
|
1141
|
+
vi.useFakeTimers();
|
|
1142
|
+
|
|
1143
|
+
const spy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => ({
|
|
1144
|
+
get direction() {
|
|
1145
|
+
let current: HTMLElement | null = el as HTMLElement;
|
|
1146
|
+
while (current) {
|
|
1147
|
+
if (current.getAttribute('dir') === 'rtl') {
|
|
1148
|
+
return 'rtl';
|
|
1149
|
+
}
|
|
1150
|
+
current = current.parentElement;
|
|
1151
|
+
}
|
|
1152
|
+
return 'ltr';
|
|
1153
|
+
},
|
|
1154
|
+
} as unknown as CSSStyleDeclaration));
|
|
1155
|
+
|
|
1156
|
+
const { result, wrapper } = setup({
|
|
1157
|
+
container,
|
|
1158
|
+
direction: 'horizontal',
|
|
1159
|
+
items: mockItems,
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
await nextTick();
|
|
1163
|
+
await nextTick();
|
|
1164
|
+
|
|
1165
|
+
expect(result.isRtl.value).toBe(false);
|
|
1166
|
+
|
|
1167
|
+
parent.setAttribute('dir', 'rtl');
|
|
1168
|
+
|
|
1169
|
+
vi.advanceTimersByTime(1000);
|
|
1170
|
+
await nextTick();
|
|
320
1171
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1172
|
+
expect(result.isRtl.value).toBe(true);
|
|
1173
|
+
|
|
1174
|
+
wrapper.unmount();
|
|
1175
|
+
spy.mockRestore();
|
|
1176
|
+
vi.useRealTimers();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it('updates host offset and direction reactively', async () => {
|
|
1180
|
+
const container = document.createElement('div');
|
|
1181
|
+
const hostRef = document.createElement('div');
|
|
1182
|
+
const hostElement = document.createElement('div');
|
|
1183
|
+
|
|
1184
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
1185
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
1186
|
+
|
|
1187
|
+
let currentDir = 'ltr';
|
|
1188
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
|
|
1189
|
+
get direction() { return currentDir; },
|
|
1190
|
+
} as unknown as CSSStyleDeclaration));
|
|
1191
|
+
|
|
1192
|
+
const { result, wrapper } = setup({
|
|
1193
|
+
container,
|
|
1194
|
+
hostRef,
|
|
1195
|
+
hostElement,
|
|
1196
|
+
items: mockItems,
|
|
1197
|
+
itemSize: 50,
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
await nextTick();
|
|
1201
|
+
await nextTick();
|
|
1202
|
+
|
|
1203
|
+
vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
|
|
1204
|
+
left: 10,
|
|
1205
|
+
top: 20,
|
|
1206
|
+
toJSON: () => {},
|
|
1207
|
+
} as DOMRect);
|
|
1208
|
+
vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
|
|
1209
|
+
left: 15,
|
|
1210
|
+
top: 25,
|
|
1211
|
+
toJSON: () => {},
|
|
1212
|
+
} as DOMRect);
|
|
1213
|
+
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
|
1214
|
+
left: 0,
|
|
1215
|
+
top: 0,
|
|
1216
|
+
toJSON: () => {},
|
|
1217
|
+
} as DOMRect);
|
|
1218
|
+
|
|
1219
|
+
result.updateHostOffset();
|
|
1220
|
+
await nextTick();
|
|
1221
|
+
|
|
1222
|
+
expect(result.scrollDetails.value.displayScrollOffset.x).toBe(0);
|
|
1223
|
+
|
|
1224
|
+
currentDir = 'rtl';
|
|
1225
|
+
result.updateDirection();
|
|
1226
|
+
expect(result.isRtl.value).toBe(true);
|
|
1227
|
+
wrapper.unmount();
|
|
1228
|
+
styleSpy.mockRestore();
|
|
327
1229
|
});
|
|
328
1230
|
|
|
329
|
-
|
|
1231
|
+
it('updates host offset but not scroll logical position when RTL changes in vertical mode', async () => {
|
|
1232
|
+
const container = document.createElement('div');
|
|
1233
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
|
|
1234
|
+
|
|
1235
|
+
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
|
1236
|
+
left: 0,
|
|
1237
|
+
right: 1000,
|
|
1238
|
+
top: 0,
|
|
1239
|
+
bottom: 500,
|
|
1240
|
+
width: 1000,
|
|
1241
|
+
height: 500,
|
|
1242
|
+
} as DOMRect);
|
|
1243
|
+
|
|
1244
|
+
const hostElement = document.createElement('div');
|
|
1245
|
+
vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
|
|
1246
|
+
left: 100,
|
|
1247
|
+
right: 200,
|
|
1248
|
+
top: 0,
|
|
1249
|
+
bottom: 50,
|
|
1250
|
+
width: 100,
|
|
1251
|
+
height: 50,
|
|
1252
|
+
} as DOMRect);
|
|
1253
|
+
|
|
1254
|
+
let currentDir = 'ltr';
|
|
1255
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
|
|
1256
|
+
get direction() { return currentDir; },
|
|
1257
|
+
} as unknown as CSSStyleDeclaration));
|
|
1258
|
+
|
|
1259
|
+
const { result, wrapper } = setup({
|
|
1260
|
+
container,
|
|
1261
|
+
hostElement,
|
|
1262
|
+
direction: 'vertical',
|
|
1263
|
+
items: mockItems,
|
|
1264
|
+
itemSize: 50,
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
await nextTick();
|
|
1268
|
+
expect(result.componentOffset.x).toBe(100);
|
|
1269
|
+
|
|
1270
|
+
currentDir = 'rtl';
|
|
1271
|
+
result.updateDirection();
|
|
1272
|
+
await nextTick();
|
|
1273
|
+
|
|
1274
|
+
expect(result.isRtl.value).toBe(true);
|
|
1275
|
+
expect(result.componentOffset.x).toBe(800);
|
|
1276
|
+
|
|
1277
|
+
wrapper.unmount();
|
|
1278
|
+
styleSpy.mockRestore();
|
|
1279
|
+
});
|
|
330
1280
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
1281
|
+
it('calculates host offset correctly in RTL mode', async () => {
|
|
1282
|
+
const container = document.createElement('div');
|
|
1283
|
+
container.setAttribute('dir', 'rtl');
|
|
1284
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
1285
|
+
if (options.left !== undefined) {
|
|
1286
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
|
|
1287
|
+
}
|
|
1288
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1289
|
+
});
|
|
1290
|
+
const hostElement = document.createElement('div');
|
|
1291
|
+
container.appendChild(hostElement);
|
|
1292
|
+
|
|
1293
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
1294
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
|
|
1295
|
+
|
|
1296
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
1297
|
+
const dir = el === container ? 'rtl' : 'ltr';
|
|
1298
|
+
return {
|
|
1299
|
+
get direction() { return dir; },
|
|
1300
|
+
} as unknown as CSSStyleDeclaration;
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
const { result, wrapper } = setup({
|
|
1304
|
+
container,
|
|
1305
|
+
hostElement,
|
|
1306
|
+
items: mockItems,
|
|
1307
|
+
itemSize: 50,
|
|
1308
|
+
direction: 'horizontal',
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
await nextTick();
|
|
1312
|
+
await nextTick();
|
|
1313
|
+
|
|
1314
|
+
expect(result.isRtl.value).toBe(true);
|
|
1315
|
+
|
|
1316
|
+
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
|
1317
|
+
left: 0,
|
|
1318
|
+
right: 1000,
|
|
1319
|
+
width: 1000,
|
|
1320
|
+
toJSON: () => {},
|
|
1321
|
+
} as DOMRect);
|
|
1322
|
+
vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
|
|
1323
|
+
left: 200,
|
|
1324
|
+
right: 700,
|
|
1325
|
+
width: 500,
|
|
1326
|
+
toJSON: () => {},
|
|
1327
|
+
} as DOMRect);
|
|
1328
|
+
|
|
1329
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: 0, writable: true });
|
|
1330
|
+
|
|
1331
|
+
result.updateHostOffset();
|
|
1332
|
+
await nextTick();
|
|
1333
|
+
|
|
1334
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(0);
|
|
1335
|
+
|
|
1336
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: -400, writable: true });
|
|
1337
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1338
|
+
vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
|
|
1339
|
+
bottom: 500,
|
|
1340
|
+
left: 600,
|
|
1341
|
+
right: 1100,
|
|
1342
|
+
toJSON: () => {},
|
|
1343
|
+
top: 0,
|
|
1344
|
+
width: 500,
|
|
1345
|
+
} as DOMRect);
|
|
1346
|
+
|
|
1347
|
+
result.updateHostOffset();
|
|
1348
|
+
await nextTick();
|
|
1349
|
+
await nextTick();
|
|
1350
|
+
|
|
1351
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
|
|
1352
|
+
|
|
1353
|
+
result.scrollToIndex(null, 4, { align: 'start', behavior: 'auto' });
|
|
1354
|
+
expect(container.scrollLeft).toBe(-500);
|
|
1355
|
+
wrapper.unmount();
|
|
1356
|
+
styleSpy.mockRestore();
|
|
336
1357
|
});
|
|
337
|
-
rowEl.appendChild(cell0);
|
|
338
1358
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
1359
|
+
it('calculates rendered item offsets correctly in RTL mode when scrolled', async () => {
|
|
1360
|
+
const container = document.createElement('div');
|
|
1361
|
+
container.setAttribute('dir', 'rtl');
|
|
1362
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
1363
|
+
|
|
1364
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
1365
|
+
const dir = el === container ? 'rtl' : 'ltr';
|
|
1366
|
+
return {
|
|
1367
|
+
get direction() { return dir; },
|
|
1368
|
+
} as unknown as CSSStyleDeclaration;
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
const { result, wrapper } = setup({
|
|
1372
|
+
container,
|
|
1373
|
+
direction: 'horizontal',
|
|
1374
|
+
itemSize: 100,
|
|
1375
|
+
items: mockItems,
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
await nextTick();
|
|
1379
|
+
await nextTick();
|
|
1380
|
+
|
|
1381
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: -200, writable: true });
|
|
1382
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1383
|
+
await nextTick();
|
|
1384
|
+
await nextTick();
|
|
1385
|
+
|
|
1386
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
|
|
345
1387
|
|
|
346
|
-
|
|
347
|
-
|
|
1388
|
+
const item2 = result.renderedItems.value.find((i) => i.index === 2);
|
|
1389
|
+
expect(item2).toBeDefined();
|
|
1390
|
+
expect(item2?.offset.x).toBe(200);
|
|
1391
|
+
wrapper.unmount();
|
|
1392
|
+
styleSpy.mockRestore();
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
it('maintains horizontal scroll position when switching between RTL and LTR', async () => {
|
|
1396
|
+
const container = document.createElement('div');
|
|
1397
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
1398
|
+
let scrollLeft = 0;
|
|
1399
|
+
Object.defineProperty(container, 'scrollLeft', {
|
|
1400
|
+
configurable: true,
|
|
1401
|
+
get: () => scrollLeft,
|
|
1402
|
+
set: (val) => { scrollLeft = val; },
|
|
1403
|
+
});
|
|
1404
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
1405
|
+
if (options.left !== undefined) {
|
|
1406
|
+
scrollLeft = options.left;
|
|
1407
|
+
}
|
|
1408
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
let currentDir = 'ltr';
|
|
1412
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
|
|
1413
|
+
get direction() { return currentDir; },
|
|
1414
|
+
} as unknown as CSSStyleDeclaration));
|
|
1415
|
+
|
|
1416
|
+
const { result, wrapper } = setup({
|
|
1417
|
+
container,
|
|
1418
|
+
direction: 'horizontal',
|
|
1419
|
+
itemSize: 100,
|
|
1420
|
+
items: mockItems,
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
await nextTick();
|
|
1424
|
+
await nextTick();
|
|
1425
|
+
|
|
1426
|
+
result.scrollToOffset(200, null, { behavior: 'auto' });
|
|
1427
|
+
await nextTick();
|
|
1428
|
+
expect(scrollLeft).toBe(200);
|
|
1429
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
|
|
1430
|
+
|
|
1431
|
+
currentDir = 'rtl';
|
|
1432
|
+
result.updateDirection();
|
|
1433
|
+
await nextTick();
|
|
1434
|
+
await nextTick();
|
|
1435
|
+
|
|
1436
|
+
expect(result.isRtl.value).toBe(true);
|
|
1437
|
+
expect(scrollLeft).toBe(-200);
|
|
1438
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
|
|
1439
|
+
|
|
1440
|
+
wrapper.unmount();
|
|
1441
|
+
styleSpy.mockRestore();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('maintains horizontal scroll position when switching between RTL and LTR with padding', async () => {
|
|
1445
|
+
const container = document.createElement('div');
|
|
1446
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
1447
|
+
let scrollLeft = 0;
|
|
1448
|
+
Object.defineProperty(container, 'scrollLeft', {
|
|
1449
|
+
configurable: true,
|
|
1450
|
+
get: () => scrollLeft,
|
|
1451
|
+
set: (val) => { scrollLeft = val; },
|
|
1452
|
+
});
|
|
1453
|
+
container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
1454
|
+
if (options.left !== undefined) {
|
|
1455
|
+
scrollLeft = options.left;
|
|
1456
|
+
}
|
|
1457
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
let currentDir = 'ltr';
|
|
1461
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
|
|
1462
|
+
get direction() { return currentDir; },
|
|
1463
|
+
} as unknown as CSSStyleDeclaration));
|
|
1464
|
+
|
|
1465
|
+
const { result, wrapper } = setup({
|
|
1466
|
+
container,
|
|
1467
|
+
direction: 'horizontal',
|
|
1468
|
+
itemSize: 100,
|
|
1469
|
+
items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
|
|
1470
|
+
scrollPaddingStart: 50,
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
await nextTick();
|
|
1474
|
+
await nextTick();
|
|
1475
|
+
|
|
1476
|
+
result.scrollToOffset(150, null, { behavior: 'auto' });
|
|
1477
|
+
await nextTick();
|
|
1478
|
+
expect(scrollLeft).toBe(150);
|
|
1479
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
|
|
1480
|
+
|
|
1481
|
+
currentDir = 'rtl';
|
|
1482
|
+
result.updateDirection();
|
|
1483
|
+
await nextTick();
|
|
1484
|
+
await nextTick();
|
|
1485
|
+
|
|
1486
|
+
expect(result.isRtl.value).toBe(true);
|
|
1487
|
+
expect(scrollLeft).toBe(-150);
|
|
1488
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
|
|
1489
|
+
|
|
1490
|
+
currentDir = 'ltr';
|
|
1491
|
+
result.updateDirection();
|
|
1492
|
+
await nextTick();
|
|
1493
|
+
await nextTick();
|
|
1494
|
+
|
|
1495
|
+
expect(result.isRtl.value).toBe(false);
|
|
1496
|
+
expect(scrollLeft).toBe(150);
|
|
1497
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
|
|
1498
|
+
|
|
1499
|
+
wrapper.unmount();
|
|
1500
|
+
styleSpy.mockRestore();
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
describe('scaling & large lists', () => {
|
|
1505
|
+
it('syncs display scroll when items count changes in a scaled list', async () => {
|
|
1506
|
+
const props = ref<VirtualScrollProps<MockItem>>({
|
|
1507
|
+
itemSize: 1000,
|
|
1508
|
+
items: Array.from({ length: 30000 }, (_, i) => ({ id: i })), // 30M VU
|
|
1509
|
+
});
|
|
1510
|
+
const result = useVirtualScroll(props);
|
|
1511
|
+
|
|
1512
|
+
result.scrollToOffset(null, 10000000);
|
|
1513
|
+
await nextTick();
|
|
1514
|
+
await nextTick();
|
|
1515
|
+
|
|
1516
|
+
props.value.items = Array.from({ length: 40000 }, (_, i) => ({ id: i }));
|
|
1517
|
+
await nextTick();
|
|
1518
|
+
await nextTick();
|
|
1519
|
+
|
|
1520
|
+
expect(result.scrollDetails.value.scrollOffset.y).toBeCloseTo(10000000, 0);
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
describe('coordinate scaling & bounds', () => {
|
|
1524
|
+
it('rendered item offsets do not grow excessively under scaling', async () => {
|
|
1525
|
+
const itemCount = 11000;
|
|
1526
|
+
const itemSize = 1000;
|
|
1527
|
+
const viewportHeight = 500;
|
|
1528
|
+
const items = Array.from({ length: itemCount }, (_, i) => ({ id: i }));
|
|
1529
|
+
|
|
1530
|
+
const { result, wrapper } = setup({
|
|
1531
|
+
container: window,
|
|
1532
|
+
direction: 'vertical',
|
|
1533
|
+
itemSize,
|
|
1534
|
+
items,
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
await nextTick();
|
|
1538
|
+
await nextTick();
|
|
1539
|
+
|
|
1540
|
+
// Viewport 500
|
|
1541
|
+
Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: viewportHeight });
|
|
1542
|
+
window.dispatchEvent(new Event('resize'));
|
|
1543
|
+
await nextTick();
|
|
1544
|
+
|
|
1545
|
+
// Scroll to item 100 (virtual 100000)
|
|
1546
|
+
result.scrollToIndex(100, null, { align: 'start', behavior: 'auto' });
|
|
1547
|
+
await nextTick();
|
|
1548
|
+
await nextTick();
|
|
1549
|
+
|
|
1550
|
+
const scaleY = result.scaleY.value;
|
|
1551
|
+
const expectedDisplayScroll = 100000 / scaleY;
|
|
1552
|
+
expect(window.scrollY).toBeCloseTo(expectedDisplayScroll, 0);
|
|
1553
|
+
|
|
1554
|
+
const item100 = result.renderedItems.value.find((i) => i.index === 100);
|
|
1555
|
+
expect(item100).toBeDefined();
|
|
1556
|
+
|
|
1557
|
+
// item100.offset.y should be (100 * 1000) / scaleY = 100000 / scaleY
|
|
1558
|
+
expect(item100?.offset.y).toBeCloseTo(expectedDisplayScroll, 0);
|
|
1559
|
+
|
|
1560
|
+
wrapper.unmount();
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
it('does not allow scrolling below the last item when sticky elements are present', async () => {
|
|
1564
|
+
const itemCount = 1000;
|
|
1565
|
+
const itemSize = 50;
|
|
1566
|
+
const headerHeight = 50;
|
|
1567
|
+
const footerHeight = 50;
|
|
1568
|
+
const viewportHeight = 500;
|
|
1569
|
+
const items = Array.from({ length: itemCount }, (_, i) => ({ id: i }));
|
|
1570
|
+
|
|
1571
|
+
const { result, wrapper } = setup({
|
|
1572
|
+
container: window,
|
|
1573
|
+
direction: 'vertical',
|
|
1574
|
+
itemSize,
|
|
1575
|
+
items,
|
|
1576
|
+
stickyStart: { y: headerHeight },
|
|
1577
|
+
stickyEnd: { y: footerHeight },
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
await nextTick();
|
|
1581
|
+
await nextTick();
|
|
1582
|
+
|
|
1583
|
+
expect(result.totalHeight.value).toBe(50100);
|
|
1584
|
+
|
|
1585
|
+
result.scrollToIndex(999, null, { align: 'end', behavior: 'auto' });
|
|
1586
|
+
await nextTick();
|
|
1587
|
+
await nextTick();
|
|
1588
|
+
|
|
1589
|
+
expect(window.scrollY).toBe(49600);
|
|
1590
|
+
|
|
1591
|
+
const lastItem = result.renderedItems.value.find((i) => i.index === 999);
|
|
1592
|
+
expect(lastItem).toBeDefined();
|
|
1593
|
+
|
|
1594
|
+
const itemBottomDisplay = lastItem!.offset.y + headerHeight + itemSize;
|
|
1595
|
+
expect(itemBottomDisplay - (window.scrollY + viewportHeight)).toBe(-footerHeight);
|
|
1596
|
+
|
|
1597
|
+
wrapper.unmount();
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
describe('host element & layout', () => {
|
|
1603
|
+
it('calculates hostRefOffset correctly', async () => {
|
|
1604
|
+
const container = document.createElement('div');
|
|
1605
|
+
const hostRef = document.createElement('div');
|
|
1606
|
+
vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
|
|
1607
|
+
left: 100,
|
|
1608
|
+
top: 100,
|
|
1609
|
+
width: 100,
|
|
1610
|
+
height: 50,
|
|
1611
|
+
} as DOMRect);
|
|
1612
|
+
|
|
1613
|
+
const { result, wrapper } = setup({
|
|
1614
|
+
container,
|
|
1615
|
+
hostRef,
|
|
1616
|
+
items: mockItems,
|
|
1617
|
+
itemSize: 50,
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
await nextTick();
|
|
1621
|
+
result.updateHostOffset();
|
|
1622
|
+
|
|
1623
|
+
expect(result.scrollDetails.value.displayScrollOffset.y).toBe(0);
|
|
1624
|
+
wrapper.unmount();
|
|
1625
|
+
});
|
|
348
1626
|
});
|
|
349
1627
|
});
|