@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,980 +1,2332 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { ItemSlotProps, ScrollbarSlotProps, ScrollDetails, VirtualScrollInstance } from '../types';
|
|
2
|
+
import type { VueWrapper } from '@vue/test-utils';
|
|
3
|
+
import type { DefineComponent } from 'vue';
|
|
3
4
|
|
|
5
|
+
/* global ScrollToOptions, ResizeObserverCallback */
|
|
4
6
|
import { mount } from '@vue/test-utils';
|
|
5
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
-
import {
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { h, nextTick, ref } from 'vue';
|
|
7
9
|
|
|
10
|
+
import { displayToVirtual, virtualToDisplay } from '../utils/virtual-scroll-logic';
|
|
8
11
|
import VirtualScroll from './VirtualScroll.vue';
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
// --- Mocks ---
|
|
14
|
+
|
|
15
|
+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
|
|
16
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
|
|
17
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
|
|
18
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
|
|
19
|
+
|
|
20
|
+
HTMLElement.prototype.scrollTo = function (this: HTMLElement, options?: number | ScrollToOptions, y?: number) {
|
|
21
|
+
if (typeof options === 'object') {
|
|
22
|
+
if (options.top !== undefined) {
|
|
23
|
+
this.scrollTop = options.top;
|
|
24
|
+
}
|
|
25
|
+
if (options.left !== undefined) {
|
|
26
|
+
this.scrollLeft = options.left;
|
|
27
|
+
}
|
|
28
|
+
} else if (typeof options === 'number' && typeof y === 'number') {
|
|
29
|
+
this.scrollLeft = options;
|
|
30
|
+
this.scrollTop = y;
|
|
31
|
+
}
|
|
32
|
+
this.dispatchEvent(new (this.ownerDocument?.defaultView?.Event || Event)('scroll'));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
36
|
+
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
|
11
37
|
|
|
12
|
-
|
|
13
|
-
interface ResizeObserverMock {
|
|
38
|
+
interface ResizeObserverMock extends ResizeObserver {
|
|
14
39
|
callback: ResizeObserverCallback;
|
|
15
40
|
targets: Set<Element>;
|
|
16
|
-
trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
|
|
17
41
|
}
|
|
18
42
|
|
|
19
|
-
|
|
43
|
+
const observers: ResizeObserverMock[] = [];
|
|
44
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
20
45
|
callback: ResizeObserverCallback;
|
|
21
|
-
|
|
22
|
-
targets: Set<Element> = new Set();
|
|
23
|
-
|
|
46
|
+
targets = new Set<Element>();
|
|
24
47
|
constructor(callback: ResizeObserverCallback) {
|
|
25
48
|
this.callback = callback;
|
|
26
|
-
|
|
49
|
+
observers.push(this as unknown as ResizeObserverMock);
|
|
27
50
|
}
|
|
28
51
|
|
|
29
|
-
observe(
|
|
30
|
-
this.targets.add(
|
|
52
|
+
observe(el: Element) {
|
|
53
|
+
this.targets.add(el);
|
|
31
54
|
}
|
|
32
55
|
|
|
33
|
-
unobserve(
|
|
34
|
-
this.targets.delete(
|
|
56
|
+
unobserve(el: Element) {
|
|
57
|
+
this.targets.delete(el);
|
|
35
58
|
}
|
|
36
59
|
|
|
37
60
|
disconnect() {
|
|
38
61
|
this.targets.clear();
|
|
39
62
|
}
|
|
63
|
+
} as unknown as typeof ResizeObserver;
|
|
64
|
+
|
|
65
|
+
function triggerResize(el: Element, width: number, height: number, useBorderBox = true) {
|
|
66
|
+
const obs = observers.find((o) => o.targets.has(el));
|
|
67
|
+
if (obs) {
|
|
68
|
+
obs.callback([ {
|
|
69
|
+
...(useBorderBox ? { borderBoxSize: [ { blockSize: height, inlineSize: width } ] } : {}),
|
|
70
|
+
contentRect: {
|
|
71
|
+
bottom: height,
|
|
72
|
+
height,
|
|
73
|
+
left: 0,
|
|
74
|
+
right: width,
|
|
75
|
+
toJSON: () => '',
|
|
76
|
+
top: 0,
|
|
77
|
+
width,
|
|
78
|
+
x: 0,
|
|
79
|
+
y: 0,
|
|
80
|
+
},
|
|
81
|
+
target: el,
|
|
82
|
+
} as unknown as ResizeObserverEntry ], obs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
40
85
|
|
|
41
|
-
|
|
42
|
-
|
|
86
|
+
// Mock window.scrollTo
|
|
87
|
+
globalThis.window.scrollTo = vi.fn().mockImplementation((options) => {
|
|
88
|
+
if (options.left !== undefined) {
|
|
89
|
+
Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
|
|
43
90
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// eslint-disable-next-line test/prefer-lowercase-title
|
|
47
|
-
describe('VirtualScroll component', () => {
|
|
48
|
-
const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
49
|
-
|
|
50
|
-
interface VSInstance {
|
|
51
|
-
scrollDetails: ScrollDetails<unknown>;
|
|
52
|
-
scrollToIndex: (rowIndex: number | null, colIndex: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
|
|
53
|
-
scrollToOffset: (x: number | null, y: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
|
|
54
|
-
setItemRef: (el: unknown, index: number) => void;
|
|
55
|
-
stopProgrammaticScroll: () => void;
|
|
56
|
-
refresh: () => void;
|
|
91
|
+
if (options.top !== undefined) {
|
|
92
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
|
|
57
93
|
}
|
|
94
|
+
document.dispatchEvent(new Event('scroll'));
|
|
95
|
+
});
|
|
58
96
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
97
|
+
// --- Tests ---
|
|
98
|
+
|
|
99
|
+
interface MockItem {
|
|
100
|
+
id: number;
|
|
101
|
+
label: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('virtualScroll', () => {
|
|
105
|
+
const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
|
|
109
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
|
|
110
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
|
|
111
|
+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
|
|
112
|
+
vi.useFakeTimers({ toFake: [ 'requestAnimationFrame' ] });
|
|
113
|
+
});
|
|
64
114
|
|
|
65
|
-
|
|
66
|
-
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
observers.length = 0;
|
|
117
|
+
vi.clearAllMocks();
|
|
118
|
+
vi.useRealTimers();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('core rendering & lifecycle', () => {
|
|
122
|
+
it('renders the visible items', async () => {
|
|
67
123
|
const wrapper = mount(VirtualScroll, {
|
|
68
124
|
props: {
|
|
69
|
-
items: mockItems,
|
|
70
125
|
itemSize: 50,
|
|
126
|
+
items: mockItems,
|
|
71
127
|
},
|
|
72
128
|
slots: {
|
|
73
|
-
item:
|
|
129
|
+
item: (props: ItemSlotProps) => {
|
|
130
|
+
const { index, item } = props as ItemSlotProps<MockItem>;
|
|
131
|
+
return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
|
|
132
|
+
},
|
|
74
133
|
},
|
|
75
134
|
});
|
|
76
|
-
|
|
77
|
-
|
|
135
|
+
|
|
136
|
+
await nextTick();
|
|
137
|
+
|
|
138
|
+
const items = wrapper.findAll('.item');
|
|
139
|
+
expect(items.length).toBe(15);
|
|
140
|
+
expect(items[ 0 ]?.text()).toBe('0: Item 0');
|
|
141
|
+
expect(items[ 14 ]?.text()).toBe('14: Item 14');
|
|
78
142
|
});
|
|
79
143
|
|
|
80
|
-
it('
|
|
144
|
+
it('updates when items change', async () => {
|
|
81
145
|
const wrapper = mount(VirtualScroll, {
|
|
82
146
|
props: {
|
|
83
|
-
items: mockItems,
|
|
84
147
|
itemSize: 50,
|
|
85
|
-
|
|
86
|
-
slots: {
|
|
87
|
-
header: '<div class="header">Header</div>',
|
|
88
|
-
footer: '<div class="footer">Footer</div>',
|
|
148
|
+
items: mockItems.slice(0, 5),
|
|
89
149
|
},
|
|
90
150
|
});
|
|
91
|
-
|
|
92
|
-
expect(wrapper.
|
|
93
|
-
|
|
94
|
-
|
|
151
|
+
await nextTick();
|
|
152
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(5);
|
|
153
|
+
|
|
154
|
+
await wrapper.setProps({ items: mockItems.slice(0, 10) });
|
|
155
|
+
await nextTick();
|
|
156
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(10);
|
|
95
157
|
});
|
|
96
158
|
|
|
97
|
-
it('
|
|
159
|
+
it('supports horizontal direction', async () => {
|
|
98
160
|
const wrapper = mount(VirtualScroll, {
|
|
99
161
|
props: {
|
|
162
|
+
direction: 'horizontal',
|
|
163
|
+
itemSize: 100,
|
|
100
164
|
items: mockItems,
|
|
101
|
-
itemSize: 50,
|
|
102
165
|
},
|
|
103
166
|
});
|
|
104
|
-
|
|
105
|
-
|
|
167
|
+
await nextTick();
|
|
168
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
169
|
+
expect(container.classes()).toContain('virtual-scroll--horizontal');
|
|
170
|
+
expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.inlineSize).toBe('10000px');
|
|
171
|
+
|
|
172
|
+
// 500px / 100px = 5 visible
|
|
173
|
+
// + 5 bufferAfter = 10 total
|
|
174
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(10);
|
|
106
175
|
});
|
|
107
176
|
|
|
108
|
-
it('
|
|
177
|
+
it('supports grid mode (both directions)', async () => {
|
|
109
178
|
const wrapper = mount(VirtualScroll, {
|
|
110
179
|
props: {
|
|
111
|
-
|
|
180
|
+
columnCount: 5,
|
|
181
|
+
columnWidth: 100,
|
|
182
|
+
direction: 'both',
|
|
112
183
|
itemSize: 50,
|
|
113
|
-
|
|
184
|
+
items: mockItems,
|
|
114
185
|
},
|
|
115
186
|
});
|
|
116
187
|
await nextTick();
|
|
117
|
-
|
|
118
|
-
expect(
|
|
188
|
+
const style = (wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style;
|
|
189
|
+
expect(style.blockSize).toBe('5000px');
|
|
190
|
+
expect(style.inlineSize).toBe('500px');
|
|
191
|
+
|
|
192
|
+
// 500px / 50px = 10 visible rows
|
|
193
|
+
// + 5 bufferAfter = 15 total rows
|
|
194
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
|
|
119
195
|
});
|
|
120
196
|
|
|
121
|
-
it('
|
|
197
|
+
it('works with window as container', async () => {
|
|
122
198
|
const wrapper = mount(VirtualScroll, {
|
|
123
199
|
props: {
|
|
124
|
-
|
|
200
|
+
container: window,
|
|
125
201
|
itemSize: 50,
|
|
126
|
-
|
|
202
|
+
items: mockItems,
|
|
127
203
|
},
|
|
128
204
|
});
|
|
129
205
|
await nextTick();
|
|
130
|
-
expect(wrapper.
|
|
131
|
-
expect(wrapper.find('.virtual-scroll-item').classes()).not.toContain('virtual-scroll--debug');
|
|
206
|
+
expect(wrapper.classes()).toContain('virtual-scroll--window');
|
|
132
207
|
});
|
|
133
208
|
|
|
134
|
-
it('
|
|
209
|
+
it('unmounts cleanly', async () => {
|
|
135
210
|
const wrapper = mount(VirtualScroll, {
|
|
136
211
|
props: {
|
|
137
|
-
items: mockItems
|
|
138
|
-
itemSize: 50,
|
|
212
|
+
items: mockItems,
|
|
139
213
|
},
|
|
140
214
|
});
|
|
141
|
-
|
|
215
|
+
await nextTick();
|
|
216
|
+
wrapper.unmount();
|
|
217
|
+
// no errors should be thrown
|
|
142
218
|
});
|
|
143
219
|
|
|
144
|
-
it('
|
|
220
|
+
it('handles hostRef change', async () => {
|
|
145
221
|
const wrapper = mount(VirtualScroll, {
|
|
146
222
|
props: {
|
|
147
|
-
items: mockItems
|
|
148
|
-
|
|
149
|
-
containerTag: 'table',
|
|
150
|
-
wrapperTag: 'tbody',
|
|
151
|
-
itemTag: 'tr',
|
|
152
|
-
},
|
|
153
|
-
slots: {
|
|
154
|
-
item: '<template #item="{ item, index }"><td>{{ index }}</td><td>{{ item.label }}</td></template>',
|
|
223
|
+
items: mockItems,
|
|
224
|
+
containerTag: 'div',
|
|
155
225
|
},
|
|
156
226
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
227
|
+
await nextTick();
|
|
228
|
+
await wrapper.setProps({ containerTag: 'section' });
|
|
229
|
+
await nextTick();
|
|
230
|
+
// should have unobserved old and observed new
|
|
160
231
|
});
|
|
161
232
|
|
|
162
|
-
it('
|
|
233
|
+
it('stops active smooth scroll via stopProgrammaticScroll', async () => {
|
|
163
234
|
const wrapper = mount(VirtualScroll, {
|
|
164
|
-
props: {
|
|
165
|
-
items: mockItems.slice(0, 10),
|
|
166
|
-
itemSize: 50,
|
|
167
|
-
containerTag: 'table',
|
|
168
|
-
wrapperTag: 'tbody',
|
|
169
|
-
itemTag: 'tr',
|
|
170
|
-
},
|
|
171
|
-
slots: {
|
|
172
|
-
header: '<tr><th>ID</th></tr>',
|
|
173
|
-
footer: '<tr><td>Footer</td></tr>',
|
|
174
|
-
},
|
|
235
|
+
props: { itemSize: 50, items: mockItems },
|
|
175
236
|
});
|
|
176
|
-
|
|
177
|
-
|
|
237
|
+
await nextTick();
|
|
238
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
239
|
+
|
|
240
|
+
vs.scrollToIndex(50, null, { behavior: 'smooth' });
|
|
241
|
+
await nextTick();
|
|
242
|
+
|
|
243
|
+
const posBefore = vs.scrollDetails.scrollOffset.y;
|
|
244
|
+
vs.stopProgrammaticScroll();
|
|
245
|
+
await nextTick();
|
|
246
|
+
|
|
247
|
+
// Should not have moved significantly or at all from where it was stopped
|
|
248
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(posBefore);
|
|
178
249
|
});
|
|
250
|
+
});
|
|
179
251
|
|
|
180
|
-
|
|
252
|
+
describe('scrolling interaction', () => {
|
|
253
|
+
it('scrolls and updates visible items', async () => {
|
|
181
254
|
const wrapper = mount(VirtualScroll, {
|
|
182
255
|
props: {
|
|
183
|
-
|
|
184
|
-
|
|
256
|
+
itemSize: 50,
|
|
257
|
+
items: mockItems,
|
|
185
258
|
},
|
|
186
259
|
slots: {
|
|
187
|
-
|
|
188
|
-
|
|
260
|
+
item: (props: ItemSlotProps) => {
|
|
261
|
+
const { item } = props as ItemSlotProps<MockItem>;
|
|
262
|
+
return h('div', { class: 'item' }, item.label);
|
|
263
|
+
},
|
|
189
264
|
},
|
|
190
265
|
});
|
|
191
|
-
|
|
192
|
-
|
|
266
|
+
await nextTick();
|
|
267
|
+
|
|
268
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
269
|
+
const el = container.element as HTMLElement;
|
|
270
|
+
|
|
271
|
+
Object.defineProperty(el, 'scrollTop', { value: 1000, writable: true });
|
|
272
|
+
await container.trigger('scroll');
|
|
273
|
+
await nextTick();
|
|
274
|
+
await nextTick();
|
|
275
|
+
|
|
276
|
+
expect(wrapper.text()).toContain('Item 20');
|
|
277
|
+
expect(wrapper.text()).toContain('Item 15');
|
|
278
|
+
|
|
279
|
+
const items = wrapper.findAll('.item');
|
|
280
|
+
expect(items.length).toBeGreaterThanOrEqual(15);
|
|
281
|
+
expect(items.length).toBeLessThanOrEqual(25);
|
|
193
282
|
});
|
|
194
283
|
|
|
195
|
-
it('
|
|
284
|
+
it('emits load event when reaching end', async () => {
|
|
196
285
|
const wrapper = mount(VirtualScroll, {
|
|
197
286
|
props: {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
},
|
|
202
|
-
slots: {
|
|
203
|
-
header: '<div>H</div>',
|
|
204
|
-
footer: '<div>F</div>',
|
|
287
|
+
itemSize: 50,
|
|
288
|
+
items: mockItems.slice(0, 20),
|
|
289
|
+
loadDistance: 100,
|
|
205
290
|
},
|
|
206
291
|
});
|
|
207
292
|
await nextTick();
|
|
208
|
-
expect(wrapper.find('.virtual-scroll-header').classes()).toContain('virtual-scroll--sticky');
|
|
209
|
-
expect(wrapper.find('.virtual-scroll-footer').classes()).toContain('virtual-scroll--sticky');
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('should handle switching containerTag', async () => {
|
|
213
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 10), containerTag: 'div' } });
|
|
214
293
|
await nextTick();
|
|
215
|
-
|
|
294
|
+
|
|
295
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
296
|
+
const el = container.element as HTMLElement;
|
|
297
|
+
|
|
298
|
+
expect(wrapper.emitted('load')).toBeUndefined();
|
|
299
|
+
|
|
300
|
+
Object.defineProperty(el, 'scrollTop', { value: 450, writable: true });
|
|
301
|
+
await container.trigger('scroll');
|
|
216
302
|
await nextTick();
|
|
217
|
-
expect(wrapper.element.tagName).toBe('TABLE');
|
|
218
|
-
await wrapper.setProps({ containerTag: 'div' });
|
|
219
303
|
await nextTick();
|
|
220
|
-
|
|
304
|
+
|
|
305
|
+
expect(wrapper.emitted('load')).toBeDefined();
|
|
221
306
|
});
|
|
222
307
|
|
|
223
|
-
it('
|
|
308
|
+
it('handles wheel when virtual scrollbars are inactive', async () => {
|
|
224
309
|
const wrapper = mount(VirtualScroll, {
|
|
225
310
|
props: {
|
|
226
|
-
items: mockItems
|
|
227
|
-
|
|
228
|
-
wrapperTag: 'tbody',
|
|
229
|
-
itemTag: 'tr',
|
|
230
|
-
},
|
|
231
|
-
slots: {
|
|
232
|
-
item: '<template #item="{ item }"><td>{{ item.label }}</td></template>',
|
|
311
|
+
items: mockItems,
|
|
312
|
+
virtualScrollbar: false,
|
|
233
313
|
},
|
|
234
314
|
});
|
|
235
|
-
|
|
236
|
-
|
|
315
|
+
await nextTick();
|
|
316
|
+
await wrapper.find('.virtual-scroll-container').trigger('wheel', { deltaY: 100 });
|
|
317
|
+
// should just stop programmatic scroll
|
|
237
318
|
});
|
|
238
319
|
|
|
239
|
-
it('should
|
|
320
|
+
it('should not enter a loop when scrolling to end with dynamic items', async () => {
|
|
321
|
+
const items = Array.from({ length: 200 }, (_, i) => ({ id: i }));
|
|
240
322
|
const wrapper = mount(VirtualScroll, {
|
|
241
323
|
props: {
|
|
242
|
-
items
|
|
243
|
-
|
|
324
|
+
items,
|
|
325
|
+
itemSize: 0, // dynamic
|
|
326
|
+
defaultItemSize: 40,
|
|
244
327
|
},
|
|
245
328
|
});
|
|
329
|
+
|
|
246
330
|
await nextTick();
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
slots,
|
|
266
|
-
});
|
|
267
|
-
await nextTick();
|
|
268
|
-
wrapper.unmount();
|
|
269
|
-
}
|
|
270
|
-
}
|
|
331
|
+
await nextTick();
|
|
332
|
+
|
|
333
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
334
|
+
|
|
335
|
+
// Press End
|
|
336
|
+
await wrapper.trigger('keydown', { key: 'End' });
|
|
337
|
+
|
|
338
|
+
// Wait for multiple ticks to let the correction logic work
|
|
339
|
+
for (let i = 0; i < 5; i++) {
|
|
340
|
+
await nextTick();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Simulate items being measured differently than estimated
|
|
344
|
+
const rendered = wrapper.findAll('.virtual-scroll-item');
|
|
345
|
+
for (const item of rendered) {
|
|
346
|
+
const idx = Number(item.attributes('data-index'));
|
|
347
|
+
if (idx >= 90) {
|
|
348
|
+
triggerResize(item.element, 500, 50); // 50 instead of 40
|
|
271
349
|
}
|
|
272
350
|
}
|
|
351
|
+
|
|
352
|
+
// Wait for corrections
|
|
353
|
+
for (let i = 0; i < 5; i++) {
|
|
354
|
+
await nextTick();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const details = vs.scrollDetails;
|
|
358
|
+
// Should be at the end
|
|
359
|
+
expect(details.scrollOffset.y).toBeGreaterThanOrEqual(details.totalSize.height - details.viewportSize.height - 1);
|
|
360
|
+
|
|
361
|
+
const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
|
|
362
|
+
|
|
363
|
+
await nextTick();
|
|
364
|
+
await nextTick();
|
|
365
|
+
|
|
366
|
+
// Should not be calling scrollToIndex anymore
|
|
367
|
+
expect(scrollToIndexSpy).not.toHaveBeenCalled();
|
|
273
368
|
});
|
|
274
369
|
});
|
|
275
370
|
|
|
276
|
-
describe('
|
|
277
|
-
it('
|
|
371
|
+
describe('keyboard navigation', () => {
|
|
372
|
+
it('responds to home and end keys in vertical mode', async () => {
|
|
278
373
|
const wrapper = mount(VirtualScroll, {
|
|
279
|
-
props: {
|
|
280
|
-
items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
|
|
281
|
-
itemSize: 100,
|
|
282
|
-
direction: 'horizontal',
|
|
283
|
-
},
|
|
374
|
+
props: { itemSize: 50, items: mockItems },
|
|
284
375
|
});
|
|
285
376
|
await nextTick();
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
377
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
378
|
+
|
|
379
|
+
await container.trigger('keydown', { key: 'End' });
|
|
380
|
+
await nextTick();
|
|
381
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
|
|
382
|
+
|
|
383
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
384
|
+
await nextTick();
|
|
385
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
290
386
|
});
|
|
291
387
|
|
|
292
|
-
it('
|
|
388
|
+
it('responds to arrows in vertical mode', async () => {
|
|
293
389
|
const wrapper = mount(VirtualScroll, {
|
|
294
|
-
props: {
|
|
295
|
-
items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
|
|
296
|
-
itemSize: 100,
|
|
297
|
-
direction: 'both',
|
|
298
|
-
columnCount: 5,
|
|
299
|
-
columnWidth: 150,
|
|
300
|
-
},
|
|
390
|
+
props: { itemSize: 50, items: mockItems },
|
|
301
391
|
});
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
392
|
+
await nextTick();
|
|
393
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
394
|
+
|
|
395
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
396
|
+
await nextTick();
|
|
397
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(50);
|
|
398
|
+
|
|
399
|
+
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
400
|
+
await nextTick();
|
|
401
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
306
402
|
});
|
|
307
403
|
|
|
308
|
-
it('
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
404
|
+
it('responds correctly to arrows in rtl mode', async () => {
|
|
405
|
+
const container = document.createElement('div');
|
|
406
|
+
container.setAttribute('dir', 'rtl');
|
|
407
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
408
|
+
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
409
|
+
if (options.left !== undefined) {
|
|
410
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
|
|
411
|
+
}
|
|
412
|
+
container.dispatchEvent(new Event('scroll'));
|
|
313
413
|
});
|
|
314
|
-
mount(VirtualScroll, { props: { items: mockItems, container: null } });
|
|
315
|
-
});
|
|
316
414
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
direction: 'horizontal',
|
|
323
|
-
},
|
|
415
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
416
|
+
if (el === container) {
|
|
417
|
+
return { direction: 'rtl' } as CSSStyleDeclaration;
|
|
418
|
+
}
|
|
419
|
+
return { direction: 'ltr' } as CSSStyleDeclaration;
|
|
324
420
|
});
|
|
325
|
-
await nextTick();
|
|
326
|
-
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
327
|
-
expect(item.style.inlineSize).toBe('50px');
|
|
328
|
-
expect(item.style.blockSize).toBe('100%');
|
|
329
|
-
});
|
|
330
421
|
|
|
331
|
-
it('should cover sticky item style branches in getItemStyle', async () => {
|
|
332
|
-
const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
333
422
|
const wrapper = mount(VirtualScroll, {
|
|
334
423
|
props: {
|
|
335
|
-
|
|
424
|
+
container,
|
|
336
425
|
direction: 'horizontal',
|
|
337
|
-
|
|
338
|
-
|
|
426
|
+
itemSize: 100,
|
|
427
|
+
items: mockItems,
|
|
339
428
|
},
|
|
340
429
|
});
|
|
430
|
+
|
|
431
|
+
await nextTick();
|
|
341
432
|
await nextTick();
|
|
342
433
|
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
await wrapper.trigger('scroll');
|
|
434
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
435
|
+
vs.updateDirection();
|
|
346
436
|
await nextTick();
|
|
437
|
+
expect(vs.isRtl).toBe(true);
|
|
438
|
+
|
|
439
|
+
const vsContainer = wrapper.find('.virtual-scroll-container');
|
|
347
440
|
|
|
348
|
-
|
|
441
|
+
await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
|
|
442
|
+
await nextTick();
|
|
443
|
+
await nextTick();
|
|
444
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
|
|
349
445
|
|
|
350
|
-
await
|
|
446
|
+
await vsContainer.trigger('keydown', { key: 'ArrowRight' });
|
|
351
447
|
await nextTick();
|
|
352
|
-
|
|
448
|
+
await nextTick();
|
|
449
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
|
|
450
|
+
|
|
451
|
+
styleSpy.mockRestore();
|
|
353
452
|
});
|
|
354
453
|
|
|
355
|
-
it('
|
|
454
|
+
it('aligns partially visible items correctly with arrows in rtl mode', async () => {
|
|
356
455
|
const container = document.createElement('div');
|
|
357
|
-
|
|
358
|
-
|
|
456
|
+
container.setAttribute('dir', 'rtl');
|
|
457
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
458
|
+
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
459
|
+
if (options.left !== undefined) {
|
|
460
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
|
|
461
|
+
}
|
|
462
|
+
container.dispatchEvent(new Event('scroll'));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
466
|
+
if (el === container) {
|
|
467
|
+
return { direction: 'rtl' } as CSSStyleDeclaration;
|
|
468
|
+
}
|
|
469
|
+
return { direction: 'ltr' } as CSSStyleDeclaration;
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const wrapper = mount(VirtualScroll, {
|
|
359
473
|
props: {
|
|
360
|
-
items,
|
|
361
474
|
container,
|
|
362
|
-
|
|
475
|
+
direction: 'horizontal',
|
|
476
|
+
itemSize: 100,
|
|
477
|
+
items: mockItems,
|
|
363
478
|
},
|
|
364
479
|
});
|
|
480
|
+
|
|
481
|
+
await nextTick();
|
|
365
482
|
await nextTick();
|
|
366
|
-
// This covers the branch where container is NOT host element and NOT window
|
|
367
|
-
});
|
|
368
483
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
484
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
485
|
+
vs.updateDirection();
|
|
486
|
+
await nextTick();
|
|
487
|
+
|
|
488
|
+
vs.scrollToOffset(50, null);
|
|
489
|
+
await nextTick();
|
|
490
|
+
await nextTick();
|
|
491
|
+
|
|
492
|
+
const vsContainer = wrapper.find('.virtual-scroll-container');
|
|
493
|
+
|
|
494
|
+
await vsContainer.trigger('keydown', { key: 'ArrowRight' });
|
|
495
|
+
await nextTick();
|
|
496
|
+
await nextTick();
|
|
497
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
|
|
498
|
+
|
|
499
|
+
await wrapper.setProps({ itemSize: 150 });
|
|
379
500
|
await nextTick();
|
|
501
|
+
await nextTick();
|
|
502
|
+
|
|
503
|
+
expect(vs.scrollDetails.currentEndIndex).toBe(3);
|
|
504
|
+
|
|
505
|
+
await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
|
|
506
|
+
await nextTick();
|
|
507
|
+
await nextTick();
|
|
508
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
|
|
509
|
+
|
|
510
|
+
styleSpy.mockRestore();
|
|
380
511
|
});
|
|
381
512
|
|
|
382
|
-
it('
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
513
|
+
it('scrolls to next item with arrowleft when current item is already at the left edge (rtl)', async () => {
|
|
514
|
+
const container = document.createElement('div');
|
|
515
|
+
container.setAttribute('dir', 'rtl');
|
|
516
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
517
|
+
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
518
|
+
if (options.left !== undefined) {
|
|
519
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
|
|
520
|
+
}
|
|
521
|
+
container.dispatchEvent(new Event('scroll'));
|
|
389
522
|
});
|
|
390
|
-
|
|
523
|
+
|
|
524
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
525
|
+
if (el === container) {
|
|
526
|
+
return { direction: 'rtl' } as CSSStyleDeclaration;
|
|
527
|
+
}
|
|
528
|
+
return { direction: 'ltr' } as CSSStyleDeclaration;
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const wrapper = mount(VirtualScroll, {
|
|
391
532
|
props: {
|
|
392
|
-
|
|
533
|
+
container,
|
|
393
534
|
direction: 'horizontal',
|
|
394
|
-
|
|
395
|
-
|
|
535
|
+
itemSize: 100,
|
|
536
|
+
items: mockItems,
|
|
396
537
|
},
|
|
397
538
|
});
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
539
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
let container: DOMWrapper<Element>;
|
|
404
|
-
let el: HTMLElement;
|
|
540
|
+
await nextTick();
|
|
541
|
+
await nextTick();
|
|
405
542
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
},
|
|
413
|
-
}) as unknown as VueWrapper<VSInstance>;
|
|
543
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
544
|
+
vs.updateDirection();
|
|
545
|
+
await nextTick();
|
|
546
|
+
|
|
547
|
+
vs.scrollToIndex(null, 4, { align: 'end', behavior: 'auto' });
|
|
548
|
+
await nextTick();
|
|
414
549
|
await nextTick();
|
|
415
|
-
container = wrapper.find('.virtual-scroll-container');
|
|
416
|
-
el = container.element as HTMLElement;
|
|
417
550
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
|
|
421
|
-
Object.defineProperty(el, 'offsetHeight', { value: 500, configurable: true });
|
|
422
|
-
Object.defineProperty(el, 'offsetWidth', { value: 500, configurable: true });
|
|
551
|
+
expect(vs.scrollDetails.currentEndColIndex).toBe(4);
|
|
552
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(0);
|
|
423
553
|
|
|
424
|
-
const
|
|
425
|
-
|
|
554
|
+
const containerEl = wrapper.find('.virtual-scroll-container');
|
|
555
|
+
await containerEl.trigger('keydown', { key: 'ArrowLeft' });
|
|
556
|
+
await nextTick();
|
|
426
557
|
await nextTick();
|
|
558
|
+
|
|
559
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(100);
|
|
560
|
+
styleSpy.mockRestore();
|
|
427
561
|
});
|
|
428
562
|
|
|
429
|
-
it('
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
563
|
+
it('scrolls to previous item with arrowup when current item is already at the top', async () => {
|
|
564
|
+
const wrapper = mount(VirtualScroll, {
|
|
565
|
+
props: { itemSize: 50, items: mockItems },
|
|
566
|
+
});
|
|
433
567
|
await nextTick();
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
568
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
569
|
+
|
|
570
|
+
vs.scrollToIndex(2, null, { align: 'start', behavior: 'auto' });
|
|
571
|
+
await nextTick();
|
|
572
|
+
await nextTick();
|
|
573
|
+
|
|
574
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(100);
|
|
575
|
+
|
|
576
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
577
|
+
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
578
|
+
await nextTick();
|
|
579
|
+
await nextTick();
|
|
580
|
+
|
|
581
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(50);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('scrolls to next item with arrowright when current item is already at the right edge (ltr)', async () => {
|
|
585
|
+
const wrapper = mount(VirtualScroll, {
|
|
586
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
587
|
+
});
|
|
588
|
+
await nextTick();
|
|
589
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
590
|
+
|
|
591
|
+
vs.scrollToIndex(4, null, { align: 'end', behavior: 'auto' });
|
|
592
|
+
await nextTick();
|
|
593
|
+
await nextTick();
|
|
594
|
+
|
|
595
|
+
expect(vs.scrollDetails.currentEndIndex).toBe(4);
|
|
596
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(0);
|
|
597
|
+
|
|
598
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
599
|
+
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
600
|
+
await nextTick();
|
|
601
|
+
await nextTick();
|
|
602
|
+
|
|
603
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(100);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('scrolls to next item with arrowdown when current item is already at the bottom edge', async () => {
|
|
607
|
+
const wrapper = mount(VirtualScroll, {
|
|
608
|
+
props: { itemSize: 50, items: mockItems },
|
|
609
|
+
});
|
|
610
|
+
await nextTick();
|
|
611
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
612
|
+
|
|
613
|
+
vs.scrollToIndex(9, null, { align: 'end', behavior: 'auto' });
|
|
614
|
+
await nextTick();
|
|
615
|
+
await nextTick();
|
|
616
|
+
|
|
617
|
+
expect(vs.scrollDetails.currentEndIndex).toBe(9);
|
|
618
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
619
|
+
|
|
620
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
621
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
622
|
+
await nextTick();
|
|
623
|
+
await nextTick();
|
|
624
|
+
|
|
625
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(50);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('does not scroll with arrowdown when already at the very last item', async () => {
|
|
629
|
+
const wrapper = mount(VirtualScroll, {
|
|
630
|
+
props: { itemSize: 50, items: mockItems },
|
|
631
|
+
});
|
|
632
|
+
await nextTick();
|
|
633
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
634
|
+
|
|
635
|
+
vs.scrollToOffset(null, 4500, { behavior: 'auto' });
|
|
636
|
+
await nextTick();
|
|
637
|
+
await nextTick();
|
|
638
|
+
|
|
639
|
+
expect(vs.scrollDetails.currentEndIndex).toBe(99);
|
|
640
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
|
|
641
|
+
|
|
642
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
643
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
644
|
+
await nextTick();
|
|
645
|
+
await nextTick();
|
|
646
|
+
|
|
647
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('does not scroll with arrowright when already at the very last item (horizontal ltr)', async () => {
|
|
651
|
+
const wrapper = mount(VirtualScroll, {
|
|
652
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
653
|
+
});
|
|
654
|
+
await nextTick();
|
|
655
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
656
|
+
|
|
657
|
+
vs.scrollToOffset(9500, null, { behavior: 'auto' });
|
|
658
|
+
await nextTick();
|
|
659
|
+
await nextTick();
|
|
660
|
+
|
|
661
|
+
expect(vs.scrollDetails.currentEndColIndex).toBe(99);
|
|
662
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
|
|
663
|
+
|
|
664
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
665
|
+
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
666
|
+
await nextTick();
|
|
667
|
+
await nextTick();
|
|
668
|
+
|
|
669
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('does not scroll with arrowleft when already at the very last item (horizontal rtl)', async () => {
|
|
673
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
|
674
|
+
direction: 'rtl',
|
|
675
|
+
} as CSSStyleDeclaration);
|
|
676
|
+
|
|
677
|
+
const wrapper = mount(VirtualScroll, {
|
|
678
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
679
|
+
});
|
|
680
|
+
await nextTick();
|
|
681
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
682
|
+
|
|
683
|
+
vs.scrollToOffset(9500, null, { behavior: 'auto' });
|
|
684
|
+
await nextTick();
|
|
685
|
+
await nextTick();
|
|
686
|
+
|
|
687
|
+
expect(vs.scrollDetails.currentEndColIndex).toBe(99);
|
|
688
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
|
|
689
|
+
|
|
690
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
691
|
+
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
692
|
+
await nextTick();
|
|
693
|
+
await nextTick();
|
|
694
|
+
|
|
695
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(9500);
|
|
696
|
+
styleSpy.mockRestore();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('responds to pageup and pagedown in vertical mode', async () => {
|
|
700
|
+
const wrapper = mount(VirtualScroll, {
|
|
701
|
+
props: { itemSize: 50, items: mockItems },
|
|
702
|
+
});
|
|
703
|
+
await nextTick();
|
|
704
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
705
|
+
|
|
706
|
+
await container.trigger('keydown', { key: 'PageDown' });
|
|
707
|
+
await nextTick();
|
|
708
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(500);
|
|
709
|
+
|
|
710
|
+
await container.trigger('keydown', { key: 'PageUp' });
|
|
711
|
+
await nextTick();
|
|
712
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('responds to home and end keys in horizontal mode', async () => {
|
|
716
|
+
const wrapper = mount(VirtualScroll, {
|
|
717
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
718
|
+
});
|
|
719
|
+
await nextTick();
|
|
720
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
721
|
+
|
|
722
|
+
await container.trigger('keydown', { key: 'End' });
|
|
723
|
+
await nextTick();
|
|
724
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(9500);
|
|
725
|
+
|
|
726
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
727
|
+
await nextTick();
|
|
728
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('responds to arrows in horizontal mode', async () => {
|
|
732
|
+
const wrapper = mount(VirtualScroll, {
|
|
733
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
734
|
+
});
|
|
735
|
+
await nextTick();
|
|
736
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
737
|
+
|
|
738
|
+
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
739
|
+
await nextTick();
|
|
740
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(100);
|
|
741
|
+
|
|
742
|
+
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
743
|
+
await nextTick();
|
|
744
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('responds to pageup and pagedown in horizontal mode', async () => {
|
|
748
|
+
const wrapper = mount(VirtualScroll, {
|
|
749
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
750
|
+
});
|
|
751
|
+
await nextTick();
|
|
752
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
753
|
+
|
|
754
|
+
await container.trigger('keydown', { key: 'PageDown' });
|
|
755
|
+
await nextTick();
|
|
756
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
|
|
757
|
+
|
|
758
|
+
await container.trigger('keydown', { key: 'PageUp' });
|
|
759
|
+
await nextTick();
|
|
760
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('disables smooth scroll for large distances (home/end)', async () => {
|
|
764
|
+
const wrapper = mount(VirtualScroll, {
|
|
765
|
+
props: {
|
|
766
|
+
itemSize: 50,
|
|
767
|
+
items: Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `Item ${ i }` })),
|
|
768
|
+
container: window,
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
await nextTick();
|
|
772
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
773
|
+
|
|
774
|
+
const scrollToSpy = vi.spyOn(window, 'scrollTo');
|
|
775
|
+
|
|
776
|
+
await container.trigger('keydown', { key: 'End' });
|
|
777
|
+
await nextTick();
|
|
778
|
+
|
|
779
|
+
expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
780
|
+
behavior: 'auto',
|
|
781
|
+
}));
|
|
782
|
+
|
|
783
|
+
scrollToSpy.mockClear();
|
|
784
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
785
|
+
await nextTick();
|
|
786
|
+
|
|
787
|
+
expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
788
|
+
behavior: 'auto',
|
|
789
|
+
}));
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('responds to home and end keys in grid mode', async () => {
|
|
793
|
+
const wrapper = mount(VirtualScroll, {
|
|
794
|
+
props: {
|
|
795
|
+
columnCount: 10,
|
|
796
|
+
columnWidth: 100,
|
|
797
|
+
direction: 'both',
|
|
798
|
+
itemSize: 50,
|
|
799
|
+
items: mockItems,
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
await nextTick();
|
|
803
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
804
|
+
|
|
805
|
+
await container.trigger('keydown', { key: 'End' });
|
|
806
|
+
await nextTick();
|
|
807
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
|
|
808
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
|
|
809
|
+
|
|
810
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
811
|
+
await nextTick();
|
|
812
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
813
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('responds to all arrows in grid mode', async () => {
|
|
817
|
+
const wrapper = mount(VirtualScroll, {
|
|
818
|
+
props: {
|
|
819
|
+
columnCount: 10,
|
|
820
|
+
columnWidth: 100,
|
|
821
|
+
direction: 'both',
|
|
822
|
+
itemSize: 50,
|
|
823
|
+
items: mockItems,
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
await nextTick();
|
|
827
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
828
|
+
|
|
829
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
830
|
+
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
831
|
+
await nextTick();
|
|
832
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(50);
|
|
833
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(100);
|
|
834
|
+
|
|
835
|
+
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
836
|
+
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
837
|
+
await nextTick();
|
|
838
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
839
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('aligns items precisely with arrow keys', async () => {
|
|
843
|
+
const wrapper = mount(VirtualScroll, {
|
|
844
|
+
props: {
|
|
845
|
+
itemSize: 100,
|
|
846
|
+
items: mockItems,
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
await nextTick();
|
|
850
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
851
|
+
|
|
852
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
853
|
+
vs.scrollToOffset(null, 50, { behavior: 'auto' });
|
|
854
|
+
await nextTick();
|
|
855
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(50);
|
|
856
|
+
|
|
857
|
+
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
858
|
+
await nextTick();
|
|
859
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
860
|
+
|
|
861
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
862
|
+
await nextTick();
|
|
863
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(100);
|
|
864
|
+
|
|
865
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
866
|
+
await nextTick();
|
|
867
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(200);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('aligns partially visible items at the bottom with arrow down', async () => {
|
|
871
|
+
const wrapper = mount(VirtualScroll, {
|
|
872
|
+
props: { itemSize: 50, items: mockItems },
|
|
873
|
+
});
|
|
874
|
+
await nextTick();
|
|
875
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
876
|
+
|
|
877
|
+
// viewport 500. item 9 ends at 500.
|
|
878
|
+
// scroll to 25. item 9 now ends at 525 (partially cut off).
|
|
879
|
+
vs.scrollToOffset(null, 25);
|
|
880
|
+
await nextTick();
|
|
881
|
+
await nextTick();
|
|
882
|
+
|
|
883
|
+
expect(vs.scrollDetails.currentEndIndex).toBe(10); // item 10 is at 500-550
|
|
884
|
+
|
|
885
|
+
// item 10 is partially visible at bottom. ArrowDown should align it to end.
|
|
886
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
887
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
888
|
+
await nextTick();
|
|
889
|
+
await nextTick();
|
|
890
|
+
|
|
891
|
+
// item 10 ends at 550. viewport 500. targetEnd = 550 - 500 = 50.
|
|
892
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(50);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('aligns partially visible columns with arrowleft and arrowright in ltr', async () => {
|
|
896
|
+
const wrapper = mount(VirtualScroll, {
|
|
897
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
898
|
+
});
|
|
899
|
+
await nextTick();
|
|
900
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
901
|
+
|
|
902
|
+
// Viewport 500. Scroll to 50.
|
|
903
|
+
// Item 0 (0-100) is partially visible at start.
|
|
904
|
+
// Item 5 (500-600) is partially visible at end.
|
|
905
|
+
vs.scrollToOffset(50, null);
|
|
906
|
+
await nextTick();
|
|
907
|
+
await nextTick();
|
|
908
|
+
|
|
909
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
910
|
+
|
|
911
|
+
// 1. ArrowLeft should align item 0 to start
|
|
912
|
+
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
913
|
+
await nextTick();
|
|
914
|
+
await nextTick();
|
|
915
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(0);
|
|
916
|
+
|
|
917
|
+
// Reset
|
|
918
|
+
vs.scrollToOffset(50, null);
|
|
919
|
+
await nextTick();
|
|
920
|
+
await nextTick();
|
|
921
|
+
|
|
922
|
+
// 2. ArrowRight should align item 5 to end
|
|
923
|
+
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
924
|
+
await nextTick();
|
|
925
|
+
await nextTick();
|
|
926
|
+
// item 5 ends at 600. viewport 500. targetEnd = 600 - 500 = 100.
|
|
927
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(100);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('aligns partially visible columns with arrowleft and arrowright in rtl', async () => {
|
|
931
|
+
const container = document.createElement('div');
|
|
932
|
+
container.setAttribute('dir', 'rtl');
|
|
933
|
+
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
|
|
934
|
+
container.scrollTo = vi.fn().mockImplementation((options) => {
|
|
935
|
+
if (options.left !== undefined) {
|
|
936
|
+
Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
|
|
937
|
+
}
|
|
938
|
+
container.dispatchEvent(new Event('scroll'));
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
942
|
+
if (el === container) {
|
|
943
|
+
return { direction: 'rtl' } as CSSStyleDeclaration;
|
|
944
|
+
}
|
|
945
|
+
return { direction: 'ltr' } as CSSStyleDeclaration;
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
const wrapper = mount(VirtualScroll, {
|
|
949
|
+
props: {
|
|
950
|
+
container,
|
|
951
|
+
direction: 'horizontal',
|
|
952
|
+
itemSize: 100,
|
|
953
|
+
items: mockItems,
|
|
954
|
+
},
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
await nextTick();
|
|
958
|
+
await nextTick();
|
|
959
|
+
|
|
960
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
961
|
+
vs.updateDirection();
|
|
962
|
+
await nextTick();
|
|
963
|
+
|
|
964
|
+
// Viewport 500. Logical scroll 50.
|
|
965
|
+
// Item 0 (0-100) is partially visible at logical START (Right edge).
|
|
966
|
+
// Item 5 (500-600) is partially visible at logical END (Left edge).
|
|
967
|
+
vs.scrollToOffset(50, null);
|
|
968
|
+
await nextTick();
|
|
969
|
+
await nextTick();
|
|
970
|
+
|
|
971
|
+
const vsContainer = wrapper.find('.virtual-scroll-container');
|
|
972
|
+
|
|
973
|
+
// 1. ArrowRight in RTL should align item 0 to logical START
|
|
974
|
+
await vsContainer.trigger('keydown', { key: 'ArrowRight' });
|
|
975
|
+
await nextTick();
|
|
976
|
+
await nextTick();
|
|
977
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(0);
|
|
978
|
+
|
|
979
|
+
// Reset
|
|
980
|
+
vs.scrollToOffset(50, null);
|
|
981
|
+
await nextTick();
|
|
982
|
+
await nextTick();
|
|
983
|
+
|
|
984
|
+
// 2. ArrowLeft in RTL should align item 5 to logical END
|
|
985
|
+
await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
|
|
986
|
+
await nextTick();
|
|
987
|
+
await nextTick();
|
|
988
|
+
// item 5 ends at 600. viewport 500. targetEnd = 600 - 500 = 100.
|
|
989
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(100);
|
|
990
|
+
|
|
991
|
+
styleSpy.mockRestore();
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('ignores vertical arrows in horizontal mode', async () => {
|
|
995
|
+
const wrapper = mount(VirtualScroll, {
|
|
996
|
+
props: {
|
|
997
|
+
items: mockItems,
|
|
998
|
+
direction: 'horizontal',
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
await nextTick();
|
|
1002
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
1003
|
+
const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
|
|
1004
|
+
|
|
1005
|
+
await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowUp' });
|
|
1006
|
+
await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowDown' });
|
|
1007
|
+
|
|
1008
|
+
expect(scrollToIndexSpy).not.toHaveBeenCalled();
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('ignores horizontal arrows in vertical mode', async () => {
|
|
1012
|
+
const wrapper = mount(VirtualScroll, {
|
|
1013
|
+
props: {
|
|
1014
|
+
items: mockItems,
|
|
1015
|
+
direction: 'vertical',
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
await nextTick();
|
|
1019
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
1020
|
+
const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
|
|
1021
|
+
|
|
1022
|
+
await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowLeft' });
|
|
1023
|
+
await wrapper.find('.virtual-scroll-container').trigger('keydown', { key: 'ArrowRight' });
|
|
1024
|
+
|
|
1025
|
+
expect(scrollToIndexSpy).not.toHaveBeenCalled();
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
describe('dynamic sizing & measurements', () => {
|
|
1030
|
+
it('adjusts total size when items are measured', async () => {
|
|
1031
|
+
const wrapper = mount(VirtualScroll, {
|
|
1032
|
+
props: {
|
|
1033
|
+
itemSize: 0,
|
|
1034
|
+
items: mockItems.slice(0, 10),
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
await nextTick();
|
|
1038
|
+
|
|
1039
|
+
expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('400px');
|
|
1040
|
+
|
|
1041
|
+
const firstItem = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
|
|
1042
|
+
triggerResize(firstItem, 100, 100);
|
|
1043
|
+
await nextTick();
|
|
1044
|
+
await nextTick();
|
|
1045
|
+
|
|
1046
|
+
expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('460px');
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it('does not allow columns to become 0 width due to 0-size measurements', async () => {
|
|
1050
|
+
const wrapper = mount(VirtualScroll, {
|
|
1051
|
+
props: {
|
|
1052
|
+
bufferAfter: 0,
|
|
1053
|
+
bufferBefore: 0,
|
|
1054
|
+
columnCount: 10,
|
|
1055
|
+
defaultColumnWidth: 100,
|
|
1056
|
+
direction: 'both',
|
|
1057
|
+
itemSize: 50,
|
|
1058
|
+
items: mockItems,
|
|
1059
|
+
},
|
|
1060
|
+
slots: {
|
|
1061
|
+
item: ({ columnRange, index }: ItemSlotProps) => h('div', {
|
|
1062
|
+
'data-index': index,
|
|
1063
|
+
}, [
|
|
1064
|
+
...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
|
|
1065
|
+
class: 'cell',
|
|
1066
|
+
'data-col-index': columnRange.start + i,
|
|
1067
|
+
})),
|
|
1068
|
+
]),
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
await nextTick();
|
|
1073
|
+
|
|
1074
|
+
const initialWidth = (wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
|
|
1075
|
+
expect(initialWidth).toBeGreaterThan(0);
|
|
1076
|
+
|
|
1077
|
+
const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
|
|
1078
|
+
const cell0 = row0.querySelector('.cell') as HTMLElement;
|
|
1079
|
+
expect(cell0).not.toBeNull();
|
|
1080
|
+
|
|
1081
|
+
triggerResize(cell0, 0, 0);
|
|
1082
|
+
|
|
1083
|
+
await nextTick();
|
|
1084
|
+
await nextTick();
|
|
1085
|
+
|
|
1086
|
+
const currentWidth = (wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
|
|
1087
|
+
expect(currentWidth).toBe(initialWidth);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it('should not shift horizontally when scrolling vertically even if measurements vary slightly', async () => {
|
|
1091
|
+
const wrapper = mount(VirtualScroll, {
|
|
1092
|
+
props: {
|
|
1093
|
+
bufferAfter: 0,
|
|
1094
|
+
bufferBefore: 0,
|
|
1095
|
+
columnCount: 10,
|
|
1096
|
+
defaultColumnWidth: 100,
|
|
1097
|
+
direction: 'both',
|
|
1098
|
+
itemSize: 50,
|
|
1099
|
+
items: mockItems,
|
|
1100
|
+
},
|
|
1101
|
+
slots: {
|
|
1102
|
+
item: ({ columnRange, index }: ItemSlotProps) => h('div', {
|
|
1103
|
+
'data-index': index,
|
|
1104
|
+
}, [
|
|
1105
|
+
...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
|
|
1106
|
+
class: 'cell',
|
|
1107
|
+
'data-col-index': columnRange.start + i,
|
|
1108
|
+
})),
|
|
1109
|
+
]),
|
|
1110
|
+
},
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
await nextTick();
|
|
1114
|
+
|
|
1115
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
1116
|
+
|
|
1117
|
+
const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
|
|
1118
|
+
const cells0 = Array.from(row0.querySelectorAll('.cell'));
|
|
1119
|
+
|
|
1120
|
+
triggerResize(row0, 1000, 50);
|
|
1121
|
+
for (const cell of cells0) {
|
|
1122
|
+
triggerResize(cell, 110, 50);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
await nextTick();
|
|
1126
|
+
await nextTick();
|
|
1127
|
+
|
|
1128
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1129
|
+
const el = container.element as HTMLElement;
|
|
1130
|
+
Object.defineProperty(el, 'scrollTop', { configurable: true, value: 1000, writable: true });
|
|
1131
|
+
await container.trigger('scroll');
|
|
1132
|
+
|
|
1133
|
+
await nextTick();
|
|
1134
|
+
await nextTick();
|
|
1135
|
+
|
|
1136
|
+
const row20 = wrapper.find('.virtual-scroll-item[data-index="20"]').element;
|
|
1137
|
+
const cells20 = Array.from(row20.querySelectorAll('.cell'));
|
|
1138
|
+
|
|
1139
|
+
for (const cell of cells20) {
|
|
1140
|
+
triggerResize(cell, 110.1, 50);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
await nextTick();
|
|
1144
|
+
await nextTick();
|
|
1145
|
+
|
|
1146
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it('correctly aligns item 50:50 auto after measurements in dynamic grid', async () => {
|
|
1150
|
+
const wrapper = mount(VirtualScroll, {
|
|
1151
|
+
props: {
|
|
1152
|
+
bufferAfter: 5,
|
|
1153
|
+
bufferBefore: 5,
|
|
1154
|
+
columnCount: 100,
|
|
1155
|
+
defaultColumnWidth: 120,
|
|
1156
|
+
defaultItemSize: 120,
|
|
1157
|
+
direction: 'both',
|
|
1158
|
+
items: mockItems,
|
|
1159
|
+
},
|
|
1160
|
+
slots: {
|
|
1161
|
+
item: ({ columnRange, index }: ItemSlotProps) => h('div', {
|
|
1162
|
+
'data-index': index,
|
|
1163
|
+
}, [
|
|
1164
|
+
...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
|
|
1165
|
+
class: 'cell',
|
|
1166
|
+
'data-col-index': columnRange.start + i,
|
|
1167
|
+
})),
|
|
1168
|
+
]),
|
|
1169
|
+
},
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
await nextTick();
|
|
1173
|
+
|
|
1174
|
+
(wrapper.vm as { scrollToIndex: (r: number, c: number, a: string) => void; }).scrollToIndex(50, 50, 'auto');
|
|
1175
|
+
await nextTick();
|
|
1176
|
+
await nextTick();
|
|
1177
|
+
|
|
1178
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5620);
|
|
1179
|
+
|
|
1180
|
+
const row45El = wrapper.find('.virtual-scroll-item[data-index="45"]').element;
|
|
1181
|
+
const cells45 = Array.from(row45El.querySelectorAll('.cell'));
|
|
1182
|
+
|
|
1183
|
+
for (const cell of cells45) {
|
|
1184
|
+
triggerResize(cell, 150, 120);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
await nextTick();
|
|
1188
|
+
await nextTick();
|
|
1189
|
+
await nextTick();
|
|
1190
|
+
|
|
1191
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1192
|
+
await nextTick();
|
|
1193
|
+
|
|
1194
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5830);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
it('handles fallback measurement when borderboxsize is missing', async () => {
|
|
1198
|
+
const wrapper = mount(VirtualScroll, {
|
|
1199
|
+
props: {
|
|
1200
|
+
items: mockItems,
|
|
1201
|
+
itemSize: 0, // dynamic
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
await nextTick();
|
|
1206
|
+
const item = wrapper.find('.virtual-scroll-item');
|
|
1207
|
+
|
|
1208
|
+
Object.defineProperty(item.element, 'offsetWidth', { value: 500, configurable: true });
|
|
1209
|
+
Object.defineProperty(item.element, 'offsetHeight', { value: 60, configurable: true });
|
|
1210
|
+
|
|
1211
|
+
triggerResize(item.element, 500, 60, false);
|
|
1212
|
+
|
|
1213
|
+
await nextTick();
|
|
1214
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
1215
|
+
expect(vs.getRowHeight(0)).toBe(60);
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
describe('sticky elements', () => {
|
|
1220
|
+
it('applies sticky styles to marked items', async () => {
|
|
1221
|
+
const wrapper = mount(VirtualScroll, {
|
|
1222
|
+
props: {
|
|
1223
|
+
itemSize: 50,
|
|
1224
|
+
items: mockItems,
|
|
1225
|
+
stickyIndices: [ 0 ],
|
|
1226
|
+
},
|
|
1227
|
+
});
|
|
1228
|
+
await nextTick();
|
|
1229
|
+
|
|
1230
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1231
|
+
const el = container.element as HTMLElement;
|
|
1232
|
+
|
|
1233
|
+
Object.defineProperty(el, 'scrollTop', { value: 100, writable: true });
|
|
1234
|
+
await container.trigger('scroll');
|
|
1235
|
+
await nextTick();
|
|
1236
|
+
await nextTick();
|
|
1237
|
+
|
|
1238
|
+
const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
|
|
1239
|
+
expect(item0.classes()).toContain('virtual-scroll--sticky');
|
|
1240
|
+
expect((item0.element as HTMLElement).style.insetBlockStart).toBe('0px');
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it('does not gather multiple sticky items at the top', async () => {
|
|
1244
|
+
const wrapper = mount(VirtualScroll, {
|
|
1245
|
+
props: {
|
|
1246
|
+
itemSize: 50,
|
|
1247
|
+
items: mockItems,
|
|
1248
|
+
stickyIndices: [ 0, 1, 2 ],
|
|
1249
|
+
},
|
|
1250
|
+
slots: {
|
|
1251
|
+
item: (props: ItemSlotProps) => {
|
|
1252
|
+
const { index, item } = props as ItemSlotProps<MockItem>;
|
|
1253
|
+
return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
|
|
1254
|
+
},
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
await nextTick();
|
|
1259
|
+
await nextTick();
|
|
1260
|
+
|
|
1261
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1262
|
+
const el = container.element as HTMLElement;
|
|
1263
|
+
|
|
1264
|
+
Object.defineProperty(el, 'scrollTop', { configurable: true, value: 150, writable: true });
|
|
1265
|
+
await container.trigger('scroll');
|
|
1266
|
+
await nextTick();
|
|
1267
|
+
await nextTick();
|
|
1268
|
+
|
|
1269
|
+
const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
|
|
1270
|
+
const item1 = wrapper.find('.virtual-scroll-item[data-index="1"]');
|
|
1271
|
+
const item2 = wrapper.find('.virtual-scroll-item[data-index="2"]');
|
|
1272
|
+
|
|
1273
|
+
expect(item2.classes()).toContain('virtual-scroll--sticky');
|
|
1274
|
+
expect(item1.classes()).not.toContain('virtual-scroll--sticky');
|
|
1275
|
+
expect(item0.classes()).not.toContain('virtual-scroll--sticky');
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it('scrolls only one item with arrowdown when sticky header is visible', async () => {
|
|
1279
|
+
const wrapper = mount(VirtualScroll, {
|
|
1280
|
+
props: {
|
|
1281
|
+
bufferAfter: 0,
|
|
1282
|
+
bufferBefore: 0,
|
|
1283
|
+
itemSize: 50,
|
|
1284
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1285
|
+
stickyHeader: true,
|
|
1286
|
+
},
|
|
1287
|
+
slots: {
|
|
1288
|
+
header: () => h('div', { class: 'header' }, 'Header'),
|
|
1289
|
+
},
|
|
1290
|
+
});
|
|
1291
|
+
await nextTick();
|
|
1292
|
+
|
|
1293
|
+
const header = wrapper.find('.virtual-scroll-header');
|
|
1294
|
+
Object.defineProperty(header.element, 'offsetHeight', { configurable: true, value: 100 });
|
|
1295
|
+
triggerResize(header.element, 500, 100);
|
|
1296
|
+
|
|
1297
|
+
await nextTick();
|
|
1298
|
+
await nextTick();
|
|
1299
|
+
|
|
1300
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1301
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1302
|
+
|
|
1303
|
+
expect(vs.scrollDetails.currentEndIndex).toBe(7);
|
|
1304
|
+
|
|
1305
|
+
vs.scrollToOffset(null, 200);
|
|
1306
|
+
await nextTick();
|
|
1307
|
+
await nextTick();
|
|
1308
|
+
|
|
1309
|
+
expect(vs.scrollDetails.currentIndex).toBe(4);
|
|
1310
|
+
|
|
1311
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
1312
|
+
await nextTick();
|
|
1313
|
+
await nextTick();
|
|
1314
|
+
|
|
1315
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(250);
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
describe('scaling & massive lists', () => {
|
|
1320
|
+
it('items should not overlap when scaling is active', async () => {
|
|
1321
|
+
const itemSize = 1000;
|
|
1322
|
+
const rowCount = 11000;
|
|
1323
|
+
const massiveItems = Array.from({ length: rowCount }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
1324
|
+
|
|
1325
|
+
const wrapper = mount(VirtualScroll, {
|
|
1326
|
+
props: {
|
|
1327
|
+
itemSize,
|
|
1328
|
+
items: massiveItems,
|
|
1329
|
+
},
|
|
1330
|
+
slots: {
|
|
1331
|
+
item: ({ index }: { index: number; }) => h('div', { class: 'item' }, `Item ${ index }`),
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
await nextTick();
|
|
1336
|
+
await nextTick();
|
|
1337
|
+
|
|
1338
|
+
const items = wrapper.findAll('.virtual-scroll-item');
|
|
1339
|
+
expect(items.length).toBeGreaterThan(1);
|
|
1340
|
+
expect(items.length).toBeLessThan(50);
|
|
1341
|
+
|
|
1342
|
+
const item0 = items[ 0 ]!.element as HTMLElement;
|
|
1343
|
+
const item1 = items[ 1 ]!.element as HTMLElement;
|
|
1344
|
+
|
|
1345
|
+
const style0 = item0.style.transform;
|
|
1346
|
+
const style1 = item1.style.transform;
|
|
1347
|
+
|
|
1348
|
+
const getY = (style: string) => {
|
|
1349
|
+
const match = style.match(/translate\([^,]+, ([^)]+)px\)/);
|
|
1350
|
+
return match ? Number.parseFloat(match[ 1 ]!) : 0;
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
const y0 = getY(style0);
|
|
1354
|
+
const y1 = getY(style1);
|
|
1355
|
+
|
|
1356
|
+
const diff = Math.abs(y1 - y0);
|
|
1357
|
+
expect(diff).toBeCloseTo(itemSize, 0);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it('emulates touch scroll when scaling is active', async () => {
|
|
1361
|
+
const itemSize = 1000;
|
|
1362
|
+
const rowCount = 11000;
|
|
1363
|
+
const massiveItems = Array.from({ length: rowCount }, (_, i) => ({ id: i }));
|
|
1364
|
+
|
|
1365
|
+
const wrapper = mount(VirtualScroll, {
|
|
1366
|
+
props: {
|
|
1367
|
+
itemSize,
|
|
1368
|
+
items: massiveItems,
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
await nextTick();
|
|
1373
|
+
await nextTick();
|
|
1374
|
+
|
|
1375
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1376
|
+
expect(vs.scaleY).toBeGreaterThan(1);
|
|
1377
|
+
|
|
1378
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1379
|
+
const containerEl = container.element as HTMLElement;
|
|
1380
|
+
|
|
1381
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
1382
|
+
|
|
1383
|
+
containerEl.dispatchEvent(new PointerEvent('pointerdown', {
|
|
1384
|
+
clientX: 0,
|
|
1385
|
+
clientY: 500,
|
|
1386
|
+
pointerId: 1,
|
|
1387
|
+
pointerType: 'touch',
|
|
1388
|
+
button: 0,
|
|
1389
|
+
bubbles: true,
|
|
1390
|
+
}));
|
|
1391
|
+
|
|
1392
|
+
containerEl.dispatchEvent(new PointerEvent('pointermove', {
|
|
1393
|
+
clientX: 0,
|
|
1394
|
+
clientY: 400,
|
|
1395
|
+
pointerId: 1,
|
|
1396
|
+
pointerType: 'touch',
|
|
1397
|
+
bubbles: true,
|
|
1398
|
+
}));
|
|
1399
|
+
|
|
1400
|
+
await vi.advanceTimersToNextFrame();
|
|
1401
|
+
expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(100, 0);
|
|
1402
|
+
|
|
1403
|
+
containerEl.dispatchEvent(new PointerEvent('pointerup', {
|
|
1404
|
+
bubbles: true,
|
|
1405
|
+
pointerId: 1,
|
|
1406
|
+
pointerType: 'touch',
|
|
1407
|
+
}));
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it('ignores pointer events when scaling is inactive', async () => {
|
|
1411
|
+
const wrapper = mount(VirtualScroll, {
|
|
1412
|
+
props: { itemSize: 50, items: mockItems },
|
|
1413
|
+
});
|
|
1414
|
+
await nextTick();
|
|
1415
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
1416
|
+
expect(vs.scaleY).toBe(1);
|
|
1417
|
+
|
|
1418
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1419
|
+
const containerEl = container.element as HTMLElement;
|
|
1420
|
+
|
|
1421
|
+
const pointerDownEvent = new PointerEvent('pointerdown', { button: 0, bubbles: true, clientY: 500 });
|
|
1422
|
+
containerEl.dispatchEvent(pointerDownEvent);
|
|
1423
|
+
|
|
1424
|
+
const scrollOffsetBefore = vs.scrollDetails.scrollOffset.y;
|
|
1425
|
+
containerEl.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 400 }));
|
|
1426
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(scrollOffsetBefore);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it('ignores non-primary mouse button pointerdown', async () => {
|
|
1430
|
+
const massiveItems = Array.from({ length: 40001 }, (_, i) => ({ id: i }));
|
|
1431
|
+
const wrapper = mount(VirtualScroll, {
|
|
1432
|
+
props: { itemSize: 250, items: massiveItems },
|
|
1433
|
+
});
|
|
1434
|
+
await nextTick();
|
|
1435
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1436
|
+
expect(vs.scaleY).toBeGreaterThan(1);
|
|
1437
|
+
|
|
1438
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1439
|
+
const containerEl = container.element as HTMLElement;
|
|
1440
|
+
|
|
1441
|
+
const pointerDownEvent = new PointerEvent('pointerdown', { button: 1, bubbles: true, clientY: 500, pointerType: 'mouse' });
|
|
1442
|
+
containerEl.dispatchEvent(pointerDownEvent);
|
|
1443
|
+
|
|
1444
|
+
const scrollOffsetBefore = vs.scrollDetails.scrollOffset.y;
|
|
1445
|
+
containerEl.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 400 }));
|
|
1446
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(scrollOffsetBefore);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it('ignores pointermove and pointerup when not dragging', async () => {
|
|
1450
|
+
const wrapper = mount(VirtualScroll, {
|
|
1451
|
+
props: { itemSize: 50, items: mockItems },
|
|
1452
|
+
});
|
|
1453
|
+
await nextTick();
|
|
1454
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1455
|
+
const containerEl = container.element as HTMLElement;
|
|
1456
|
+
|
|
1457
|
+
const pointerMoveEvent = new PointerEvent('pointermove', { bubbles: true, clientY: 400 });
|
|
1458
|
+
containerEl.dispatchEvent(pointerMoveEvent);
|
|
1459
|
+
|
|
1460
|
+
const pointerUpEvent = new PointerEvent('pointerup', { bubbles: true });
|
|
1461
|
+
containerEl.dispatchEvent(pointerUpEvent);
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it('handles pointer-based scrolling when scaling is active', async () => {
|
|
1465
|
+
const items = Array.from({ length: 11000 }, (_, i) => ({ id: i }));
|
|
1466
|
+
const wrapper = mount(VirtualScroll, {
|
|
1467
|
+
props: {
|
|
1468
|
+
itemSize: 1000, // 11M VU
|
|
1469
|
+
items,
|
|
1470
|
+
},
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
await nextTick();
|
|
1474
|
+
await nextTick();
|
|
1475
|
+
|
|
1476
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<unknown>;
|
|
1477
|
+
expect(vs.scaleY).toBeGreaterThan(1);
|
|
1478
|
+
|
|
1479
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1480
|
+
|
|
1481
|
+
container.element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 100, button: 0, pointerId: 1, bubbles: true }));
|
|
1482
|
+
container.element.dispatchEvent(new PointerEvent('pointermove', { clientX: 0, clientY: 50, pointerId: 1, bubbles: true }));
|
|
1483
|
+
await nextTick();
|
|
1484
|
+
vi.runAllTimers(); // process requestAnimationFrame
|
|
1485
|
+
await nextTick();
|
|
1486
|
+
|
|
1487
|
+
// Dragged 50px up.
|
|
1488
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(50);
|
|
1489
|
+
|
|
1490
|
+
// pointerup
|
|
1491
|
+
container.element.dispatchEvent(new PointerEvent('pointerup', { clientX: 0, clientY: 50, pointerId: 1, bubbles: true }));
|
|
1492
|
+
await nextTick();
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it('implements inertia scrolling with friction and cancellation', async () => {
|
|
1496
|
+
const items = Array.from({ length: 11000 }, (_, i) => ({ id: i }));
|
|
1497
|
+
const wrapper = mount(VirtualScroll, {
|
|
1498
|
+
props: {
|
|
1499
|
+
itemSize: 1000,
|
|
1500
|
+
items,
|
|
1501
|
+
},
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
await nextTick();
|
|
1505
|
+
await nextTick();
|
|
1506
|
+
|
|
1507
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<unknown>;
|
|
1508
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1509
|
+
|
|
1510
|
+
// 1. Start inertia by swiping quickly
|
|
1511
|
+
container.element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 400, button: 0, pointerId: 1, bubbles: true }));
|
|
1512
|
+
container.element.dispatchEvent(new PointerEvent('pointermove', { clientX: 0, clientY: 300, pointerId: 1, bubbles: true }));
|
|
1513
|
+
await nextTick();
|
|
1514
|
+
|
|
1515
|
+
// Swipe fast
|
|
1516
|
+
container.element.dispatchEvent(new PointerEvent('pointerup', { clientX: 0, clientY: 200, pointerId: 1, bubbles: true }));
|
|
1517
|
+
await nextTick();
|
|
1518
|
+
|
|
1519
|
+
// 2. Verify it continues to scroll
|
|
1520
|
+
vi.advanceTimersByTime(16); // step 1
|
|
1521
|
+
await nextTick();
|
|
1522
|
+
const pos1 = vs.scrollDetails.scrollOffset.y;
|
|
1523
|
+
expect(pos1).toBeGreaterThan(200);
|
|
1524
|
+
|
|
1525
|
+
vi.advanceTimersByTime(16); // step 2
|
|
1526
|
+
await nextTick();
|
|
1527
|
+
const pos2 = vs.scrollDetails.scrollOffset.y;
|
|
1528
|
+
expect(pos2).toBeGreaterThan(pos1);
|
|
1529
|
+
|
|
1530
|
+
// 3. Stop inertia via stopProgrammaticScroll
|
|
1531
|
+
vs.stopProgrammaticScroll();
|
|
1532
|
+
vi.advanceTimersByTime(16);
|
|
1533
|
+
await nextTick();
|
|
1534
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(pos2);
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
it('prevents cross-axis drift during inertia', async () => {
|
|
1538
|
+
const items = Array.from({ length: 11000 }, (_, i) => ({ id: i }));
|
|
1539
|
+
const wrapper = mount(VirtualScroll, {
|
|
1540
|
+
props: {
|
|
1541
|
+
itemSize: 1000,
|
|
1542
|
+
columnCount: 11000,
|
|
1543
|
+
columnWidth: 1000,
|
|
1544
|
+
direction: 'both',
|
|
1545
|
+
items,
|
|
1546
|
+
},
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
await nextTick();
|
|
1550
|
+
await nextTick();
|
|
1551
|
+
|
|
1552
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<unknown>;
|
|
1553
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1554
|
+
|
|
1555
|
+
// Swipe horizontally with very small vertical component
|
|
1556
|
+
container.element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 400, clientY: 100, button: 0, pointerId: 1, bubbles: true }));
|
|
1557
|
+
container.element.dispatchEvent(new PointerEvent('pointermove', { clientX: 300, clientY: 98, pointerId: 1, bubbles: true }));
|
|
1558
|
+
await nextTick();
|
|
1559
|
+
vi.runAllTimers();
|
|
1560
|
+
await nextTick();
|
|
1561
|
+
|
|
1562
|
+
container.element.dispatchEvent(new PointerEvent('pointerup', { clientX: 200, clientY: 98, pointerId: 1, bubbles: true }));
|
|
1563
|
+
await nextTick();
|
|
1564
|
+
|
|
1565
|
+
// Velocity Y should have been zeroed because X velocity is much higher
|
|
1566
|
+
vi.advanceTimersByTime(16);
|
|
1567
|
+
await nextTick();
|
|
1568
|
+
|
|
1569
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeGreaterThan(200);
|
|
1570
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(2); // Initial deltaY was 2 (100 -> 98). No more movement.
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
describe('large scale rendering boundaries', () => {
|
|
1574
|
+
const rowHeight = 1000;
|
|
1575
|
+
const rowCount = 11000; // 11,000,000px
|
|
1576
|
+
const massiveItems = Array.from({ length: rowCount }, (_, i) => ({ id: i }));
|
|
1577
|
+
const viewportHeight = 500;
|
|
1578
|
+
|
|
1579
|
+
const variants = [
|
|
1580
|
+
{ name: 'plain', props: {} },
|
|
1581
|
+
{ name: 'sticky header', props: { stickyHeader: true }, hasHeader: true },
|
|
1582
|
+
{ name: 'sticky footer', props: { stickyFooter: true }, hasFooter: true },
|
|
1583
|
+
{ name: 'both sticky', props: { stickyHeader: true, stickyFooter: true }, hasHeader: true, hasFooter: true },
|
|
1584
|
+
{ name: 'plain with gap', props: {} },
|
|
1585
|
+
{ name: 'sticky header with gap', props: { stickyHeader: true, gap: 50 }, hasHeader: true },
|
|
1586
|
+
{ name: 'sticky footer with gap', props: { stickyFooter: true, gap: 50 }, hasFooter: true },
|
|
1587
|
+
{ name: 'both sticky with gap', props: { stickyHeader: true, stickyFooter: true, gap: 50 }, hasHeader: true, hasFooter: true },
|
|
1588
|
+
];
|
|
1589
|
+
|
|
1590
|
+
for (const variant of variants) {
|
|
1591
|
+
describe(variant.name, () => {
|
|
1592
|
+
it('renders last items when scrolled to end manually', async () => {
|
|
1593
|
+
const wrapper = mount(VirtualScroll, {
|
|
1594
|
+
props: {
|
|
1595
|
+
items: massiveItems,
|
|
1596
|
+
itemSize: rowHeight,
|
|
1597
|
+
...variant.props,
|
|
1598
|
+
},
|
|
1599
|
+
slots: {
|
|
1600
|
+
...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
|
|
1601
|
+
...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
await nextTick();
|
|
1606
|
+
await nextTick();
|
|
1607
|
+
|
|
1608
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1609
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1610
|
+
|
|
1611
|
+
const totalRUHeight = vs.scrollDetails.totalSize.height;
|
|
1612
|
+
const maxRUOffset = totalRUHeight - vs.scrollDetails.viewportSize.height;
|
|
1613
|
+
const maxScroll = virtualToDisplay(maxRUOffset, vs.componentOffset.y, vs.scaleY);
|
|
1614
|
+
Object.defineProperty(container.element, 'scrollTop', { configurable: true, value: maxScroll });
|
|
1615
|
+
await container.trigger('scroll');
|
|
1616
|
+
|
|
1617
|
+
await nextTick();
|
|
1618
|
+
await nextTick();
|
|
1619
|
+
|
|
1620
|
+
const renderedIndices = vs.scrollDetails.items.map((i) => i.index);
|
|
1621
|
+
expect(renderedIndices).toContain(rowCount - 1);
|
|
1622
|
+
expect(renderedIndices).toContain(rowCount - 2);
|
|
1623
|
+
|
|
1624
|
+
const lastItem = vs.scrollDetails.items.find((i) => i.index === rowCount - 1)!;
|
|
1625
|
+
expect(lastItem.originalY + lastItem.size.height).toBeCloseTo(vs.scrollDetails.scrollOffset.y + viewportHeight, 0);
|
|
1626
|
+
expect(vs.scrollDetails.scrollOffset.y + viewportHeight).toBeCloseTo(totalRUHeight, 0);
|
|
1627
|
+
|
|
1628
|
+
wrapper.unmount();
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
it('renders last items when end key is pressed', async () => {
|
|
1632
|
+
const wrapper = mount(VirtualScroll, {
|
|
1633
|
+
props: {
|
|
1634
|
+
items: massiveItems,
|
|
1635
|
+
itemSize: rowHeight,
|
|
1636
|
+
...variant.props,
|
|
1637
|
+
},
|
|
1638
|
+
slots: {
|
|
1639
|
+
...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
|
|
1640
|
+
...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
|
|
1641
|
+
},
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
await nextTick();
|
|
1645
|
+
await nextTick();
|
|
1646
|
+
|
|
1647
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1648
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1649
|
+
|
|
1650
|
+
await container.trigger('keydown', { key: 'End' });
|
|
1651
|
+
|
|
1652
|
+
await nextTick();
|
|
1653
|
+
await nextTick();
|
|
1654
|
+
|
|
1655
|
+
const renderedIndices = vs.scrollDetails.items.map((i) => i.index);
|
|
1656
|
+
expect(renderedIndices).toContain(rowCount - 1);
|
|
1657
|
+
|
|
1658
|
+
const lastItem = vs.scrollDetails.items.find((i) => i.index === rowCount - 1)!;
|
|
1659
|
+
expect(lastItem.originalY + lastItem.size.height).toBeCloseTo(vs.scrollDetails.scrollOffset.y + viewportHeight, 0);
|
|
1660
|
+
|
|
1661
|
+
wrapper.unmount();
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it('renders first items when home key is pressed after being at end', async () => {
|
|
1665
|
+
const wrapper = mount(VirtualScroll, {
|
|
1666
|
+
props: {
|
|
1667
|
+
items: massiveItems,
|
|
1668
|
+
itemSize: rowHeight,
|
|
1669
|
+
...variant.props,
|
|
1670
|
+
},
|
|
1671
|
+
slots: {
|
|
1672
|
+
...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
|
|
1673
|
+
...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
|
|
1674
|
+
},
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
await nextTick();
|
|
1678
|
+
await nextTick();
|
|
1679
|
+
|
|
1680
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1681
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
1682
|
+
|
|
1683
|
+
vs.scrollToIndex(rowCount - 1, null, { align: 'end', behavior: 'auto' });
|
|
1684
|
+
await nextTick();
|
|
1685
|
+
await nextTick();
|
|
1686
|
+
|
|
1687
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
1688
|
+
await nextTick();
|
|
1689
|
+
await nextTick();
|
|
1690
|
+
|
|
1691
|
+
const renderedIndices = vs.scrollDetails.items.map((i) => i.index);
|
|
1692
|
+
expect(renderedIndices).toContain(0);
|
|
1693
|
+
expect(renderedIndices).toContain(1);
|
|
1694
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
1695
|
+
|
|
1696
|
+
wrapper.unmount();
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
it('renders correct items when scrollbar is at boundaries', async () => {
|
|
1700
|
+
const wrapper = mount(VirtualScroll, {
|
|
1701
|
+
props: {
|
|
1702
|
+
items: massiveItems,
|
|
1703
|
+
itemSize: rowHeight,
|
|
1704
|
+
virtualScrollbar: true,
|
|
1705
|
+
...variant.props,
|
|
1706
|
+
},
|
|
1707
|
+
slots: {
|
|
1708
|
+
...(variant.hasHeader ? { header: '<div style="height: 50px">Header</div>' } : {}),
|
|
1709
|
+
...(variant.hasFooter ? { footer: '<div style="height: 50px">Footer</div>' } : {}),
|
|
1710
|
+
},
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
await nextTick();
|
|
1714
|
+
await nextTick();
|
|
1715
|
+
|
|
1716
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1717
|
+
|
|
1718
|
+
const maxDisplayOffset = vs.renderedHeight - vs.scrollDetails.displayViewportSize.height;
|
|
1719
|
+
vs.scrollToOffset(null, displayToVirtual(maxDisplayOffset, vs.componentOffset.y, vs.scaleY));
|
|
1720
|
+
|
|
1721
|
+
await nextTick();
|
|
1722
|
+
await nextTick();
|
|
1723
|
+
|
|
1724
|
+
expect(vs.scrollDetails.items.map((i) => i.index)).toContain(rowCount - 1);
|
|
1725
|
+
|
|
1726
|
+
vs.scrollToOffset(null, 0);
|
|
1727
|
+
await nextTick();
|
|
1728
|
+
await nextTick();
|
|
1729
|
+
|
|
1730
|
+
expect(vs.scrollDetails.items.map((i) => i.index)).toContain(0);
|
|
1731
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
1732
|
+
|
|
1733
|
+
wrapper.unmount();
|
|
1734
|
+
});
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
describe('virtual scrollbars', () => {
|
|
1741
|
+
it('scrolls horizontally with shift + mousewheel when scaling is active', async () => {
|
|
1742
|
+
const massiveColCount = 200000;
|
|
1743
|
+
const massiveItems = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
1744
|
+
const wrapper = mount(VirtualScroll, {
|
|
1745
|
+
props: {
|
|
1746
|
+
columnCount: massiveColCount,
|
|
1747
|
+
columnWidth: 100,
|
|
1748
|
+
direction: 'both',
|
|
1749
|
+
itemSize: 50,
|
|
1750
|
+
items: massiveItems,
|
|
1751
|
+
},
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
await nextTick();
|
|
1755
|
+
await nextTick();
|
|
1756
|
+
|
|
1757
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1758
|
+
expect(vs.scaleX).toBeGreaterThan(1);
|
|
1759
|
+
|
|
1760
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(0);
|
|
1761
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
1762
|
+
|
|
1763
|
+
const wheelEvent = new WheelEvent('wheel', {
|
|
1764
|
+
bubbles: true,
|
|
1765
|
+
cancelable: true,
|
|
1766
|
+
deltaX: 0,
|
|
1767
|
+
deltaY: 100,
|
|
1768
|
+
shiftKey: true,
|
|
1769
|
+
});
|
|
1770
|
+
wrapper.find('.virtual-scroll-container').element.dispatchEvent(wheelEvent);
|
|
437
1771
|
|
|
438
|
-
it('should handle End key (vertical)', async () => {
|
|
439
|
-
el.scrollLeft = 0;
|
|
440
|
-
await container.trigger('keydown', { key: 'End' });
|
|
441
1772
|
await nextTick();
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
// alignment 'end' -> targetY = 4950 - (500 - 50) = 4500
|
|
446
|
-
expect(el.scrollTop).toBe(4500);
|
|
447
|
-
expect(el.scrollLeft).toBe(0);
|
|
1773
|
+
|
|
1774
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
|
|
1775
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
448
1776
|
});
|
|
449
1777
|
|
|
450
|
-
it('
|
|
451
|
-
|
|
1778
|
+
it('updates thumb size when total size changes', async () => {
|
|
1779
|
+
const wrapper = mount(VirtualScroll, {
|
|
1780
|
+
props: {
|
|
1781
|
+
itemSize: 50,
|
|
1782
|
+
items: Array.from({ length: 20 }, (_, i) => ({ id: i })),
|
|
1783
|
+
virtualScrollbar: true,
|
|
1784
|
+
},
|
|
1785
|
+
});
|
|
1786
|
+
|
|
452
1787
|
await nextTick();
|
|
453
|
-
// Trigger resize again for horizontal
|
|
454
|
-
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
455
|
-
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
456
1788
|
await nextTick();
|
|
457
1789
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
expect(el.scrollLeft).toBe(4500);
|
|
462
|
-
expect(el.scrollTop).toBe(0);
|
|
463
|
-
});
|
|
1790
|
+
const verticalThumb = wrapper.find('.virtual-scroll-scrollbar-container .virtual-scrollbar-thumb--vertical');
|
|
1791
|
+
expect(verticalThumb.exists()).toBe(true);
|
|
1792
|
+
expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('50%');
|
|
464
1793
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
1794
|
+
await wrapper.setProps({
|
|
1795
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1796
|
+
});
|
|
468
1797
|
|
|
469
|
-
// Trigger a resize
|
|
470
|
-
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
471
|
-
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
472
1798
|
await nextTick();
|
|
473
|
-
|
|
474
|
-
await container.trigger('keydown', { key: 'End' });
|
|
1799
|
+
await nextTick();
|
|
475
1800
|
await nextTick();
|
|
476
1801
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
// targetX = 4 * 100 = 400. end alignment: 400 - (500 - 100) = 0
|
|
483
|
-
expect(el.scrollTop).toBe(4500);
|
|
484
|
-
expect(el.scrollLeft).toBe(0);
|
|
485
|
-
});
|
|
1802
|
+
expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('10%');
|
|
1803
|
+
|
|
1804
|
+
await wrapper.setProps({
|
|
1805
|
+
items: Array.from({ length: 1000 }, (_, i) => ({ id: i })),
|
|
1806
|
+
});
|
|
486
1807
|
|
|
487
|
-
it('should handle End key with empty items', async () => {
|
|
488
|
-
await wrapper.setProps({ items: [] });
|
|
489
1808
|
await nextTick();
|
|
490
|
-
await container.trigger('keydown', { key: 'End' });
|
|
491
1809
|
await nextTick();
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
it('should handle ArrowDown / ArrowUp', async () => {
|
|
495
|
-
el.scrollLeft = 0;
|
|
496
|
-
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
497
1810
|
await nextTick();
|
|
498
|
-
expect(el.scrollTop).toBe(40);
|
|
499
|
-
expect(el.scrollLeft).toBe(0);
|
|
500
1811
|
|
|
501
|
-
|
|
502
|
-
await nextTick();
|
|
503
|
-
expect(el.scrollTop).toBe(0);
|
|
504
|
-
expect(el.scrollLeft).toBe(0);
|
|
1812
|
+
expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('6.4%');
|
|
505
1813
|
});
|
|
506
1814
|
|
|
507
|
-
it('
|
|
508
|
-
|
|
1815
|
+
it('scrolls when clicking on vertical scrollbar track', async () => {
|
|
1816
|
+
const wrapper = mount(VirtualScroll, {
|
|
1817
|
+
props: {
|
|
1818
|
+
itemSize: 50,
|
|
1819
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1820
|
+
virtualScrollbar: true,
|
|
1821
|
+
},
|
|
1822
|
+
});
|
|
1823
|
+
|
|
509
1824
|
await nextTick();
|
|
510
|
-
el.scrollTop = 0;
|
|
511
|
-
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
512
1825
|
await nextTick();
|
|
513
|
-
expect(el.scrollLeft).toBe(40);
|
|
514
|
-
expect(el.scrollTop).toBe(0);
|
|
515
1826
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
expect(el.scrollLeft).toBe(0);
|
|
519
|
-
expect(el.scrollTop).toBe(0);
|
|
520
|
-
});
|
|
1827
|
+
const track = wrapper.find('.virtual-scrollbar-track--vertical');
|
|
1828
|
+
expect(track.exists()).toBe(true);
|
|
521
1829
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1830
|
+
vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
|
|
1831
|
+
top: 0,
|
|
1832
|
+
left: 490,
|
|
1833
|
+
width: 10,
|
|
1834
|
+
height: 500,
|
|
1835
|
+
bottom: 500,
|
|
1836
|
+
right: 500,
|
|
1837
|
+
} as DOMRect);
|
|
528
1838
|
|
|
529
|
-
await
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
expect(el.scrollLeft).toBe(0);
|
|
533
|
-
});
|
|
1839
|
+
await track.trigger('mousedown', {
|
|
1840
|
+
clientY: 250,
|
|
1841
|
+
});
|
|
534
1842
|
|
|
535
|
-
it('should handle PageDown / PageUp in horizontal mode', async () => {
|
|
536
|
-
await wrapper.setProps({ direction: 'horizontal' });
|
|
537
|
-
await nextTick();
|
|
538
|
-
el.scrollTop = 0;
|
|
539
|
-
await container.trigger('keydown', { key: 'PageDown' });
|
|
540
1843
|
await nextTick();
|
|
541
|
-
expect(el.scrollLeft).toBe(500);
|
|
542
|
-
expect(el.scrollTop).toBe(0);
|
|
543
1844
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
expect(el.scrollLeft).toBe(0);
|
|
547
|
-
expect(el.scrollTop).toBe(0);
|
|
1845
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1846
|
+
expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(2250, 0);
|
|
548
1847
|
});
|
|
549
1848
|
|
|
550
|
-
it('
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1849
|
+
it('scrolls to absolute end when clicking near the end of the vertical track', async () => {
|
|
1850
|
+
const wrapper = mount(VirtualScroll, {
|
|
1851
|
+
props: { itemSize: 50, items: mockItems, virtualScrollbar: true },
|
|
1852
|
+
});
|
|
554
1853
|
await nextTick();
|
|
555
|
-
await container.trigger('keydown', { key: 'PageDown' });
|
|
556
1854
|
await nextTick();
|
|
557
|
-
expect(el.scrollTop).toBe(4500);
|
|
558
|
-
});
|
|
559
1855
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
(wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4480);
|
|
563
|
-
await nextTick();
|
|
564
|
-
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
565
|
-
await nextTick();
|
|
566
|
-
expect(el.scrollTop).toBe(4500);
|
|
567
|
-
});
|
|
1856
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1857
|
+
const track = wrapper.find('.virtual-scrollbar-track--vertical');
|
|
568
1858
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1859
|
+
vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
|
|
1860
|
+
bottom: 500,
|
|
1861
|
+
height: 500,
|
|
1862
|
+
left: 490,
|
|
1863
|
+
right: 500,
|
|
1864
|
+
top: 0,
|
|
1865
|
+
width: 10,
|
|
1866
|
+
x: 490,
|
|
1867
|
+
y: 0,
|
|
1868
|
+
} as DOMRect);
|
|
575
1869
|
|
|
576
|
-
|
|
577
|
-
it('should update item size on resize', async () => {
|
|
578
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
|
|
579
|
-
await nextTick();
|
|
580
|
-
const firstItem = wrapper.find('.virtual-scroll-item').element;
|
|
581
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
|
|
582
|
-
if (observer) {
|
|
583
|
-
observer.trigger([ {
|
|
584
|
-
target: firstItem,
|
|
585
|
-
contentRect: { width: 100, height: 100 } as DOMRectReadOnly,
|
|
586
|
-
borderBoxSize: [ { inlineSize: 110, blockSize: 110 } ],
|
|
587
|
-
} ]);
|
|
588
|
-
}
|
|
1870
|
+
await track.trigger('mousedown', { clientY: 500 });
|
|
589
1871
|
await nextTick();
|
|
1872
|
+
|
|
1873
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
|
|
590
1874
|
});
|
|
591
1875
|
|
|
592
|
-
it('
|
|
593
|
-
const wrapper = mount(VirtualScroll, {
|
|
1876
|
+
it('scrolls to absolute end when clicking near the end of the horizontal track', async () => {
|
|
1877
|
+
const wrapper = mount(VirtualScroll, {
|
|
1878
|
+
props: { direction: 'horizontal', itemSize: 50, items: mockItems, virtualScrollbar: true },
|
|
1879
|
+
});
|
|
594
1880
|
await nextTick();
|
|
595
|
-
const firstItem = wrapper.find('.virtual-scroll-item').element;
|
|
596
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
|
|
597
|
-
if (observer) {
|
|
598
|
-
observer.trigger([ { target: firstItem, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
|
|
599
|
-
}
|
|
600
1881
|
await nextTick();
|
|
601
|
-
});
|
|
602
1882
|
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
1883
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1884
|
+
const track = wrapper.find('.virtual-scrollbar-track--horizontal');
|
|
1885
|
+
|
|
1886
|
+
vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
|
|
1887
|
+
bottom: 500,
|
|
1888
|
+
height: 10,
|
|
1889
|
+
left: 0,
|
|
1890
|
+
right: 500,
|
|
1891
|
+
top: 490,
|
|
1892
|
+
width: 500,
|
|
1893
|
+
x: 0,
|
|
1894
|
+
y: 490,
|
|
1895
|
+
} as DOMRect);
|
|
1896
|
+
|
|
1897
|
+
await track.trigger('mousedown', { clientX: 500 });
|
|
611
1898
|
await nextTick();
|
|
1899
|
+
|
|
1900
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(4500);
|
|
612
1901
|
});
|
|
613
1902
|
|
|
614
|
-
it('
|
|
1903
|
+
it('scrolls when clicking on horizontal scrollbar track', async () => {
|
|
615
1904
|
const wrapper = mount(VirtualScroll, {
|
|
616
1905
|
props: {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
slots: {
|
|
622
|
-
item: '<template #item="{ index }"><div class="row"><div class="cell" data-col-index="0">Cell {{ index }}</div></div></template>',
|
|
1906
|
+
direction: 'horizontal',
|
|
1907
|
+
itemSize: 100,
|
|
1908
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1909
|
+
virtualScrollbar: true,
|
|
623
1910
|
},
|
|
624
1911
|
});
|
|
1912
|
+
|
|
1913
|
+
await nextTick();
|
|
625
1914
|
await nextTick();
|
|
626
|
-
const cell = wrapper.find('.cell').element;
|
|
627
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(cell));
|
|
628
|
-
expect(observer).toBeDefined();
|
|
629
1915
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1916
|
+
const track = wrapper.find('.virtual-scrollbar-track--horizontal');
|
|
1917
|
+
expect(track.exists()).toBe(true);
|
|
1918
|
+
|
|
1919
|
+
vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
|
|
1920
|
+
top: 490,
|
|
1921
|
+
left: 0,
|
|
1922
|
+
width: 500,
|
|
1923
|
+
height: 10,
|
|
1924
|
+
bottom: 500,
|
|
1925
|
+
right: 500,
|
|
1926
|
+
} as DOMRect);
|
|
1927
|
+
|
|
1928
|
+
await track.trigger('mousedown', {
|
|
1929
|
+
clientX: 250,
|
|
1930
|
+
});
|
|
1931
|
+
|
|
636
1932
|
await nextTick();
|
|
1933
|
+
|
|
1934
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1935
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(4750, 0);
|
|
637
1936
|
});
|
|
638
1937
|
|
|
639
|
-
it('
|
|
1938
|
+
it('calls internal scrolltooffset with infinity when scrollbar reaches the end', async () => {
|
|
1939
|
+
let capturedCallback: ((offset: number) => void) | undefined;
|
|
640
1940
|
const wrapper = mount(VirtualScroll, {
|
|
641
|
-
props: { items: mockItems,
|
|
1941
|
+
props: { itemSize: 50, items: mockItems, virtualScrollbar: true },
|
|
642
1942
|
slots: {
|
|
643
|
-
|
|
644
|
-
|
|
1943
|
+
scrollbar: (slotProps: ScrollbarSlotProps) => {
|
|
1944
|
+
if (slotProps.scrollbarProps.axis === 'vertical') {
|
|
1945
|
+
capturedCallback = slotProps.scrollbarProps.scrollToOffset;
|
|
1946
|
+
}
|
|
1947
|
+
return h('div', { class: 'captured-scrollbar' });
|
|
1948
|
+
},
|
|
645
1949
|
},
|
|
646
1950
|
});
|
|
1951
|
+
|
|
1952
|
+
await nextTick();
|
|
647
1953
|
await nextTick();
|
|
648
|
-
const header = wrapper.find('.virtual-scroll-header').element;
|
|
649
|
-
const footer = wrapper.find('.virtual-scroll-footer').element;
|
|
650
1954
|
|
|
651
|
-
const
|
|
652
|
-
if (headerObserver) {
|
|
653
|
-
headerObserver.trigger([ { target: header } ]);
|
|
654
|
-
}
|
|
1955
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
655
1956
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
footerObserver.trigger([ { target: footer } ]);
|
|
659
|
-
}
|
|
1957
|
+
triggerResize(wrapper.element as HTMLElement, 500, 500);
|
|
1958
|
+
await nextTick();
|
|
660
1959
|
await nextTick();
|
|
661
|
-
});
|
|
662
1960
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1961
|
+
expect(vs.isHydrated).toBe(true);
|
|
1962
|
+
expect(wrapper.find('.captured-scrollbar').exists()).toBe(true);
|
|
1963
|
+
expect(typeof capturedCallback).toBe('function');
|
|
1964
|
+
|
|
1965
|
+
capturedCallback!(4500);
|
|
1966
|
+
await nextTick();
|
|
668
1967
|
await nextTick();
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
expect(observer).toBeDefined();
|
|
1968
|
+
|
|
1969
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
|
|
672
1970
|
});
|
|
673
1971
|
|
|
674
|
-
it('
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1972
|
+
it('does not show horizontal scrollbar if items fit', async () => {
|
|
1973
|
+
const wrapper = mount(VirtualScroll, {
|
|
1974
|
+
props: {
|
|
1975
|
+
direction: 'horizontal',
|
|
1976
|
+
itemSize: 100,
|
|
1977
|
+
items: Array.from({ length: 2 }, (_, i) => ({ id: i })),
|
|
1978
|
+
virtualScrollbar: true,
|
|
681
1979
|
},
|
|
682
|
-
template: `
|
|
683
|
-
<VirtualScroll :items="mockItems">
|
|
684
|
-
<template v-if="show" #header><div>H</div></template>
|
|
685
|
-
<template v-if="showFooter" #footer><div>F</div></template>
|
|
686
|
-
</VirtualScroll>
|
|
687
|
-
`,
|
|
688
1980
|
});
|
|
689
|
-
const wrapper = mount(TestComp);
|
|
690
|
-
await nextTick();
|
|
691
1981
|
|
|
692
|
-
(wrapper.vm as unknown as TestCompInstance).show = false;
|
|
693
|
-
(wrapper.vm as unknown as TestCompInstance).showFooter = false;
|
|
694
1982
|
await nextTick();
|
|
695
|
-
|
|
696
|
-
(wrapper.vm as unknown as TestCompInstance).show = true;
|
|
697
|
-
(wrapper.vm as unknown as TestCompInstance).showFooter = true;
|
|
698
1983
|
await nextTick();
|
|
1984
|
+
|
|
1985
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1986
|
+
expect(vs.scrollbarPropsHorizontal).toBeNull();
|
|
699
1987
|
});
|
|
700
1988
|
|
|
701
|
-
it('
|
|
1989
|
+
it('forces virtual scrollbars when virtualscrollbar prop is true', async () => {
|
|
702
1990
|
const wrapper = mount(VirtualScroll, {
|
|
703
|
-
props: {
|
|
704
|
-
|
|
1991
|
+
props: {
|
|
1992
|
+
items: [ { id: 1 } ],
|
|
1993
|
+
itemSize: 50,
|
|
1994
|
+
virtualScrollbar: true,
|
|
1995
|
+
},
|
|
705
1996
|
});
|
|
706
1997
|
await nextTick();
|
|
707
|
-
wrapper.
|
|
1998
|
+
expect(wrapper.find('.virtual-scroll-scrollbar-container').exists()).toBe(true);
|
|
708
1999
|
});
|
|
2000
|
+
});
|
|
709
2001
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
2002
|
+
describe('ssr & hydration', () => {
|
|
2003
|
+
it('renders ssr range if provided', async () => {
|
|
2004
|
+
const wrapper = mount(VirtualScroll, {
|
|
2005
|
+
props: {
|
|
2006
|
+
itemSize: 50,
|
|
2007
|
+
items: mockItems,
|
|
2008
|
+
ssrRange: { end: 20, start: 10 },
|
|
2009
|
+
},
|
|
2010
|
+
slots: {
|
|
2011
|
+
item: (props: ItemSlotProps) => {
|
|
2012
|
+
const { item } = props as ItemSlotProps<MockItem>;
|
|
2013
|
+
return h('div', item.label);
|
|
2014
|
+
},
|
|
2015
|
+
},
|
|
2016
|
+
});
|
|
2017
|
+
const items = wrapper.findAll('.virtual-scroll-item');
|
|
2018
|
+
expect(items.length).toBe(10);
|
|
2019
|
+
expect(items[ 0 ]?.attributes('data-index')).toBe('10');
|
|
2020
|
+
expect(wrapper.text()).toContain('Item 10');
|
|
725
2021
|
});
|
|
726
|
-
});
|
|
727
2022
|
|
|
728
|
-
|
|
729
|
-
it('should cover firstRenderedIndex watcher for grid', async () => {
|
|
2023
|
+
it('hydrates and scrolls to initial index', async () => {
|
|
730
2024
|
const wrapper = mount(VirtualScroll, {
|
|
731
2025
|
props: {
|
|
732
|
-
|
|
733
|
-
columnCount: 5,
|
|
734
|
-
direction: 'both',
|
|
2026
|
+
initialScrollIndex: 50,
|
|
735
2027
|
itemSize: 50,
|
|
736
2028
|
items: mockItems,
|
|
737
2029
|
},
|
|
738
2030
|
slots: {
|
|
739
|
-
item:
|
|
2031
|
+
item: (props: ItemSlotProps) => {
|
|
2032
|
+
const { item } = props as ItemSlotProps<MockItem>;
|
|
2033
|
+
return h('div', item.label);
|
|
2034
|
+
},
|
|
740
2035
|
},
|
|
741
2036
|
});
|
|
742
2037
|
await nextTick();
|
|
743
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
744
|
-
|
|
745
|
-
// Scroll to 10
|
|
746
|
-
vm.scrollToIndex(10, 0, { align: 'start', behavior: 'auto' });
|
|
747
2038
|
await nextTick();
|
|
748
2039
|
await nextTick();
|
|
749
|
-
|
|
750
|
-
const item8 = wrapper.find('.virtual-scroll-item[data-index="8"]').element;
|
|
751
|
-
const itemResizeObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item8));
|
|
752
|
-
expect(itemResizeObserver).toBeDefined();
|
|
753
|
-
|
|
754
|
-
// Scroll to 9
|
|
755
|
-
vm.scrollToIndex(9, 0, { align: 'start', behavior: 'auto' });
|
|
756
2040
|
await nextTick();
|
|
757
2041
|
await nextTick();
|
|
758
2042
|
|
|
759
|
-
|
|
760
|
-
vm.scrollToIndex(50, 0, { align: 'start', behavior: 'auto' });
|
|
761
|
-
await nextTick();
|
|
762
|
-
await nextTick();
|
|
2043
|
+
expect(wrapper.text()).toContain('Item 50');
|
|
763
2044
|
});
|
|
764
2045
|
|
|
765
|
-
it('
|
|
2046
|
+
it('renders gaps correctly during initial mount/ssr', async () => {
|
|
766
2047
|
const wrapper = mount(VirtualScroll, {
|
|
767
2048
|
props: {
|
|
768
|
-
columnCount: 5,
|
|
769
2049
|
direction: 'both',
|
|
2050
|
+
items: mockItems.slice(0, 10),
|
|
770
2051
|
itemSize: 50,
|
|
771
|
-
|
|
2052
|
+
columnCount: 5,
|
|
2053
|
+
columnWidth: 100,
|
|
2054
|
+
gap: 10,
|
|
2055
|
+
columnGap: 20,
|
|
772
2056
|
},
|
|
773
2057
|
});
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
2058
|
+
|
|
2059
|
+
// Check styles immediately after mount (before hydration)
|
|
2060
|
+
const vsWrapper = wrapper.find('.virtual-scroll-wrapper');
|
|
2061
|
+
const vsWrapperStyle = (vsWrapper.element as HTMLElement).style;
|
|
2062
|
+
expect(vsWrapperStyle.rowGap).toBe('10px');
|
|
2063
|
+
expect(vsWrapperStyle.columnGap).toBe('20px');
|
|
2064
|
+
|
|
2065
|
+
const vsItem = wrapper.find('.virtual-scroll-item');
|
|
2066
|
+
const vsItemStyle = (vsItem.element as HTMLElement).style;
|
|
2067
|
+
expect(vsItemStyle.columnGap).toBe('20px');
|
|
777
2068
|
});
|
|
778
2069
|
});
|
|
779
2070
|
|
|
780
|
-
describe('
|
|
781
|
-
it('
|
|
2071
|
+
describe('slots & custom content', () => {
|
|
2072
|
+
it('renders header and footer', async () => {
|
|
782
2073
|
const wrapper = mount(VirtualScroll, {
|
|
783
|
-
props: {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
2074
|
+
props: { items: mockItems.slice(0, 1) },
|
|
2075
|
+
slots: {
|
|
2076
|
+
footer: () => h('div', 'FOOTER'),
|
|
2077
|
+
header: () => h('div', 'HEADER'),
|
|
787
2078
|
},
|
|
788
2079
|
});
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
await nextTick();
|
|
793
|
-
await nextTick();
|
|
2080
|
+
expect(wrapper.text()).toContain('HEADER');
|
|
2081
|
+
expect(wrapper.text()).toContain('FOOTER');
|
|
2082
|
+
});
|
|
794
2083
|
|
|
795
|
-
|
|
796
|
-
|
|
2084
|
+
it('shows loading indicator', async () => {
|
|
2085
|
+
const wrapper = mount(VirtualScroll, {
|
|
2086
|
+
props: { items: [], loading: true },
|
|
2087
|
+
slots: {
|
|
2088
|
+
loading: () => h('div', 'LOADING...'),
|
|
2089
|
+
},
|
|
2090
|
+
});
|
|
2091
|
+
expect(wrapper.text()).toContain('LOADING...');
|
|
797
2092
|
});
|
|
798
2093
|
|
|
799
|
-
it('
|
|
2094
|
+
it('uses correct html tags', () => {
|
|
800
2095
|
const wrapper = mount(VirtualScroll, {
|
|
801
2096
|
props: {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
items:
|
|
805
|
-
|
|
2097
|
+
containerTag: 'table',
|
|
2098
|
+
itemTag: 'tr',
|
|
2099
|
+
items: [],
|
|
2100
|
+
wrapperTag: 'tbody',
|
|
806
2101
|
},
|
|
807
2102
|
});
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
(wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
|
|
811
|
-
await nextTick();
|
|
812
|
-
await nextTick();
|
|
813
|
-
|
|
814
|
-
expect(wrapper.emitted('load')).toBeDefined();
|
|
815
|
-
expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'horizontal' ]);
|
|
2103
|
+
expect(wrapper.element.tagName).toBe('TABLE');
|
|
2104
|
+
expect(wrapper.find('tbody').exists()).toBe(true);
|
|
816
2105
|
});
|
|
817
2106
|
|
|
818
|
-
it('
|
|
2107
|
+
it('triggers refresh and updates items', async () => {
|
|
819
2108
|
const wrapper = mount(VirtualScroll, {
|
|
820
2109
|
props: {
|
|
821
2110
|
itemSize: 50,
|
|
822
2111
|
items: mockItems.slice(0, 10),
|
|
823
|
-
loadDistance: 100,
|
|
824
|
-
loading: true,
|
|
825
2112
|
},
|
|
826
2113
|
});
|
|
827
2114
|
await nextTick();
|
|
828
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
829
|
-
const el = container.element as HTMLElement;
|
|
830
|
-
Object.defineProperty(el, 'clientHeight', { value: 200, configurable: true });
|
|
831
|
-
Object.defineProperty(el, 'scrollHeight', { value: 500, configurable: true });
|
|
832
2115
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
await nextTick();
|
|
2116
|
+
const vs = wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
|
|
2117
|
+
vs.refresh();
|
|
836
2118
|
await nextTick();
|
|
837
|
-
|
|
838
|
-
expect(
|
|
2119
|
+
expect(vs.scrollDetails.items.length).toBeGreaterThan(0);
|
|
2120
|
+
expect(vs.scrollDetails.items.length).toBeLessThan(50);
|
|
839
2121
|
});
|
|
840
2122
|
|
|
841
|
-
it('
|
|
842
|
-
|
|
2123
|
+
it('handles sticky header and footer measurements', async () => {
|
|
2124
|
+
mount(VirtualScroll, {
|
|
843
2125
|
props: {
|
|
844
|
-
itemSize: 50,
|
|
845
2126
|
items: mockItems.slice(0, 10),
|
|
846
|
-
|
|
2127
|
+
stickyFooter: true,
|
|
2128
|
+
stickyHeader: true,
|
|
847
2129
|
},
|
|
848
2130
|
slots: {
|
|
849
|
-
|
|
2131
|
+
footer: () => h('div', { class: 'footer', style: 'height: 30px' }, 'FOOTER'),
|
|
2132
|
+
header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
|
|
850
2133
|
},
|
|
851
2134
|
});
|
|
852
2135
|
await nextTick();
|
|
853
|
-
expect(wrapper.find('.loading-indicator').exists()).toBe(true);
|
|
854
|
-
|
|
855
|
-
await wrapper.setProps({ direction: 'horizontal' });
|
|
856
|
-
await nextTick();
|
|
857
|
-
expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
|
|
858
2136
|
});
|
|
859
2137
|
|
|
860
|
-
it('
|
|
2138
|
+
it('accounts for sticky header and footer in scroll padding', async () => {
|
|
861
2139
|
const wrapper = mount(VirtualScroll, {
|
|
862
2140
|
props: {
|
|
863
|
-
items: mockItems
|
|
864
|
-
|
|
2141
|
+
items: mockItems,
|
|
2142
|
+
itemSize: 50,
|
|
2143
|
+
stickyHeader: true,
|
|
865
2144
|
},
|
|
866
2145
|
slots: {
|
|
867
|
-
|
|
2146
|
+
header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
|
|
868
2147
|
},
|
|
869
2148
|
});
|
|
2149
|
+
|
|
870
2150
|
await nextTick();
|
|
2151
|
+
await nextTick();
|
|
2152
|
+
|
|
2153
|
+
const vs = wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; };
|
|
2154
|
+
expect(vs.scrollDetails.totalSize.height).toBeGreaterThan(0);
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
it('resets measured padding when header/footer is removed', async () => {
|
|
2158
|
+
const TestComponent = {
|
|
2159
|
+
components: { VirtualScroll },
|
|
2160
|
+
props: [ 'showHeader', 'showFooter' ],
|
|
2161
|
+
template: `
|
|
2162
|
+
<VirtualScroll :itemSize="50" :items="items">
|
|
2163
|
+
<template v-if="showHeader" #header>
|
|
2164
|
+
<div class="header" style="height: 100px">HEADER</div>
|
|
2165
|
+
</template>
|
|
2166
|
+
<template v-if="showFooter" #footer>
|
|
2167
|
+
<div class="footer" style="height: 100px">FOOTER</div>
|
|
2168
|
+
</template>
|
|
2169
|
+
</VirtualScroll>
|
|
2170
|
+
`,
|
|
2171
|
+
data() {
|
|
2172
|
+
return { items: Array.from({ length: 10 }, (_, i) => ({ id: i })) };
|
|
2173
|
+
},
|
|
2174
|
+
};
|
|
871
2175
|
|
|
872
|
-
|
|
873
|
-
|
|
2176
|
+
const wrapper = mount(TestComponent, {
|
|
2177
|
+
props: {
|
|
2178
|
+
showHeader: true,
|
|
2179
|
+
showFooter: true,
|
|
2180
|
+
},
|
|
2181
|
+
});
|
|
874
2182
|
|
|
875
|
-
await wrapper.setProps({ loading: true });
|
|
876
2183
|
await nextTick();
|
|
877
|
-
expect(wrapper.find('.loader').exists()).toBe(true);
|
|
878
|
-
expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(true);
|
|
879
2184
|
|
|
880
|
-
|
|
2185
|
+
const vs = wrapper.findComponent(VirtualScroll as unknown as DefineComponent).vm as unknown as VirtualScrollInstance<MockItem>;
|
|
2186
|
+
|
|
2187
|
+
const headerEl = wrapper.find('.virtual-scroll-header').element as HTMLElement;
|
|
2188
|
+
const footerEl = wrapper.find('.virtual-scroll-footer').element as HTMLElement;
|
|
2189
|
+
|
|
2190
|
+
Object.defineProperty(headerEl, 'offsetHeight', { configurable: true, value: 100 });
|
|
2191
|
+
Object.defineProperty(footerEl, 'offsetHeight', { configurable: true, value: 100 });
|
|
2192
|
+
|
|
2193
|
+
triggerResize(headerEl, 500, 100);
|
|
2194
|
+
triggerResize(footerEl, 500, 100);
|
|
2195
|
+
|
|
881
2196
|
await nextTick();
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
});
|
|
2197
|
+
await nextTick();
|
|
2198
|
+
|
|
2199
|
+
expect(vs.scrollDetails.totalSize.height).toBe(700);
|
|
886
2200
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
2201
|
+
await wrapper.setProps({ showHeader: false });
|
|
2202
|
+
await nextTick();
|
|
890
2203
|
await nextTick();
|
|
891
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
892
|
-
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
893
|
-
vm.setItemRef(item, 0);
|
|
894
|
-
vm.setItemRef(null, 0);
|
|
895
|
-
});
|
|
896
2204
|
|
|
897
|
-
|
|
898
|
-
|
|
2205
|
+
expect(vs.scrollDetails.totalSize.height).toBe(600);
|
|
2206
|
+
|
|
2207
|
+
await wrapper.setProps({ showFooter: false });
|
|
2208
|
+
await nextTick();
|
|
899
2209
|
await nextTick();
|
|
900
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ];
|
|
901
|
-
const div = document.createElement('div');
|
|
902
|
-
observer?.trigger([ { target: div, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
|
|
903
|
-
});
|
|
904
2210
|
|
|
905
|
-
|
|
906
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
907
|
-
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToIndex).toBe('function');
|
|
908
|
-
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToOffset).toBe('function');
|
|
2211
|
+
expect(vs.scrollDetails.totalSize.height).toBe(500);
|
|
909
2212
|
});
|
|
2213
|
+
});
|
|
910
2214
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
2215
|
+
describe('dynamic list changes', () => {
|
|
2216
|
+
it('clamps scroll position when items count decreases (with scaling)', async () => {
|
|
2217
|
+
const items = ref(Array.from({ length: 11000 }, (_, i) => ({ id: i })));
|
|
2218
|
+
const wrapper = mount({
|
|
2219
|
+
components: { VirtualScroll },
|
|
2220
|
+
setup() {
|
|
2221
|
+
return { items };
|
|
2222
|
+
},
|
|
2223
|
+
template: '<VirtualScroll :items="items" :item-size="1000" style="height: 500px" />',
|
|
916
2224
|
});
|
|
917
2225
|
await nextTick();
|
|
2226
|
+
await nextTick();
|
|
2227
|
+
const vs = wrapper.findComponent(VirtualScroll as unknown as VueWrapper).vm as VirtualScrollInstance<{ id: number; }>;
|
|
2228
|
+
|
|
2229
|
+
expect(vs.scaleY).toBeGreaterThan(1);
|
|
2230
|
+
|
|
2231
|
+
vs.scrollToIndex(10500, null, { align: 'start', behavior: 'auto' });
|
|
2232
|
+
await nextTick();
|
|
2233
|
+
await nextTick();
|
|
918
2234
|
|
|
919
|
-
|
|
920
|
-
Object.defineProperty(el, 'offsetHeight', { value: 100 });
|
|
921
|
-
Object.defineProperty(el, 'offsetWidth', { value: 100 });
|
|
2235
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(10500000);
|
|
922
2236
|
|
|
923
|
-
|
|
924
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
925
|
-
vm.refresh();
|
|
2237
|
+
items.value = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
|
926
2238
|
await nextTick();
|
|
927
2239
|
await nextTick();
|
|
928
2240
|
|
|
929
|
-
expect(
|
|
2241
|
+
expect(vs.scrollDetails.scrollOffset.y).toBeLessThanOrEqual(1000000 - 500);
|
|
930
2242
|
});
|
|
931
2243
|
|
|
932
|
-
it('
|
|
933
|
-
const
|
|
934
|
-
|
|
2244
|
+
it('syncs display scroll position when total height changes (with scaling)', async () => {
|
|
2245
|
+
const items = ref(Array.from({ length: 30000 }, (_, i) => ({ id: i })));
|
|
2246
|
+
const wrapper = mount({
|
|
2247
|
+
components: { VirtualScroll },
|
|
2248
|
+
setup() {
|
|
2249
|
+
return { items };
|
|
2250
|
+
},
|
|
2251
|
+
template: '<VirtualScroll :items="items" :item-size="1000" style="height: 500px" />',
|
|
935
2252
|
});
|
|
936
2253
|
await nextTick();
|
|
937
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
938
|
-
vm.refresh();
|
|
939
2254
|
await nextTick();
|
|
2255
|
+
const vs = wrapper.findComponent(VirtualScroll as unknown as VueWrapper).vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
2256
|
+
|
|
2257
|
+
vs.scrollToOffset(null, 10000000);
|
|
2258
|
+
await nextTick();
|
|
2259
|
+
await nextTick();
|
|
2260
|
+
|
|
2261
|
+
const initialDisplayScroll = (wrapper.find('.virtual-scroll-container').element as HTMLElement).scrollTop;
|
|
2262
|
+
|
|
2263
|
+
items.value = Array.from({ length: 40000 }, (_, i) => ({ id: i }));
|
|
2264
|
+
await nextTick();
|
|
2265
|
+
await nextTick();
|
|
2266
|
+
|
|
2267
|
+
const newDisplayScroll = (wrapper.find('.virtual-scroll-container').element as HTMLElement).scrollTop;
|
|
2268
|
+
expect(newDisplayScroll).not.toBe(initialDisplayScroll);
|
|
2269
|
+
expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(10000000, 0);
|
|
940
2270
|
});
|
|
941
2271
|
|
|
942
|
-
it('
|
|
943
|
-
const
|
|
944
|
-
|
|
2272
|
+
it('updates pending scroll index when items are prepended in a dynamic list', async () => {
|
|
2273
|
+
const items = ref(Array.from({ length: 50 }, (_, i) => ({ id: i })));
|
|
2274
|
+
const wrapper = mount({
|
|
2275
|
+
components: { VirtualScroll },
|
|
2276
|
+
setup() {
|
|
2277
|
+
return { items };
|
|
2278
|
+
},
|
|
2279
|
+
template: '<VirtualScroll :items="items" :item-size="50" restore-scroll-on-prepend style="height: 200px" />',
|
|
945
2280
|
});
|
|
2281
|
+
|
|
2282
|
+
await nextTick();
|
|
946
2283
|
await nextTick();
|
|
947
2284
|
await nextTick();
|
|
948
|
-
expect(wrapper.emitted('visibleRangeChange')).toBeDefined();
|
|
949
2285
|
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
2286
|
+
const vs = wrapper.findComponent(VirtualScroll as unknown as VueWrapper).vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
2287
|
+
|
|
2288
|
+
expect(vs.isHydrated).toBe(true);
|
|
2289
|
+
|
|
2290
|
+
vs.scrollToIndex(10, null, { behavior: 'smooth', align: 'start' });
|
|
953
2291
|
await nextTick();
|
|
954
2292
|
await nextTick();
|
|
955
|
-
|
|
2293
|
+
|
|
2294
|
+
items.value = [ { id: -2 }, { id: -1 }, ...items.value ];
|
|
2295
|
+
|
|
2296
|
+
for (let i = 0; i < 15; i++) {
|
|
2297
|
+
await nextTick();
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(600, 0);
|
|
956
2301
|
});
|
|
957
2302
|
|
|
958
|
-
it('
|
|
959
|
-
|
|
2303
|
+
it('recycles items and maintains a small rendered item count', async () => {
|
|
2304
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
|
960
2305
|
const wrapper = mount(VirtualScroll, {
|
|
961
2306
|
props: {
|
|
962
|
-
|
|
2307
|
+
items,
|
|
963
2308
|
itemSize: 50,
|
|
964
|
-
items: mockItems.slice(0, 10),
|
|
965
2309
|
},
|
|
966
2310
|
});
|
|
967
2311
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
2312
|
+
await nextTick();
|
|
2313
|
+
await nextTick();
|
|
2314
|
+
|
|
2315
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
|
|
2316
|
+
|
|
2317
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
2318
|
+
|
|
2319
|
+
vs.scrollToOffset(null, 5000, { behavior: 'auto' });
|
|
2320
|
+
await nextTick();
|
|
2321
|
+
await nextTick();
|
|
2322
|
+
|
|
2323
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(20);
|
|
971
2324
|
|
|
972
|
-
|
|
973
|
-
|
|
2325
|
+
vs.scrollToOffset(null, 49500, { behavior: 'auto' });
|
|
2326
|
+
await nextTick();
|
|
2327
|
+
await nextTick();
|
|
974
2328
|
|
|
975
|
-
|
|
976
|
-
await nextTick(); // one more for good measure
|
|
977
|
-
expect(wrapper.emitted('scroll')).toBeDefined();
|
|
2329
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
|
|
978
2330
|
});
|
|
979
2331
|
});
|
|
980
2332
|
});
|