@pdanpdan/virtual-scroll 0.3.0 → 0.5.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 +268 -275
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1497 -192
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2219 -896
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -2
- package/package.json +5 -1
- package/src/components/VirtualScroll.test.ts +1979 -627
- package/src/components/VirtualScroll.vue +951 -349
- package/src/components/VirtualScrollbar.test.ts +174 -0
- package/src/components/VirtualScrollbar.vue +102 -0
- package/src/composables/useVirtualScroll.test.ts +1160 -1521
- package/src/composables/useVirtualScroll.ts +1135 -791
- package/src/composables/useVirtualScrollbar.test.ts +526 -0
- package/src/composables/useVirtualScrollbar.ts +239 -0
- package/src/index.ts +4 -0
- package/src/types.ts +816 -0
- package/src/utils/fenwick-tree.test.ts +39 -39
- package/src/utils/fenwick-tree.ts +38 -18
- package/src/utils/scroll.test.ts +174 -0
- package/src/utils/scroll.ts +50 -13
- package/src/utils/virtual-scroll-logic.test.ts +2850 -0
- package/src/utils/virtual-scroll-logic.ts +901 -0
|
@@ -1,53 +1,85 @@
|
|
|
1
|
-
/* global ScrollToOptions */
|
|
2
|
-
import type { VirtualScrollProps } from '
|
|
1
|
+
/* global ScrollToOptions, ResizeObserverCallback */
|
|
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
|
-
import { getPaddingX, getPaddingY } from '../utils/scroll';
|
|
10
9
|
import { useVirtualScroll } from './useVirtualScroll';
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
// --- Mocks ---
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
interface ResizeObserverMock {
|
|
13
|
+
interface ResizeObserverMock extends ResizeObserver {
|
|
16
14
|
callback: ResizeObserverCallback;
|
|
17
15
|
targets: Set<Element>;
|
|
18
|
-
trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
|
-
|
|
18
|
+
const observers: ResizeObserverMock[] = [];
|
|
19
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
22
20
|
callback: ResizeObserverCallback;
|
|
23
|
-
|
|
24
|
-
targets: Set<Element> = new Set();
|
|
25
|
-
|
|
21
|
+
targets = new Set<Element>();
|
|
26
22
|
constructor(callback: ResizeObserverCallback) {
|
|
27
23
|
this.callback = callback;
|
|
28
|
-
|
|
24
|
+
observers.push(this as unknown as ResizeObserverMock);
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
observe(
|
|
32
|
-
this.targets.add(
|
|
27
|
+
observe(el: Element) {
|
|
28
|
+
this.targets.add(el);
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
unobserve(
|
|
36
|
-
this.targets.delete(
|
|
31
|
+
unobserve(el: Element) {
|
|
32
|
+
this.targets.delete(el);
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
disconnect() {
|
|
40
36
|
this.targets.clear();
|
|
41
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 });
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
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 });
|
|
45
71
|
}
|
|
46
|
-
|
|
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
|
+
});
|
|
47
77
|
|
|
48
|
-
|
|
78
|
+
interface MockItem {
|
|
79
|
+
id: number;
|
|
80
|
+
}
|
|
49
81
|
|
|
50
|
-
// Helper to test composable
|
|
82
|
+
// Helper to test composable
|
|
51
83
|
function setup<T>(propsValue: VirtualScrollProps<T>) {
|
|
52
84
|
const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
|
|
53
85
|
let result: ReturnType<typeof useVirtualScroll<T>>;
|
|
@@ -59,1930 +91,1537 @@ function setup<T>(propsValue: VirtualScrollProps<T>) {
|
|
|
59
91
|
},
|
|
60
92
|
});
|
|
61
93
|
const wrapper = mount(TestComponent);
|
|
62
|
-
return { result: result!,
|
|
94
|
+
return { props, result: result!, wrapper };
|
|
63
95
|
}
|
|
64
96
|
|
|
65
|
-
const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
|
|
66
|
-
const defaultProps: VirtualScrollProps<{ id: number; }> = {
|
|
67
|
-
items: mockItems,
|
|
68
|
-
itemSize: 50,
|
|
69
|
-
direction: 'vertical' as const,
|
|
70
|
-
bufferBefore: 2,
|
|
71
|
-
bufferAfter: 2,
|
|
72
|
-
container: window,
|
|
73
|
-
};
|
|
74
|
-
|
|
75
97
|
describe('useVirtualScroll', () => {
|
|
98
|
+
const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i }));
|
|
99
|
+
|
|
76
100
|
beforeEach(() => {
|
|
77
|
-
window
|
|
78
|
-
window
|
|
79
|
-
Object.defineProperty(
|
|
80
|
-
Object.defineProperty(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
if (options.top !== undefined) {
|
|
86
|
-
window.scrollY = options.top;
|
|
87
|
-
}
|
|
88
|
-
window.dispatchEvent(new Event('scroll'));
|
|
89
|
-
});
|
|
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(() => {
|
|
90
108
|
vi.clearAllMocks();
|
|
91
|
-
vi.useRealTimers();
|
|
92
109
|
});
|
|
93
110
|
|
|
94
|
-
describe('
|
|
95
|
-
it('
|
|
96
|
-
const { result } = setup({
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
});
|
|
99
119
|
|
|
100
|
-
it('should update total size when items length changes', async () => {
|
|
101
|
-
const { result, props } = setup({ ...defaultProps });
|
|
102
120
|
expect(result.totalHeight.value).toBe(5000);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await nextTick();
|
|
106
|
-
expect(result.totalHeight.value).toBe(2500);
|
|
121
|
+
expect(result.totalWidth.value).toBe(500);
|
|
122
|
+
wrapper.unmount();
|
|
107
123
|
});
|
|
108
124
|
|
|
109
|
-
it('
|
|
110
|
-
const { result,
|
|
111
|
-
|
|
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
|
+
});
|
|
112
132
|
|
|
113
|
-
props.value.itemSize = 100;
|
|
114
133
|
await nextTick();
|
|
115
|
-
expect(result.totalHeight.value).toBe(10000);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('should recalculate when gaps change', async () => {
|
|
119
|
-
const { result, props } = setup({ ...defaultProps, gap: 10 });
|
|
120
|
-
expect(result.totalHeight.value).toBe(5990); // 100 * (50 + 10) - 10
|
|
121
|
-
|
|
122
|
-
props.value.gap = 20;
|
|
123
134
|
await nextTick();
|
|
124
|
-
expect(result.totalHeight.value).toBe(6980); // 100 * (50 + 20) - 20
|
|
125
|
-
});
|
|
126
135
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
// 50*100 + (0+99)*100/2 = 5000 + 4950 = 9950
|
|
133
|
-
// 9950 - gap(0) = 9950
|
|
134
|
-
expect(result.totalHeight.value).toBe(9950);
|
|
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();
|
|
135
140
|
});
|
|
136
141
|
|
|
137
|
-
it('
|
|
138
|
-
const {
|
|
139
|
-
|
|
142
|
+
it('provides getRowHeight and getColumnWidth helpers', async () => {
|
|
143
|
+
const items: MockItem[] = [ { id: 1 }, { id: 2 } ];
|
|
144
|
+
const { result, wrapper } = setup({
|
|
140
145
|
direction: 'both',
|
|
141
|
-
|
|
142
|
-
columnWidth: 100,
|
|
146
|
+
itemSize: (item: MockItem) => (item.id === 1 ? 60 : 40),
|
|
147
|
+
columnWidth: [ 100, 200 ],
|
|
148
|
+
items,
|
|
149
|
+
gap: 10,
|
|
150
|
+
columnGap: 20,
|
|
143
151
|
});
|
|
144
|
-
expect(result.totalWidth.value).toBe(1000); // 10 * 100 - 0
|
|
145
|
-
expect(result.totalHeight.value).toBe(5000); // 100 * 50 - 0
|
|
146
|
-
});
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
const { result } = setup({ ...defaultProps, direction: 'horizontal' });
|
|
150
|
-
expect(result.totalWidth.value).toBe(5000); // 100 * 50 - 0
|
|
151
|
-
expect(result.totalHeight.value).toBe(0);
|
|
152
|
-
});
|
|
153
|
+
await nextTick();
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
itemSize: 50,
|
|
158
|
-
} as unknown as VirtualScrollProps<{ id: number; }>);
|
|
159
|
-
expect(result.renderedItems.value.length).toBeGreaterThan(0);
|
|
160
|
-
});
|
|
161
|
-
});
|
|
155
|
+
// getRowHeight returns item size WITHOUT gap
|
|
156
|
+
expect(result.getRowHeight(0)).toBe(60);
|
|
157
|
+
expect(result.getRowHeight(1)).toBe(40);
|
|
162
158
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
expect(result.
|
|
167
|
-
|
|
168
|
-
});
|
|
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();
|
|
169
164
|
|
|
170
|
-
|
|
171
|
-
const { result } = setup({
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
165
|
+
// Dynamic sizes
|
|
166
|
+
const { result: result2, wrapper: wrapper2 } = setup({
|
|
167
|
+
direction: 'vertical',
|
|
168
|
+
itemSize: 0,
|
|
169
|
+
items: [ { id: 1 } ],
|
|
170
|
+
defaultItemSize: 45,
|
|
171
|
+
});
|
|
175
172
|
|
|
176
|
-
it('should handle horizontal non-fixed size range', async () => {
|
|
177
|
-
const container = document.createElement('div');
|
|
178
|
-
Object.defineProperty(container, 'clientWidth', { value: 500 });
|
|
179
|
-
Object.defineProperty(container, 'clientHeight', { value: 500 });
|
|
180
|
-
const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined, container });
|
|
181
|
-
for (let i = 0; i < 20; i++) {
|
|
182
|
-
result.updateItemSize(i, 50, 50);
|
|
183
|
-
}
|
|
184
173
|
await nextTick();
|
|
174
|
+
expect(result2.getRowHeight(0)).toBe(45); // default before measurement
|
|
185
175
|
|
|
186
|
-
|
|
187
|
-
container.dispatchEvent(new Event('scroll'));
|
|
176
|
+
result2.updateItemSize(0, 100, 100);
|
|
188
177
|
await nextTick();
|
|
189
|
-
expect(
|
|
178
|
+
expect(result2.getRowHeight(0)).toBe(100);
|
|
179
|
+
wrapper2.unmount();
|
|
190
180
|
});
|
|
191
|
-
});
|
|
192
181
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
columnWidth: undefined,
|
|
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,
|
|
200
188
|
});
|
|
201
189
|
await nextTick();
|
|
190
|
+
expect(result.getItemSize(0)).toBe(50);
|
|
202
191
|
|
|
203
|
-
|
|
204
|
-
cell.dataset.colIndex = '0';
|
|
192
|
+
await wrapper.unmount();
|
|
205
193
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
count++;
|
|
211
|
-
return count === 1 ? 10 : null;
|
|
212
|
-
},
|
|
213
|
-
configurable: true,
|
|
194
|
+
const { result: res2, wrapper: w2 } = setup({
|
|
195
|
+
direction: 'vertical',
|
|
196
|
+
itemSize: (item: MockItem) => (item.id === 0 ? 100 : 40),
|
|
197
|
+
items: mockItems,
|
|
214
198
|
});
|
|
215
|
-
|
|
216
|
-
result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
|
|
217
199
|
await nextTick();
|
|
200
|
+
expect(res2.getItemSize(0)).toBe(100);
|
|
201
|
+
expect(res2.getItemSize(1)).toBe(40);
|
|
202
|
+
w2.unmount();
|
|
218
203
|
});
|
|
219
204
|
|
|
220
|
-
it('
|
|
221
|
-
const { result } = setup({
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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,
|
|
226
211
|
});
|
|
227
212
|
await nextTick();
|
|
228
|
-
|
|
229
|
-
const cell = document.createElement('div');
|
|
230
|
-
Object.defineProperty(cell, 'offsetWidth', { value: 200 });
|
|
231
|
-
cell.dataset.colIndex = '0';
|
|
232
|
-
|
|
233
|
-
result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
|
|
213
|
+
result.updateItemSize(0, 100, 100);
|
|
234
214
|
await nextTick();
|
|
235
|
-
|
|
236
|
-
|
|
215
|
+
// In horizontal, getItemSize uses itemSizesX - columnGap
|
|
216
|
+
expect(result.getItemSize(0)).toBe(100);
|
|
237
217
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const { result: rH } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
|
|
241
|
-
await nextTick();
|
|
242
|
-
// Estimate is 40 (new DEFAULT_ITEM_SIZE). Update with 30.
|
|
243
|
-
rH.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
|
|
244
|
-
await nextTick();
|
|
245
|
-
expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(30);
|
|
218
|
+
// Test fallback to default when not measured yet
|
|
219
|
+
expect(result.getItemSize(1)).toBe(40);
|
|
246
220
|
|
|
247
|
-
|
|
248
|
-
rH.updateItemSizes([ { index: 0, inlineSize: 25, blockSize: 25 } ]);
|
|
249
|
-
await nextTick();
|
|
250
|
-
expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(25);
|
|
221
|
+
wrapper.unmount();
|
|
251
222
|
|
|
252
|
-
|
|
253
|
-
|
|
223
|
+
const { result: res2, wrapper: w2 } = setup({
|
|
224
|
+
direction: 'vertical',
|
|
225
|
+
itemSize: 0,
|
|
226
|
+
items: mockItems,
|
|
227
|
+
gap: 10,
|
|
228
|
+
});
|
|
254
229
|
await nextTick();
|
|
255
|
-
|
|
230
|
+
res2.updateItemSize(0, 100, 100);
|
|
256
231
|
await nextTick();
|
|
257
|
-
|
|
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
|
+
});
|
|
258
244
|
|
|
259
|
-
// Subsequent update with smaller size should be applied
|
|
260
|
-
rV.updateItemSizes([ { index: 0, inlineSize: 20, blockSize: 20 } ]);
|
|
261
245
|
await nextTick();
|
|
262
|
-
expect(
|
|
246
|
+
expect(result.getColumnWidth(0)).toBe(100);
|
|
247
|
+
expect(result.getColumnWidth(1)).toBe(200);
|
|
248
|
+
expect(result.getColumnWidth(2)).toBe(100);
|
|
249
|
+
wrapper.unmount();
|
|
263
250
|
});
|
|
264
251
|
|
|
265
|
-
it('
|
|
266
|
-
const { result } = setup({
|
|
267
|
-
|
|
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
|
+
});
|
|
268
258
|
|
|
269
|
-
result.updateItemSize(0, 100, 100);
|
|
270
259
|
await nextTick();
|
|
271
|
-
|
|
272
|
-
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
|
|
273
|
-
});
|
|
260
|
+
await nextTick();
|
|
274
261
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
await nextTick();
|
|
281
|
-
expect(result.totalHeight.value).toBe(4060);
|
|
282
|
-
}
|
|
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();
|
|
283
267
|
});
|
|
284
268
|
|
|
285
|
-
it('
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
direction: 'both',
|
|
290
|
-
columnCount: 2,
|
|
291
|
-
columnWidth: val as unknown as undefined,
|
|
292
|
-
});
|
|
293
|
-
expect(result.getColumnWidth(0)).toBe(100); // DEFAULT_COLUMN_WIDTH
|
|
294
|
-
const parent = document.createElement('div');
|
|
295
|
-
const col0 = document.createElement('div');
|
|
296
|
-
Object.defineProperty(col0, 'offsetWidth', { value: 200, configurable: true });
|
|
297
|
-
col0.dataset.colIndex = '0';
|
|
298
|
-
parent.appendChild(col0);
|
|
299
|
-
result.updateItemSize(0, 200, 50, parent);
|
|
300
|
-
await nextTick();
|
|
301
|
-
expect(result.totalWidth.value).toBe(300); // 200 + 100
|
|
302
|
-
}
|
|
303
|
-
});
|
|
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
|
|
304
273
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
columnCount: 2,
|
|
310
|
-
columnWidth: undefined,
|
|
274
|
+
const { result, wrapper } = setup({
|
|
275
|
+
direction: 'vertical',
|
|
276
|
+
itemSize: 50,
|
|
277
|
+
items: sparseItems,
|
|
311
278
|
});
|
|
312
|
-
const parent = document.createElement('div');
|
|
313
|
-
const child1 = document.createElement('div');
|
|
314
|
-
Object.defineProperty(child1, 'offsetWidth', { value: 200 });
|
|
315
|
-
child1.dataset.colIndex = '0';
|
|
316
|
-
const child2 = document.createElement('div');
|
|
317
|
-
Object.defineProperty(child2, 'offsetWidth', { value: 300 });
|
|
318
|
-
child2.dataset.colIndex = '1';
|
|
319
|
-
parent.appendChild(child1);
|
|
320
|
-
parent.appendChild(child2);
|
|
321
|
-
|
|
322
|
-
result.updateItemSize(0, 500, 50, parent);
|
|
323
|
-
await nextTick();
|
|
324
|
-
expect(result.getColumnWidth(0)).toBe(200);
|
|
325
|
-
expect(result.getColumnWidth(1)).toBe(300);
|
|
326
|
-
});
|
|
327
279
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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();
|
|
332
287
|
});
|
|
333
288
|
|
|
334
|
-
it('
|
|
335
|
-
const { result } = setup({
|
|
336
|
-
|
|
337
|
-
itemSize:
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
direction: 'both',
|
|
342
|
-
columnCount: 10,
|
|
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,
|
|
343
296
|
});
|
|
344
297
|
|
|
345
|
-
expect(result.totalHeight.value).toBe(100 * 100); // 100 items * 100 defaultItemSize
|
|
346
|
-
expect(result.totalWidth.value).toBe(10 * 200); // 10 columns * 200 defaultColumnWidth
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it('should ignore small delta updates in updateItemSize only after first measurement', async () => {
|
|
350
|
-
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
351
|
-
// Default is 40. 40.1 is < 0.5 delta.
|
|
352
|
-
// First measurement should be accepted even if small delta from estimate.
|
|
353
|
-
result.updateItemSize(0, 40.1, 40.1);
|
|
354
298
|
await nextTick();
|
|
355
|
-
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(40.1);
|
|
356
|
-
|
|
357
|
-
// Second measurement with small delta from first should be ignored.
|
|
358
|
-
result.updateItemSize(0, 40.2, 40.2);
|
|
359
299
|
await nextTick();
|
|
360
|
-
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(40.1);
|
|
361
|
-
});
|
|
362
300
|
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
expect(
|
|
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);
|
|
368
306
|
|
|
369
|
-
|
|
370
|
-
await nextTick();
|
|
371
|
-
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(80);
|
|
307
|
+
wrapper.unmount();
|
|
372
308
|
});
|
|
373
309
|
|
|
374
|
-
it('
|
|
375
|
-
const { result } = setup({
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
});
|
|
379
317
|
|
|
380
|
-
result.updateItemSize(0, 100, 70);
|
|
381
318
|
await nextTick();
|
|
382
|
-
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(70);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it('should handle updateItemSize for horizontal direction', async () => {
|
|
386
|
-
const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
|
|
387
|
-
result.updateItemSize(0, 100, 50);
|
|
388
319
|
await nextTick();
|
|
389
|
-
expect(result.totalWidth.value).toBe(4060); // 4000 - 40 + 100
|
|
390
|
-
});
|
|
391
320
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
result.updateItemSize(0, 100, 100);
|
|
321
|
+
// Scroll to end (4500)
|
|
322
|
+
result.scrollToOffset(null, 4500);
|
|
395
323
|
await nextTick();
|
|
396
|
-
expect(result.totalHeight.value).toBe(4060);
|
|
397
|
-
|
|
398
|
-
// Trigger initializeSizes by changing length
|
|
399
|
-
props.value.items = Array.from({ length: 101 }, (_, i) => ({ id: i }));
|
|
400
324
|
await nextTick();
|
|
401
|
-
|
|
402
|
-
|
|
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();
|
|
403
330
|
});
|
|
404
|
-
});
|
|
405
331
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
+
});
|
|
412
341
|
|
|
413
|
-
//
|
|
414
|
-
result.
|
|
415
|
-
await nextTick();
|
|
416
|
-
expect(result.scrollDetails.value.scrollOffset.x).toBe(400);
|
|
417
|
-
});
|
|
342
|
+
// isHydrated is false initially
|
|
343
|
+
expect(result.isHydrated.value).toBe(false);
|
|
418
344
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
result.
|
|
423
|
-
|
|
424
|
-
|
|
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();
|
|
425
360
|
});
|
|
426
361
|
|
|
427
|
-
it('
|
|
428
|
-
const { result } = setup({
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
362
|
+
it('supports refresh method', async () => {
|
|
363
|
+
const { result, wrapper } = setup({
|
|
364
|
+
container: window,
|
|
365
|
+
direction: 'vertical',
|
|
366
|
+
itemSize: 50,
|
|
367
|
+
items: mockItems,
|
|
368
|
+
});
|
|
433
369
|
|
|
434
|
-
// Col past end (in grid mode)
|
|
435
|
-
const { result: r_grid } = setup({ ...defaultProps, direction: 'both', columnCount: 5, columnWidth: 100 });
|
|
436
|
-
r_grid.scrollToIndex(0, 10);
|
|
437
370
|
await nextTick();
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// Column past end in horizontal mode
|
|
441
|
-
const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal' });
|
|
442
|
-
r_horiz.scrollToIndex(0, 200);
|
|
371
|
+
result.refresh();
|
|
443
372
|
await nextTick();
|
|
373
|
+
expect(result.totalHeight.value).toBe(5000);
|
|
374
|
+
wrapper.unmount();
|
|
444
375
|
});
|
|
445
376
|
|
|
446
|
-
it('
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
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;
|
|
450
384
|
|
|
451
|
-
const {
|
|
452
|
-
|
|
385
|
+
const { wrapper } = setup({
|
|
386
|
+
container: document.createElement('div'),
|
|
387
|
+
items: mockItems,
|
|
388
|
+
});
|
|
453
389
|
|
|
454
|
-
// Current visible range: [scrollTop + paddingStart, scrollTop + viewport - paddingEnd] = [300, 700]
|
|
455
|
-
// Scroll to item at y=250. 250 < 300, so not visible.
|
|
456
|
-
// targetY < relativeScrollY + paddingStart (250 < 200 + 100)
|
|
457
|
-
result.scrollToIndex(5, null, 'auto');
|
|
458
390
|
await nextTick();
|
|
391
|
+
wrapper.unmount();
|
|
392
|
+
expect(disconnectSpy).toHaveBeenCalled();
|
|
393
|
+
globalThis.MutationObserver = oldMutationObserver;
|
|
459
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
|
+
});
|
|
460
405
|
|
|
461
|
-
it('should hit scrollToIndex X calculation branches', async () => {
|
|
462
|
-
const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', itemSize: 100 });
|
|
463
406
|
await nextTick();
|
|
464
|
-
// colIndex null
|
|
465
|
-
r_horiz.scrollToIndex(0, null);
|
|
466
|
-
// rowIndex null
|
|
467
|
-
r_horiz.scrollToIndex(null, 5);
|
|
468
407
|
await nextTick();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it('should handle scrollToOffset with element container and scrollTo method', async () => {
|
|
472
|
-
const container = document.createElement('div');
|
|
473
|
-
container.scrollTo = vi.fn();
|
|
474
|
-
const { result } = setup({ ...defaultProps, container });
|
|
475
|
-
result.scrollToOffset(100, 200);
|
|
476
|
-
expect(container.scrollTo).toHaveBeenCalled();
|
|
477
|
-
});
|
|
478
408
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
Object.defineProperty(container, 'scrollLeft', { value: 50, writable: true });
|
|
482
|
-
Object.defineProperty(container, 'scrollTop', { value: 60, writable: true });
|
|
409
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: 500, writable: true });
|
|
410
|
+
document.dispatchEvent(new Event('scroll'));
|
|
483
411
|
|
|
484
|
-
const { result } = setup({ ...defaultProps, container });
|
|
485
412
|
await nextTick();
|
|
486
|
-
|
|
487
|
-
// Pass null to x and y to trigger fallbacks to currentX and currentY
|
|
488
|
-
result.scrollToOffset(null, null);
|
|
489
413
|
await nextTick();
|
|
490
414
|
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
expect(result.scrollDetails.value.scrollOffset.y).toBe(60);
|
|
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();
|
|
496
419
|
});
|
|
497
420
|
|
|
498
|
-
it('
|
|
499
|
-
const
|
|
500
|
-
|
|
421
|
+
it('supports programmatic scrolling', async () => {
|
|
422
|
+
const { result, wrapper } = setup({
|
|
423
|
+
container: window,
|
|
424
|
+
direction: 'vertical',
|
|
425
|
+
itemSize: 50,
|
|
426
|
+
items: mockItems,
|
|
427
|
+
});
|
|
501
428
|
|
|
502
|
-
// Horizontal direction: isVertical will be false, so targetY padding fallback will be 0
|
|
503
|
-
const { result } = setup({ ...defaultProps, container, direction: 'horizontal', scrollPaddingStart: 10 });
|
|
504
429
|
await nextTick();
|
|
505
|
-
|
|
506
|
-
result.scrollToOffset(100, 100);
|
|
507
430
|
await nextTick();
|
|
508
|
-
// targetY = 100 + hostOffset.y - (isVertical ? paddingStartY : 0)
|
|
509
|
-
// Since isVertical is false, it uses 0. hostOffset.y is 0 here.
|
|
510
|
-
expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
|
|
511
|
-
top: 100,
|
|
512
|
-
}));
|
|
513
431
|
|
|
514
|
-
|
|
515
|
-
const { result: r2 } = setup({ ...defaultProps, container, direction: 'vertical', scrollPaddingStart: 10 });
|
|
516
|
-
await nextTick();
|
|
517
|
-
r2.scrollToOffset(100, 100);
|
|
518
|
-
await nextTick();
|
|
519
|
-
expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
|
|
520
|
-
left: 100,
|
|
521
|
-
}));
|
|
522
|
-
});
|
|
432
|
+
result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
|
|
523
433
|
|
|
524
|
-
it('should handle scrollToOffset with window fallback when container is missing', async () => {
|
|
525
|
-
const { result } = setup({ ...defaultProps, container: undefined });
|
|
526
434
|
await nextTick();
|
|
527
|
-
result.scrollToOffset(100, 200);
|
|
528
435
|
await nextTick();
|
|
436
|
+
|
|
529
437
|
expect(window.scrollTo).toHaveBeenCalled();
|
|
438
|
+
expect(result.scrollDetails.value.currentIndex).toBe(20);
|
|
439
|
+
wrapper.unmount();
|
|
530
440
|
});
|
|
531
441
|
|
|
532
|
-
it('
|
|
533
|
-
const { result } = setup({
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
+
|
|
537
450
|
await nextTick();
|
|
538
|
-
result.scrollToIndex(null, 10);
|
|
539
451
|
await nextTick();
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
it('should handle scrollToIndex auto alignment', async () => {
|
|
543
|
-
const container = document.createElement('div');
|
|
544
|
-
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
545
|
-
Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
|
|
546
452
|
|
|
547
|
-
|
|
453
|
+
// Set a pending scroll
|
|
454
|
+
result.scrollToIndex(50, null, { align: 'start', behavior: 'smooth' });
|
|
548
455
|
await nextTick();
|
|
549
456
|
|
|
550
|
-
//
|
|
551
|
-
result.
|
|
457
|
+
// Call scrollToOffset
|
|
458
|
+
result.scrollToOffset(null, 100);
|
|
552
459
|
await nextTick();
|
|
553
460
|
|
|
554
|
-
//
|
|
555
|
-
|
|
461
|
+
// Wait for scroll timeout (250ms)
|
|
462
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
556
463
|
await nextTick();
|
|
557
464
|
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
465
|
+
// The index 50 should NOT be corrected back because pendingScroll was cleared
|
|
466
|
+
expect(window.scrollY).toBe(100);
|
|
467
|
+
wrapper.unmount();
|
|
468
|
+
});
|
|
561
469
|
|
|
562
|
-
|
|
563
|
-
result
|
|
564
|
-
|
|
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
|
+
});
|
|
565
477
|
|
|
566
|
-
// Horizontal auto align
|
|
567
|
-
const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', container, itemSize: 100 });
|
|
568
478
|
await nextTick();
|
|
569
|
-
|
|
570
|
-
r_horiz.scrollToIndex(0, 20, 'start');
|
|
571
479
|
await nextTick();
|
|
572
480
|
|
|
573
|
-
|
|
481
|
+
// Scroll to item 50 auto
|
|
482
|
+
result.scrollToIndex(50, null, { align: 'auto', behavior: 'auto' });
|
|
574
483
|
await nextTick();
|
|
575
484
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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'));
|
|
579
491
|
|
|
580
|
-
it('should handle scrollToIndex with various alignments', async () => {
|
|
581
|
-
const { result } = setup({ ...defaultProps });
|
|
582
|
-
result.scrollToIndex(50, 0, 'center');
|
|
583
|
-
await nextTick();
|
|
584
|
-
result.scrollToIndex(50, 0, 'end');
|
|
585
492
|
await nextTick();
|
|
586
|
-
result.scrollToIndex(50, 0, { x: 'center', y: 'end' });
|
|
587
493
|
await nextTick();
|
|
494
|
+
|
|
495
|
+
// It should have corrected to: 2500 - (485 - 50) = 2065.
|
|
496
|
+
expect(window.scrollY).toBe(2065);
|
|
497
|
+
wrapper.unmount();
|
|
588
498
|
});
|
|
589
499
|
|
|
590
|
-
it('
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
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 });
|
|
594
504
|
|
|
595
|
-
result
|
|
596
|
-
|
|
597
|
-
|
|
505
|
+
const { result, wrapper } = setup({
|
|
506
|
+
container,
|
|
507
|
+
itemSize: 50,
|
|
508
|
+
items: mockItems,
|
|
509
|
+
});
|
|
598
510
|
|
|
599
|
-
it('should handle scrollToIndex with auto alignment and axis preservation', async () => {
|
|
600
|
-
const { result } = setup({ ...defaultProps });
|
|
601
|
-
// Axis preservation (null index)
|
|
602
|
-
result.scrollToIndex(10, null, 'auto');
|
|
603
511
|
await nextTick();
|
|
604
|
-
result.scrollToIndex(null, 5, 'auto');
|
|
605
512
|
await nextTick();
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
it('should handle scrollToOffset with nulls to keep current position', async () => {
|
|
609
|
-
const { result } = setup({ ...defaultProps, container: window });
|
|
610
|
-
window.scrollX = 50;
|
|
611
|
-
window.scrollY = 60;
|
|
612
513
|
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
514
|
+
// Change dimensions
|
|
515
|
+
Object.defineProperty(container, 'clientHeight', { value: 800 });
|
|
516
|
+
Object.defineProperty(container, 'clientWidth', { value: 800 });
|
|
517
|
+
triggerResize(container, 800, 800);
|
|
616
518
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 }));
|
|
620
|
-
});
|
|
519
|
+
await nextTick();
|
|
520
|
+
await nextTick();
|
|
621
521
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
result.scrollToOffset(100, 200);
|
|
625
|
-
expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100, top: 200 }));
|
|
522
|
+
expect(result.scrollDetails.value.displayViewportSize.height).toBe(800);
|
|
523
|
+
wrapper.unmount();
|
|
626
524
|
});
|
|
525
|
+
});
|
|
627
526
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
// X only
|
|
638
|
-
result.scrollToOffset(300, null);
|
|
639
|
-
expect(container.scrollLeft).toBe(300);
|
|
640
|
-
|
|
641
|
-
// Y only
|
|
642
|
-
result.scrollToOffset(null, 400);
|
|
643
|
-
expect(container.scrollTop).toBe(400);
|
|
644
|
-
});
|
|
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
|
+
});
|
|
645
535
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
result.scrollToIndex(10, null, { behavior: 'smooth' });
|
|
649
|
-
expect(result.scrollDetails.value.isProgrammaticScroll).toBe(true);
|
|
536
|
+
await nextTick();
|
|
537
|
+
await nextTick();
|
|
650
538
|
|
|
651
|
-
|
|
652
|
-
expect(result.
|
|
653
|
-
});
|
|
539
|
+
// Initial estimate 100 * 40 = 4000
|
|
540
|
+
expect(result.totalHeight.value).toBe(4000);
|
|
654
541
|
|
|
655
|
-
|
|
656
|
-
const container = document.createElement('div');
|
|
657
|
-
container.scrollTo = vi.fn();
|
|
658
|
-
const { result } = setup({ ...defaultProps, container });
|
|
542
|
+
result.updateItemSize(0, 100, 100);
|
|
659
543
|
await nextTick();
|
|
660
544
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
545
|
+
// Now 1*100 + 99*40 = 4060
|
|
546
|
+
expect(result.totalHeight.value).toBe(4060);
|
|
547
|
+
wrapper.unmount();
|
|
664
548
|
});
|
|
665
549
|
|
|
666
|
-
it('
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
result.scrollToIndex(10, null, { behavior: 'auto' });
|
|
674
|
-
await nextTick();
|
|
675
|
-
expect(container.scrollTop).toBeGreaterThan(0);
|
|
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
|
+
});
|
|
676
557
|
|
|
677
|
-
// col only
|
|
678
|
-
const { result: resH } = setup({ ...defaultProps, container, direction: 'horizontal' });
|
|
679
558
|
await nextTick();
|
|
680
|
-
resH.scrollToIndex(null, 10, { behavior: 'auto' });
|
|
681
559
|
await nextTick();
|
|
682
|
-
expect(container.scrollLeft).toBeGreaterThan(0);
|
|
683
|
-
});
|
|
684
560
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// other indices are undefined
|
|
689
|
-
const { result } = setup({ ...defaultProps, items, itemSize: 50 });
|
|
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'));
|
|
690
564
|
await nextTick();
|
|
691
|
-
// only index 0 should be rendered
|
|
692
|
-
expect(result.renderedItems.value.length).toBe(1);
|
|
693
|
-
expect(result.renderedItems.value[ 0 ]?.index).toBe(0);
|
|
694
|
-
});
|
|
695
|
-
});
|
|
696
565
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
setup({ ...defaultProps });
|
|
700
|
-
window.scrollX = 150;
|
|
701
|
-
window.scrollY = 250;
|
|
702
|
-
window.dispatchEvent(new Event('scroll'));
|
|
566
|
+
// Update item 0 (above viewport) from 40 to 100
|
|
567
|
+
result.updateItemSize(0, 100, 100);
|
|
703
568
|
await nextTick();
|
|
704
|
-
});
|
|
705
569
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
removeEventListener: vi.fn(),
|
|
711
|
-
} as unknown as HTMLElement;
|
|
570
|
+
// Scroll position should have been adjusted by 60px
|
|
571
|
+
expect(window.scrollY).toBe(460);
|
|
572
|
+
wrapper.unmount();
|
|
573
|
+
});
|
|
712
574
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
575
|
+
it('supports batched updateItemSizes', async () => {
|
|
576
|
+
const { result, wrapper } = setup({
|
|
577
|
+
itemSize: 0,
|
|
578
|
+
items: mockItems,
|
|
717
579
|
});
|
|
718
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
|
+
});
|
|
719
590
|
|
|
720
|
-
|
|
721
|
-
result
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
// 2. Invalid direction (hits 958 else branch)
|
|
725
|
-
const { result: r2 } = setup({
|
|
726
|
-
...defaultProps,
|
|
727
|
-
direction: undefined as unknown as 'vertical',
|
|
728
|
-
stickyIndices: [ 0 ],
|
|
591
|
+
it('ignores out of bounds updates in updateItemSize', async () => {
|
|
592
|
+
const { result, wrapper } = setup({
|
|
593
|
+
itemSize: 0,
|
|
594
|
+
items: mockItems,
|
|
729
595
|
});
|
|
730
596
|
await nextTick();
|
|
731
|
-
|
|
597
|
+
const initialHeight = result.scrollDetails.value.totalSize.height;
|
|
598
|
+
result.updateItemSize(1000, 100, 100); // Out of bounds
|
|
732
599
|
await nextTick();
|
|
733
|
-
expect(
|
|
734
|
-
|
|
735
|
-
// 3. Unknown target in handleScroll (hits 1100 else branch)
|
|
736
|
-
const container = document.createElement('div');
|
|
737
|
-
setup({ ...defaultProps, container });
|
|
738
|
-
const event = new Event('scroll');
|
|
739
|
-
Object.defineProperty(event, 'target', { value: { } });
|
|
740
|
-
container.dispatchEvent(event);
|
|
600
|
+
expect(result.scrollDetails.value.totalSize.height).toBe(initialHeight);
|
|
601
|
+
wrapper.unmount();
|
|
741
602
|
});
|
|
742
603
|
|
|
743
|
-
it('
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
+
});
|
|
748
611
|
|
|
749
|
-
// Change container to trigger cleanup of old one
|
|
750
|
-
props.value.container = document.createElement('div');
|
|
751
612
|
await nextTick();
|
|
752
613
|
|
|
753
|
-
|
|
754
|
-
|
|
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
|
+
} ]);
|
|
755
634
|
|
|
756
|
-
it('should cleanup when unmounted and container is window', async () => {
|
|
757
|
-
const { wrapper } = setup({ ...defaultProps, container: window });
|
|
758
635
|
await nextTick();
|
|
636
|
+
expect(result.getColumnWidth(0)).toBe(120);
|
|
637
|
+
expect(result.getColumnWidth(1)).toBe(180);
|
|
759
638
|
wrapper.unmount();
|
|
760
639
|
});
|
|
761
640
|
|
|
762
|
-
it('
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
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
|
+
|
|
766
649
|
await nextTick();
|
|
767
650
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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);
|
|
771
658
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
659
|
+
result.updateItemSizes([ {
|
|
660
|
+
blockSize: 100,
|
|
661
|
+
element: rowEl,
|
|
662
|
+
index: 0,
|
|
663
|
+
inlineSize: 0,
|
|
664
|
+
} ]);
|
|
777
665
|
|
|
778
|
-
it('should handle scroll events on container element', async () => {
|
|
779
|
-
const container = document.createElement('div');
|
|
780
|
-
setup({ ...defaultProps, container });
|
|
781
|
-
Object.defineProperty(container, 'scrollTop', { value: 100 });
|
|
782
|
-
container.dispatchEvent(new Event('scroll'));
|
|
783
666
|
await nextTick();
|
|
667
|
+
expect(result.getColumnWidth(0)).toBe(150);
|
|
668
|
+
wrapper.unmount();
|
|
784
669
|
});
|
|
785
670
|
|
|
786
|
-
it('
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
+
});
|
|
793
680
|
|
|
794
|
-
Object.defineProperty(container, 'clientWidth', { value: 800 });
|
|
795
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(container));
|
|
796
|
-
if (observer) {
|
|
797
|
-
observer.trigger([ { target: container } ]);
|
|
798
|
-
}
|
|
799
681
|
await nextTick();
|
|
800
|
-
|
|
801
|
-
});
|
|
682
|
+
await nextTick();
|
|
802
683
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const container = document.createElement('div');
|
|
806
|
-
const { result } = setup({ ...defaultProps, container });
|
|
807
|
-
container.dispatchEvent(new Event('scroll'));
|
|
684
|
+
// Scroll to index 5 (250px)
|
|
685
|
+
result.scrollToOffset(0, 250, { behavior: 'auto' });
|
|
808
686
|
await nextTick();
|
|
809
|
-
expect(result.scrollDetails.value.isScrolling).toBe(true);
|
|
810
|
-
vi.advanceTimersByTime(250);
|
|
811
687
|
await nextTick();
|
|
812
|
-
expect(result.scrollDetails.value.isScrolling).toBe(false);
|
|
813
|
-
vi.useRealTimers();
|
|
814
|
-
});
|
|
815
688
|
|
|
816
|
-
|
|
817
|
-
|
|
689
|
+
expect(window.scrollY).toBe(250);
|
|
690
|
+
|
|
691
|
+
// Prepend 2 items (100px)
|
|
692
|
+
props.value.items = [ { id: -1 }, { id: -2 }, ...items ];
|
|
693
|
+
|
|
818
694
|
await nextTick();
|
|
819
|
-
props.value.container = null;
|
|
820
695
|
await nextTick();
|
|
821
|
-
props.value.container = window;
|
|
822
696
|
await nextTick();
|
|
823
|
-
});
|
|
824
697
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
window.dispatchEvent(new Event('resize'));
|
|
829
|
-
await nextTick();
|
|
698
|
+
// Scroll should be adjusted to 350
|
|
699
|
+
expect(window.scrollY).toBe(350);
|
|
700
|
+
wrapper.unmount();
|
|
830
701
|
});
|
|
831
702
|
|
|
832
|
-
it('
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
+
});
|
|
837
711
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
setup({ ...defaultProps, container });
|
|
712
|
+
await nextTick();
|
|
713
|
+
result.scrollToOffset(200, null);
|
|
714
|
+
await nextTick();
|
|
842
715
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
});
|
|
851
|
-
});
|
|
716
|
+
// Prepend 5 items
|
|
717
|
+
const newItems = [
|
|
718
|
+
...Array.from({ length: 5 }, (_, i) => ({ id: i })),
|
|
719
|
+
...initialItems,
|
|
720
|
+
];
|
|
721
|
+
props.value.items = newItems;
|
|
852
722
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const { result } = setup({
|
|
856
|
-
...defaultProps,
|
|
857
|
-
direction: 'both',
|
|
858
|
-
columnCount: 4,
|
|
859
|
-
columnWidth: [ 100, 200 ],
|
|
860
|
-
});
|
|
861
|
-
expect(result.getColumnWidth(0)).toBe(100);
|
|
862
|
-
expect(result.getColumnWidth(1)).toBe(200);
|
|
863
|
-
expect(result.totalWidth.value).toBe(600);
|
|
864
|
-
});
|
|
723
|
+
await nextTick();
|
|
724
|
+
await nextTick();
|
|
865
725
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
direction: 'both',
|
|
870
|
-
columnCount: 2,
|
|
871
|
-
columnWidth: [ 0 ] as unknown as number[],
|
|
872
|
-
});
|
|
873
|
-
expect(result.getColumnWidth(0)).toBe(100); // DEFAULT_COLUMN_WIDTH
|
|
726
|
+
// Should have added 5 * 100 = 500px to scroll position
|
|
727
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(700);
|
|
728
|
+
wrapper.unmount();
|
|
874
729
|
});
|
|
875
730
|
|
|
876
|
-
it('
|
|
877
|
-
const { result } = setup({
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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,
|
|
882
737
|
});
|
|
883
|
-
expect(result.getColumnWidth(0)).toBe(100);
|
|
884
|
-
expect(result.totalWidth.value).toBe(1500); // 5*100 + 5*200 - 0
|
|
885
|
-
});
|
|
886
738
|
|
|
887
|
-
|
|
888
|
-
const { result } = setup({
|
|
889
|
-
...defaultProps,
|
|
890
|
-
direction: 'both',
|
|
891
|
-
columnCount: 2,
|
|
892
|
-
columnWidth: undefined,
|
|
893
|
-
});
|
|
894
|
-
expect(result.getColumnWidth(0)).toBe(100);
|
|
895
|
-
});
|
|
739
|
+
await nextTick();
|
|
896
740
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const { result } = setup({
|
|
900
|
-
...defaultProps,
|
|
901
|
-
direction: 'both',
|
|
902
|
-
columnCount: 50,
|
|
903
|
-
columnWidth: undefined,
|
|
904
|
-
container,
|
|
905
|
-
});
|
|
906
|
-
// Initialize some column widths
|
|
907
|
-
for (let i = 0; i < 20; i++) {
|
|
908
|
-
const parent = document.createElement('div');
|
|
909
|
-
const child = document.createElement('div');
|
|
910
|
-
Object.defineProperty(child, 'offsetWidth', { value: 100 });
|
|
911
|
-
child.dataset.colIndex = String(i);
|
|
912
|
-
parent.appendChild(child);
|
|
913
|
-
result.updateItemSize(0, 100, 50, parent);
|
|
914
|
-
}
|
|
915
|
-
await nextTick();
|
|
916
|
-
expect(result.columnRange.value.end).toBeGreaterThan(result.columnRange.value.start);
|
|
917
|
-
});
|
|
741
|
+
// Initially estimated
|
|
742
|
+
expect(result.getItemSize(0)).toBe(100);
|
|
918
743
|
|
|
919
|
-
|
|
920
|
-
|
|
744
|
+
// Update size
|
|
745
|
+
result.updateItemSize(0, 150, 500);
|
|
921
746
|
await nextTick();
|
|
922
|
-
expect(result.
|
|
923
|
-
|
|
747
|
+
expect(result.getItemSize(0)).toBe(150);
|
|
748
|
+
expect(result.scrollDetails.value.totalSize.width).toBe(150 + 99 * 100);
|
|
924
749
|
|
|
925
|
-
|
|
926
|
-
const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 10, columnWidth: 100 });
|
|
927
|
-
await nextTick();
|
|
928
|
-
expect(result.columnRange.value.start).toBe(0);
|
|
750
|
+
wrapper.unmount();
|
|
929
751
|
});
|
|
930
752
|
});
|
|
931
753
|
|
|
932
|
-
describe('
|
|
933
|
-
it('
|
|
934
|
-
const
|
|
935
|
-
|
|
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();
|
|
936
759
|
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
760
|
+
const { result, wrapper } = setup({
|
|
761
|
+
container,
|
|
762
|
+
direction: 'vertical',
|
|
763
|
+
itemSize: 0,
|
|
764
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
765
|
+
});
|
|
943
766
|
|
|
944
|
-
it('should make sticky items active when scrolled past', async () => {
|
|
945
|
-
const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ] });
|
|
946
767
|
await nextTick();
|
|
947
|
-
|
|
948
|
-
result.scrollToOffset(0, 100);
|
|
949
768
|
await nextTick();
|
|
950
769
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
it('should include current sticky item in rendered items even if range is ahead', async () => {
|
|
956
|
-
const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ], bufferBefore: 0 });
|
|
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'));
|
|
957
774
|
await nextTick();
|
|
958
775
|
|
|
959
|
-
//
|
|
960
|
-
result.
|
|
776
|
+
// Simulate measurement update while scrolling
|
|
777
|
+
result.updateItemSize(0, 100, 100);
|
|
961
778
|
await nextTick();
|
|
962
779
|
|
|
963
|
-
expect(
|
|
964
|
-
const item0 = result.renderedItems.value.find((i) => i.index === 0);
|
|
965
|
-
expect(item0).toBeDefined();
|
|
966
|
-
expect(item0?.isStickyActive).toBe(true);
|
|
967
|
-
});
|
|
780
|
+
expect(container.scrollTo).toHaveBeenCalledTimes(1);
|
|
968
781
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
Object.defineProperty(container, 'scrollTop', { value: 480, writable: true });
|
|
973
|
-
const { result } = setup({ ...defaultProps, container, stickyIndices: [ 0, 10 ], itemSize: 50 });
|
|
974
|
-
// We need to trigger scroll to update scrollY
|
|
975
|
-
container.dispatchEvent(new Event('scroll'));
|
|
782
|
+
// End scroll by waiting for timeout (default 250ms)
|
|
783
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
784
|
+
await nextTick();
|
|
976
785
|
|
|
977
|
-
|
|
978
|
-
expect(
|
|
786
|
+
// Now correction should trigger
|
|
787
|
+
expect(container.scrollTo).toHaveBeenCalledTimes(2);
|
|
788
|
+
wrapper.unmount();
|
|
979
789
|
});
|
|
980
790
|
|
|
981
|
-
it('
|
|
791
|
+
it('performs scroll correction when item sizes change during/after scrollToIndex', async () => {
|
|
982
792
|
const container = document.createElement('div');
|
|
983
|
-
Object.defineProperty(container, '
|
|
984
|
-
|
|
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
|
+
});
|
|
985
806
|
|
|
986
|
-
const { result } = setup({
|
|
987
|
-
...defaultProps,
|
|
988
|
-
direction: 'horizontal',
|
|
807
|
+
const { result, wrapper } = setup({
|
|
989
808
|
container,
|
|
990
|
-
|
|
991
|
-
itemSize:
|
|
992
|
-
|
|
809
|
+
direction: 'vertical',
|
|
810
|
+
itemSize: 0,
|
|
811
|
+
items: mockItems,
|
|
812
|
+
defaultItemSize: 40,
|
|
993
813
|
});
|
|
994
|
-
container.dispatchEvent(new Event('scroll'));
|
|
995
|
-
|
|
996
|
-
const item0 = result.renderedItems.value.find((i) => i.index === 0);
|
|
997
|
-
expect(item0!.offset.x).toBeLessThanOrEqual(450);
|
|
998
|
-
});
|
|
999
814
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
Object.defineProperty(container, 'clientHeight', { value: 500 });
|
|
1003
|
-
Object.defineProperty(container, 'scrollTop', { value: 380, writable: true });
|
|
815
|
+
await nextTick();
|
|
816
|
+
await nextTick();
|
|
1004
817
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
itemSize: undefined, // dynamic
|
|
1009
|
-
stickyIndices: [ 0, 10 ],
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
// Item 0 is sticky. Item 10 is next sticky.
|
|
1013
|
-
// Default size = 40.
|
|
1014
|
-
// nextStickyY = itemSizesY.query(10) = 400.
|
|
1015
|
-
// distance = 400 - 380 = 20.
|
|
1016
|
-
// 20 < 40 (item 0 height), so it should be pushed.
|
|
1017
|
-
// stickyOffset.y = -(40 - 20) = -20.
|
|
1018
|
-
const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
|
|
1019
|
-
expect(stickyItem?.stickyOffset.y).toBe(-20);
|
|
1020
|
-
});
|
|
818
|
+
result.scrollToIndex(50, null, { align: 'start', behavior: 'auto' });
|
|
819
|
+
await nextTick();
|
|
820
|
+
await nextTick();
|
|
1021
821
|
|
|
1022
|
-
|
|
1023
|
-
const container = document.createElement('div');
|
|
1024
|
-
Object.defineProperty(container, 'clientWidth', { value: 500 });
|
|
1025
|
-
Object.defineProperty(container, 'scrollLeft', { value: 380, writable: true });
|
|
822
|
+
expect(scrollTop).toBe(2000);
|
|
1026
823
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
itemSize: undefined, // dynamic
|
|
1032
|
-
stickyIndices: [ 0, 10 ],
|
|
1033
|
-
});
|
|
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();
|
|
1034
828
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
// 20 < 40, so stickyOffset.x = -20.
|
|
1038
|
-
const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
|
|
1039
|
-
expect(stickyItem?.stickyOffset.x).toBe(-20);
|
|
829
|
+
expect(scrollTop).toBe(2060);
|
|
830
|
+
wrapper.unmount();
|
|
1040
831
|
});
|
|
1041
|
-
});
|
|
1042
832
|
|
|
1043
|
-
|
|
1044
|
-
it('should restore scroll position when items are prepended', async () => {
|
|
1045
|
-
vi.useFakeTimers();
|
|
833
|
+
it('performs scroll correction for "end" alignment when item sizes change', async () => {
|
|
1046
834
|
const container = document.createElement('div');
|
|
1047
|
-
Object.defineProperty(container, 'clientHeight', { value: 500 });
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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'));
|
|
1051
847
|
});
|
|
1052
848
|
|
|
1053
|
-
const
|
|
1054
|
-
const { result, props } = setup({
|
|
1055
|
-
...defaultProps,
|
|
1056
|
-
items,
|
|
849
|
+
const { result, wrapper } = setup({
|
|
1057
850
|
container,
|
|
1058
|
-
|
|
1059
|
-
|
|
851
|
+
direction: 'vertical',
|
|
852
|
+
itemSize: 0,
|
|
853
|
+
items: mockItems,
|
|
854
|
+
defaultItemSize: 40,
|
|
1060
855
|
});
|
|
1061
|
-
|
|
856
|
+
|
|
857
|
+
await nextTick();
|
|
858
|
+
await nextTick();
|
|
859
|
+
|
|
860
|
+
result.scrollToIndex(50, null, { align: 'end', behavior: 'auto' });
|
|
861
|
+
await nextTick();
|
|
1062
862
|
await nextTick();
|
|
1063
863
|
|
|
1064
|
-
expect(
|
|
864
|
+
expect(scrollTop).toBe(1540);
|
|
1065
865
|
|
|
1066
|
-
|
|
1067
|
-
const newItems = [ { id: -1 }, { id: -2 }, ...items ];
|
|
1068
|
-
props.value.items = newItems;
|
|
866
|
+
result.updateItemSize(50, 100, 100);
|
|
1069
867
|
await nextTick();
|
|
1070
|
-
// Trigger initializeSizes
|
|
1071
868
|
await nextTick();
|
|
1072
869
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
vi.useRealTimers();
|
|
870
|
+
expect(scrollTop).toBe(1600);
|
|
871
|
+
wrapper.unmount();
|
|
1076
872
|
});
|
|
1077
873
|
|
|
1078
|
-
it('
|
|
1079
|
-
vi.useFakeTimers();
|
|
874
|
+
it('correctly scrolls to the end of a dynamic list with corrections', async () => {
|
|
1080
875
|
const container = document.createElement('div');
|
|
1081
|
-
Object.defineProperty(container, '
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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'));
|
|
1085
888
|
});
|
|
1086
889
|
|
|
1087
|
-
const
|
|
1088
|
-
const { result, props } = setup({
|
|
1089
|
-
...defaultProps,
|
|
1090
|
-
direction: 'horizontal',
|
|
1091
|
-
items,
|
|
890
|
+
const { result, wrapper } = setup({
|
|
1092
891
|
container,
|
|
1093
|
-
|
|
1094
|
-
|
|
892
|
+
direction: 'vertical',
|
|
893
|
+
itemSize: 0,
|
|
894
|
+
items: mockItems,
|
|
895
|
+
defaultItemSize: 40,
|
|
1095
896
|
});
|
|
1096
|
-
|
|
897
|
+
|
|
898
|
+
await nextTick();
|
|
1097
899
|
await nextTick();
|
|
1098
900
|
|
|
1099
|
-
|
|
901
|
+
result.scrollToIndex(99, null, { align: 'end', behavior: 'auto' });
|
|
902
|
+
await nextTick();
|
|
903
|
+
await nextTick();
|
|
904
|
+
expect(scrollTop).toBe(3500);
|
|
1100
905
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
props.value.items = newItems;
|
|
906
|
+
const updates = Array.from({ length: 90 }, (_, i) => ({ index: i, inlineSize: 100, blockSize: 50 }));
|
|
907
|
+
result.updateItemSizes(updates);
|
|
1104
908
|
await nextTick();
|
|
1105
909
|
await nextTick();
|
|
1106
910
|
|
|
1107
|
-
expect(
|
|
1108
|
-
|
|
911
|
+
expect(scrollTop).toBe(4400);
|
|
912
|
+
wrapper.unmount();
|
|
1109
913
|
});
|
|
914
|
+
});
|
|
1110
915
|
|
|
1111
|
-
|
|
1112
|
-
|
|
916
|
+
describe('sticky elements', () => {
|
|
917
|
+
it('renders sticky indices correctly using optimized search', async () => {
|
|
1113
918
|
const container = document.createElement('div');
|
|
1114
|
-
Object.defineProperty(container, '
|
|
1115
|
-
container
|
|
1116
|
-
|
|
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'));
|
|
1117
929
|
});
|
|
1118
930
|
|
|
1119
|
-
const
|
|
1120
|
-
const { props } = setup({
|
|
1121
|
-
...defaultProps,
|
|
1122
|
-
items,
|
|
931
|
+
const { result, wrapper } = setup({
|
|
1123
932
|
container,
|
|
1124
|
-
|
|
1125
|
-
|
|
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,
|
|
1126
939
|
});
|
|
1127
|
-
await nextTick();
|
|
1128
940
|
|
|
1129
|
-
// Prepend 1 item with id -1 (size 100)
|
|
1130
|
-
const newItems = [ { id: -1 }, ...items ];
|
|
1131
|
-
props.value.items = newItems;
|
|
1132
941
|
await nextTick();
|
|
1133
942
|
await nextTick();
|
|
1134
943
|
|
|
1135
|
-
|
|
1136
|
-
expect(container.scrollTop).toBe(200);
|
|
1137
|
-
vi.useRealTimers();
|
|
1138
|
-
});
|
|
944
|
+
expect(result.renderedItems.value.map((i) => i.index)).toEqual([ 0, 1, 2, 3 ]);
|
|
1139
945
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
|
|
1143
|
-
const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
|
1144
|
-
const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: false });
|
|
946
|
+
container.scrollTop = 100;
|
|
947
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1145
948
|
await nextTick();
|
|
1146
|
-
|
|
1147
|
-
const newItems = [ { id: -1 }, ...items ];
|
|
1148
|
-
props.value.items = newItems;
|
|
1149
949
|
await nextTick();
|
|
1150
|
-
await nextTick();
|
|
1151
|
-
expect(container.scrollTop).toBe(100);
|
|
1152
|
-
});
|
|
1153
950
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
|
1158
|
-
const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: true });
|
|
1159
|
-
await nextTick();
|
|
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);
|
|
1160
954
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
await nextTick();
|
|
955
|
+
container.scrollTop = 500;
|
|
956
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1164
957
|
await nextTick();
|
|
1165
|
-
expect(container.scrollTop).toBe(100);
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
it('should update pendingScroll rowIndex when items are prepended', async () => {
|
|
1169
|
-
const container = document.createElement('div');
|
|
1170
|
-
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
1171
|
-
Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
|
|
1172
|
-
const { result, props } = setup({ ...defaultProps, container, restoreScrollOnPrepend: true });
|
|
1173
958
|
await nextTick();
|
|
1174
959
|
|
|
1175
|
-
result.
|
|
1176
|
-
|
|
1177
|
-
|
|
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();
|
|
1178
967
|
});
|
|
1179
|
-
});
|
|
1180
968
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
await nextTick();
|
|
1191
|
-
expect(result.scrollDetails.value.isScrolling).toBe(false);
|
|
1192
|
-
vi.useRealTimers();
|
|
1193
|
-
});
|
|
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
|
+
});
|
|
1194
978
|
|
|
1195
|
-
it('should trigger scroll correction when treeUpdateFlag changes', async () => {
|
|
1196
|
-
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
1197
979
|
await nextTick();
|
|
1198
|
-
result.scrollToIndex(10, 0, 'start');
|
|
1199
|
-
// Trigger tree update
|
|
1200
|
-
result.updateItemSize(5, 100, 100);
|
|
1201
980
|
await nextTick();
|
|
1202
|
-
});
|
|
1203
981
|
|
|
1204
|
-
|
|
1205
|
-
const { result, props } = setup({ ...defaultProps, container: window });
|
|
1206
|
-
const host = document.createElement('div');
|
|
1207
|
-
props.value.hostElement = host;
|
|
982
|
+
result.scrollToOffset(null, 1000, { behavior: 'auto' });
|
|
1208
983
|
await nextTick();
|
|
1209
|
-
result.updateHostOffset();
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
it('should cover updateHostOffset when container is hostElement', async () => {
|
|
1213
|
-
const host = document.createElement('div');
|
|
1214
|
-
const { result } = setup({ ...defaultProps, container: host, hostElement: host });
|
|
1215
984
|
await nextTick();
|
|
1216
|
-
result.updateHostOffset();
|
|
1217
|
-
});
|
|
1218
985
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
await nextTick();
|
|
1224
|
-
result.updateHostOffset();
|
|
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();
|
|
1225
990
|
});
|
|
1226
991
|
|
|
1227
|
-
it('
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
+
});
|
|
1234
999
|
|
|
1235
|
-
const { result } = setup({ ...defaultProps, container, hostElement });
|
|
1236
1000
|
await nextTick();
|
|
1237
|
-
result.updateHostOffset();
|
|
1238
|
-
expect(result.scrollDetails.value.scrollOffset.y).toBeDefined();
|
|
1239
|
-
});
|
|
1240
1001
|
|
|
1241
|
-
|
|
1242
|
-
const { result } = setup({ ...defaultProps, itemSize: 0 });
|
|
1243
|
-
result.updateItemSize(0, 100, 100);
|
|
1002
|
+
result.scrollToOffset(200, null);
|
|
1244
1003
|
await nextTick();
|
|
1245
|
-
expect(result.totalHeight.value).toBe(4060);
|
|
1246
1004
|
|
|
1247
|
-
result.
|
|
1248
|
-
|
|
1249
|
-
|
|
1005
|
+
const item0 = result.renderedItems.value.find((i) => i.index === 0);
|
|
1006
|
+
expect(item0?.isStickyActive).toBe(true);
|
|
1007
|
+
|
|
1008
|
+
wrapper.unmount();
|
|
1250
1009
|
});
|
|
1251
1010
|
|
|
1252
|
-
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
+
});
|
|
1259
1044
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
});
|
|
1045
|
+
await nextTick();
|
|
1046
|
+
await nextTick();
|
|
1047
|
+
result.updateHostOffset();
|
|
1264
1048
|
|
|
1265
|
-
|
|
1266
|
-
const container = document.createElement('div');
|
|
1267
|
-
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
1268
|
-
Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
|
|
1269
|
-
const { result } = setup({ ...defaultProps, container, itemSize: undefined });
|
|
1270
|
-
// Set a pending scroll
|
|
1271
|
-
result.scrollToIndex(10, null, { behavior: 'smooth' });
|
|
1049
|
+
expect(result.totalHeight.value).toBe(600);
|
|
1272
1050
|
|
|
1273
|
-
|
|
1274
|
-
result.updateItemSize(0, 100, 100);
|
|
1275
|
-
await nextTick();
|
|
1276
|
-
});
|
|
1051
|
+
result.scrollToIndex(9, 0, { align: 'end', behavior: 'auto' });
|
|
1277
1052
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
const container = document.createElement('div');
|
|
1281
|
-
Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
|
|
1282
|
-
Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
|
|
1283
|
-
const { result } = setup({ ...defaultProps, container, itemSize: undefined });
|
|
1284
|
-
result.scrollToIndex(10, null, { behavior: 'smooth' });
|
|
1053
|
+
await nextTick();
|
|
1054
|
+
await nextTick();
|
|
1285
1055
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
await nextTick();
|
|
1289
|
-
expect(result.scrollDetails.value.isScrolling).toBe(true);
|
|
1056
|
+
expect(hostRef.scrollTop).toBe(100);
|
|
1057
|
+
expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
|
|
1290
1058
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
await nextTick();
|
|
1294
|
-
expect(result.scrollDetails.value.isScrolling).toBe(false);
|
|
1295
|
-
vi.useRealTimers();
|
|
1296
|
-
});
|
|
1059
|
+
wrapper.unmount();
|
|
1060
|
+
});
|
|
1297
1061
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
+
});
|
|
1301
1070
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
direction: 'vertical' as const,
|
|
1305
|
-
itemSize: getRowHeight,
|
|
1306
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1071
|
+
await nextTick();
|
|
1072
|
+
await nextTick();
|
|
1307
1073
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1074
|
+
window.scrollTo({ top: 50 });
|
|
1075
|
+
await nextTick();
|
|
1076
|
+
await nextTick();
|
|
1310
1077
|
|
|
1311
|
-
|
|
1312
|
-
// Total height should still be 5000 because getRowHeight reference didn't change
|
|
1313
|
-
// and initializeSizes hasn't been called automatically.
|
|
1314
|
-
expect(result.totalHeight.value).toBe(5000);
|
|
1078
|
+
expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
|
|
1315
1079
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
expect(result.totalHeight.value).toBe(6000);
|
|
1080
|
+
wrapper.unmount();
|
|
1081
|
+
});
|
|
1319
1082
|
});
|
|
1083
|
+
});
|
|
1320
1084
|
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
+
});
|
|
1323
1111
|
|
|
1324
|
-
const
|
|
1112
|
+
const { result, wrapper } = setup({
|
|
1113
|
+
container,
|
|
1114
|
+
direction: 'horizontal',
|
|
1115
|
+
itemSize: 100,
|
|
1325
1116
|
items: mockItems,
|
|
1326
|
-
|
|
1327
|
-
itemSize: getRowHeight,
|
|
1328
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1117
|
+
});
|
|
1329
1118
|
|
|
1330
|
-
|
|
1331
|
-
|
|
1119
|
+
await nextTick();
|
|
1120
|
+
await nextTick();
|
|
1332
1121
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1122
|
+
expect(result.isRtl.value).toBe(true);
|
|
1123
|
+
|
|
1124
|
+
container.scrollLeft = -100;
|
|
1125
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1335
1126
|
await nextTick();
|
|
1336
1127
|
|
|
1337
|
-
|
|
1338
|
-
|
|
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();
|
|
1339
1134
|
});
|
|
1340
1135
|
|
|
1341
|
-
it('
|
|
1342
|
-
const
|
|
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));
|
|
1343
1155
|
|
|
1344
|
-
const
|
|
1156
|
+
const { result, wrapper } = setup({
|
|
1157
|
+
container,
|
|
1158
|
+
direction: 'horizontal',
|
|
1345
1159
|
items: mockItems,
|
|
1346
|
-
|
|
1347
|
-
columnCount: 5,
|
|
1348
|
-
columnWidth: getColWidth,
|
|
1349
|
-
itemSize: 50,
|
|
1350
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1160
|
+
});
|
|
1351
1161
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1162
|
+
await nextTick();
|
|
1163
|
+
await nextTick();
|
|
1164
|
+
|
|
1165
|
+
expect(result.isRtl.value).toBe(false);
|
|
1354
1166
|
|
|
1355
|
-
|
|
1356
|
-
// We need to provide an element with data-col-index
|
|
1357
|
-
const element = document.createElement('div');
|
|
1358
|
-
const cell = document.createElement('div');
|
|
1359
|
-
cell.dataset.colIndex = '0';
|
|
1360
|
-
Object.defineProperty(cell, 'offsetWidth', { value: 120 });
|
|
1361
|
-
element.appendChild(cell);
|
|
1167
|
+
parent.setAttribute('dir', 'rtl');
|
|
1362
1168
|
|
|
1363
|
-
|
|
1169
|
+
vi.advanceTimersByTime(1000);
|
|
1364
1170
|
await nextTick();
|
|
1365
1171
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1172
|
+
expect(result.isRtl.value).toBe(true);
|
|
1173
|
+
|
|
1174
|
+
wrapper.unmount();
|
|
1175
|
+
spy.mockRestore();
|
|
1176
|
+
vi.useRealTimers();
|
|
1368
1177
|
});
|
|
1369
|
-
});
|
|
1370
1178
|
|
|
1371
|
-
|
|
1372
|
-
describe('SSR support', () => {
|
|
1373
|
-
it('should handle colBuffer when ssrRange is present and not scrolling', async () => {
|
|
1374
|
-
vi.useFakeTimers();
|
|
1179
|
+
it('updates host offset and direction reactively', async () => {
|
|
1375
1180
|
const container = document.createElement('div');
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
1379
|
-
if (options.left !== undefined) {
|
|
1380
|
-
Object.defineProperty(container, 'scrollLeft', { value: options.left, writable: true, configurable: true });
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1181
|
+
const hostRef = document.createElement('div');
|
|
1182
|
+
const hostElement = document.createElement('div');
|
|
1383
1183
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
container,
|
|
1387
|
-
direction: 'both',
|
|
1388
|
-
columnCount: 20,
|
|
1389
|
-
columnWidth: 100,
|
|
1390
|
-
ssrRange: { start: 0, end: 10, colStart: 1, colEnd: 2 },
|
|
1391
|
-
initialScrollIndex: 0,
|
|
1392
|
-
});
|
|
1184
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
1185
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
1393
1186
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1187
|
+
let currentDir = 'ltr';
|
|
1188
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
|
|
1189
|
+
get direction() { return currentDir; },
|
|
1190
|
+
} as unknown as CSSStyleDeclaration));
|
|
1397
1191
|
|
|
1398
|
-
|
|
1192
|
+
const { result, wrapper } = setup({
|
|
1193
|
+
container,
|
|
1194
|
+
hostRef,
|
|
1195
|
+
hostElement,
|
|
1196
|
+
items: mockItems,
|
|
1197
|
+
itemSize: 50,
|
|
1198
|
+
});
|
|
1399
1199
|
|
|
1400
|
-
// Scroll to col 5 (offset 500)
|
|
1401
|
-
result.scrollToIndex(null, 5, { align: 'start', behavior: 'auto' });
|
|
1402
1200
|
await nextTick();
|
|
1403
|
-
|
|
1404
|
-
vi.runAllTimers(); // Clear isScrolling timeout
|
|
1405
1201
|
await nextTick();
|
|
1406
1202
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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);
|
|
1410
1218
|
|
|
1411
|
-
|
|
1412
|
-
container.dispatchEvent(new Event('scroll'));
|
|
1219
|
+
result.updateHostOffset();
|
|
1413
1220
|
await nextTick();
|
|
1414
|
-
|
|
1415
|
-
expect(result.
|
|
1416
|
-
|
|
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();
|
|
1417
1229
|
});
|
|
1418
1230
|
|
|
1419
|
-
it('
|
|
1420
|
-
vi.useFakeTimers();
|
|
1231
|
+
it('updates host offset but not scroll logical position when RTL changes in vertical mode', async () => {
|
|
1421
1232
|
const container = document.createElement('div');
|
|
1422
|
-
Object.defineProperty(container, '
|
|
1423
|
-
Object.defineProperty(container, 'scrollTop', { value: 0, writable: true, configurable: true });
|
|
1424
|
-
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
1425
|
-
if (options.top !== undefined) {
|
|
1426
|
-
Object.defineProperty(container, 'scrollTop', { value: options.top, writable: true, configurable: true });
|
|
1427
|
-
}
|
|
1428
|
-
});
|
|
1233
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
|
|
1429
1234
|
|
|
1430
|
-
|
|
1431
|
-
|
|
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({
|
|
1432
1260
|
container,
|
|
1261
|
+
hostElement,
|
|
1262
|
+
direction: 'vertical',
|
|
1263
|
+
items: mockItems,
|
|
1433
1264
|
itemSize: 50,
|
|
1434
|
-
bufferBefore: 5,
|
|
1435
|
-
ssrRange: { start: 0, end: 10 },
|
|
1436
|
-
initialScrollIndex: 10,
|
|
1437
1265
|
});
|
|
1438
1266
|
|
|
1439
|
-
await nextTick(); // schedules hydration
|
|
1440
|
-
await nextTick(); // hydration tick scrolls to 10
|
|
1441
1267
|
await nextTick();
|
|
1268
|
+
expect(result.componentOffset.x).toBe(100);
|
|
1442
1269
|
|
|
1443
|
-
|
|
1270
|
+
currentDir = 'rtl';
|
|
1271
|
+
result.updateDirection();
|
|
1444
1272
|
await nextTick();
|
|
1445
1273
|
|
|
1446
|
-
expect(result.
|
|
1447
|
-
|
|
1448
|
-
// Since ssrRange is present and isScrolling is false, bufferBefore should be 0.
|
|
1449
|
-
expect(result.renderedItems.value[ 0 ]?.index).toBe(10);
|
|
1274
|
+
expect(result.isRtl.value).toBe(true);
|
|
1275
|
+
expect(result.componentOffset.x).toBe(800);
|
|
1450
1276
|
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
await nextTick();
|
|
1454
|
-
// isScrolling is now true. bufferBefore should be 5.
|
|
1455
|
-
expect(result.renderedItems.value[ 0 ]?.index).toBe(5);
|
|
1456
|
-
vi.useRealTimers();
|
|
1277
|
+
wrapper.unmount();
|
|
1278
|
+
styleSpy.mockRestore();
|
|
1457
1279
|
});
|
|
1458
1280
|
|
|
1459
|
-
it('
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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);
|
|
1467
1292
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
items: mockItems,
|
|
1471
|
-
columnCount: 10,
|
|
1472
|
-
ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
|
|
1473
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1474
|
-
const result = useVirtualScroll(props);
|
|
1475
|
-
expect(result.columnRange.value.end).toBe(5);
|
|
1476
|
-
});
|
|
1293
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
|
|
1294
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
|
|
1477
1295
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
})
|
|
1484
|
-
const result = useVirtualScroll(props);
|
|
1485
|
-
// colEnd is 0, so it should use columnCount (10)
|
|
1486
|
-
expect(result.columnRange.value.end).toBe(10);
|
|
1487
|
-
});
|
|
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
|
+
});
|
|
1488
1302
|
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
columnCount: 10,
|
|
1494
|
-
columnWidth: 100,
|
|
1303
|
+
const { result, wrapper } = setup({
|
|
1304
|
+
container,
|
|
1305
|
+
hostElement,
|
|
1306
|
+
items: mockItems,
|
|
1495
1307
|
itemSize: 50,
|
|
1496
|
-
ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
|
|
1497
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1498
|
-
const result = useVirtualScroll(props);
|
|
1499
|
-
expect(result.totalWidth.value).toBe(300); // (5-2) * 100 - gap(0)
|
|
1500
|
-
expect(result.totalHeight.value).toBe(500); // (20-10) * 50 - gap(0)
|
|
1501
|
-
});
|
|
1502
|
-
|
|
1503
|
-
it('should handle SSR range with horizontal direction for total sizes', () => {
|
|
1504
|
-
const props = ref({
|
|
1505
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1506
1308
|
direction: 'horizontal',
|
|
1507
|
-
|
|
1508
|
-
ssrRange: { start: 10, end: 20 },
|
|
1509
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1510
|
-
const result = useVirtualScroll(props);
|
|
1511
|
-
expect(result.totalWidth.value).toBe(500); // (20-10) * 50 - gap(0)
|
|
1512
|
-
});
|
|
1513
|
-
|
|
1514
|
-
it('should handle SSR range with vertical offset in renderedItems', () => {
|
|
1515
|
-
const props = ref({
|
|
1516
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1517
|
-
direction: 'vertical',
|
|
1518
|
-
itemSize: 50,
|
|
1519
|
-
ssrRange: { start: 10, end: 20 },
|
|
1520
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1521
|
-
const result = useVirtualScroll(props);
|
|
1522
|
-
expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
|
|
1523
|
-
});
|
|
1309
|
+
});
|
|
1524
1310
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1528
|
-
direction: 'horizontal',
|
|
1529
|
-
itemSize: undefined, // dynamic
|
|
1530
|
-
ssrRange: { start: 10, end: 20 },
|
|
1531
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1532
|
-
const result = useVirtualScroll(props);
|
|
1533
|
-
// ssrOffsetX = itemSizesX.query(10) = 10 * 40 = 400
|
|
1534
|
-
expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(400);
|
|
1535
|
-
});
|
|
1311
|
+
await nextTick();
|
|
1312
|
+
await nextTick();
|
|
1536
1313
|
|
|
1537
|
-
|
|
1538
|
-
const props = ref({
|
|
1539
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1540
|
-
direction: 'vertical',
|
|
1541
|
-
itemSize: 0,
|
|
1542
|
-
ssrRange: { start: 10, end: 20 },
|
|
1543
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1544
|
-
const result = useVirtualScroll(props);
|
|
1545
|
-
expect(result.totalHeight.value).toBe(400); // (20-10) * 40 - gap(0)
|
|
1546
|
-
});
|
|
1314
|
+
expect(result.isRtl.value).toBe(true);
|
|
1547
1315
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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);
|
|
1558
1328
|
|
|
1559
|
-
|
|
1560
|
-
const props = ref({
|
|
1561
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1562
|
-
direction: 'both',
|
|
1563
|
-
columnCount: 10,
|
|
1564
|
-
itemSize: 0,
|
|
1565
|
-
ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
|
|
1566
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1567
|
-
const result = useVirtualScroll(props);
|
|
1568
|
-
expect(result.totalWidth.value).toBe(300); // (5-2) * 100
|
|
1569
|
-
expect(result.totalHeight.value).toBe(400); // (20-10) * 40
|
|
1570
|
-
});
|
|
1329
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: 0, writable: true });
|
|
1571
1330
|
|
|
1572
|
-
|
|
1573
|
-
setup({ ...defaultProps, ssrRange: { start: 50, end: 60 } });
|
|
1331
|
+
result.updateHostOffset();
|
|
1574
1332
|
await nextTick();
|
|
1575
|
-
expect(window.scrollTo).toHaveBeenCalled();
|
|
1576
|
-
});
|
|
1577
1333
|
|
|
1578
|
-
|
|
1579
|
-
const props = ref({
|
|
1580
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1581
|
-
direction: 'horizontal',
|
|
1582
|
-
itemSize: 50,
|
|
1583
|
-
ssrRange: { start: 0, end: 10, colStart: 5 },
|
|
1584
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1585
|
-
const result = useVirtualScroll(props);
|
|
1586
|
-
expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-250);
|
|
1587
|
-
});
|
|
1334
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(0);
|
|
1588
1335
|
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-500);
|
|
1600
|
-
});
|
|
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);
|
|
1601
1346
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
columnWidth: 100,
|
|
1606
|
-
direction: 'both' as const,
|
|
1607
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1608
|
-
ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
|
|
1609
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1610
|
-
const result = useVirtualScroll(propsValue);
|
|
1611
|
-
// colEnd is 0, so it should use colCount (10)
|
|
1612
|
-
// totalWidth = columnSizes.query(10) - columnSizes.query(5) = 1000 - 500 = 500
|
|
1613
|
-
expect(result.totalWidth.value).toBe(500);
|
|
1614
|
-
});
|
|
1347
|
+
result.updateHostOffset();
|
|
1348
|
+
await nextTick();
|
|
1349
|
+
await nextTick();
|
|
1615
1350
|
|
|
1616
|
-
|
|
1617
|
-
const props = ref({
|
|
1618
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1619
|
-
direction: 'both',
|
|
1620
|
-
columnCount: 10,
|
|
1621
|
-
columnWidth: 100,
|
|
1622
|
-
ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
|
|
1623
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1624
|
-
const result = useVirtualScroll(props);
|
|
1625
|
-
expect(result.totalWidth.value).toBe(500);
|
|
1626
|
-
});
|
|
1627
|
-
});
|
|
1351
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
|
|
1628
1352
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1353
|
+
result.scrollToIndex(null, 4, { align: 'start', behavior: 'auto' });
|
|
1354
|
+
expect(container.scrollLeft).toBe(-500);
|
|
1355
|
+
wrapper.unmount();
|
|
1356
|
+
styleSpy.mockRestore();
|
|
1633
1357
|
});
|
|
1634
1358
|
|
|
1635
|
-
it('
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1638
|
-
|
|
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 });
|
|
1639
1363
|
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
+
});
|
|
1644
1370
|
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1371
|
+
const { result, wrapper } = setup({
|
|
1372
|
+
container,
|
|
1373
|
+
direction: 'horizontal',
|
|
1374
|
+
itemSize: 100,
|
|
1375
|
+
items: mockItems,
|
|
1376
|
+
});
|
|
1648
1377
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
});
|
|
1378
|
+
await nextTick();
|
|
1379
|
+
await nextTick();
|
|
1652
1380
|
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
columnCount: 0,
|
|
1658
|
-
ssrRange: { start: 0, end: 0, colStart: 0, colEnd: 0 },
|
|
1659
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1660
|
-
const result = useVirtualScroll(props);
|
|
1661
|
-
expect(result.totalWidth.value).toBe(0);
|
|
1662
|
-
expect(result.totalHeight.value).toBe(0);
|
|
1663
|
-
});
|
|
1381
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: -200, writable: true });
|
|
1382
|
+
container.dispatchEvent(new Event('scroll'));
|
|
1383
|
+
await nextTick();
|
|
1384
|
+
await nextTick();
|
|
1664
1385
|
|
|
1665
|
-
|
|
1666
|
-
const props = ref({
|
|
1667
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1668
|
-
direction: 'both',
|
|
1669
|
-
columnCount: 0,
|
|
1670
|
-
ssrRange: { start: 10, end: 20, colStart: 0, colEnd: 0 },
|
|
1671
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1672
|
-
const result = useVirtualScroll(props);
|
|
1673
|
-
expect(result.totalWidth.value).toBe(0);
|
|
1674
|
-
});
|
|
1386
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
|
|
1675
1387
|
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
ssrRange: { start: 0, end: 10, colStart: 5, colEnd: 5 },
|
|
1682
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1683
|
-
const result = useVirtualScroll(props);
|
|
1684
|
-
expect(result.totalWidth.value).toBe(0);
|
|
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();
|
|
1685
1393
|
});
|
|
1686
1394
|
|
|
1687
|
-
it('
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
+
});
|
|
1696
1410
|
|
|
1697
|
-
|
|
1698
|
-
const
|
|
1699
|
-
|
|
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,
|
|
1700
1418
|
direction: 'horizontal',
|
|
1701
|
-
itemSize:
|
|
1702
|
-
|
|
1703
|
-
})
|
|
1704
|
-
const result = useVirtualScroll(props);
|
|
1705
|
-
expect(result.totalWidth.value).toBe(400); // (20-10) * 40
|
|
1706
|
-
});
|
|
1419
|
+
itemSize: 100,
|
|
1420
|
+
items: mockItems,
|
|
1421
|
+
});
|
|
1707
1422
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1711
|
-
direction: 'both',
|
|
1712
|
-
columnCount: 10,
|
|
1713
|
-
itemSize: 0,
|
|
1714
|
-
ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
|
|
1715
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1716
|
-
const result = useVirtualScroll(props);
|
|
1717
|
-
expect(result.totalWidth.value).toBe(300);
|
|
1718
|
-
});
|
|
1423
|
+
await nextTick();
|
|
1424
|
+
await nextTick();
|
|
1719
1425
|
|
|
1720
|
-
|
|
1721
|
-
const { result } = setup({ ...defaultProps, itemSize: undefined });
|
|
1722
|
-
result.updateItemSizes([ { index: -1, inlineSize: 100, blockSize: 100 } ]);
|
|
1426
|
+
result.scrollToOffset(200, null, { behavior: 'auto' });
|
|
1723
1427
|
await nextTick();
|
|
1724
|
-
|
|
1725
|
-
expect(result.
|
|
1726
|
-
});
|
|
1428
|
+
expect(scrollLeft).toBe(200);
|
|
1429
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
|
|
1727
1430
|
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 100 } ]);
|
|
1431
|
+
currentDir = 'rtl';
|
|
1432
|
+
result.updateDirection();
|
|
1433
|
+
await nextTick();
|
|
1732
1434
|
await nextTick();
|
|
1733
|
-
expect(result.totalWidth.value).toBe(0);
|
|
1734
|
-
});
|
|
1735
1435
|
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
itemSize: 50,
|
|
1740
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1741
|
-
ssrRange: { end: 20, start: 10 },
|
|
1742
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1743
|
-
const result = useVirtualScroll(propsValue);
|
|
1744
|
-
expect(result.totalWidth.value).toBe(500); // (20-10) * 50 - 0 gap
|
|
1745
|
-
});
|
|
1436
|
+
expect(result.isRtl.value).toBe(true);
|
|
1437
|
+
expect(scrollLeft).toBe(-200);
|
|
1438
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
|
|
1746
1439
|
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
direction: 'vertical' as const,
|
|
1750
|
-
itemSize: 50,
|
|
1751
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1752
|
-
ssrRange: { end: 20, start: 10 },
|
|
1753
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1754
|
-
const result = useVirtualScroll(propsValue);
|
|
1755
|
-
expect(result.totalHeight.value).toBe(500); // (20-10) * 50 - 0 gap
|
|
1440
|
+
wrapper.unmount();
|
|
1441
|
+
styleSpy.mockRestore();
|
|
1756
1442
|
});
|
|
1757
1443
|
|
|
1758
|
-
it('
|
|
1759
|
-
const
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
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
|
+
});
|
|
1769
1459
|
|
|
1770
|
-
|
|
1771
|
-
const
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
direction: 'both' as const,
|
|
1775
|
-
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1776
|
-
ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
|
|
1777
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1778
|
-
const result = useVirtualScroll(propsValue);
|
|
1779
|
-
// colEnd is 0, so it should use colCount (10)
|
|
1780
|
-
// totalWidth = (10 - 5) * 100 = 500
|
|
1781
|
-
expect(result.totalWidth.value).toBe(500);
|
|
1782
|
-
});
|
|
1460
|
+
let currentDir = 'ltr';
|
|
1461
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
|
|
1462
|
+
get direction() { return currentDir; },
|
|
1463
|
+
} as unknown as CSSStyleDeclaration));
|
|
1783
1464
|
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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();
|
|
1788
1474
|
await nextTick();
|
|
1789
|
-
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
|
|
1790
1475
|
|
|
1791
|
-
|
|
1792
|
-
result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 120 } ]);
|
|
1476
|
+
result.scrollToOffset(150, null, { behavior: 'auto' });
|
|
1793
1477
|
await nextTick();
|
|
1794
|
-
expect(
|
|
1478
|
+
expect(scrollLeft).toBe(150);
|
|
1479
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
|
|
1795
1480
|
|
|
1796
|
-
|
|
1797
|
-
result.
|
|
1481
|
+
currentDir = 'rtl';
|
|
1482
|
+
result.updateDirection();
|
|
1483
|
+
await nextTick();
|
|
1798
1484
|
await nextTick();
|
|
1799
|
-
expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
|
|
1800
|
-
});
|
|
1801
1485
|
|
|
1802
|
-
|
|
1803
|
-
expect(
|
|
1804
|
-
expect(
|
|
1805
|
-
});
|
|
1486
|
+
expect(result.isRtl.value).toBe(true);
|
|
1487
|
+
expect(scrollLeft).toBe(-150);
|
|
1488
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
|
|
1806
1489
|
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
itemSize: 50,
|
|
1812
|
-
ssrRange: { start: 10, end: 10 },
|
|
1813
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1814
|
-
const result = useVirtualScroll(propsValue);
|
|
1815
|
-
expect(result.totalWidth.value).toBe(0);
|
|
1816
|
-
});
|
|
1490
|
+
currentDir = 'ltr';
|
|
1491
|
+
result.updateDirection();
|
|
1492
|
+
await nextTick();
|
|
1493
|
+
await nextTick();
|
|
1817
1494
|
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
direction: 'horizontal' as const,
|
|
1822
|
-
itemSize: undefined,
|
|
1823
|
-
ssrRange: { start: 10, end: 10 },
|
|
1824
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1825
|
-
const result = useVirtualScroll(propsValue);
|
|
1826
|
-
expect(result.totalWidth.value).toBe(0);
|
|
1827
|
-
});
|
|
1495
|
+
expect(result.isRtl.value).toBe(false);
|
|
1496
|
+
expect(scrollLeft).toBe(150);
|
|
1497
|
+
expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
|
|
1828
1498
|
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
items: [],
|
|
1832
|
-
direction: 'horizontal' as const,
|
|
1833
|
-
itemSize: undefined,
|
|
1834
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1835
|
-
const result = useVirtualScroll(propsValue);
|
|
1836
|
-
expect(result.totalWidth.value).toBe(0);
|
|
1499
|
+
wrapper.unmount();
|
|
1500
|
+
styleSpy.mockRestore();
|
|
1837
1501
|
});
|
|
1502
|
+
});
|
|
1838
1503
|
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
const result = useVirtualScroll(propsValue);
|
|
1847
|
-
expect(result.totalHeight.value).toBe(0);
|
|
1848
|
-
});
|
|
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);
|
|
1849
1511
|
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
direction: 'vertical' as const,
|
|
1854
|
-
itemSize: undefined,
|
|
1855
|
-
ssrRange: { start: 10, end: 10 },
|
|
1856
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1857
|
-
const result = useVirtualScroll(propsValue);
|
|
1858
|
-
expect(result.totalHeight.value).toBe(0);
|
|
1859
|
-
});
|
|
1512
|
+
result.scrollToOffset(null, 10000000);
|
|
1513
|
+
await nextTick();
|
|
1514
|
+
await nextTick();
|
|
1860
1515
|
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
}) as Ref<VirtualScrollProps<unknown>>;
|
|
1867
|
-
const result = useVirtualScroll(propsValue);
|
|
1868
|
-
expect(result.totalHeight.value).toBe(0);
|
|
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);
|
|
1869
1521
|
});
|
|
1870
1522
|
|
|
1871
|
-
describe('
|
|
1872
|
-
|
|
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 }));
|
|
1873
1529
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
})
|
|
1530
|
+
const { result, wrapper } = setup({
|
|
1531
|
+
container: window,
|
|
1532
|
+
direction: 'vertical',
|
|
1533
|
+
itemSize,
|
|
1534
|
+
items,
|
|
1535
|
+
});
|
|
1880
1536
|
|
|
1881
|
-
const result = useVirtualScroll(props);
|
|
1882
1537
|
await nextTick();
|
|
1883
|
-
|
|
1884
|
-
// Measure item 0 and col 0
|
|
1885
|
-
const parent = document.createElement('div');
|
|
1886
|
-
const col0 = document.createElement('div');
|
|
1887
|
-
col0.dataset.colIndex = '0';
|
|
1888
|
-
Object.defineProperty(col0, 'offsetWidth', { value: 200 });
|
|
1889
|
-
parent.appendChild(col0);
|
|
1890
|
-
|
|
1891
|
-
result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 150, element: parent } ]);
|
|
1892
1538
|
await nextTick();
|
|
1893
1539
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
// Trigger initializeSizes by changing items length
|
|
1898
|
-
props.value.items = Array.from({ length: 11 }, (_, i) => ({ id: i }));
|
|
1540
|
+
// Viewport 500
|
|
1541
|
+
Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: viewportHeight });
|
|
1542
|
+
window.dispatchEvent(new Event('resize'));
|
|
1899
1543
|
await nextTick();
|
|
1900
1544
|
|
|
1901
|
-
//
|
|
1902
|
-
|
|
1903
|
-
expect(result.renderedItems.value[ 0 ]?.size.height).toBe(150);
|
|
1904
|
-
});
|
|
1905
|
-
|
|
1906
|
-
it('should mark items as measured when fixed size matches current size within tolerance', async () => {
|
|
1907
|
-
const props = ref({
|
|
1908
|
-
items: mockItems,
|
|
1909
|
-
direction: 'horizontal' as const,
|
|
1910
|
-
itemSize: 50,
|
|
1911
|
-
}) as Ref<VirtualScrollProps<{ id: number; }>>;
|
|
1912
|
-
|
|
1913
|
-
useVirtualScroll(props);
|
|
1545
|
+
// Scroll to item 100 (virtual 100000)
|
|
1546
|
+
result.scrollToIndex(100, null, { align: 'start', behavior: 'auto' });
|
|
1914
1547
|
await nextTick();
|
|
1915
|
-
|
|
1916
|
-
// Trigger initializeSizes again with same prop
|
|
1917
|
-
props.value.columnGap = 0;
|
|
1918
1548
|
await nextTick();
|
|
1919
|
-
// Hits the branch where Math.abs(current - target) <= 0.5
|
|
1920
|
-
});
|
|
1921
1549
|
|
|
1922
|
-
|
|
1923
|
-
const
|
|
1924
|
-
|
|
1925
|
-
direction: 'both' as const,
|
|
1926
|
-
columnCount: 2,
|
|
1927
|
-
columnWidth: 100,
|
|
1928
|
-
}) as Ref<VirtualScrollProps<{ id: number; }>>;
|
|
1550
|
+
const scaleY = result.scaleY.value;
|
|
1551
|
+
const expectedDisplayScroll = 100000 / scaleY;
|
|
1552
|
+
expect(window.scrollY).toBeCloseTo(expectedDisplayScroll, 0);
|
|
1929
1553
|
|
|
1930
|
-
|
|
1931
|
-
|
|
1554
|
+
const item100 = result.renderedItems.value.find((i) => i.index === 100);
|
|
1555
|
+
expect(item100).toBeDefined();
|
|
1932
1556
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1557
|
+
// item100.offset.y should be (100 * 1000) / scaleY = 100000 / scaleY
|
|
1558
|
+
expect(item100?.offset.y).toBeCloseTo(expectedDisplayScroll, 0);
|
|
1559
|
+
|
|
1560
|
+
wrapper.unmount();
|
|
1935
1561
|
});
|
|
1936
1562
|
|
|
1937
|
-
it('
|
|
1938
|
-
const
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
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
|
+
});
|
|
1943
1579
|
|
|
1944
|
-
const result = useVirtualScroll(props);
|
|
1945
1580
|
await nextTick();
|
|
1946
|
-
expect(result.totalWidth.value).toBe(500);
|
|
1947
|
-
|
|
1948
|
-
// Switch to vertical (resets X)
|
|
1949
|
-
props.value.direction = 'vertical';
|
|
1950
1581
|
await nextTick();
|
|
1951
|
-
expect(result.totalWidth.value).toBe(0);
|
|
1952
1582
|
|
|
1953
|
-
|
|
1954
|
-
props.value.direction = 'both';
|
|
1955
|
-
props.value.columnCount = 10;
|
|
1956
|
-
props.value.columnWidth = 100;
|
|
1957
|
-
await nextTick();
|
|
1958
|
-
expect(result.totalHeight.value).toBe(500);
|
|
1959
|
-
expect(result.totalWidth.value).toBe(1000);
|
|
1583
|
+
expect(result.totalHeight.value).toBe(50100);
|
|
1960
1584
|
|
|
1961
|
-
|
|
1962
|
-
props.value.direction = 'horizontal';
|
|
1585
|
+
result.scrollToIndex(999, null, { align: 'end', behavior: 'auto' });
|
|
1963
1586
|
await nextTick();
|
|
1964
1587
|
await nextTick();
|
|
1965
|
-
expect(result.totalHeight.value).toBe(0);
|
|
1966
|
-
});
|
|
1967
1588
|
|
|
1968
|
-
|
|
1969
|
-
const props = ref({
|
|
1970
|
-
items: mockItems,
|
|
1971
|
-
direction: 'horizontal' as const,
|
|
1972
|
-
itemSize: undefined, // dynamic
|
|
1973
|
-
}) as Ref<VirtualScrollProps<{ id: number; }>>;
|
|
1589
|
+
expect(window.scrollY).toBe(49600);
|
|
1974
1590
|
|
|
1975
|
-
const
|
|
1976
|
-
|
|
1591
|
+
const lastItem = result.renderedItems.value.find((i) => i.index === 999);
|
|
1592
|
+
expect(lastItem).toBeDefined();
|
|
1977
1593
|
|
|
1978
|
-
|
|
1979
|
-
|
|
1594
|
+
const itemBottomDisplay = lastItem!.offset.y + headerHeight + itemSize;
|
|
1595
|
+
expect(itemBottomDisplay - (window.scrollY + viewportHeight)).toBe(-footerHeight);
|
|
1980
1596
|
|
|
1981
|
-
|
|
1982
|
-
|
|
1597
|
+
wrapper.unmount();
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1983
1601
|
|
|
1984
|
-
|
|
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,
|
|
1985
1618
|
});
|
|
1619
|
+
|
|
1620
|
+
await nextTick();
|
|
1621
|
+
result.updateHostOffset();
|
|
1622
|
+
|
|
1623
|
+
expect(result.scrollDetails.value.displayScrollOffset.y).toBe(0);
|
|
1624
|
+
wrapper.unmount();
|
|
1986
1625
|
});
|
|
1987
1626
|
});
|
|
1988
1627
|
});
|