@pdanpdan/virtual-scroll 0.1.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 +292 -0
- package/dist/index.css +1 -0
- package/dist/index.js +961 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/components/VirtualScroll.test.ts +912 -0
- package/src/components/VirtualScroll.vue +748 -0
- package/src/composables/useVirtualScroll.test.ts +1214 -0
- package/src/composables/useVirtualScroll.ts +1407 -0
- package/src/index.ts +4 -0
- package/src/utils/fenwick-tree.test.ts +119 -0
- package/src/utils/fenwick-tree.ts +155 -0
- package/src/utils/scroll.ts +59 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
import type { ScrollDetails } from '../composables/useVirtualScroll';
|
|
2
|
+
|
|
3
|
+
import { mount } from '@vue/test-utils';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { defineComponent, nextTick, ref } from 'vue';
|
|
6
|
+
|
|
7
|
+
import VirtualScroll from './VirtualScroll.vue';
|
|
8
|
+
|
|
9
|
+
type ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
|
|
10
|
+
|
|
11
|
+
// Mock ResizeObserver
|
|
12
|
+
interface ResizeObserverMock {
|
|
13
|
+
callback: ResizeObserverCallback;
|
|
14
|
+
targets: Set<Element>;
|
|
15
|
+
trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
globalThis.ResizeObserver = class {
|
|
19
|
+
callback: ResizeObserverCallback;
|
|
20
|
+
static instances: ResizeObserverMock[] = [];
|
|
21
|
+
targets: Set<Element> = new Set();
|
|
22
|
+
|
|
23
|
+
constructor(callback: ResizeObserverCallback) {
|
|
24
|
+
this.callback = callback;
|
|
25
|
+
(this.constructor as unknown as { instances: ResizeObserverMock[]; }).instances.push(this as unknown as ResizeObserverMock);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
observe(target: Element) {
|
|
29
|
+
this.targets.add(target);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
unobserve(target: Element) {
|
|
33
|
+
this.targets.delete(target);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
disconnect() {
|
|
37
|
+
this.targets.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
trigger(entries: Partial<ResizeObserverEntry>[]) {
|
|
41
|
+
this.callback(entries as ResizeObserverEntry[], this as unknown as ResizeObserver);
|
|
42
|
+
}
|
|
43
|
+
} as unknown as typeof ResizeObserver & { instances: ResizeObserverMock[]; };
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line test/prefer-lowercase-title
|
|
46
|
+
describe('VirtualScroll component', () => {
|
|
47
|
+
const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
48
|
+
interface VSInstance {
|
|
49
|
+
scrollToIndex: (rowIndex: number | null, colIndex: number | null, options?: unknown) => void;
|
|
50
|
+
scrollToOffset: (x: number | null, y: number | null, options?: unknown) => void;
|
|
51
|
+
setItemRef: (el: unknown, index: number) => void;
|
|
52
|
+
scrollDetails: ScrollDetails<unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('rendering and structure', () => {
|
|
56
|
+
it('should render items correctly', () => {
|
|
57
|
+
const wrapper = mount(VirtualScroll, {
|
|
58
|
+
props: {
|
|
59
|
+
items: mockItems,
|
|
60
|
+
itemSize: 50,
|
|
61
|
+
},
|
|
62
|
+
slots: {
|
|
63
|
+
item: '<template #item="{ item, index }"><div class="item">{{ index }}: {{ item.label }}</div></template>',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
expect(wrapper.findAll('.item').length).toBeGreaterThan(0);
|
|
67
|
+
expect(wrapper.text()).toContain('0: Item 0');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should render header and footer slots', () => {
|
|
71
|
+
const wrapper = mount(VirtualScroll, {
|
|
72
|
+
props: {
|
|
73
|
+
items: mockItems,
|
|
74
|
+
itemSize: 50,
|
|
75
|
+
},
|
|
76
|
+
slots: {
|
|
77
|
+
header: '<div class="header">Header</div>',
|
|
78
|
+
footer: '<div class="footer">Footer</div>',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
expect(wrapper.find('.header').exists()).toBe(true);
|
|
82
|
+
expect(wrapper.find('.footer').exists()).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should handle missing slots gracefully', () => {
|
|
86
|
+
const wrapper = mount(VirtualScroll, {
|
|
87
|
+
props: {
|
|
88
|
+
items: mockItems.slice(0, 1),
|
|
89
|
+
itemSize: 50,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
expect(wrapper.exists()).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should render table correctly', () => {
|
|
96
|
+
const wrapper = mount(VirtualScroll, {
|
|
97
|
+
props: {
|
|
98
|
+
items: mockItems.slice(0, 10),
|
|
99
|
+
itemSize: 50,
|
|
100
|
+
containerTag: 'table',
|
|
101
|
+
wrapperTag: 'tbody',
|
|
102
|
+
itemTag: 'tr',
|
|
103
|
+
},
|
|
104
|
+
slots: {
|
|
105
|
+
item: '<template #item="{ item, index }"><td>{{ index }}</td><td>{{ item.label }}</td></template>',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
expect(wrapper.element.tagName).toBe('TABLE');
|
|
109
|
+
expect(wrapper.find('tbody').exists()).toBe(true);
|
|
110
|
+
expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should render table with header and footer', () => {
|
|
114
|
+
const wrapper = mount(VirtualScroll, {
|
|
115
|
+
props: {
|
|
116
|
+
items: mockItems.slice(0, 10),
|
|
117
|
+
itemSize: 50,
|
|
118
|
+
containerTag: 'table',
|
|
119
|
+
wrapperTag: 'tbody',
|
|
120
|
+
itemTag: 'tr',
|
|
121
|
+
},
|
|
122
|
+
slots: {
|
|
123
|
+
header: '<tr><th>ID</th></tr>',
|
|
124
|
+
footer: '<tr><td>Footer</td></tr>',
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
expect(wrapper.find('thead').exists()).toBe(true);
|
|
128
|
+
expect(wrapper.find('tfoot').exists()).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should render div header and footer', () => {
|
|
132
|
+
const wrapper = mount(VirtualScroll, {
|
|
133
|
+
props: {
|
|
134
|
+
items: mockItems.slice(0, 10),
|
|
135
|
+
containerTag: 'div',
|
|
136
|
+
},
|
|
137
|
+
slots: {
|
|
138
|
+
header: '<div class="header">Header</div>',
|
|
139
|
+
footer: '<div class="footer">Footer</div>',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
expect(wrapper.find('div.virtual-scroll-header').exists()).toBe(true);
|
|
143
|
+
expect(wrapper.find('div.virtual-scroll-footer').exists()).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should apply sticky classes to header and footer', async () => {
|
|
147
|
+
const wrapper = mount(VirtualScroll, {
|
|
148
|
+
props: {
|
|
149
|
+
items: mockItems,
|
|
150
|
+
stickyHeader: true,
|
|
151
|
+
stickyFooter: true,
|
|
152
|
+
},
|
|
153
|
+
slots: {
|
|
154
|
+
header: '<div>H</div>',
|
|
155
|
+
footer: '<div>F</div>',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
await nextTick();
|
|
159
|
+
expect(wrapper.find('.virtual-scroll-header').classes()).toContain('virtual-scroll--sticky');
|
|
160
|
+
expect(wrapper.find('.virtual-scroll-footer').classes()).toContain('virtual-scroll--sticky');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should handle switching containerTag', async () => {
|
|
164
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 10), containerTag: 'div' } });
|
|
165
|
+
await nextTick();
|
|
166
|
+
await wrapper.setProps({ containerTag: 'table' });
|
|
167
|
+
await nextTick();
|
|
168
|
+
expect(wrapper.element.tagName).toBe('TABLE');
|
|
169
|
+
await wrapper.setProps({ containerTag: 'div' });
|
|
170
|
+
await nextTick();
|
|
171
|
+
expect(wrapper.element.tagName).toBe('DIV');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should render table spacer and items', () => {
|
|
175
|
+
const wrapper = mount(VirtualScroll, {
|
|
176
|
+
props: {
|
|
177
|
+
items: mockItems.slice(0, 5),
|
|
178
|
+
containerTag: 'table',
|
|
179
|
+
wrapperTag: 'tbody',
|
|
180
|
+
itemTag: 'tr',
|
|
181
|
+
},
|
|
182
|
+
slots: {
|
|
183
|
+
item: '<template #item="{ item }"><td>{{ item.label }}</td></template>',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
expect(wrapper.find('tr.virtual-scroll-spacer').exists()).toBe(true);
|
|
187
|
+
expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('styling and directions', () => {
|
|
192
|
+
it('should render items horizontally when direction is horizontal', async () => {
|
|
193
|
+
const wrapper = mount(VirtualScroll, {
|
|
194
|
+
props: {
|
|
195
|
+
items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
|
|
196
|
+
itemSize: 100,
|
|
197
|
+
direction: 'horizontal',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
await nextTick();
|
|
201
|
+
const items = wrapper.findAll('.virtual-scroll-item');
|
|
202
|
+
expect(items.length).toBeGreaterThan(0);
|
|
203
|
+
const firstItem = items[ 0 ]?.element as HTMLElement;
|
|
204
|
+
expect(firstItem.style.transform).toBe('translate(0px, 0px)');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should handle bidirectional scroll dimensions', async () => {
|
|
208
|
+
const wrapper = mount(VirtualScroll, {
|
|
209
|
+
props: {
|
|
210
|
+
items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
|
|
211
|
+
itemSize: 100,
|
|
212
|
+
direction: 'both',
|
|
213
|
+
columnCount: 5,
|
|
214
|
+
columnWidth: 150,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
const VS_wrapper = wrapper.find('.virtual-scroll-wrapper');
|
|
218
|
+
const style = (VS_wrapper.element as HTMLElement).style;
|
|
219
|
+
expect(style.blockSize).toBe('1000px');
|
|
220
|
+
expect(style.inlineSize).toBe('750px');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should cover all containerStyle branches', () => {
|
|
224
|
+
[ 'vertical', 'horizontal', 'both' ].forEach((direction) => {
|
|
225
|
+
mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both' } });
|
|
226
|
+
mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both', container: document.body } });
|
|
227
|
+
mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both', containerTag: 'table' } });
|
|
228
|
+
});
|
|
229
|
+
mount(VirtualScroll, { props: { items: mockItems, container: null } });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should cover getItemStyle branches', async () => {
|
|
233
|
+
const wrapper = mount(VirtualScroll, {
|
|
234
|
+
props: {
|
|
235
|
+
items: mockItems.slice(0, 5),
|
|
236
|
+
itemSize: 50,
|
|
237
|
+
direction: 'horizontal',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
await nextTick();
|
|
241
|
+
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
242
|
+
expect(item.style.inlineSize).toBe('50px');
|
|
243
|
+
expect(item.style.blockSize).toBe('100%');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should cover sticky item style branches in getItemStyle', async () => {
|
|
247
|
+
const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
248
|
+
const wrapper = mount(VirtualScroll, {
|
|
249
|
+
props: {
|
|
250
|
+
items,
|
|
251
|
+
direction: 'horizontal',
|
|
252
|
+
stickyIndices: [ 0 ],
|
|
253
|
+
scrollPaddingStart: 10,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
await nextTick();
|
|
257
|
+
|
|
258
|
+
const stickyItem = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
259
|
+
// It should be sticky active if we scroll
|
|
260
|
+
await wrapper.trigger('scroll');
|
|
261
|
+
await nextTick();
|
|
262
|
+
|
|
263
|
+
expect(stickyItem.style.insetInlineStart).toBe('10px');
|
|
264
|
+
|
|
265
|
+
await wrapper.setProps({ direction: 'vertical', scrollPaddingStart: 20 });
|
|
266
|
+
await nextTick();
|
|
267
|
+
expect(stickyItem.style.insetBlockStart).toBe('20px');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should handle custom container element for header/footer padding', async () => {
|
|
271
|
+
const container = document.createElement('div');
|
|
272
|
+
const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
273
|
+
mount(VirtualScroll, {
|
|
274
|
+
props: {
|
|
275
|
+
items,
|
|
276
|
+
container,
|
|
277
|
+
stickyHeader: true,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
await nextTick();
|
|
281
|
+
// This covers the branch where container is NOT host element and NOT window
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('events and interaction', () => {
|
|
286
|
+
it('should emit scroll event', async () => {
|
|
287
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
288
|
+
await nextTick();
|
|
289
|
+
expect(wrapper.emitted('scroll')).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should not emit scroll before hydration', async () => {
|
|
293
|
+
const wrapper = mount(VirtualScroll, {
|
|
294
|
+
props: {
|
|
295
|
+
items: mockItems.slice(0, 5),
|
|
296
|
+
initialScrollIndex: 0,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
// Hydration is delayed via nextTick in useVirtualScroll when initialScrollIndex is set
|
|
300
|
+
// Trigger scrollDetails update
|
|
301
|
+
await wrapper.setProps({ items: mockItems.slice(0, 10) });
|
|
302
|
+
expect(wrapper.emitted('scroll')).toBeUndefined();
|
|
303
|
+
await nextTick();
|
|
304
|
+
// Still might not be hydrated because it's nextTick within nextTick?
|
|
305
|
+
// Actually, useVirtualScroll uses nextTick inside onMounted.
|
|
306
|
+
// mount() calls onMounted.
|
|
307
|
+
// So we need one nextTick to reach isHydrated = true.
|
|
308
|
+
await nextTick();
|
|
309
|
+
expect(wrapper.emitted('scroll')).toBeDefined();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should emit visibleRangeChange event', async () => {
|
|
313
|
+
const wrapper = mount(VirtualScroll, {
|
|
314
|
+
props: {
|
|
315
|
+
items: mockItems,
|
|
316
|
+
itemSize: 50,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await nextTick();
|
|
321
|
+
await nextTick();
|
|
322
|
+
// Initially it should emit on mount (via scrollDetails watch)
|
|
323
|
+
const emits = wrapper.emitted('visibleRangeChange');
|
|
324
|
+
expect(emits).toBeTruthy();
|
|
325
|
+
const firstEmit = (emits as unknown[][])[ 0 ]![ 0 ] as { start: number; };
|
|
326
|
+
expect(firstEmit).toMatchObject({ start: 0 });
|
|
327
|
+
|
|
328
|
+
// Scroll to trigger change
|
|
329
|
+
const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
|
|
330
|
+
Object.defineProperty(container, 'scrollTop', { value: 500, writable: true });
|
|
331
|
+
await container.dispatchEvent(new Event('scroll'));
|
|
332
|
+
await nextTick();
|
|
333
|
+
await nextTick();
|
|
334
|
+
|
|
335
|
+
const lastEmits = wrapper.emitted('visibleRangeChange') as unknown[][];
|
|
336
|
+
expect(lastEmits).toBeTruthy();
|
|
337
|
+
const lastEmit = lastEmits[ lastEmits.length - 1 ]![ 0 ] as { start: number; };
|
|
338
|
+
expect(lastEmit.start).toBeGreaterThan(0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should handle keyboard navigation', async () => {
|
|
342
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
343
|
+
await nextTick();
|
|
344
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
345
|
+
const el = container.element as HTMLElement;
|
|
346
|
+
Object.defineProperty(el, 'scrollHeight', { value: 5000, configurable: true });
|
|
347
|
+
Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
|
|
348
|
+
|
|
349
|
+
await container.trigger('keydown', { key: 'End' });
|
|
350
|
+
await nextTick();
|
|
351
|
+
expect(el.scrollTop).toBeGreaterThan(0);
|
|
352
|
+
|
|
353
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
354
|
+
await nextTick();
|
|
355
|
+
expect(el.scrollTop).toBe(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should handle horizontal keyboard navigation', async () => {
|
|
359
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50, direction: 'horizontal' } });
|
|
360
|
+
await nextTick();
|
|
361
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
362
|
+
const el = container.element as HTMLElement;
|
|
363
|
+
Object.defineProperty(el, 'scrollWidth', { value: 5000, configurable: true });
|
|
364
|
+
Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
|
|
365
|
+
|
|
366
|
+
await container.trigger('keydown', { key: 'End' });
|
|
367
|
+
await nextTick();
|
|
368
|
+
expect(el.scrollLeft).toBeGreaterThan(0);
|
|
369
|
+
|
|
370
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
371
|
+
await nextTick();
|
|
372
|
+
expect(el.scrollLeft).toBe(0);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should handle handled keys in handleKeyDown', async () => {
|
|
376
|
+
const wrapper = mount(VirtualScroll, {
|
|
377
|
+
props: {
|
|
378
|
+
items: mockItems,
|
|
379
|
+
itemSize: 50,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await nextTick();
|
|
384
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
385
|
+
const scrollToSpy = vi.fn();
|
|
386
|
+
container.element.scrollTo = scrollToSpy;
|
|
387
|
+
|
|
388
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
389
|
+
expect(scrollToSpy).toHaveBeenCalled();
|
|
390
|
+
|
|
391
|
+
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
392
|
+
expect(scrollToSpy).toHaveBeenCalled();
|
|
393
|
+
|
|
394
|
+
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
395
|
+
expect(scrollToSpy).toHaveBeenCalled();
|
|
396
|
+
|
|
397
|
+
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
398
|
+
expect(scrollToSpy).toHaveBeenCalled();
|
|
399
|
+
|
|
400
|
+
await container.trigger('keydown', { key: 'PageDown' });
|
|
401
|
+
expect(scrollToSpy).toHaveBeenCalled();
|
|
402
|
+
|
|
403
|
+
await container.trigger('keydown', { key: 'PageUp' });
|
|
404
|
+
expect(scrollToSpy).toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should handle unhandled keys in handleKeyDown', async () => {
|
|
408
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems } });
|
|
409
|
+
await nextTick();
|
|
410
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
411
|
+
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
412
|
+
// Should just call stopProgrammaticScroll
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('lifecycle and observers', () => {
|
|
417
|
+
it('should update item size on resize', async () => {
|
|
418
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
|
|
419
|
+
await nextTick();
|
|
420
|
+
const firstItem = wrapper.find('.virtual-scroll-item').element;
|
|
421
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
|
|
422
|
+
if (observer) {
|
|
423
|
+
observer.trigger([ {
|
|
424
|
+
target: firstItem,
|
|
425
|
+
contentRect: { width: 100, height: 100 } as DOMRectReadOnly,
|
|
426
|
+
borderBoxSize: [ { inlineSize: 110, blockSize: 110 } ],
|
|
427
|
+
} ]);
|
|
428
|
+
}
|
|
429
|
+
await nextTick();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should handle resize fallback (no borderBoxSize)', async () => {
|
|
433
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
|
|
434
|
+
await nextTick();
|
|
435
|
+
const firstItem = wrapper.find('.virtual-scroll-item').element;
|
|
436
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
|
|
437
|
+
if (observer) {
|
|
438
|
+
observer.trigger([ { target: firstItem, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
|
|
439
|
+
}
|
|
440
|
+
await nextTick();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should observe host resize and update offset', async () => {
|
|
444
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
445
|
+
await nextTick();
|
|
446
|
+
const host = wrapper.find('.virtual-scroll-container').element;
|
|
447
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(host));
|
|
448
|
+
if (observer) {
|
|
449
|
+
observer.trigger([ { target: host } ]);
|
|
450
|
+
}
|
|
451
|
+
await nextTick();
|
|
452
|
+
// Should have called updateHostOffset (internal)
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should observe cell resize with data-col-index', async () => {
|
|
456
|
+
const wrapper = mount(VirtualScroll, {
|
|
457
|
+
props: {
|
|
458
|
+
items: mockItems.slice(0, 1),
|
|
459
|
+
direction: 'both',
|
|
460
|
+
columnCount: 2,
|
|
461
|
+
},
|
|
462
|
+
slots: {
|
|
463
|
+
item: '<template #item="{ index }"><div class="row"><div class="cell" data-col-index="0">Cell {{ index }}</div></div></template>',
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
await nextTick();
|
|
467
|
+
const cell = wrapper.find('.cell').element;
|
|
468
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(cell));
|
|
469
|
+
expect(observer).toBeDefined();
|
|
470
|
+
|
|
471
|
+
if (observer) {
|
|
472
|
+
observer.trigger([ {
|
|
473
|
+
target: cell,
|
|
474
|
+
contentRect: { width: 100, height: 50 } as DOMRectReadOnly,
|
|
475
|
+
} ]);
|
|
476
|
+
}
|
|
477
|
+
await nextTick();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('should observe header and footer resize', async () => {
|
|
481
|
+
const wrapper = mount(VirtualScroll, {
|
|
482
|
+
props: { items: mockItems, itemSize: 50 },
|
|
483
|
+
slots: {
|
|
484
|
+
header: '<div class="header">H</div>',
|
|
485
|
+
footer: '<div class="footer">F</div>',
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
await nextTick();
|
|
489
|
+
const header = wrapper.find('.virtual-scroll-header').element;
|
|
490
|
+
const footer = wrapper.find('.virtual-scroll-footer').element;
|
|
491
|
+
|
|
492
|
+
const headerObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(header));
|
|
493
|
+
if (headerObserver) {
|
|
494
|
+
headerObserver.trigger([ { target: header } ]);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const footerObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(footer));
|
|
498
|
+
if (footerObserver) {
|
|
499
|
+
footerObserver.trigger([ { target: footer } ]);
|
|
500
|
+
}
|
|
501
|
+
await nextTick();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should handle missing footerRef gracefully in onMounted', () => {
|
|
505
|
+
mount(VirtualScroll, {
|
|
506
|
+
props: { items: mockItems, itemSize: 50 },
|
|
507
|
+
slots: { header: '<div>H</div>' },
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('should observe footer on mount if slot exists', async () => {
|
|
512
|
+
const wrapper = mount(VirtualScroll, {
|
|
513
|
+
props: { items: mockItems, itemSize: 50 },
|
|
514
|
+
slots: { footer: '<div class="footer">F</div>' },
|
|
515
|
+
});
|
|
516
|
+
await nextTick();
|
|
517
|
+
const footer = wrapper.find('.virtual-scroll-footer').element;
|
|
518
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(footer));
|
|
519
|
+
expect(observer).toBeDefined();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should cover header/footer unobserve when removed/replaced', async () => {
|
|
523
|
+
const TestComp = defineComponent({
|
|
524
|
+
components: { VirtualScroll },
|
|
525
|
+
setup() {
|
|
526
|
+
const show = ref(true);
|
|
527
|
+
const showFooter = ref(true);
|
|
528
|
+
return { show, showFooter, mockItems };
|
|
529
|
+
},
|
|
530
|
+
template: `
|
|
531
|
+
<VirtualScroll :items="mockItems">
|
|
532
|
+
<template v-if="show" #header><div>H</div></template>
|
|
533
|
+
<template v-if="showFooter" #footer><div>F</div></template>
|
|
534
|
+
</VirtualScroll>
|
|
535
|
+
`,
|
|
536
|
+
});
|
|
537
|
+
const wrapper = mount(TestComp);
|
|
538
|
+
await nextTick();
|
|
539
|
+
|
|
540
|
+
// Toggle off to trigger 'unobserve'
|
|
541
|
+
(wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).show = false;
|
|
542
|
+
(wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).showFooter = false;
|
|
543
|
+
await nextTick();
|
|
544
|
+
|
|
545
|
+
// Toggle on to trigger 'observe' (newEl)
|
|
546
|
+
(wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).show = true;
|
|
547
|
+
(wrapper.vm as unknown as { show: boolean; showFooter: boolean; }).showFooter = true;
|
|
548
|
+
await nextTick();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should cover ResizeObserver cell measurement', async () => {
|
|
552
|
+
const wrapper = mount(VirtualScroll, {
|
|
553
|
+
props: { items: mockItems.slice(0, 1), direction: 'both', columnCount: 5 },
|
|
554
|
+
slots: { item: '<template #item><div data-col-index="0">cell</div></template>' },
|
|
555
|
+
});
|
|
556
|
+
await nextTick();
|
|
557
|
+
const cell = wrapper.find('[data-col-index="0"]').element;
|
|
558
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(cell));
|
|
559
|
+
if (observer) {
|
|
560
|
+
observer.trigger([ { target: cell } ]);
|
|
561
|
+
}
|
|
562
|
+
await nextTick();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('should handle keyboard navigation End key horizontal', async () => {
|
|
566
|
+
const wrapper = mount(VirtualScroll, {
|
|
567
|
+
props: { items: mockItems, direction: 'horizontal', itemSize: 50 },
|
|
568
|
+
});
|
|
569
|
+
await nextTick();
|
|
570
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
571
|
+
const el = container.element as HTMLElement;
|
|
572
|
+
Object.defineProperty(el, 'scrollWidth', { value: 5000, configurable: true });
|
|
573
|
+
Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
|
|
574
|
+
|
|
575
|
+
await container.trigger('keydown', { key: 'End' });
|
|
576
|
+
await nextTick();
|
|
577
|
+
expect(el.scrollLeft).toBeGreaterThan(0);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('should handle keyboard navigation End key vertical', async () => {
|
|
581
|
+
const wrapper = mount(VirtualScroll, {
|
|
582
|
+
props: { items: mockItems, direction: 'vertical', itemSize: 50 },
|
|
583
|
+
});
|
|
584
|
+
await nextTick();
|
|
585
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
586
|
+
const el = container.element as HTMLElement;
|
|
587
|
+
Object.defineProperty(el, 'scrollHeight', { value: 5000, configurable: true });
|
|
588
|
+
Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
|
|
589
|
+
|
|
590
|
+
await container.trigger('keydown', { key: 'End' });
|
|
591
|
+
await nextTick();
|
|
592
|
+
expect(el.scrollTop).toBeGreaterThan(0);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('should handle keyboard navigation End key with empty items', async () => {
|
|
596
|
+
const wrapper = mount(VirtualScroll, {
|
|
597
|
+
props: { items: [], direction: 'vertical', itemSize: 50 },
|
|
598
|
+
});
|
|
599
|
+
await nextTick();
|
|
600
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
601
|
+
await container.trigger('keydown', { key: 'End' });
|
|
602
|
+
await nextTick();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('should handle keyboard navigation End key with columnCount 0 in both mode', async () => {
|
|
606
|
+
const wrapper = mount(VirtualScroll, {
|
|
607
|
+
props: { items: mockItems, direction: 'both', columnCount: 0, itemSize: 50 },
|
|
608
|
+
});
|
|
609
|
+
await nextTick();
|
|
610
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
611
|
+
await container.trigger('keydown', { key: 'End' });
|
|
612
|
+
await nextTick();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('should handle keyboard navigation End key in both mode', async () => {
|
|
616
|
+
const wrapper = mount(VirtualScroll, {
|
|
617
|
+
props: { items: mockItems, direction: 'both', columnCount: 5, itemSize: 50, columnWidth: 100 },
|
|
618
|
+
});
|
|
619
|
+
await nextTick();
|
|
620
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
621
|
+
await container.trigger('keydown', { key: 'End' });
|
|
622
|
+
await nextTick();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('should handle load event for horizontal direction', async () => {
|
|
626
|
+
const wrapper = mount(VirtualScroll, {
|
|
627
|
+
props: { items: mockItems.slice(0, 10), direction: 'horizontal', itemSize: 50, loadDistance: 400 },
|
|
628
|
+
});
|
|
629
|
+
await nextTick();
|
|
630
|
+
(wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
|
|
631
|
+
await nextTick();
|
|
632
|
+
await nextTick();
|
|
633
|
+
expect(wrapper.emitted('load')).toBeDefined();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('should cover itemResizeObserver branches', async () => {
|
|
637
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
638
|
+
await nextTick();
|
|
639
|
+
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
640
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item));
|
|
641
|
+
|
|
642
|
+
// Trigger with data-index but without borderBoxSize
|
|
643
|
+
observer?.trigger([ { target: item, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
|
|
644
|
+
|
|
645
|
+
// Trigger with NaN index
|
|
646
|
+
const div = document.createElement('div');
|
|
647
|
+
observer?.trigger([ { target: div } ]);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('should cleanup observers on unmount', async () => {
|
|
651
|
+
const wrapper = mount(VirtualScroll, {
|
|
652
|
+
props: { items: mockItems, stickyHeader: true, stickyFooter: true },
|
|
653
|
+
slots: { header: '<div>H</div>', footer: '<div>F</div>' },
|
|
654
|
+
});
|
|
655
|
+
await nextTick();
|
|
656
|
+
wrapper.unmount();
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
describe('grid mode logic', () => {
|
|
661
|
+
it('should cover colIndex measurement in itemResizeObserver', async () => {
|
|
662
|
+
mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
663
|
+
await nextTick();
|
|
664
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.callback.toString().includes('colIndex'));
|
|
665
|
+
const div = document.createElement('div');
|
|
666
|
+
div.dataset.colIndex = '0';
|
|
667
|
+
observer!.trigger([ { target: div } ]);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('should cover firstRenderedIndex watcher for grid old/new and other branches', async () => {
|
|
671
|
+
// Test direction !== 'both' branch
|
|
672
|
+
const wrapperV = mount(VirtualScroll, {
|
|
673
|
+
props: { items: mockItems, direction: 'vertical', itemSize: 50 },
|
|
674
|
+
});
|
|
675
|
+
await nextTick();
|
|
676
|
+
(wrapperV.vm as unknown as VSInstance).scrollToIndex(10, 0);
|
|
677
|
+
await nextTick();
|
|
678
|
+
await nextTick();
|
|
679
|
+
|
|
680
|
+
const wrapper = mount(VirtualScroll, {
|
|
681
|
+
props: {
|
|
682
|
+
items: mockItems,
|
|
683
|
+
direction: 'both',
|
|
684
|
+
columnCount: 5,
|
|
685
|
+
itemSize: 50,
|
|
686
|
+
bufferBefore: 2,
|
|
687
|
+
bufferAfter: 10,
|
|
688
|
+
},
|
|
689
|
+
slots: {
|
|
690
|
+
item: '<template #item="{ index }"><div class="cell" :data-col-index="0">Item {{ index }}</div></template>',
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
|
|
694
|
+
Object.defineProperty(container, 'clientHeight', { value: 200, configurable: true });
|
|
695
|
+
Object.defineProperty(container, 'clientWidth', { value: 200, configurable: true });
|
|
696
|
+
|
|
697
|
+
// Trigger host resize observer
|
|
698
|
+
(globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.forEach((i) => {
|
|
699
|
+
if (i.targets.has(container)) {
|
|
700
|
+
i.trigger([ { target: container } ]);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
await nextTick();
|
|
704
|
+
|
|
705
|
+
const vm = wrapper.vm as unknown as VSInstance;
|
|
706
|
+
|
|
707
|
+
// Initial scroll to 10. range starts at 10-2 = 8.
|
|
708
|
+
vm.scrollToIndex(10, 0, { behavior: 'auto', align: 'start' });
|
|
709
|
+
await nextTick();
|
|
710
|
+
await nextTick();
|
|
711
|
+
|
|
712
|
+
const item8 = wrapper.find('.virtual-scroll-item[data-index="8"]').element;
|
|
713
|
+
const itemResizeObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item8));
|
|
714
|
+
expect(itemResizeObserver).toBeDefined();
|
|
715
|
+
|
|
716
|
+
const cell8 = item8.querySelector('[data-col-index="0"]');
|
|
717
|
+
expect(itemResizeObserver!.targets.has(cell8!)).toBe(true);
|
|
718
|
+
|
|
719
|
+
// Scroll to 9. range starts at 9-2 = 7.
|
|
720
|
+
// oldIdx was 8. newIdx is 7. Item 8 is still in DOM.
|
|
721
|
+
vm.scrollToIndex(9, 0, { behavior: 'auto', align: 'start' });
|
|
722
|
+
await nextTick();
|
|
723
|
+
await nextTick();
|
|
724
|
+
|
|
725
|
+
// Item 8 should have its cells unobserved
|
|
726
|
+
expect(itemResizeObserver!.targets.has(cell8!)).toBe(false);
|
|
727
|
+
|
|
728
|
+
// Item 7 should have its cells observed
|
|
729
|
+
const item7 = wrapper.find('.virtual-scroll-item[data-index="7"]').element;
|
|
730
|
+
const cell7 = item7.querySelector('[data-col-index="0"]');
|
|
731
|
+
expect(itemResizeObserver!.targets.has(cell7!)).toBe(true);
|
|
732
|
+
|
|
733
|
+
// Scroll to 50. range starts at 50-2 = 48.
|
|
734
|
+
// oldIdx was 7. Item 7 is definitely NOT in DOM anymore.
|
|
735
|
+
// This covers the if (oldEl) branch being false.
|
|
736
|
+
vm.scrollToIndex(50, 0, { behavior: 'auto', align: 'start' });
|
|
737
|
+
await nextTick();
|
|
738
|
+
await nextTick();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('should cover firstRenderedIndex watcher logic for grid cells', async () => {
|
|
742
|
+
const wrapper = mount(VirtualScroll, {
|
|
743
|
+
props: {
|
|
744
|
+
items: mockItems,
|
|
745
|
+
direction: 'both',
|
|
746
|
+
columnCount: 5,
|
|
747
|
+
itemSize: 50,
|
|
748
|
+
},
|
|
749
|
+
slots: {
|
|
750
|
+
item: '<template #item="{ index }"><div :data-col-index="0">Item {{ index }}</div></template>',
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
await nextTick();
|
|
754
|
+
|
|
755
|
+
// Initial state: firstRenderedIndex should be 0.
|
|
756
|
+
// Scroll to change it.
|
|
757
|
+
const vm = wrapper.vm as unknown as VSInstance;
|
|
758
|
+
vm.scrollToIndex(10, 0);
|
|
759
|
+
await nextTick();
|
|
760
|
+
// This should trigger the watcher (oldIdx 0 -> newIdx 10)
|
|
761
|
+
|
|
762
|
+
// Scroll back
|
|
763
|
+
vm.scrollToIndex(0, 0);
|
|
764
|
+
await nextTick();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('should cover firstRenderedIndex watcher when items becomes empty', async () => {
|
|
768
|
+
const wrapper = mount(VirtualScroll, {
|
|
769
|
+
props: {
|
|
770
|
+
items: mockItems,
|
|
771
|
+
direction: 'both',
|
|
772
|
+
columnCount: 5,
|
|
773
|
+
itemSize: 50,
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
await nextTick();
|
|
777
|
+
await wrapper.setProps({ items: [] });
|
|
778
|
+
await nextTick();
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
describe('internal methods and exports', () => {
|
|
783
|
+
it('should handle setItemRef', async () => {
|
|
784
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
785
|
+
await nextTick();
|
|
786
|
+
const vm = wrapper.vm as unknown as VSInstance;
|
|
787
|
+
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
788
|
+
vm.setItemRef(item, 0);
|
|
789
|
+
vm.setItemRef(null, 0);
|
|
790
|
+
vm.setItemRef(null, 999);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('should handle setItemRef with NaN index', async () => {
|
|
794
|
+
mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
795
|
+
await nextTick();
|
|
796
|
+
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ];
|
|
797
|
+
const div = document.createElement('div');
|
|
798
|
+
// No data-index
|
|
799
|
+
observer?.trigger([ { target: div, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('should handle firstRenderedIndex being undefined', async () => {
|
|
803
|
+
// items empty
|
|
804
|
+
mount(VirtualScroll, { props: { items: [] } });
|
|
805
|
+
await nextTick();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('should expose methods', () => {
|
|
809
|
+
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
810
|
+
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToIndex).toBe('function');
|
|
811
|
+
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToOffset).toBe('function');
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
describe('infinite scroll and loading', () => {
|
|
816
|
+
it('should emit load event when reaching scroll end (vertical)', async () => {
|
|
817
|
+
const wrapper = mount(VirtualScroll, {
|
|
818
|
+
props: {
|
|
819
|
+
items: mockItems.slice(0, 10),
|
|
820
|
+
itemSize: 50,
|
|
821
|
+
loadDistance: 400,
|
|
822
|
+
useRAF: false,
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
await nextTick();
|
|
826
|
+
|
|
827
|
+
// Scroll to near end
|
|
828
|
+
(wrapper.vm as unknown as VSInstance).scrollToOffset(0, 250);
|
|
829
|
+
await nextTick();
|
|
830
|
+
await nextTick();
|
|
831
|
+
|
|
832
|
+
expect(wrapper.emitted('load')).toBeDefined();
|
|
833
|
+
expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'vertical' ]);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('should emit load event when reaching scroll end (horizontal)', async () => {
|
|
837
|
+
const wrapper = mount(VirtualScroll, {
|
|
838
|
+
props: {
|
|
839
|
+
items: mockItems.slice(0, 10),
|
|
840
|
+
itemSize: 50,
|
|
841
|
+
direction: 'horizontal',
|
|
842
|
+
loadDistance: 400,
|
|
843
|
+
useRAF: false,
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
await nextTick();
|
|
847
|
+
|
|
848
|
+
// Scroll to near end
|
|
849
|
+
(wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
|
|
850
|
+
await nextTick();
|
|
851
|
+
await nextTick();
|
|
852
|
+
|
|
853
|
+
expect(wrapper.emitted('load')).toBeDefined();
|
|
854
|
+
expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'horizontal' ]);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('should not emit load event when loading is true', async () => {
|
|
858
|
+
const wrapper = mount(VirtualScroll, {
|
|
859
|
+
props: {
|
|
860
|
+
items: mockItems.slice(0, 10),
|
|
861
|
+
itemSize: 50,
|
|
862
|
+
loadDistance: 100,
|
|
863
|
+
loading: true,
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
await nextTick();
|
|
867
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
868
|
+
const el = container.element as HTMLElement;
|
|
869
|
+
Object.defineProperty(el, 'clientHeight', { value: 200, configurable: true });
|
|
870
|
+
Object.defineProperty(el, 'scrollHeight', { value: 500, configurable: true });
|
|
871
|
+
|
|
872
|
+
el.scrollTop = 250;
|
|
873
|
+
container.element.dispatchEvent(new Event('scroll'));
|
|
874
|
+
await nextTick();
|
|
875
|
+
await nextTick();
|
|
876
|
+
|
|
877
|
+
expect(wrapper.emitted('load')).toBeUndefined();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('should render loading slot when loading is true', async () => {
|
|
881
|
+
const wrapper = mount(VirtualScroll, {
|
|
882
|
+
props: {
|
|
883
|
+
items: mockItems.slice(0, 10),
|
|
884
|
+
itemSize: 50,
|
|
885
|
+
loading: true,
|
|
886
|
+
},
|
|
887
|
+
slots: {
|
|
888
|
+
loading: '<div class="loading-indicator">Loading...</div>',
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
await nextTick();
|
|
892
|
+
expect(wrapper.find('.loading-indicator').exists()).toBe(true);
|
|
893
|
+
expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('block');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('should render horizontal loading slot correctly', async () => {
|
|
897
|
+
const wrapper = mount(VirtualScroll, {
|
|
898
|
+
props: {
|
|
899
|
+
items: mockItems.slice(0, 10),
|
|
900
|
+
itemSize: 50,
|
|
901
|
+
direction: 'horizontal',
|
|
902
|
+
loading: true,
|
|
903
|
+
},
|
|
904
|
+
slots: {
|
|
905
|
+
loading: '<div class="loading-indicator">Loading...</div>',
|
|
906
|
+
},
|
|
907
|
+
});
|
|
908
|
+
await nextTick();
|
|
909
|
+
expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
});
|