@pdanpdan/virtual-scroll 0.3.0 → 0.4.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 +160 -116
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +834 -145
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +639 -416
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +1 -1
- package/src/components/VirtualScroll.test.ts +523 -731
- package/src/components/VirtualScroll.vue +343 -214
- package/src/composables/useVirtualScroll.test.ts +240 -1879
- package/src/composables/useVirtualScroll.ts +482 -554
- package/src/index.ts +2 -0
- package/src/types.ts +535 -0
- package/src/utils/fenwick-tree.ts +38 -18
- package/src/utils/scroll.test.ts +148 -0
- package/src/utils/scroll.ts +40 -10
- package/src/utils/virtual-scroll-logic.test.ts +2517 -0
- package/src/utils/virtual-scroll-logic.ts +605 -0
|
@@ -1,980 +1,772 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { DOMWrapper, VueWrapper } from '@vue/test-utils';
|
|
1
|
+
import type { ItemSlotProps, ScrollDetails } from '../types';
|
|
3
2
|
|
|
3
|
+
/* global ScrollToOptions, ResizeObserverCallback */
|
|
4
4
|
import { mount } from '@vue/test-utils';
|
|
5
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
-
import {
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { h, nextTick } from 'vue';
|
|
7
7
|
|
|
8
8
|
import VirtualScroll from './VirtualScroll.vue';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// --- Mocks ---
|
|
11
|
+
|
|
12
|
+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
|
|
13
|
+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
|
|
14
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
|
|
15
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
|
|
16
|
+
|
|
17
|
+
HTMLElement.prototype.scrollTo = function (this: HTMLElement, options?: number | ScrollToOptions, y?: number) {
|
|
18
|
+
if (typeof options === 'object') {
|
|
19
|
+
if (options.top !== undefined) {
|
|
20
|
+
this.scrollTop = options.top;
|
|
21
|
+
}
|
|
22
|
+
if (options.left !== undefined) {
|
|
23
|
+
this.scrollLeft = options.left;
|
|
24
|
+
}
|
|
25
|
+
} else if (typeof options === 'number' && typeof y === 'number') {
|
|
26
|
+
this.scrollLeft = options;
|
|
27
|
+
this.scrollTop = y;
|
|
28
|
+
}
|
|
29
|
+
this.dispatchEvent(new Event('scroll'));
|
|
30
|
+
};
|
|
11
31
|
|
|
12
|
-
|
|
13
|
-
interface ResizeObserverMock {
|
|
32
|
+
interface ResizeObserverMock extends ResizeObserver {
|
|
14
33
|
callback: ResizeObserverCallback;
|
|
15
34
|
targets: Set<Element>;
|
|
16
|
-
trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
|
|
17
35
|
}
|
|
18
36
|
|
|
19
|
-
|
|
37
|
+
const observers: ResizeObserverMock[] = [];
|
|
38
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
20
39
|
callback: ResizeObserverCallback;
|
|
21
|
-
|
|
22
|
-
targets: Set<Element> = new Set();
|
|
23
|
-
|
|
40
|
+
targets = new Set<Element>();
|
|
24
41
|
constructor(callback: ResizeObserverCallback) {
|
|
25
42
|
this.callback = callback;
|
|
26
|
-
|
|
43
|
+
observers.push(this as unknown as ResizeObserverMock);
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
observe(
|
|
30
|
-
this.targets.add(
|
|
46
|
+
observe(el: Element) {
|
|
47
|
+
this.targets.add(el);
|
|
31
48
|
}
|
|
32
49
|
|
|
33
|
-
unobserve(
|
|
34
|
-
this.targets.delete(
|
|
50
|
+
unobserve(el: Element) {
|
|
51
|
+
this.targets.delete(el);
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
disconnect() {
|
|
38
55
|
this.targets.clear();
|
|
39
56
|
}
|
|
57
|
+
} as unknown as typeof ResizeObserver;
|
|
58
|
+
|
|
59
|
+
function triggerResize(el: Element, width: number, height: number) {
|
|
60
|
+
const obs = observers.find((o) => o.targets.has(el));
|
|
61
|
+
if (obs) {
|
|
62
|
+
obs.callback([ {
|
|
63
|
+
borderBoxSize: [ { blockSize: height, inlineSize: width } ],
|
|
64
|
+
contentRect: {
|
|
65
|
+
bottom: height,
|
|
66
|
+
height,
|
|
67
|
+
left: 0,
|
|
68
|
+
right: width,
|
|
69
|
+
toJSON: () => '',
|
|
70
|
+
top: 0,
|
|
71
|
+
width,
|
|
72
|
+
x: 0,
|
|
73
|
+
y: 0,
|
|
74
|
+
},
|
|
75
|
+
target: el,
|
|
76
|
+
} as unknown as ResizeObserverEntry ], obs);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
40
79
|
|
|
41
|
-
|
|
42
|
-
|
|
80
|
+
// Mock window.scrollTo
|
|
81
|
+
globalThis.window.scrollTo = vi.fn().mockImplementation((options) => {
|
|
82
|
+
if (options.left !== undefined) {
|
|
83
|
+
Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
|
|
43
84
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// eslint-disable-next-line test/prefer-lowercase-title
|
|
47
|
-
describe('VirtualScroll component', () => {
|
|
48
|
-
const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
49
|
-
|
|
50
|
-
interface VSInstance {
|
|
51
|
-
scrollDetails: ScrollDetails<unknown>;
|
|
52
|
-
scrollToIndex: (rowIndex: number | null, colIndex: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
|
|
53
|
-
scrollToOffset: (x: number | null, y: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
|
|
54
|
-
setItemRef: (el: unknown, index: number) => void;
|
|
55
|
-
stopProgrammaticScroll: () => void;
|
|
56
|
-
refresh: () => void;
|
|
85
|
+
if (options.top !== undefined) {
|
|
86
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
|
|
57
87
|
}
|
|
88
|
+
document.dispatchEvent(new Event('scroll'));
|
|
89
|
+
});
|
|
58
90
|
|
|
59
|
-
|
|
60
|
-
mockItems: typeof mockItems;
|
|
61
|
-
show: boolean;
|
|
62
|
-
showFooter: boolean;
|
|
63
|
-
}
|
|
91
|
+
// --- Tests ---
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
});
|
|
93
|
+
interface MockItem {
|
|
94
|
+
id: number;
|
|
95
|
+
label: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('virtualScroll', () => {
|
|
99
|
+
const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.clearAllMocks();
|
|
103
|
+
observers.length = 0;
|
|
104
|
+
Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
|
|
105
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
|
|
106
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
|
|
107
|
+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
|
|
108
|
+
});
|
|
79
109
|
|
|
80
|
-
|
|
110
|
+
describe('basic Rendering', () => {
|
|
111
|
+
it('renders the visible items', async () => {
|
|
81
112
|
const wrapper = mount(VirtualScroll, {
|
|
82
113
|
props: {
|
|
83
|
-
items: mockItems,
|
|
84
114
|
itemSize: 50,
|
|
115
|
+
items: mockItems,
|
|
85
116
|
},
|
|
86
117
|
slots: {
|
|
87
|
-
|
|
88
|
-
|
|
118
|
+
item: (props: ItemSlotProps) => {
|
|
119
|
+
const { index, item } = props as ItemSlotProps<MockItem>;
|
|
120
|
+
return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
|
|
121
|
+
},
|
|
89
122
|
},
|
|
90
123
|
});
|
|
91
|
-
expect(wrapper.find('.virtual-scroll-header').exists()).toBe(true);
|
|
92
|
-
expect(wrapper.find('.header').exists()).toBe(true);
|
|
93
|
-
expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(true);
|
|
94
|
-
expect(wrapper.find('.footer').exists()).toBe(true);
|
|
95
|
-
});
|
|
96
124
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
expect(wrapper.find('.virtual-scroll-header').exists()).toBe(false);
|
|
105
|
-
expect(wrapper.find('.virtual-scroll-footer').exists()).toBe(false);
|
|
125
|
+
await nextTick();
|
|
126
|
+
|
|
127
|
+
const items = wrapper.findAll('.item');
|
|
128
|
+
expect(items.length).toBe(15);
|
|
129
|
+
expect(items[ 0 ]?.text()).toBe('0: Item 0');
|
|
130
|
+
expect(items[ 14 ]?.text()).toBe('14: Item 14');
|
|
106
131
|
});
|
|
107
132
|
|
|
108
|
-
it('
|
|
133
|
+
it('updates when items change', async () => {
|
|
109
134
|
const wrapper = mount(VirtualScroll, {
|
|
110
135
|
props: {
|
|
111
|
-
items: mockItems.slice(0, 5),
|
|
112
136
|
itemSize: 50,
|
|
113
|
-
|
|
137
|
+
items: mockItems.slice(0, 5),
|
|
114
138
|
},
|
|
115
139
|
});
|
|
116
140
|
await nextTick();
|
|
117
|
-
expect(wrapper.
|
|
118
|
-
expect(wrapper.find('.virtual-scroll-item').classes()).toContain('virtual-scroll--debug');
|
|
119
|
-
});
|
|
141
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(5);
|
|
120
142
|
|
|
121
|
-
|
|
122
|
-
const wrapper = mount(VirtualScroll, {
|
|
123
|
-
props: {
|
|
124
|
-
items: mockItems.slice(0, 5),
|
|
125
|
-
itemSize: 50,
|
|
126
|
-
debug: false,
|
|
127
|
-
},
|
|
128
|
-
});
|
|
143
|
+
await wrapper.setProps({ items: mockItems.slice(0, 10) });
|
|
129
144
|
await nextTick();
|
|
130
|
-
expect(wrapper.
|
|
131
|
-
expect(wrapper.find('.virtual-scroll-item').classes()).not.toContain('virtual-scroll--debug');
|
|
145
|
+
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(10);
|
|
132
146
|
});
|
|
133
147
|
|
|
134
|
-
it('
|
|
148
|
+
it('supports horizontal direction', async () => {
|
|
135
149
|
const wrapper = mount(VirtualScroll, {
|
|
136
150
|
props: {
|
|
137
|
-
|
|
138
|
-
itemSize:
|
|
151
|
+
direction: 'horizontal',
|
|
152
|
+
itemSize: 100,
|
|
153
|
+
items: mockItems,
|
|
139
154
|
},
|
|
140
155
|
});
|
|
141
|
-
|
|
156
|
+
await nextTick();
|
|
157
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
158
|
+
expect(container.classes()).toContain('virtual-scroll--horizontal');
|
|
159
|
+
expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.inlineSize).toBe('10000px');
|
|
142
160
|
});
|
|
143
161
|
|
|
144
|
-
it('
|
|
162
|
+
it('supports grid mode (both directions)', async () => {
|
|
145
163
|
const wrapper = mount(VirtualScroll, {
|
|
146
164
|
props: {
|
|
147
|
-
|
|
165
|
+
columnCount: 5,
|
|
166
|
+
columnWidth: 100,
|
|
167
|
+
direction: 'both',
|
|
148
168
|
itemSize: 50,
|
|
149
|
-
|
|
150
|
-
wrapperTag: 'tbody',
|
|
151
|
-
itemTag: 'tr',
|
|
152
|
-
},
|
|
153
|
-
slots: {
|
|
154
|
-
item: '<template #item="{ item, index }"><td>{{ index }}</td><td>{{ item.label }}</td></template>',
|
|
169
|
+
items: mockItems,
|
|
155
170
|
},
|
|
156
171
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
expect(
|
|
172
|
+
await nextTick();
|
|
173
|
+
const style = (wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style;
|
|
174
|
+
expect(style.blockSize).toBe('5000px');
|
|
175
|
+
expect(style.inlineSize).toBe('500px');
|
|
160
176
|
});
|
|
177
|
+
});
|
|
161
178
|
|
|
162
|
-
|
|
179
|
+
describe('interactions', () => {
|
|
180
|
+
it('scrolls and updates visible items', async () => {
|
|
163
181
|
const wrapper = mount(VirtualScroll, {
|
|
164
182
|
props: {
|
|
165
|
-
items: mockItems.slice(0, 10),
|
|
166
183
|
itemSize: 50,
|
|
167
|
-
containerTag: 'table',
|
|
168
|
-
wrapperTag: 'tbody',
|
|
169
|
-
itemTag: 'tr',
|
|
170
|
-
},
|
|
171
|
-
slots: {
|
|
172
|
-
header: '<tr><th>ID</th></tr>',
|
|
173
|
-
footer: '<tr><td>Footer</td></tr>',
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
expect(wrapper.find('thead').exists()).toBe(true);
|
|
177
|
-
expect(wrapper.find('tfoot').exists()).toBe(true);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('should render div header and footer', () => {
|
|
181
|
-
const wrapper = mount(VirtualScroll, {
|
|
182
|
-
props: {
|
|
183
|
-
items: mockItems.slice(0, 10),
|
|
184
|
-
containerTag: 'div',
|
|
185
|
-
},
|
|
186
|
-
slots: {
|
|
187
|
-
header: '<div class="header">Header</div>',
|
|
188
|
-
footer: '<div class="footer">Footer</div>',
|
|
189
|
-
},
|
|
190
|
-
});
|
|
191
|
-
expect(wrapper.find('div.virtual-scroll-header').exists()).toBe(true);
|
|
192
|
-
expect(wrapper.find('div.virtual-scroll-footer').exists()).toBe(true);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('should apply sticky classes to header and footer', async () => {
|
|
196
|
-
const wrapper = mount(VirtualScroll, {
|
|
197
|
-
props: {
|
|
198
184
|
items: mockItems,
|
|
199
|
-
stickyHeader: true,
|
|
200
|
-
stickyFooter: true,
|
|
201
185
|
},
|
|
202
186
|
slots: {
|
|
203
|
-
|
|
204
|
-
|
|
187
|
+
item: (props: ItemSlotProps) => {
|
|
188
|
+
const { item } = props as ItemSlotProps<MockItem>;
|
|
189
|
+
return h('div', { class: 'item' }, item.label);
|
|
190
|
+
},
|
|
205
191
|
},
|
|
206
192
|
});
|
|
207
193
|
await nextTick();
|
|
208
|
-
expect(wrapper.find('.virtual-scroll-header').classes()).toContain('virtual-scroll--sticky');
|
|
209
|
-
expect(wrapper.find('.virtual-scroll-footer').classes()).toContain('virtual-scroll--sticky');
|
|
210
|
-
});
|
|
211
194
|
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
195
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
196
|
+
const el = container.element as HTMLElement;
|
|
197
|
+
|
|
198
|
+
Object.defineProperty(el, 'scrollTop', { value: 1000, writable: true });
|
|
199
|
+
await container.trigger('scroll');
|
|
216
200
|
await nextTick();
|
|
217
|
-
expect(wrapper.element.tagName).toBe('TABLE');
|
|
218
|
-
await wrapper.setProps({ containerTag: 'div' });
|
|
219
201
|
await nextTick();
|
|
220
|
-
expect(wrapper.element.tagName).toBe('DIV');
|
|
221
|
-
});
|
|
222
202
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
props: {
|
|
226
|
-
items: mockItems.slice(0, 5),
|
|
227
|
-
containerTag: 'table',
|
|
228
|
-
wrapperTag: 'tbody',
|
|
229
|
-
itemTag: 'tr',
|
|
230
|
-
},
|
|
231
|
-
slots: {
|
|
232
|
-
item: '<template #item="{ item }"><td>{{ item.label }}</td></template>',
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
expect(wrapper.find('tr.virtual-scroll-spacer').exists()).toBe(true);
|
|
236
|
-
expect(wrapper.find('tr.virtual-scroll-item').exists()).toBe(true);
|
|
203
|
+
expect(wrapper.text()).toContain('Item 20');
|
|
204
|
+
expect(wrapper.text()).toContain('Item 15');
|
|
237
205
|
});
|
|
238
206
|
|
|
239
|
-
it('
|
|
207
|
+
it('emits load event when reaching end', async () => {
|
|
240
208
|
const wrapper = mount(VirtualScroll, {
|
|
241
209
|
props: {
|
|
242
|
-
|
|
243
|
-
|
|
210
|
+
itemSize: 50,
|
|
211
|
+
items: mockItems.slice(0, 20),
|
|
212
|
+
loadDistance: 100,
|
|
244
213
|
},
|
|
245
214
|
});
|
|
246
215
|
await nextTick();
|
|
247
|
-
|
|
248
|
-
expect(wrapper.find('tfoot').exists()).toBe(false);
|
|
249
|
-
});
|
|
216
|
+
await nextTick();
|
|
250
217
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
for (const direction of [ 'vertical', 'horizontal', 'both' ] as const) {
|
|
254
|
-
for (const loading of [ true, false ]) {
|
|
255
|
-
for (const withSlots of [ true, false ]) {
|
|
256
|
-
const slots = withSlots
|
|
257
|
-
? {
|
|
258
|
-
header: tag === 'table' ? '<tr><td>H</td></tr>' : '<div>H</div>',
|
|
259
|
-
footer: tag === 'table' ? '<tr><td>F</td></tr>' : '<div>F</div>',
|
|
260
|
-
loading: tag === 'table' ? '<tr><td>L</td></tr>' : '<div>L</div>',
|
|
261
|
-
}
|
|
262
|
-
: {};
|
|
263
|
-
const wrapper = mount(VirtualScroll, {
|
|
264
|
-
props: { items: mockItems.slice(0, 1), containerTag: tag, loading, direction },
|
|
265
|
-
slots,
|
|
266
|
-
});
|
|
267
|
-
await nextTick();
|
|
268
|
-
wrapper.unmount();
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
});
|
|
218
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
219
|
+
const el = container.element as HTMLElement;
|
|
275
220
|
|
|
276
|
-
|
|
277
|
-
it('should render items horizontally when direction is horizontal', async () => {
|
|
278
|
-
const wrapper = mount(VirtualScroll, {
|
|
279
|
-
props: {
|
|
280
|
-
items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
|
|
281
|
-
itemSize: 100,
|
|
282
|
-
direction: 'horizontal',
|
|
283
|
-
},
|
|
284
|
-
});
|
|
285
|
-
await nextTick();
|
|
286
|
-
const items = wrapper.findAll('.virtual-scroll-item');
|
|
287
|
-
expect(items.length).toBeGreaterThan(0);
|
|
288
|
-
const firstItem = items[ 0 ]?.element as HTMLElement;
|
|
289
|
-
expect(firstItem.style.transform).toBe('translate(0px, 0px)');
|
|
290
|
-
});
|
|
221
|
+
expect(wrapper.emitted('load')).toBeUndefined();
|
|
291
222
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
itemSize: 100,
|
|
297
|
-
direction: 'both',
|
|
298
|
-
columnCount: 5,
|
|
299
|
-
columnWidth: 150,
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
const VS_wrapper = wrapper.find('.virtual-scroll-wrapper');
|
|
303
|
-
const style = (VS_wrapper.element as HTMLElement).style;
|
|
304
|
-
expect(style.blockSize).toBe('1000px');
|
|
305
|
-
expect(style.inlineSize).toBe('750px');
|
|
306
|
-
});
|
|
223
|
+
Object.defineProperty(el, 'scrollTop', { value: 450, writable: true });
|
|
224
|
+
await container.trigger('scroll');
|
|
225
|
+
await nextTick();
|
|
226
|
+
await nextTick();
|
|
307
227
|
|
|
308
|
-
|
|
309
|
-
[ 'vertical', 'horizontal', 'both' ].forEach((direction) => {
|
|
310
|
-
mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both' } });
|
|
311
|
-
mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both', container: document.body } });
|
|
312
|
-
mount(VirtualScroll, { props: { items: mockItems, direction: direction as 'vertical' | 'horizontal' | 'both', containerTag: 'table' } });
|
|
313
|
-
});
|
|
314
|
-
mount(VirtualScroll, { props: { items: mockItems, container: null } });
|
|
228
|
+
expect(wrapper.emitted('load')).toBeDefined();
|
|
315
229
|
});
|
|
316
230
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
items: mockItems
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
231
|
+
describe('keyboard Navigation', () => {
|
|
232
|
+
it('responds to Home and End keys in vertical mode', async () => {
|
|
233
|
+
const wrapper = mount(VirtualScroll, {
|
|
234
|
+
props: { itemSize: 50, items: mockItems },
|
|
235
|
+
});
|
|
236
|
+
await nextTick();
|
|
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);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('responds to PageUp and PageDown in vertical mode', async () => {
|
|
266
|
+
const wrapper = mount(VirtualScroll, {
|
|
267
|
+
props: { itemSize: 50, items: mockItems },
|
|
268
|
+
});
|
|
269
|
+
await nextTick();
|
|
270
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
271
|
+
|
|
272
|
+
await container.trigger('keydown', { key: 'PageDown' });
|
|
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);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('responds to Home and End keys in horizontal mode', async () => {
|
|
282
|
+
const wrapper = mount(VirtualScroll, {
|
|
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);
|
|
292
|
+
|
|
293
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
294
|
+
await nextTick();
|
|
295
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('responds to Arrows in horizontal mode', async () => {
|
|
299
|
+
const wrapper = mount(VirtualScroll, {
|
|
300
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
301
|
+
});
|
|
302
|
+
await nextTick();
|
|
303
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
304
|
+
|
|
305
|
+
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
306
|
+
await nextTick();
|
|
307
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(40);
|
|
308
|
+
|
|
309
|
+
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
310
|
+
await nextTick();
|
|
311
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('responds to PageUp and PageDown in horizontal mode', async () => {
|
|
315
|
+
const wrapper = mount(VirtualScroll, {
|
|
316
|
+
props: { direction: 'horizontal', itemSize: 100, items: mockItems },
|
|
317
|
+
});
|
|
318
|
+
await nextTick();
|
|
319
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
320
|
+
|
|
321
|
+
await container.trigger('keydown', { key: 'PageDown' });
|
|
322
|
+
await nextTick();
|
|
323
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(500);
|
|
324
|
+
|
|
325
|
+
await container.trigger('keydown', { key: 'PageUp' });
|
|
326
|
+
await nextTick();
|
|
327
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('responds to Home and End keys in grid mode', async () => {
|
|
331
|
+
const wrapper = mount(VirtualScroll, {
|
|
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');
|
|
342
|
+
|
|
343
|
+
await container.trigger('keydown', { key: 'End' });
|
|
344
|
+
await nextTick();
|
|
345
|
+
// last row 99 at 4950. end align -> 4500.
|
|
346
|
+
// last col 9 at 900. end align -> 900 - (500 - 100) = 500.
|
|
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);
|
|
349
|
+
|
|
350
|
+
await container.trigger('keydown', { key: 'Home' });
|
|
351
|
+
await nextTick();
|
|
352
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.y).toBe(0);
|
|
353
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('responds to all Arrows in grid mode', async () => {
|
|
357
|
+
const wrapper = mount(VirtualScroll, {
|
|
358
|
+
props: {
|
|
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);
|
|
374
|
+
|
|
375
|
+
await container.trigger('keydown', { key: 'ArrowUp' });
|
|
376
|
+
await container.trigger('keydown', { key: 'ArrowLeft' });
|
|
377
|
+
await nextTick();
|
|
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);
|
|
324
380
|
});
|
|
325
|
-
await nextTick();
|
|
326
|
-
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
327
|
-
expect(item.style.inlineSize).toBe('50px');
|
|
328
|
-
expect(item.style.blockSize).toBe('100%');
|
|
329
381
|
});
|
|
382
|
+
});
|
|
330
383
|
|
|
331
|
-
|
|
332
|
-
|
|
384
|
+
describe('dynamic Sizing', () => {
|
|
385
|
+
it('adjusts total size when items are measured', async () => {
|
|
333
386
|
const wrapper = mount(VirtualScroll, {
|
|
334
387
|
props: {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
stickyIndices: [ 0 ],
|
|
338
|
-
scrollPaddingStart: 10,
|
|
388
|
+
itemSize: 0,
|
|
389
|
+
items: mockItems.slice(0, 10),
|
|
339
390
|
},
|
|
340
391
|
});
|
|
341
392
|
await nextTick();
|
|
342
393
|
|
|
343
|
-
|
|
344
|
-
// It should be sticky active if we scroll
|
|
345
|
-
await wrapper.trigger('scroll');
|
|
346
|
-
await nextTick();
|
|
347
|
-
|
|
348
|
-
expect(stickyItem.style.insetInlineStart).toBe('10px');
|
|
394
|
+
expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('400px');
|
|
349
395
|
|
|
350
|
-
|
|
396
|
+
const firstItem = wrapper.find('.virtual-scroll-item[data-index="0"]').element;
|
|
397
|
+
triggerResize(firstItem, 100, 100);
|
|
351
398
|
await nextTick();
|
|
352
|
-
expect(stickyItem.style.insetBlockStart).toBe('20px');
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it('should handle custom container element for header/footer padding', async () => {
|
|
356
|
-
const container = document.createElement('div');
|
|
357
|
-
const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
358
|
-
mount(VirtualScroll, {
|
|
359
|
-
props: {
|
|
360
|
-
items,
|
|
361
|
-
container,
|
|
362
|
-
stickyHeader: true,
|
|
363
|
-
},
|
|
364
|
-
});
|
|
365
399
|
await nextTick();
|
|
366
|
-
// This covers the branch where container is NOT host element and NOT window
|
|
367
|
-
});
|
|
368
400
|
|
|
369
|
-
|
|
370
|
-
const items = Array.from({ length: 10 }, (_, i) => ({ id: i }));
|
|
371
|
-
mount(VirtualScroll, {
|
|
372
|
-
props: {
|
|
373
|
-
items,
|
|
374
|
-
container: window,
|
|
375
|
-
stickyHeader: true,
|
|
376
|
-
},
|
|
377
|
-
slots: { header: '<div>H</div>' },
|
|
378
|
-
});
|
|
379
|
-
await nextTick();
|
|
401
|
+
expect((wrapper.find('.virtual-scroll-wrapper').element as HTMLElement).style.blockSize).toBe('460px');
|
|
380
402
|
});
|
|
381
403
|
|
|
382
|
-
it('
|
|
383
|
-
mount(VirtualScroll, {
|
|
404
|
+
it('does not allow columns to become 0 width due to 0-size measurements', async () => {
|
|
405
|
+
const wrapper = mount(VirtualScroll, {
|
|
384
406
|
props: {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
407
|
+
bufferAfter: 0,
|
|
408
|
+
bufferBefore: 0,
|
|
409
|
+
columnCount: 10,
|
|
410
|
+
defaultColumnWidth: 100,
|
|
411
|
+
direction: 'both',
|
|
412
|
+
itemSize: 50,
|
|
413
|
+
items: mockItems,
|
|
388
414
|
},
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
]),
|
|
396
424
|
},
|
|
397
425
|
});
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
describe('keyboard navigation', () => {
|
|
402
|
-
let wrapper: VueWrapper<VSInstance>;
|
|
403
|
-
let container: DOMWrapper<Element>;
|
|
404
|
-
let el: HTMLElement;
|
|
405
426
|
|
|
406
|
-
beforeEach(async () => {
|
|
407
|
-
wrapper = mount(VirtualScroll, {
|
|
408
|
-
props: {
|
|
409
|
-
items: mockItems,
|
|
410
|
-
itemSize: 50,
|
|
411
|
-
direction: 'vertical',
|
|
412
|
-
},
|
|
413
|
-
}) as unknown as VueWrapper<VSInstance>;
|
|
414
|
-
await nextTick();
|
|
415
|
-
container = wrapper.find('.virtual-scroll-container');
|
|
416
|
-
el = container.element as HTMLElement;
|
|
417
|
-
|
|
418
|
-
// Mock dimensions
|
|
419
|
-
Object.defineProperty(el, 'clientHeight', { value: 500, configurable: true });
|
|
420
|
-
Object.defineProperty(el, 'clientWidth', { value: 500, configurable: true });
|
|
421
|
-
Object.defineProperty(el, 'offsetHeight', { value: 500, configurable: true });
|
|
422
|
-
Object.defineProperty(el, 'offsetWidth', { value: 500, configurable: true });
|
|
423
|
-
|
|
424
|
-
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
425
|
-
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
426
427
|
await nextTick();
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it('should handle Home key', async () => {
|
|
430
|
-
el.scrollTop = 1000;
|
|
431
|
-
el.scrollLeft = 500;
|
|
432
|
-
await container.trigger('keydown', { key: 'Home' });
|
|
433
|
-
await nextTick();
|
|
434
|
-
expect(el.scrollTop).toBe(0);
|
|
435
|
-
expect(el.scrollLeft).toBe(0);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it('should handle End key (vertical)', async () => {
|
|
439
|
-
el.scrollLeft = 0;
|
|
440
|
-
await container.trigger('keydown', { key: 'End' });
|
|
441
|
-
await nextTick();
|
|
442
|
-
// totalHeight = 100 items * 50px = 5000px
|
|
443
|
-
// viewportHeight = 500px
|
|
444
|
-
// scrollToIndex(99, 0, 'end') -> targetY = 99 * 50 = 4950
|
|
445
|
-
// alignment 'end' -> targetY = 4950 - (500 - 50) = 4500
|
|
446
|
-
expect(el.scrollTop).toBe(4500);
|
|
447
|
-
expect(el.scrollLeft).toBe(0);
|
|
448
|
-
});
|
|
449
428
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
await nextTick();
|
|
453
|
-
// Trigger resize again for horizontal
|
|
454
|
-
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
455
|
-
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
456
|
-
await nextTick();
|
|
429
|
+
const initialWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
|
|
430
|
+
expect(initialWidth).toBeGreaterThan(0);
|
|
457
431
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
expect(
|
|
462
|
-
expect(el.scrollTop).toBe(0);
|
|
463
|
-
});
|
|
432
|
+
// Find a cell from the first row
|
|
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();
|
|
464
436
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
await nextTick();
|
|
437
|
+
// Simulate 0-size measurement (e.g. from removal or being hidden)
|
|
438
|
+
triggerResize(cell0, 0, 0);
|
|
468
439
|
|
|
469
|
-
// Trigger a resize
|
|
470
|
-
const observers = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.filter((i) => i.targets.has(el));
|
|
471
|
-
observers.forEach((i) => i.trigger([ { target: el, contentRect: { width: 500, height: 500 } as unknown as DOMRectReadOnly } ]));
|
|
472
440
|
await nextTick();
|
|
473
|
-
|
|
474
|
-
await container.trigger('keydown', { key: 'End' });
|
|
475
441
|
await nextTick();
|
|
476
442
|
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
// scrollToIndex(99, 4, 'end')
|
|
481
|
-
// targetY = 99 * 50 = 4950. end alignment: 4950 - (500 - 50) = 4500
|
|
482
|
-
// targetX = 4 * 100 = 400. end alignment: 400 - (500 - 100) = 0
|
|
483
|
-
expect(el.scrollTop).toBe(4500);
|
|
484
|
-
expect(el.scrollLeft).toBe(0);
|
|
443
|
+
// totalWidth should NOT have decreased if we ignore 0 measurements
|
|
444
|
+
const currentWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.totalSize.width;
|
|
445
|
+
expect(currentWidth).toBe(initialWidth);
|
|
485
446
|
});
|
|
486
447
|
|
|
487
|
-
it('should
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
448
|
+
it('should not shift horizontally when scrolling vertically even if measurements vary slightly', async () => {
|
|
449
|
+
const wrapper = mount(VirtualScroll, {
|
|
450
|
+
props: {
|
|
451
|
+
bufferAfter: 0,
|
|
452
|
+
bufferBefore: 0,
|
|
453
|
+
columnCount: 10,
|
|
454
|
+
defaultColumnWidth: 100,
|
|
455
|
+
direction: 'both',
|
|
456
|
+
itemSize: 50,
|
|
457
|
+
items: mockItems,
|
|
458
|
+
},
|
|
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
|
+
});
|
|
493
470
|
|
|
494
|
-
it('should handle ArrowDown / ArrowUp', async () => {
|
|
495
|
-
el.scrollLeft = 0;
|
|
496
|
-
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
497
471
|
await nextTick();
|
|
498
|
-
expect(el.scrollTop).toBe(40);
|
|
499
|
-
expect(el.scrollLeft).toBe(0);
|
|
500
472
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
expect(el.scrollTop).toBe(0);
|
|
504
|
-
expect(el.scrollLeft).toBe(0);
|
|
505
|
-
});
|
|
473
|
+
// Initial scroll
|
|
474
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
506
475
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
el.scrollTop = 0;
|
|
511
|
-
await container.trigger('keydown', { key: 'ArrowRight' });
|
|
512
|
-
await nextTick();
|
|
513
|
-
expect(el.scrollLeft).toBe(40);
|
|
514
|
-
expect(el.scrollTop).toBe(0);
|
|
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'));
|
|
515
479
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
it('should handle PageDown / PageUp', async () => {
|
|
523
|
-
el.scrollLeft = 0;
|
|
524
|
-
await container.trigger('keydown', { key: 'PageDown' });
|
|
525
|
-
await nextTick();
|
|
526
|
-
expect(el.scrollTop).toBe(500);
|
|
527
|
-
expect(el.scrollLeft).toBe(0);
|
|
528
|
-
|
|
529
|
-
await container.trigger('keydown', { key: 'PageUp' });
|
|
530
|
-
await nextTick();
|
|
531
|
-
expect(el.scrollTop).toBe(0);
|
|
532
|
-
expect(el.scrollLeft).toBe(0);
|
|
533
|
-
});
|
|
480
|
+
// Measure row 0 and its cells
|
|
481
|
+
triggerResize(row0, 1000, 50);
|
|
482
|
+
for (const cell of cells0) {
|
|
483
|
+
triggerResize(cell, 110, 50);
|
|
484
|
+
}
|
|
534
485
|
|
|
535
|
-
it('should handle PageDown / PageUp in horizontal mode', async () => {
|
|
536
|
-
await wrapper.setProps({ direction: 'horizontal' });
|
|
537
486
|
await nextTick();
|
|
538
|
-
el.scrollTop = 0;
|
|
539
|
-
await container.trigger('keydown', { key: 'PageDown' });
|
|
540
487
|
await nextTick();
|
|
541
|
-
expect(el.scrollLeft).toBe(500);
|
|
542
|
-
expect(el.scrollTop).toBe(0);
|
|
543
488
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
it('should not scroll past the end using PageDown', async () => {
|
|
551
|
-
// items: 100, itemSize: 50 -> totalHeight = 5000
|
|
552
|
-
// viewportHeight: 500 -> maxScroll = 4500
|
|
553
|
-
(wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4400);
|
|
554
|
-
await nextTick();
|
|
555
|
-
await container.trigger('keydown', { key: 'PageDown' });
|
|
556
|
-
await nextTick();
|
|
557
|
-
expect(el.scrollTop).toBe(4500);
|
|
558
|
-
});
|
|
489
|
+
// Scroll down to row 20
|
|
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');
|
|
559
494
|
|
|
560
|
-
it('should not scroll past the end using ArrowDown', async () => {
|
|
561
|
-
// maxScroll = 4500
|
|
562
|
-
(wrapper.vm as unknown as VSInstance).scrollToOffset(null, 4480);
|
|
563
495
|
await nextTick();
|
|
564
|
-
await container.trigger('keydown', { key: 'ArrowDown' });
|
|
565
496
|
await nextTick();
|
|
566
|
-
expect(el.scrollTop).toBe(4500);
|
|
567
|
-
});
|
|
568
497
|
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
expect(event.defaultPrevented).toBe(false);
|
|
573
|
-
});
|
|
574
|
-
});
|
|
498
|
+
// Now row 20 is at the top. Measure its cells with slightly different width.
|
|
499
|
+
const row20 = wrapper.find('.virtual-scroll-item[data-index="20"]').element;
|
|
500
|
+
const cells20 = Array.from(row20.querySelectorAll('.cell'));
|
|
575
501
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
|
|
579
|
-
await nextTick();
|
|
580
|
-
const firstItem = wrapper.find('.virtual-scroll-item').element;
|
|
581
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
|
|
582
|
-
if (observer) {
|
|
583
|
-
observer.trigger([ {
|
|
584
|
-
target: firstItem,
|
|
585
|
-
contentRect: { width: 100, height: 100 } as DOMRectReadOnly,
|
|
586
|
-
borderBoxSize: [ { inlineSize: 110, blockSize: 110 } ],
|
|
587
|
-
} ]);
|
|
502
|
+
for (const cell of cells20) {
|
|
503
|
+
triggerResize(cell, 110.1, 50);
|
|
588
504
|
}
|
|
589
|
-
await nextTick();
|
|
590
|
-
});
|
|
591
505
|
|
|
592
|
-
it('should handle resize fallback (no borderBoxSize)', async () => {
|
|
593
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 5) } });
|
|
594
506
|
await nextTick();
|
|
595
|
-
const firstItem = wrapper.find('.virtual-scroll-item').element;
|
|
596
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(firstItem));
|
|
597
|
-
if (observer) {
|
|
598
|
-
observer.trigger([ { target: firstItem, contentRect: { width: 100, height: 100 } as DOMRectReadOnly } ]);
|
|
599
|
-
}
|
|
600
507
|
await nextTick();
|
|
601
|
-
});
|
|
602
508
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
await nextTick();
|
|
606
|
-
const host = wrapper.find('.virtual-scroll-container').element;
|
|
607
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(host));
|
|
608
|
-
if (observer) {
|
|
609
|
-
observer.trigger([ { target: host } ]);
|
|
610
|
-
}
|
|
611
|
-
await nextTick();
|
|
509
|
+
// ScrollOffset.x should STILL BE 0. It should not have shifted because of d = 0.1
|
|
510
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(0);
|
|
612
511
|
});
|
|
613
512
|
|
|
614
|
-
it('
|
|
513
|
+
it('correctly aligns item 50:50 auto after measurements in dynamic grid', async () => {
|
|
615
514
|
const wrapper = mount(VirtualScroll, {
|
|
616
515
|
props: {
|
|
617
|
-
|
|
516
|
+
bufferAfter: 5,
|
|
517
|
+
bufferBefore: 5,
|
|
518
|
+
columnCount: 100,
|
|
519
|
+
defaultColumnWidth: 120,
|
|
520
|
+
defaultItemSize: 120,
|
|
618
521
|
direction: 'both',
|
|
619
|
-
|
|
522
|
+
items: mockItems,
|
|
620
523
|
},
|
|
621
524
|
slots: {
|
|
622
|
-
item:
|
|
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
|
+
]),
|
|
623
533
|
},
|
|
624
534
|
});
|
|
625
|
-
await nextTick();
|
|
626
|
-
const cell = wrapper.find('.cell').element;
|
|
627
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(cell));
|
|
628
|
-
expect(observer).toBeDefined();
|
|
629
535
|
|
|
630
|
-
if (observer) {
|
|
631
|
-
observer.trigger([ {
|
|
632
|
-
target: cell,
|
|
633
|
-
contentRect: { width: 100, height: 50 } as DOMRectReadOnly,
|
|
634
|
-
} ]);
|
|
635
|
-
}
|
|
636
536
|
await nextTick();
|
|
637
|
-
});
|
|
638
537
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
slots: {
|
|
643
|
-
header: '<div class="header">H</div>',
|
|
644
|
-
footer: '<div class="footer">F</div>',
|
|
645
|
-
},
|
|
646
|
-
});
|
|
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
|
+
await nextTick();
|
|
647
541
|
await nextTick();
|
|
648
|
-
const header = wrapper.find('.virtual-scroll-header').element;
|
|
649
|
-
const footer = wrapper.find('.virtual-scroll-footer').element;
|
|
650
542
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
543
|
+
// Initial scroll position (estimates)
|
|
544
|
+
// itemX = 50 * 120 = 6000. itemWidth = 120. viewport = 500.
|
|
545
|
+
// targetEnd = 6000 + 120 - 500 = 5620.
|
|
546
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5620);
|
|
655
547
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
await nextTick();
|
|
661
|
-
});
|
|
548
|
+
// Row 50 should be rendered. Row 45 should be the first rendered row.
|
|
549
|
+
const row45El = wrapper.find('.virtual-scroll-item[data-index="45"]').element;
|
|
550
|
+
const cells45 = Array.from(row45El.querySelectorAll('.cell'));
|
|
662
551
|
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
});
|
|
668
|
-
await nextTick();
|
|
669
|
-
const footer = wrapper.find('.virtual-scroll-footer').element;
|
|
670
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(footer));
|
|
671
|
-
expect(observer).toBeDefined();
|
|
672
|
-
});
|
|
552
|
+
// Simulate measurements for all rendered cells in row 45 as 150px
|
|
553
|
+
for (const cell of cells45) {
|
|
554
|
+
triggerResize(cell, 150, 120);
|
|
555
|
+
}
|
|
673
556
|
|
|
674
|
-
it('should cover header/footer unobserve when removed/replaced', async () => {
|
|
675
|
-
const TestComp = defineComponent({
|
|
676
|
-
components: { VirtualScroll },
|
|
677
|
-
setup() {
|
|
678
|
-
const show = ref(true);
|
|
679
|
-
const showFooter = ref(true);
|
|
680
|
-
return { mockItems, show, showFooter };
|
|
681
|
-
},
|
|
682
|
-
template: `
|
|
683
|
-
<VirtualScroll :items="mockItems">
|
|
684
|
-
<template v-if="show" #header><div>H</div></template>
|
|
685
|
-
<template v-if="showFooter" #footer><div>F</div></template>
|
|
686
|
-
</VirtualScroll>
|
|
687
|
-
`,
|
|
688
|
-
});
|
|
689
|
-
const wrapper = mount(TestComp);
|
|
690
557
|
await nextTick();
|
|
691
|
-
|
|
692
|
-
(wrapper.vm as unknown as TestCompInstance).show = false;
|
|
693
|
-
(wrapper.vm as unknown as TestCompInstance).showFooter = false;
|
|
694
558
|
await nextTick();
|
|
695
|
-
|
|
696
|
-
(wrapper.vm as unknown as TestCompInstance).show = true;
|
|
697
|
-
(wrapper.vm as unknown as TestCompInstance).showFooter = true;
|
|
698
559
|
await nextTick();
|
|
699
|
-
});
|
|
700
560
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
wrapper.unmount();
|
|
708
|
-
});
|
|
561
|
+
// Correction should have triggered.
|
|
562
|
+
// At x=5620, rendered columns are 44..52 (inclusive).
|
|
563
|
+
// If columns 44..52 are all 150px:
|
|
564
|
+
// New itemX for col 50: 44 * 120 + 6 * 150 = 5280 + 900 = 6180.
|
|
565
|
+
// itemWidth = 150. viewport = 500.
|
|
566
|
+
// targetEnd = 6180 + 150 - 500 = 5830.
|
|
709
567
|
|
|
710
|
-
|
|
711
|
-
|
|
568
|
+
// wait for async correction cycle
|
|
569
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
712
570
|
await nextTick();
|
|
713
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ]!;
|
|
714
571
|
|
|
715
|
-
|
|
716
|
-
const div1 = document.createElement('div');
|
|
717
|
-
div1.dataset.index = 'invalid';
|
|
718
|
-
observer.trigger([ { target: div1, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
|
|
572
|
+
expect((wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x).toBe(5830);
|
|
719
573
|
|
|
720
|
-
//
|
|
721
|
-
const
|
|
722
|
-
|
|
574
|
+
// Check if it's fully visible
|
|
575
|
+
const offset = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.scrollOffset.x;
|
|
576
|
+
const viewportWidth = (wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; }).scrollDetails.viewportSize.width;
|
|
577
|
+
const itemX = 6180;
|
|
578
|
+
const itemWidth = 150;
|
|
723
579
|
|
|
724
|
-
|
|
580
|
+
expect(itemX).toBeGreaterThanOrEqual(offset);
|
|
581
|
+
expect(itemX + itemWidth).toBeLessThanOrEqual(offset + viewportWidth);
|
|
725
582
|
});
|
|
726
583
|
});
|
|
727
584
|
|
|
728
|
-
describe('
|
|
729
|
-
it('
|
|
585
|
+
describe('sticky Items', () => {
|
|
586
|
+
it('applies sticky styles to marked items', async () => {
|
|
730
587
|
const wrapper = mount(VirtualScroll, {
|
|
731
588
|
props: {
|
|
732
|
-
bufferBefore: 2,
|
|
733
|
-
columnCount: 5,
|
|
734
|
-
direction: 'both',
|
|
735
589
|
itemSize: 50,
|
|
736
590
|
items: mockItems,
|
|
737
|
-
|
|
738
|
-
slots: {
|
|
739
|
-
item: '<template #item="{ index }"><div class="cell" :data-col-index="0">Item {{ index }}</div></template>',
|
|
591
|
+
stickyIndices: [ 0 ],
|
|
740
592
|
},
|
|
741
593
|
});
|
|
742
594
|
await nextTick();
|
|
743
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
744
595
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
await nextTick();
|
|
748
|
-
await nextTick();
|
|
749
|
-
|
|
750
|
-
const item8 = wrapper.find('.virtual-scroll-item[data-index="8"]').element;
|
|
751
|
-
const itemResizeObserver = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(item8));
|
|
752
|
-
expect(itemResizeObserver).toBeDefined();
|
|
596
|
+
const container = wrapper.find('.virtual-scroll-container');
|
|
597
|
+
const el = container.element as HTMLElement;
|
|
753
598
|
|
|
754
|
-
|
|
755
|
-
|
|
599
|
+
Object.defineProperty(el, 'scrollTop', { value: 100, writable: true });
|
|
600
|
+
await container.trigger('scroll');
|
|
756
601
|
await nextTick();
|
|
757
602
|
await nextTick();
|
|
758
603
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
await nextTick();
|
|
604
|
+
const item0 = wrapper.find('.virtual-scroll-item[data-index="0"]');
|
|
605
|
+
expect(item0.classes()).toContain('virtual-scroll--sticky');
|
|
606
|
+
expect((item0.element as HTMLElement).style.insetBlockStart).toBe('0px');
|
|
763
607
|
});
|
|
608
|
+
});
|
|
764
609
|
|
|
765
|
-
|
|
610
|
+
describe('ssr and Initial State', () => {
|
|
611
|
+
it('renders SSR range if provided', async () => {
|
|
766
612
|
const wrapper = mount(VirtualScroll, {
|
|
767
613
|
props: {
|
|
768
|
-
columnCount: 5,
|
|
769
|
-
direction: 'both',
|
|
770
614
|
itemSize: 50,
|
|
771
615
|
items: mockItems,
|
|
616
|
+
ssrRange: { end: 20, start: 10 },
|
|
617
|
+
},
|
|
618
|
+
slots: {
|
|
619
|
+
item: (props: ItemSlotProps) => {
|
|
620
|
+
const { item } = props as ItemSlotProps<MockItem>;
|
|
621
|
+
return h('div', item.label);
|
|
622
|
+
},
|
|
772
623
|
},
|
|
773
624
|
});
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
625
|
+
const items = wrapper.findAll('.virtual-scroll-item');
|
|
626
|
+
expect(items.length).toBe(10);
|
|
627
|
+
expect(items[ 0 ]?.attributes('data-index')).toBe('10');
|
|
628
|
+
expect(wrapper.text()).toContain('Item 10');
|
|
777
629
|
});
|
|
778
|
-
});
|
|
779
630
|
|
|
780
|
-
|
|
781
|
-
it('should emit load event when reaching scroll end (vertical)', async () => {
|
|
631
|
+
it('hydrates and scrolls to initial index', async () => {
|
|
782
632
|
const wrapper = mount(VirtualScroll, {
|
|
783
633
|
props: {
|
|
634
|
+
initialScrollIndex: 50,
|
|
784
635
|
itemSize: 50,
|
|
785
|
-
items: mockItems
|
|
786
|
-
|
|
636
|
+
items: mockItems,
|
|
637
|
+
},
|
|
638
|
+
slots: {
|
|
639
|
+
item: (props: ItemSlotProps) => {
|
|
640
|
+
const { item } = props as ItemSlotProps<MockItem>;
|
|
641
|
+
return h('div', item.label);
|
|
642
|
+
},
|
|
787
643
|
},
|
|
788
644
|
});
|
|
645
|
+
await nextTick(); // onMounted
|
|
646
|
+
await nextTick(); // hydration + scrollToIndex
|
|
789
647
|
await nextTick();
|
|
790
|
-
|
|
791
|
-
(wrapper.vm as unknown as VSInstance).scrollToOffset(0, 250);
|
|
792
648
|
await nextTick();
|
|
793
649
|
await nextTick();
|
|
794
650
|
|
|
795
|
-
expect(wrapper.
|
|
796
|
-
expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'vertical' ]);
|
|
651
|
+
expect(wrapper.text()).toContain('Item 50');
|
|
797
652
|
});
|
|
798
653
|
|
|
799
|
-
it('
|
|
654
|
+
it('does not gather multiple sticky items at the top', async () => {
|
|
800
655
|
const wrapper = mount(VirtualScroll, {
|
|
801
656
|
props: {
|
|
802
|
-
direction: 'horizontal',
|
|
803
657
|
itemSize: 50,
|
|
804
|
-
items: mockItems
|
|
805
|
-
|
|
658
|
+
items: mockItems,
|
|
659
|
+
stickyIndices: [ 0, 1, 2 ],
|
|
660
|
+
},
|
|
661
|
+
slots: {
|
|
662
|
+
item: (props: ItemSlotProps) => {
|
|
663
|
+
const { index, item } = props as ItemSlotProps<MockItem>;
|
|
664
|
+
return h('div', { class: 'item' }, `${ index }: ${ item.label }`);
|
|
665
|
+
},
|
|
806
666
|
},
|
|
807
667
|
});
|
|
808
|
-
await nextTick();
|
|
809
668
|
|
|
810
|
-
(wrapper.vm as unknown as VSInstance).scrollToOffset(250, 0);
|
|
811
669
|
await nextTick();
|
|
812
670
|
await nextTick();
|
|
813
671
|
|
|
814
|
-
expect(wrapper.emitted('load')).toBeDefined();
|
|
815
|
-
expect(wrapper.emitted('load')![ 0 ]).toEqual([ 'horizontal' ]);
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
it('should not emit load event when loading is true', async () => {
|
|
819
|
-
const wrapper = mount(VirtualScroll, {
|
|
820
|
-
props: {
|
|
821
|
-
itemSize: 50,
|
|
822
|
-
items: mockItems.slice(0, 10),
|
|
823
|
-
loadDistance: 100,
|
|
824
|
-
loading: true,
|
|
825
|
-
},
|
|
826
|
-
});
|
|
827
|
-
await nextTick();
|
|
828
672
|
const container = wrapper.find('.virtual-scroll-container');
|
|
829
673
|
const el = container.element as HTMLElement;
|
|
830
|
-
Object.defineProperty(el, 'clientHeight', { value: 200, configurable: true });
|
|
831
|
-
Object.defineProperty(el, 'scrollHeight', { value: 500, configurable: true });
|
|
832
674
|
|
|
833
|
-
|
|
834
|
-
|
|
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');
|
|
835
678
|
await nextTick();
|
|
836
679
|
await nextTick();
|
|
837
680
|
|
|
838
|
-
|
|
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"]');
|
|
686
|
+
|
|
687
|
+
expect(item2.classes()).toContain('virtual-scroll--sticky');
|
|
688
|
+
expect(item1.classes()).not.toContain('virtual-scroll--sticky');
|
|
689
|
+
expect(item0.classes()).not.toContain('virtual-scroll--sticky');
|
|
839
690
|
});
|
|
691
|
+
});
|
|
840
692
|
|
|
841
|
-
|
|
693
|
+
describe('slots and Options', () => {
|
|
694
|
+
it('renders header and footer', async () => {
|
|
842
695
|
const wrapper = mount(VirtualScroll, {
|
|
843
|
-
props: {
|
|
844
|
-
itemSize: 50,
|
|
845
|
-
items: mockItems.slice(0, 10),
|
|
846
|
-
loading: true,
|
|
847
|
-
},
|
|
696
|
+
props: { items: mockItems.slice(0, 1) },
|
|
848
697
|
slots: {
|
|
849
|
-
|
|
698
|
+
footer: () => h('div', 'FOOTER'),
|
|
699
|
+
header: () => h('div', 'HEADER'),
|
|
850
700
|
},
|
|
851
701
|
});
|
|
852
|
-
|
|
853
|
-
expect(wrapper.
|
|
854
|
-
|
|
855
|
-
await wrapper.setProps({ direction: 'horizontal' });
|
|
856
|
-
await nextTick();
|
|
857
|
-
expect((wrapper.find('.virtual-scroll-loading').element as HTMLElement).style.display).toBe('inline-block');
|
|
702
|
+
expect(wrapper.text()).toContain('HEADER');
|
|
703
|
+
expect(wrapper.text()).toContain('FOOTER');
|
|
858
704
|
});
|
|
859
705
|
|
|
860
|
-
it('
|
|
706
|
+
it('shows loading indicator', async () => {
|
|
861
707
|
const wrapper = mount(VirtualScroll, {
|
|
862
|
-
props: {
|
|
863
|
-
items: mockItems.slice(0, 5),
|
|
864
|
-
loading: false,
|
|
865
|
-
},
|
|
708
|
+
props: { items: [], loading: true },
|
|
866
709
|
slots: {
|
|
867
|
-
loading: '
|
|
710
|
+
loading: () => h('div', 'LOADING...'),
|
|
868
711
|
},
|
|
869
712
|
});
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
expect(wrapper.find('.loader').exists()).toBe(false);
|
|
873
|
-
expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
|
|
874
|
-
|
|
875
|
-
await wrapper.setProps({ loading: true });
|
|
876
|
-
await nextTick();
|
|
877
|
-
expect(wrapper.find('.loader').exists()).toBe(true);
|
|
878
|
-
expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(true);
|
|
879
|
-
|
|
880
|
-
await wrapper.setProps({ loading: false });
|
|
881
|
-
await nextTick();
|
|
882
|
-
expect(wrapper.find('.loader').exists()).toBe(false);
|
|
883
|
-
expect(wrapper.find('.virtual-scroll-loading').exists()).toBe(false);
|
|
713
|
+
expect(wrapper.text()).toContain('LOADING...');
|
|
884
714
|
});
|
|
885
|
-
});
|
|
886
715
|
|
|
887
|
-
|
|
888
|
-
it('should handle setItemRef', async () => {
|
|
889
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
890
|
-
await nextTick();
|
|
891
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
892
|
-
const item = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
893
|
-
vm.setItemRef(item, 0);
|
|
894
|
-
vm.setItemRef(null, 0);
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
it('should handle setItemRef with NaN index', async () => {
|
|
898
|
-
mount(VirtualScroll, { props: { items: mockItems.slice(0, 1) } });
|
|
899
|
-
await nextTick();
|
|
900
|
-
const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances[ 0 ];
|
|
901
|
-
const div = document.createElement('div');
|
|
902
|
-
observer?.trigger([ { target: div, contentRect: { width: 100, height: 100 } as unknown as DOMRectReadOnly } ]);
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
it('should expose methods', () => {
|
|
906
|
-
const wrapper = mount(VirtualScroll, { props: { items: mockItems, itemSize: 50 } });
|
|
907
|
-
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToIndex).toBe('function');
|
|
908
|
-
expect(typeof (wrapper.vm as unknown as VSInstance).scrollToOffset).toBe('function');
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
it('should manually re-measure items on refresh', async () => {
|
|
912
|
-
const items = [ { id: 1 } ];
|
|
716
|
+
it('uses correct HTML tags', () => {
|
|
913
717
|
const wrapper = mount(VirtualScroll, {
|
|
914
|
-
props: {
|
|
915
|
-
|
|
718
|
+
props: {
|
|
719
|
+
containerTag: 'table',
|
|
720
|
+
itemTag: 'tr',
|
|
721
|
+
items: [],
|
|
722
|
+
wrapperTag: 'tbody',
|
|
723
|
+
},
|
|
916
724
|
});
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
const el = wrapper.find('.virtual-scroll-item').element as HTMLElement;
|
|
920
|
-
Object.defineProperty(el, 'offsetHeight', { value: 100 });
|
|
921
|
-
Object.defineProperty(el, 'offsetWidth', { value: 100 });
|
|
922
|
-
|
|
923
|
-
// First measurement via refresh (simulating manual trigger or initial measurement)
|
|
924
|
-
const vm = wrapper.vm as unknown as VSInstance;
|
|
925
|
-
vm.refresh();
|
|
926
|
-
await nextTick();
|
|
927
|
-
await nextTick();
|
|
928
|
-
|
|
929
|
-
expect(vm.scrollDetails.totalSize.height).toBe(100);
|
|
725
|
+
expect(wrapper.element.tagName).toBe('TABLE');
|
|
726
|
+
expect(wrapper.find('tbody').exists()).toBe(true);
|
|
930
727
|
});
|
|
931
728
|
|
|
932
|
-
it('
|
|
729
|
+
it('triggers refresh and updates items', async () => {
|
|
933
730
|
const wrapper = mount(VirtualScroll, {
|
|
934
|
-
props: {
|
|
731
|
+
props: {
|
|
732
|
+
itemSize: 50,
|
|
733
|
+
items: mockItems.slice(0, 10),
|
|
734
|
+
},
|
|
935
735
|
});
|
|
936
736
|
await nextTick();
|
|
937
|
-
|
|
938
|
-
vm
|
|
737
|
+
|
|
738
|
+
const vs = wrapper.vm as unknown as { scrollDetails: ScrollDetails<MockItem>; refresh: () => void; };
|
|
739
|
+
vs.refresh();
|
|
939
740
|
await nextTick();
|
|
741
|
+
// Should not crash
|
|
742
|
+
expect(vs.scrollDetails.items.length).toBeGreaterThan(0);
|
|
940
743
|
});
|
|
941
744
|
|
|
942
|
-
it('
|
|
943
|
-
|
|
944
|
-
props: {
|
|
745
|
+
it('handles sticky header and footer measurements', async () => {
|
|
746
|
+
mount(VirtualScroll, {
|
|
747
|
+
props: {
|
|
748
|
+
items: mockItems.slice(0, 10),
|
|
749
|
+
stickyFooter: true,
|
|
750
|
+
stickyHeader: true,
|
|
751
|
+
},
|
|
752
|
+
slots: {
|
|
753
|
+
footer: () => h('div', { class: 'footer', style: 'height: 30px' }, 'FOOTER'),
|
|
754
|
+
header: () => h('div', { class: 'header', style: 'height: 40px' }, 'HEADER'),
|
|
755
|
+
},
|
|
945
756
|
});
|
|
946
757
|
await nextTick();
|
|
947
|
-
await nextTick();
|
|
948
|
-
expect(wrapper.emitted('visibleRangeChange')).toBeDefined();
|
|
949
|
-
|
|
950
|
-
const container = wrapper.find('.virtual-scroll-container').element as HTMLElement;
|
|
951
|
-
Object.defineProperty(container, 'scrollTop', { value: 500, writable: true });
|
|
952
|
-
await container.dispatchEvent(new Event('scroll'));
|
|
953
|
-
await nextTick();
|
|
954
|
-
await nextTick();
|
|
955
|
-
expect(wrapper.emitted('visibleRangeChange')!.length).toBeGreaterThan(1);
|
|
956
758
|
});
|
|
957
759
|
|
|
958
|
-
it('
|
|
959
|
-
// initialScrollIndex triggers delayed hydration via nextTick in useVirtualScroll
|
|
760
|
+
it('works with window as container', async () => {
|
|
960
761
|
const wrapper = mount(VirtualScroll, {
|
|
961
762
|
props: {
|
|
962
|
-
|
|
763
|
+
container: window,
|
|
963
764
|
itemSize: 50,
|
|
964
|
-
items: mockItems
|
|
765
|
+
items: mockItems,
|
|
965
766
|
},
|
|
966
767
|
});
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
// Changing items will trigger scrollDetails update.
|
|
970
|
-
await wrapper.setProps({ items: mockItems.slice(0, 20) });
|
|
971
|
-
|
|
972
|
-
// Line 196 in VirtualScroll.vue should be hit here (return if !isHydrated)
|
|
973
|
-
expect(wrapper.emitted('scroll')).toBeUndefined();
|
|
974
|
-
|
|
975
|
-
await nextTick(); // hydration tick
|
|
976
|
-
await nextTick(); // one more for good measure
|
|
977
|
-
expect(wrapper.emitted('scroll')).toBeDefined();
|
|
768
|
+
await nextTick();
|
|
769
|
+
expect(wrapper.classes()).toContain('virtual-scroll--window');
|
|
978
770
|
});
|
|
979
771
|
});
|
|
980
772
|
});
|