@pdanpdan/virtual-scroll 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -324
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +836 -376
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1334 -741
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +8 -2
- package/src/components/VirtualScroll.test.ts +1921 -325
- package/src/components/VirtualScroll.vue +829 -386
- package/src/components/VirtualScrollbar.test.ts +174 -0
- package/src/components/VirtualScrollbar.vue +102 -0
- package/src/composables/useVirtualScroll.test.ts +1506 -228
- package/src/composables/useVirtualScroll.ts +869 -517
- package/src/composables/useVirtualScrollbar.test.ts +526 -0
- package/src/composables/useVirtualScrollbar.ts +244 -0
- package/src/index.ts +9 -0
- package/src/types.ts +353 -110
- package/src/utils/fenwick-tree.test.ts +39 -39
- package/src/utils/scroll.test.ts +181 -101
- package/src/utils/scroll.ts +43 -5
- package/src/utils/virtual-scroll-logic.test.ts +673 -323
- package/src/utils/virtual-scroll-logic.ts +759 -430
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { ItemSlotProps, ScrollDetails } from '../types';
|
|
1
|
+
import type { ItemSlotProps, ScrollbarSlotProps, ScrollDetails, VirtualScrollInstance } from '../types';
|
|
2
|
+
import type { VueWrapper } from '@vue/test-utils';
|
|
3
|
+
import type { DefineComponent } from 'vue';
|
|
2
4
|
|
|
3
5
|
/* global ScrollToOptions, ResizeObserverCallback */
|
|
4
6
|
import { mount } from '@vue/test-utils';
|
|
5
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import { h, nextTick } from 'vue';
|
|
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 ---
|
|
@@ -26,9 +29,12 @@ HTMLElement.prototype.scrollTo = function (this: HTMLElement, options?: number |
|
|
|
26
29
|
this.scrollLeft = options;
|
|
27
30
|
this.scrollTop = y;
|
|
28
31
|
}
|
|
29
|
-
this.dispatchEvent(new Event('scroll'));
|
|
32
|
+
this.dispatchEvent(new (this.ownerDocument?.defaultView?.Event || Event)('scroll'));
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
36
|
+
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
|
37
|
+
|
|
32
38
|
interface ResizeObserverMock extends ResizeObserver {
|
|
33
39
|
callback: ResizeObserverCallback;
|
|
34
40
|
targets: Set<Element>;
|
|
@@ -56,11 +62,11 @@ globalThis.ResizeObserver = class ResizeObserver {
|
|
|
56
62
|
}
|
|
57
63
|
} as unknown as typeof ResizeObserver;
|
|
58
64
|
|
|
59
|
-
function triggerResize(el: Element, width: number, height: number) {
|
|
65
|
+
function triggerResize(el: Element, width: number, height: number, useBorderBox = true) {
|
|
60
66
|
const obs = observers.find((o) => o.targets.has(el));
|
|
61
67
|
if (obs) {
|
|
62
68
|
obs.callback([ {
|
|
63
|
-
borderBoxSize: [ { blockSize: height, inlineSize: width } ],
|
|
69
|
+
...(useBorderBox ? { borderBoxSize: [ { blockSize: height, inlineSize: width } ] } : {}),
|
|
64
70
|
contentRect: {
|
|
65
71
|
bottom: height,
|
|
66
72
|
height,
|
|
@@ -99,15 +105,20 @@ describe('virtualScroll', () => {
|
|
|
99
105
|
const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
100
106
|
|
|
101
107
|
beforeEach(() => {
|
|
102
|
-
vi.clearAllMocks();
|
|
103
|
-
observers.length = 0;
|
|
104
108
|
Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
|
|
105
109
|
Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
|
|
106
110
|
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
|
|
107
111
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
|
|
112
|
+
vi.useFakeTimers({ toFake: [ 'requestAnimationFrame' ] });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
observers.length = 0;
|
|
117
|
+
vi.clearAllMocks();
|
|
118
|
+
vi.useRealTimers();
|
|
108
119
|
});
|
|
109
120
|
|
|
110
|
-
describe('
|
|
121
|
+
describe('core rendering & lifecycle', () => {
|
|
111
122
|
it('renders the visible items', async () => {
|
|
112
123
|
const wrapper = mount(VirtualScroll, {
|
|
113
124
|
props: {
|
|
@@ -157,6 +168,10 @@ describe('virtualScroll', () => {
|
|
|
157
168
|
const container = wrapper.find('.virtual-scroll-container');
|
|
158
169
|
expect(container.classes()).toContain('virtual-scroll--horizontal');
|
|
159
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);
|
|
160
175
|
});
|
|
161
176
|
|
|
162
177
|
it('supports grid mode (both directions)', async () => {
|
|
@@ -173,10 +188,68 @@ describe('virtualScroll', () => {
|
|
|
173
188
|
const style = (wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style;
|
|
174
189
|
expect(style.blockSize).toBe('5000px');
|
|
175
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);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('works with window as container', async () => {
|
|
198
|
+
const wrapper = mount(VirtualScroll, {
|
|
199
|
+
props: {
|
|
200
|
+
container: window,
|
|
201
|
+
itemSize: 50,
|
|
202
|
+
items: mockItems,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
await nextTick();
|
|
206
|
+
expect(wrapper.classes()).toContain('virtual-scroll--window');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('unmounts cleanly', async () => {
|
|
210
|
+
const wrapper = mount(VirtualScroll, {
|
|
211
|
+
props: {
|
|
212
|
+
items: mockItems,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
await nextTick();
|
|
216
|
+
wrapper.unmount();
|
|
217
|
+
// no errors should be thrown
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('handles hostRef change', async () => {
|
|
221
|
+
const wrapper = mount(VirtualScroll, {
|
|
222
|
+
props: {
|
|
223
|
+
items: mockItems,
|
|
224
|
+
containerTag: 'div',
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
await nextTick();
|
|
228
|
+
await wrapper.setProps({ containerTag: 'section' });
|
|
229
|
+
await nextTick();
|
|
230
|
+
// should have unobserved old and observed new
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('stops active smooth scroll via stopProgrammaticScroll', async () => {
|
|
234
|
+
const wrapper = mount(VirtualScroll, {
|
|
235
|
+
props: { itemSize: 50, items: mockItems },
|
|
236
|
+
});
|
|
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);
|
|
176
249
|
});
|
|
177
250
|
});
|
|
178
251
|
|
|
179
|
-
describe('
|
|
252
|
+
describe('scrolling interaction', () => {
|
|
180
253
|
it('scrolls and updates visible items', async () => {
|
|
181
254
|
const wrapper = mount(VirtualScroll, {
|
|
182
255
|
props: {
|
|
@@ -202,6 +275,10 @@ describe('virtualScroll', () => {
|
|
|
202
275
|
|
|
203
276
|
expect(wrapper.text()).toContain('Item 20');
|
|
204
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);
|
|
205
282
|
});
|
|
206
283
|
|
|
207
284
|
it('emits load event when reaching end', async () => {
|
|
@@ -228,387 +305,1702 @@ describe('virtualScroll', () => {
|
|
|
228
305
|
expect(wrapper.emitted('load')).toBeDefined();
|
|
229
306
|
});
|
|
230
307
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
238
|
-
|
|
239
|
-
await container.trigger('keydown', { key: 'End' });
|
|
240
|
-
await nextTick();
|
|
241
|
-
// item 99 at 4950. end align -> 4950 - (500 - 50) = 4500.
|
|
242
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
|
|
243
|
-
|
|
244
|
-
await container.trigger('keydown', { key: 'Home' });
|
|
245
|
-
await nextTick();
|
|
246
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('responds to Arrows in vertical mode', async () => {
|
|
250
|
-
const wrapper = mount(VirtualScroll, {
|
|
251
|
-
props: { itemSize: 50, items: mockItems },
|
|
252
|
-
});
|
|
253
|
-
await nextTick();
|
|
254
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
255
|
-
|
|
256
|
-
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
257
|
-
await nextTick();
|
|
258
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(40); // DEFAULT_ITEM_SIZE
|
|
259
|
-
|
|
260
|
-
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
261
|
-
await nextTick();
|
|
262
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
308
|
+
it('handles wheel when virtual scrollbars are inactive', async () => {
|
|
309
|
+
const wrapper = mount(VirtualScroll, {
|
|
310
|
+
props: {
|
|
311
|
+
items: mockItems,
|
|
312
|
+
virtualScrollbar: false,
|
|
313
|
+
},
|
|
263
314
|
});
|
|
315
|
+
await nextTick();
|
|
316
|
+
await wrapper.find('.virtual-scroll-container').trigger('wheel', { deltaY: 100 });
|
|
317
|
+
// should just stop programmatic scroll
|
|
318
|
+
});
|
|
264
319
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
await nextTick();
|
|
274
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(500);
|
|
275
|
-
|
|
276
|
-
await container.trigger('keydown', { key: 'PageUp' });
|
|
277
|
-
await nextTick();
|
|
278
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
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 }));
|
|
322
|
+
const wrapper = mount(VirtualScroll, {
|
|
323
|
+
props: {
|
|
324
|
+
items,
|
|
325
|
+
itemSize: 0, // dynamic
|
|
326
|
+
defaultItemSize: 40,
|
|
327
|
+
},
|
|
279
328
|
});
|
|
280
329
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
284
|
-
});
|
|
285
|
-
await nextTick();
|
|
286
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
287
|
-
|
|
288
|
-
await container.trigger('keydown', { key: 'End' });
|
|
289
|
-
await nextTick();
|
|
290
|
-
// last item 99 at 9900. end align -> 9900 - (500 - 100) = 9500.
|
|
291
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(9500);
|
|
330
|
+
await nextTick();
|
|
331
|
+
await nextTick();
|
|
292
332
|
|
|
293
|
-
|
|
294
|
-
await nextTick();
|
|
295
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
296
|
-
});
|
|
333
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
297
334
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
301
|
-
});
|
|
302
|
-
await nextTick();
|
|
303
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
335
|
+
// Press End
|
|
336
|
+
await wrapper.trigger('keydown', { key: 'End' });
|
|
304
337
|
|
|
305
|
-
|
|
338
|
+
// Wait for multiple ticks to let the correction logic work
|
|
339
|
+
for (let i = 0; i < 5; i++) {
|
|
306
340
|
await nextTick();
|
|
307
|
-
|
|
341
|
+
}
|
|
308
342
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
349
|
+
}
|
|
350
|
+
}
|
|
313
351
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
317
|
-
});
|
|
352
|
+
// Wait for corrections
|
|
353
|
+
for (let i = 0; i < 5; i++) {
|
|
318
354
|
await nextTick();
|
|
319
|
-
|
|
355
|
+
}
|
|
320
356
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
357
|
+
const details = vs.scrollDetails;
|
|
358
|
+
// Should be at the end
|
|
359
|
+
expect(details.scrollOffset.y).toBeGreaterThanOrEqual(details.totalSize.height - details.viewportSize.height - 1);
|
|
324
360
|
|
|
325
|
-
|
|
326
|
-
await nextTick();
|
|
327
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
328
|
-
});
|
|
361
|
+
const scrollToIndexSpy = vi.spyOn(vs, 'scrollToIndex');
|
|
329
362
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
props: {
|
|
333
|
-
columnCount: 10,
|
|
334
|
-
columnWidth: 100,
|
|
335
|
-
direction: 'both',
|
|
336
|
-
itemSize: 50,
|
|
337
|
-
items: mockItems,
|
|
338
|
-
},
|
|
339
|
-
});
|
|
340
|
-
await nextTick();
|
|
341
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
363
|
+
await nextTick();
|
|
364
|
+
await nextTick();
|
|
342
365
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
|
|
348
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
|
|
366
|
+
// Should not be calling scrollToIndex anymore
|
|
367
|
+
expect(scrollToIndexSpy).not.toHaveBeenCalled();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
349
370
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
371
|
+
describe('keyboard navigation', () => {
|
|
372
|
+
it('responds to home and end keys in vertical mode', async () => {
|
|
373
|
+
const wrapper = mount(VirtualScroll, {
|
|
374
|
+
props: { itemSize: 50, items: mockItems },
|
|
354
375
|
});
|
|
376
|
+
await nextTick();
|
|
377
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
355
378
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
columnCount: 10,
|
|
360
|
-
columnWidth: 100,
|
|
361
|
-
direction: 'both',
|
|
362
|
-
itemSize: 50,
|
|
363
|
-
items: mockItems,
|
|
364
|
-
},
|
|
365
|
-
});
|
|
366
|
-
await nextTick();
|
|
367
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
368
|
-
|
|
369
|
-
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
370
|
-
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
371
|
-
await nextTick();
|
|
372
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(40);
|
|
373
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(40);
|
|
379
|
+
await container.trigger('keydown', { key: 'End' });
|
|
380
|
+
await nextTick();
|
|
381
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(4500);
|
|
374
382
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
379
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
380
|
-
});
|
|
383
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
384
|
+
await nextTick();
|
|
385
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
381
386
|
});
|
|
382
|
-
});
|
|
383
387
|
|
|
384
|
-
|
|
385
|
-
it('adjusts total size when items are measured', async () => {
|
|
388
|
+
it('responds to arrows in vertical mode', async () => {
|
|
386
389
|
const wrapper = mount(VirtualScroll, {
|
|
387
|
-
props: {
|
|
388
|
-
itemSize: 0,
|
|
389
|
-
items: mockItems.slice(0, 10),
|
|
390
|
-
},
|
|
390
|
+
props: { itemSize: 50, items: mockItems },
|
|
391
391
|
});
|
|
392
392
|
await nextTick();
|
|
393
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
393
394
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const firstItem = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
|
|
397
|
-
triggerResize(firstItem, 100, 100);
|
|
398
|
-
await nextTick();
|
|
395
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
399
396
|
await nextTick();
|
|
397
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(50);
|
|
400
398
|
|
|
401
|
-
|
|
399
|
+
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
400
|
+
await nextTick();
|
|
401
|
+
expect((wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
402
402
|
});
|
|
403
403
|
|
|
404
|
-
it('
|
|
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'));
|
|
413
|
+
});
|
|
414
|
+
|
|
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;
|
|
420
|
+
});
|
|
421
|
+
|
|
405
422
|
const wrapper = mount(VirtualScroll, {
|
|
406
423
|
props: {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
defaultColumnWidth: 100,
|
|
411
|
-
direction: 'both',
|
|
412
|
-
itemSize: 50,
|
|
424
|
+
container,
|
|
425
|
+
direction: 'horizontal',
|
|
426
|
+
itemSize: 100,
|
|
413
427
|
items: mockItems,
|
|
414
428
|
},
|
|
415
|
-
slots: {
|
|
416
|
-
item: ({ columnRange, index }: ItemSlotProps) => h('div', {
|
|
417
|
-
'data-index': index,
|
|
418
|
-
}, [
|
|
419
|
-
...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
|
|
420
|
-
class: 'cell',
|
|
421
|
-
'data-col-index': columnRange.start + i,
|
|
422
|
-
})),
|
|
423
|
-
]),
|
|
424
|
-
},
|
|
425
429
|
});
|
|
426
430
|
|
|
431
|
+
await nextTick();
|
|
427
432
|
await nextTick();
|
|
428
433
|
|
|
429
|
-
const
|
|
430
|
-
|
|
434
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
435
|
+
vs.updateDirection();
|
|
436
|
+
await nextTick();
|
|
437
|
+
expect(vs.isRtl).toBe(true);
|
|
431
438
|
|
|
432
|
-
|
|
433
|
-
const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
|
|
434
|
-
const cell0 = row0.querySelector('.cell') as HTMLElement;
|
|
435
|
-
expect(cell0).not.toBeNull();
|
|
439
|
+
const vsContainer = wrapper.find('.virtual-scroll-container');
|
|
436
440
|
|
|
437
|
-
|
|
438
|
-
|
|
441
|
+
await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
|
|
442
|
+
await nextTick();
|
|
443
|
+
await nextTick();
|
|
444
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
|
|
439
445
|
|
|
446
|
+
await vsContainer.trigger('keydown', { key: 'ArrowRight' });
|
|
440
447
|
await nextTick();
|
|
441
448
|
await nextTick();
|
|
449
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
|
|
442
450
|
|
|
443
|
-
|
|
444
|
-
const currentWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
|
|
445
|
-
expect(currentWidth).toBe(initialWidth);
|
|
451
|
+
styleSpy.mockRestore();
|
|
446
452
|
});
|
|
447
453
|
|
|
448
|
-
it('
|
|
454
|
+
it('aligns partially visible items correctly with arrows in rtl mode', async () => {
|
|
455
|
+
const container = document.createElement('div');
|
|
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
|
+
|
|
449
472
|
const wrapper = mount(VirtualScroll, {
|
|
450
473
|
props: {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
defaultColumnWidth: 100,
|
|
455
|
-
direction: 'both',
|
|
456
|
-
itemSize: 50,
|
|
474
|
+
container,
|
|
475
|
+
direction: 'horizontal',
|
|
476
|
+
itemSize: 100,
|
|
457
477
|
items: mockItems,
|
|
458
478
|
},
|
|
459
|
-
slots: {
|
|
460
|
-
item: ({ columnRange, index }: ItemSlotProps) => h('div', {
|
|
461
|
-
'data-index': index,
|
|
462
|
-
}, [
|
|
463
|
-
...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
|
|
464
|
-
class: 'cell',
|
|
465
|
-
'data-col-index': columnRange.start + i,
|
|
466
|
-
})),
|
|
467
|
-
]),
|
|
468
|
-
},
|
|
469
479
|
});
|
|
470
480
|
|
|
481
|
+
await nextTick();
|
|
471
482
|
await nextTick();
|
|
472
483
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
// Measure some columns of row 0
|
|
477
|
-
const row0 = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
|
|
478
|
-
const cells0 = Array.from(row0.querySelectorAll('.cell'));
|
|
479
|
-
|
|
480
|
-
// Measure row 0 and its cells
|
|
481
|
-
triggerResize(row0, 1000, 50);
|
|
482
|
-
for (const cell of cells0) {
|
|
483
|
-
triggerResize(cell, 110, 50);
|
|
484
|
-
}
|
|
484
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
485
|
+
vs.updateDirection();
|
|
486
|
+
await nextTick();
|
|
485
487
|
|
|
488
|
+
vs.scrollToOffset(50, null);
|
|
486
489
|
await nextTick();
|
|
487
490
|
await nextTick();
|
|
488
491
|
|
|
489
|
-
|
|
490
|
-
const container = wrapper.find('.virtual-scroll-container');
|
|
491
|
-
const el = container.element as HTMLElement;
|
|
492
|
-
Object.defineProperty(el, 'scrollTop', { configurable: true, value: 1000, writable: true });
|
|
493
|
-
await container.trigger('scroll');
|
|
492
|
+
const vsContainer = wrapper.find('.virtual-scroll-container');
|
|
494
493
|
|
|
494
|
+
await vsContainer.trigger('keydown', { key: 'ArrowRight' });
|
|
495
495
|
await nextTick();
|
|
496
496
|
await nextTick();
|
|
497
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(0, 0);
|
|
497
498
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
499
|
+
await wrapper.setProps({ itemSize: 150 });
|
|
500
|
+
await nextTick();
|
|
501
|
+
await nextTick();
|
|
501
502
|
|
|
502
|
-
|
|
503
|
-
triggerResize(cell, 110.1, 50);
|
|
504
|
-
}
|
|
503
|
+
expect(vs.scrollDetails.currentEndIndex).toBe(3);
|
|
505
504
|
|
|
505
|
+
await vsContainer.trigger('keydown', { key: 'ArrowLeft' });
|
|
506
506
|
await nextTick();
|
|
507
507
|
await nextTick();
|
|
508
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
|
|
508
509
|
|
|
509
|
-
|
|
510
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
510
|
+
styleSpy.mockRestore();
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
-
it('
|
|
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'));
|
|
522
|
+
});
|
|
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
|
+
|
|
514
531
|
const wrapper = mount(VirtualScroll, {
|
|
515
532
|
props: {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
defaultColumnWidth: 120,
|
|
520
|
-
defaultItemSize: 120,
|
|
521
|
-
direction: 'both',
|
|
533
|
+
container,
|
|
534
|
+
direction: 'horizontal',
|
|
535
|
+
itemSize: 100,
|
|
522
536
|
items: mockItems,
|
|
523
537
|
},
|
|
524
|
-
slots: {
|
|
525
|
-
item: ({ columnRange, index }: ItemSlotProps) => h('div', {
|
|
526
|
-
'data-index': index,
|
|
527
|
-
}, [
|
|
528
|
-
...Array.from({ length: columnRange.end - columnRange.start }, (_, i) => h('div', {
|
|
529
|
-
class: 'cell',
|
|
530
|
-
'data-col-index': columnRange.start + i,
|
|
531
|
-
})),
|
|
532
|
-
]),
|
|
533
|
-
},
|
|
534
538
|
});
|
|
535
539
|
|
|
536
|
-
await nextTick();
|
|
537
|
-
|
|
538
|
-
// Jump to 50:50 auto
|
|
539
|
-
(wrapper.vm as unknown as { scrollToIndex: (r: number, c: number, a: string) => void; }).scrollToIndex(50, 50, 'auto');
|
|
540
540
|
await nextTick();
|
|
541
541
|
await nextTick();
|
|
542
542
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5620);
|
|
543
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
544
|
+
vs.updateDirection();
|
|
545
|
+
await nextTick();
|
|
547
546
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
547
|
+
vs.scrollToIndex(null, 4, { align: 'end', behavior: 'auto' });
|
|
548
|
+
await nextTick();
|
|
549
|
+
await nextTick();
|
|
551
550
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
triggerResize(cell, 150, 120);
|
|
555
|
-
}
|
|
551
|
+
expect(vs.scrollDetails.currentEndColIndex).toBe(4);
|
|
552
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(0);
|
|
556
553
|
|
|
557
|
-
|
|
554
|
+
const containerEl = wrapper.find('.virtual-scroll-container');
|
|
555
|
+
await containerEl.trigger('keydown', { key: 'ArrowLeft' });
|
|
558
556
|
await nextTick();
|
|
559
557
|
await nextTick();
|
|
560
558
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
559
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(100);
|
|
560
|
+
styleSpy.mockRestore();
|
|
561
|
+
});
|
|
562
|
+
|
|
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
|
+
});
|
|
567
|
+
await nextTick();
|
|
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();
|
|
567
1190
|
|
|
568
|
-
// wait for async correction cycle
|
|
569
1191
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
570
1192
|
await nextTick();
|
|
571
1193
|
|
|
572
|
-
expect((wrapper.vm as
|
|
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);
|
|
1771
|
+
|
|
1772
|
+
await nextTick();
|
|
1773
|
+
|
|
1774
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(100, 0);
|
|
1775
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(0);
|
|
1776
|
+
});
|
|
1777
|
+
|
|
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
|
+
|
|
1787
|
+
await nextTick();
|
|
1788
|
+
await nextTick();
|
|
1789
|
+
|
|
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%');
|
|
1793
|
+
|
|
1794
|
+
await wrapper.setProps({
|
|
1795
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
await nextTick();
|
|
1799
|
+
await nextTick();
|
|
1800
|
+
await nextTick();
|
|
1801
|
+
|
|
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
|
+
});
|
|
573
1807
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const itemX = 6180;
|
|
578
|
-
const itemWidth = 150;
|
|
1808
|
+
await nextTick();
|
|
1809
|
+
await nextTick();
|
|
1810
|
+
await nextTick();
|
|
579
1811
|
|
|
580
|
-
expect(
|
|
581
|
-
expect(itemX + itemWidth).toBeLessThanOrEqual(offset + viewportWidth);
|
|
1812
|
+
expect((verticalThumb.element as HTMLElement).style.blockSize).toBe('6.4%');
|
|
582
1813
|
});
|
|
583
|
-
});
|
|
584
1814
|
|
|
585
|
-
|
|
586
|
-
it('applies sticky styles to marked items', async () => {
|
|
1815
|
+
it('scrolls when clicking on vertical scrollbar track', async () => {
|
|
587
1816
|
const wrapper = mount(VirtualScroll, {
|
|
588
1817
|
props: {
|
|
589
1818
|
itemSize: 50,
|
|
590
|
-
items:
|
|
591
|
-
|
|
1819
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1820
|
+
virtualScrollbar: true,
|
|
592
1821
|
},
|
|
593
1822
|
});
|
|
1823
|
+
|
|
1824
|
+
await nextTick();
|
|
594
1825
|
await nextTick();
|
|
595
1826
|
|
|
596
|
-
const
|
|
597
|
-
|
|
1827
|
+
const track = wrapper.find('.virtual-scrollbar-track--vertical');
|
|
1828
|
+
expect(track.exists()).toBe(true);
|
|
598
1829
|
|
|
599
|
-
|
|
600
|
-
|
|
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);
|
|
1838
|
+
|
|
1839
|
+
await track.trigger('mousedown', {
|
|
1840
|
+
clientY: 250,
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
await nextTick();
|
|
1844
|
+
|
|
1845
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1846
|
+
expect(vs.scrollDetails.scrollOffset.y).toBeCloseTo(2250, 0);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
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
|
+
});
|
|
601
1853
|
await nextTick();
|
|
602
1854
|
await nextTick();
|
|
603
1855
|
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
1856
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1857
|
+
const track = wrapper.find('.virtual-scrollbar-track--vertical');
|
|
1858
|
+
|
|
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);
|
|
1869
|
+
|
|
1870
|
+
await track.trigger('mousedown', { clientY: 500 });
|
|
1871
|
+
await nextTick();
|
|
1872
|
+
|
|
1873
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
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
|
+
});
|
|
1880
|
+
await nextTick();
|
|
1881
|
+
await nextTick();
|
|
1882
|
+
|
|
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 });
|
|
1898
|
+
await nextTick();
|
|
1899
|
+
|
|
1900
|
+
expect(vs.scrollDetails.scrollOffset.x).toBe(4500);
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
it('scrolls when clicking on horizontal scrollbar track', async () => {
|
|
1904
|
+
const wrapper = mount(VirtualScroll, {
|
|
1905
|
+
props: {
|
|
1906
|
+
direction: 'horizontal',
|
|
1907
|
+
itemSize: 100,
|
|
1908
|
+
items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
|
|
1909
|
+
virtualScrollbar: true,
|
|
1910
|
+
},
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
await nextTick();
|
|
1914
|
+
await nextTick();
|
|
1915
|
+
|
|
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
|
+
|
|
1932
|
+
await nextTick();
|
|
1933
|
+
|
|
1934
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1935
|
+
expect(vs.scrollDetails.scrollOffset.x).toBeCloseTo(4750, 0);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it('calls internal scrolltooffset with infinity when scrollbar reaches the end', async () => {
|
|
1939
|
+
let capturedCallback: ((offset: number) => void) | undefined;
|
|
1940
|
+
const wrapper = mount(VirtualScroll, {
|
|
1941
|
+
props: { itemSize: 50, items: mockItems, virtualScrollbar: true },
|
|
1942
|
+
slots: {
|
|
1943
|
+
scrollbar: (slotProps: ScrollbarSlotProps) => {
|
|
1944
|
+
if (slotProps.scrollbarProps.axis === 'vertical') {
|
|
1945
|
+
capturedCallback = slotProps.scrollbarProps.scrollToOffset;
|
|
1946
|
+
}
|
|
1947
|
+
return h('div', { class: 'captured-scrollbar' });
|
|
1948
|
+
},
|
|
1949
|
+
},
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
await nextTick();
|
|
1953
|
+
await nextTick();
|
|
1954
|
+
|
|
1955
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
1956
|
+
|
|
1957
|
+
triggerResize(wrapper.element as HTMLElement, 500, 500);
|
|
1958
|
+
await nextTick();
|
|
1959
|
+
await nextTick();
|
|
1960
|
+
|
|
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();
|
|
1967
|
+
await nextTick();
|
|
1968
|
+
|
|
1969
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(4500);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
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,
|
|
1979
|
+
},
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
await nextTick();
|
|
1983
|
+
await nextTick();
|
|
1984
|
+
|
|
1985
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<{ id: number; }>;
|
|
1986
|
+
expect(vs.scrollbarPropsHorizontal).toBeNull();
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
it('forces virtual scrollbars when virtualscrollbar prop is true', async () => {
|
|
1990
|
+
const wrapper = mount(VirtualScroll, {
|
|
1991
|
+
props: {
|
|
1992
|
+
items: [ { id: 1 } ],
|
|
1993
|
+
itemSize: 50,
|
|
1994
|
+
virtualScrollbar: true,
|
|
1995
|
+
},
|
|
1996
|
+
});
|
|
1997
|
+
await nextTick();
|
|
1998
|
+
expect(wrapper.find('.virtual-scroll-scrollbar-container').exists()).toBe(true);
|
|
607
1999
|
});
|
|
608
2000
|
});
|
|
609
2001
|
|
|
610
|
-
describe('ssr
|
|
611
|
-
it('renders
|
|
2002
|
+
describe('ssr & hydration', () => {
|
|
2003
|
+
it('renders ssr range if provided', async () => {
|
|
612
2004
|
const wrapper = mount(VirtualScroll, {
|
|
613
2005
|
props: {
|
|
614
2006
|
itemSize: 50,
|
|
@@ -642,8 +2034,8 @@ describe('virtualScroll', () => {
|
|
|
642
2034
|
},
|
|
643
2035
|
},
|
|
644
2036
|
});
|
|
645
|
-
await nextTick();
|
|
646
|
-
await nextTick();
|
|
2037
|
+
await nextTick();
|
|
2038
|
+
await nextTick();
|
|
647
2039
|
await nextTick();
|
|
648
2040
|
await nextTick();
|
|
649
2041
|
await nextTick();
|
|
@@ -651,46 +2043,32 @@ describe('virtualScroll', () => {
|
|
|
651
2043
|
expect(wrapper.text()).toContain('Item 50');
|
|
652
2044
|
});
|
|
653
2045
|
|
|
654
|
-
it('
|
|
2046
|
+
it('renders gaps correctly during initial mount/ssr', async () => {
|
|
655
2047
|
const wrapper = mount(VirtualScroll, {
|
|
656
2048
|
props: {
|
|
2049
|
+
direction: 'both',
|
|
2050
|
+
items: mockItems.slice(0, 10),
|
|
657
2051
|
itemSize: 50,
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
item: (props: ItemSlotProps) => {
|
|
663
|
-
const { index, item } = props as ItemSlotProps<MockItem>;
|
|
664
|
-
return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
|
|
665
|
-
},
|
|
2052
|
+
columnCount: 5,
|
|
2053
|
+
columnWidth: 100,
|
|
2054
|
+
gap: 10,
|
|
2055
|
+
columnGap: 20,
|
|
666
2056
|
},
|
|
667
2057
|
});
|
|
668
2058
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// Scroll past item 2 (originalY = 100). relativeScrollY = 150.
|
|
676
|
-
Object.defineProperty(el, 'scrollTop', { configurable: true, value: 150, writable: true });
|
|
677
|
-
await container.trigger('scroll');
|
|
678
|
-
await nextTick();
|
|
679
|
-
await nextTick();
|
|
680
|
-
|
|
681
|
-
// Only item 2 should be active sticky.
|
|
682
|
-
// Item 0 and 1 should have isStickyActive = false.
|
|
683
|
-
const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
|
|
684
|
-
const item1 = wrapper.find('.virtual-scroll-item[data-index="1"]');
|
|
685
|
-
const item2 = wrapper.find('.virtual-scroll-item[data-index="2"]');
|
|
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');
|
|
686
2064
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
expect(
|
|
2065
|
+
const vsItem = wrapper.find('.virtual-scroll-item');
|
|
2066
|
+
const vsItemStyle = (vsItem.element as HTMLElement).style;
|
|
2067
|
+
expect(vsItemStyle.columnGap).toBe('20px');
|
|
690
2068
|
});
|
|
691
2069
|
});
|
|
692
2070
|
|
|
693
|
-
describe('slots
|
|
2071
|
+
describe('slots & custom content', () => {
|
|
694
2072
|
it('renders header and footer', async () => {
|
|
695
2073
|
const wrapper = mount(VirtualScroll, {
|
|
696
2074
|
props: { items: mockItems.slice(0, 1) },
|
|
@@ -713,7 +2091,7 @@ describe('virtualScroll', () => {
|
|
|
713
2091
|
expect(wrapper.text()).toContain('LOADING...');
|
|
714
2092
|
});
|
|
715
2093
|
|
|
716
|
-
it('uses correct
|
|
2094
|
+
it('uses correct html tags', () => {
|
|
717
2095
|
const wrapper = mount(VirtualScroll, {
|
|
718
2096
|
props: {
|
|
719
2097
|
containerTag: 'table',
|
|
@@ -735,11 +2113,11 @@ describe('virtualScroll', () => {
|
|
|
735
2113
|
});
|
|
736
2114
|
await nextTick();
|
|
737
2115
|
|
|
738
|
-
const vs = wrapper.vm as
|
|
2116
|
+
const vs = wrapper.vm as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
|
|
739
2117
|
vs.refresh();
|
|
740
2118
|
await nextTick();
|
|
741
|
-
// Should not crash
|
|
742
2119
|
expect(vs.scrollDetails.items.length).toBeGreaterThan(0);
|
|
2120
|
+
expect(vs.scrollDetails.items.length).toBeLessThan(50);
|
|
743
2121
|
});
|
|
744
2122
|
|
|
745
2123
|
it('handles sticky header and footer measurements', async () => {
|
|
@@ -757,16 +2135,234 @@ describe('virtualScroll', () => {
|
|
|
757
2135
|
await nextTick();
|
|
758
2136
|
});
|
|
759
2137
|
|
|
760
|
-
it('
|
|
2138
|
+
it('accounts for sticky header and footer in scroll padding', async () => {
|
|
761
2139
|
const wrapper = mount(VirtualScroll, {
|
|
762
2140
|
props: {
|
|
763
|
-
container: window,
|
|
764
|
-
itemSize: 50,
|
|
765
2141
|
items: mockItems,
|
|
2142
|
+
itemSize: 50,
|
|
2143
|
+
stickyHeader: true,
|
|
2144
|
+
},
|
|
2145
|
+
slots: {
|
|
2146
|
+
header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
|
|
766
2147
|
},
|
|
767
2148
|
});
|
|
2149
|
+
|
|
768
2150
|
await nextTick();
|
|
769
|
-
|
|
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
|
+
};
|
|
2175
|
+
|
|
2176
|
+
const wrapper = mount(TestComponent, {
|
|
2177
|
+
props: {
|
|
2178
|
+
showHeader: true,
|
|
2179
|
+
showFooter: true,
|
|
2180
|
+
},
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
await nextTick();
|
|
2184
|
+
|
|
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
|
+
|
|
2196
|
+
await nextTick();
|
|
2197
|
+
await nextTick();
|
|
2198
|
+
|
|
2199
|
+
expect(vs.scrollDetails.totalSize.height).toBe(700);
|
|
2200
|
+
|
|
2201
|
+
await wrapper.setProps({ showHeader: false });
|
|
2202
|
+
await nextTick();
|
|
2203
|
+
await nextTick();
|
|
2204
|
+
|
|
2205
|
+
expect(vs.scrollDetails.totalSize.height).toBe(600);
|
|
2206
|
+
|
|
2207
|
+
await wrapper.setProps({ showFooter: false });
|
|
2208
|
+
await nextTick();
|
|
2209
|
+
await nextTick();
|
|
2210
|
+
|
|
2211
|
+
expect(vs.scrollDetails.totalSize.height).toBe(500);
|
|
2212
|
+
});
|
|
2213
|
+
});
|
|
2214
|
+
|
|
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" />',
|
|
2224
|
+
});
|
|
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();
|
|
2234
|
+
|
|
2235
|
+
expect(vs.scrollDetails.scrollOffset.y).toBe(10500000);
|
|
2236
|
+
|
|
2237
|
+
items.value = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
|
2238
|
+
await nextTick();
|
|
2239
|
+
await nextTick();
|
|
2240
|
+
|
|
2241
|
+
expect(vs.scrollDetails.scrollOffset.y).toBeLessThanOrEqual(1000000 - 500);
|
|
2242
|
+
});
|
|
2243
|
+
|
|
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" />',
|
|
2252
|
+
});
|
|
2253
|
+
await nextTick();
|
|
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);
|
|
2270
|
+
});
|
|
2271
|
+
|
|
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" />',
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
await nextTick();
|
|
2283
|
+
await nextTick();
|
|
2284
|
+
await nextTick();
|
|
2285
|
+
|
|
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' });
|
|
2291
|
+
await nextTick();
|
|
2292
|
+
await nextTick();
|
|
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);
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
it('recycles items and maintains a small rendered item count', async () => {
|
|
2304
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
|
2305
|
+
const wrapper = mount(VirtualScroll, {
|
|
2306
|
+
props: {
|
|
2307
|
+
items,
|
|
2308
|
+
itemSize: 50,
|
|
2309
|
+
},
|
|
2310
|
+
});
|
|
2311
|
+
|
|
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);
|
|
2324
|
+
|
|
2325
|
+
vs.scrollToOffset(null, 49500, { behavior: 'auto' });
|
|
2326
|
+
await nextTick();
|
|
2327
|
+
await nextTick();
|
|
2328
|
+
|
|
2329
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
describe('table virtualization', () => {
|
|
2333
|
+
it('correctly virtualizes when using table tags and constrained height', async () => {
|
|
2334
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
|
2335
|
+
const wrapper = mount(VirtualScroll, {
|
|
2336
|
+
props: {
|
|
2337
|
+
items,
|
|
2338
|
+
itemSize: 40,
|
|
2339
|
+
containerTag: 'table',
|
|
2340
|
+
wrapperTag: 'tbody',
|
|
2341
|
+
itemTag: 'tr',
|
|
2342
|
+
style: { height: '400px', display: 'block' },
|
|
2343
|
+
},
|
|
2344
|
+
slots: {
|
|
2345
|
+
item: '<td class="item">{{ index }}</td>',
|
|
2346
|
+
},
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
await nextTick();
|
|
2350
|
+
// Since it's mounted in JSDOM, we need to mock clientHeight/clientWidth if they are 0
|
|
2351
|
+
const el = wrapper.element as HTMLElement;
|
|
2352
|
+
Object.defineProperty(el, 'clientHeight', { value: 400, configurable: true });
|
|
2353
|
+
Object.defineProperty(el, 'clientWidth', { value: 800, configurable: true });
|
|
2354
|
+
|
|
2355
|
+
// Trigger resize observation
|
|
2356
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
2357
|
+
vs.refresh();
|
|
2358
|
+
await nextTick();
|
|
2359
|
+
await nextTick();
|
|
2360
|
+
|
|
2361
|
+
// 400px height / 40px itemSize = 10 items + buffer
|
|
2362
|
+
const renderedCount = wrapper.findAll('tr.virtual-scroll-item').length;
|
|
2363
|
+
expect(renderedCount).toBeLessThan(30);
|
|
2364
|
+
expect(renderedCount).toBeGreaterThan(10);
|
|
2365
|
+
});
|
|
770
2366
|
});
|
|
771
2367
|
});
|
|
772
2368
|
});
|