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