@pdanpdan/virtual-scroll 0.6.0 → 0.7.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/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +49 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +615 -559
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +2 -4
- package/src/components/VirtualScroll.vue +133 -0
- package/src/composables/useVirtualScrollbar.ts +3 -0
- package/src/types.ts +37 -0
- package/src/components/VirtualScroll.test.ts +0 -2368
- package/src/components/VirtualScrollbar.test.ts +0 -174
- package/src/composables/useVirtualScroll.test.ts +0 -1627
- package/src/composables/useVirtualScrollbar.test.ts +0 -526
- package/src/utils/fenwick-tree.test.ts +0 -134
- package/src/utils/scroll.test.ts +0 -228
- package/src/utils/virtual-scroll-logic.test.ts +0 -2867
|
@@ -1,1627 +0,0 @@
|
|
|
1
|
-
/* global ScrollToOptions, ResizeObserverCallback */
|
|
2
|
-
import type { VirtualScrollProps } from '../types';
|
|
3
|
-
import type { Ref } from 'vue';
|
|
4
|
-
|
|
5
|
-
import { mount } from '@vue/test-utils';
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import { defineComponent, nextTick, ref } from 'vue';
|
|
8
|
-
|
|
9
|
-
import { useVirtualScroll } from './useVirtualScroll';
|
|
10
|
-
|
|
11
|
-
// --- Mocks ---
|
|
12
|
-
|
|
13
|
-
interface ResizeObserverMock extends ResizeObserver {
|
|
14
|
-
callback: ResizeObserverCallback;
|
|
15
|
-
targets: Set<Element>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const observers: ResizeObserverMock[] = [];
|
|
19
|
-
globalThis.ResizeObserver = class ResizeObserver {
|
|
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
|
-
}
|
|
60
|
-
|
|
61
|
-
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
|
|
62
|
-
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
|
|
63
|
-
Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 500 });
|
|
64
|
-
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 500 });
|
|
65
|
-
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
|
|
66
|
-
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
|
|
67
|
-
|
|
68
|
-
globalThis.window.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
|
|
69
|
-
if (options.left !== undefined) {
|
|
70
|
-
Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
|
|
71
|
-
}
|
|
72
|
-
if (options.top !== undefined) {
|
|
73
|
-
Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
|
|
74
|
-
}
|
|
75
|
-
document.dispatchEvent(new Event('scroll'));
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
interface MockItem {
|
|
79
|
-
id: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Helper to test composable
|
|
83
|
-
function setup<T>(propsValue: VirtualScrollProps<T>) {
|
|
84
|
-
const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
|
|
85
|
-
let result: ReturnType<typeof useVirtualScroll<T>>;
|
|
86
|
-
|
|
87
|
-
const TestComponent = defineComponent({
|
|
88
|
-
setup() {
|
|
89
|
-
result = useVirtualScroll(props);
|
|
90
|
-
return () => null;
|
|
91
|
-
},
|
|
92
|
-
});
|
|
93
|
-
const wrapper = mount(TestComponent);
|
|
94
|
-
return { props, result: result!, wrapper };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
describe('useVirtualScroll', () => {
|
|
98
|
-
const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i }));
|
|
99
|
-
|
|
100
|
-
beforeEach(() => {
|
|
101
|
-
Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
|
|
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();
|
|
109
|
-
});
|
|
110
|
-
|
|
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();
|
|
123
|
-
});
|
|
124
|
-
|
|
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
|
-
});
|
|
141
|
-
|
|
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();
|
|
180
|
-
});
|
|
181
|
-
|
|
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
|
-
});
|
|
204
|
-
|
|
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
|
-
});
|
|
236
|
-
|
|
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();
|
|
250
|
-
});
|
|
251
|
-
|
|
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
|
-
});
|
|
268
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
const { result, wrapper } = setup({
|
|
275
|
-
direction: 'vertical',
|
|
276
|
-
itemSize: 50,
|
|
277
|
-
items: sparseItems,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
await nextTick();
|
|
281
|
-
await nextTick();
|
|
282
|
-
|
|
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();
|
|
287
|
-
});
|
|
288
|
-
|
|
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
|
-
});
|
|
331
|
-
|
|
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
|
-
});
|
|
361
|
-
|
|
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
|
-
});
|
|
376
|
-
|
|
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
|
-
});
|
|
395
|
-
});
|
|
396
|
-
|
|
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();
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it('supports programmatic scrolling', async () => {
|
|
422
|
-
const { result, wrapper } = setup({
|
|
423
|
-
container: window,
|
|
424
|
-
direction: 'vertical',
|
|
425
|
-
itemSize: 50,
|
|
426
|
-
items: mockItems,
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
await nextTick();
|
|
430
|
-
await nextTick();
|
|
431
|
-
|
|
432
|
-
result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
|
|
433
|
-
|
|
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();
|
|
456
|
-
|
|
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();
|
|
468
|
-
});
|
|
469
|
-
|
|
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();
|
|
484
|
-
|
|
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
|
-
});
|
|
499
|
-
|
|
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 });
|
|
504
|
-
|
|
505
|
-
const { result, wrapper } = setup({
|
|
506
|
-
container,
|
|
507
|
-
itemSize: 50,
|
|
508
|
-
items: mockItems,
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
await nextTick();
|
|
512
|
-
await nextTick();
|
|
513
|
-
|
|
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
|
-
});
|
|
525
|
-
});
|
|
526
|
-
|
|
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();
|
|
639
|
-
});
|
|
640
|
-
|
|
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
|
-
});
|
|
730
|
-
|
|
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
|
-
});
|
|
738
|
-
|
|
739
|
-
await nextTick();
|
|
740
|
-
|
|
741
|
-
// Initially estimated
|
|
742
|
-
expect(result.getItemSize(0)).toBe(100);
|
|
743
|
-
|
|
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);
|
|
749
|
-
|
|
750
|
-
wrapper.unmount();
|
|
751
|
-
});
|
|
752
|
-
});
|
|
753
|
-
|
|
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
|
|
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();
|
|
789
|
-
});
|
|
790
|
-
|
|
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
|
-
});
|
|
914
|
-
});
|
|
915
|
-
|
|
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();
|
|
990
|
-
});
|
|
991
|
-
|
|
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
|
-
});
|
|
999
|
-
|
|
1000
|
-
await nextTick();
|
|
1001
|
-
|
|
1002
|
-
result.scrollToOffset(200, null);
|
|
1003
|
-
await nextTick();
|
|
1004
|
-
|
|
1005
|
-
const item0 = result.renderedItems.value.find((i) => i.index === 0);
|
|
1006
|
-
expect(item0?.isStickyActive).toBe(true);
|
|
1007
|
-
|
|
1008
|
-
wrapper.unmount();
|
|
1009
|
-
});
|
|
1010
|
-
|
|
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
|
-
});
|
|
1083
|
-
});
|
|
1084
|
-
|
|
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();
|
|
1134
|
-
});
|
|
1135
|
-
|
|
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();
|
|
1171
|
-
|
|
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();
|
|
1229
|
-
});
|
|
1230
|
-
|
|
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
|
-
});
|
|
1280
|
-
|
|
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();
|
|
1357
|
-
});
|
|
1358
|
-
|
|
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);
|
|
1387
|
-
|
|
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
|
-
});
|
|
1626
|
-
});
|
|
1627
|
-
});
|