@pdanpdan/virtual-scroll 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -0
- package/dist/index.css +1 -0
- package/dist/index.js +961 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/components/VirtualScroll.test.ts +912 -0
- package/src/components/VirtualScroll.vue +748 -0
- package/src/composables/useVirtualScroll.test.ts +1214 -0
- package/src/composables/useVirtualScroll.ts +1407 -0
- package/src/index.ts +4 -0
- package/src/utils/fenwick-tree.test.ts +119 -0
- package/src/utils/fenwick-tree.ts +155 -0
- package/src/utils/scroll.ts +59 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
import type {
|
|
3
|
+
RenderedItem,
|
|
4
|
+
ScrollAlignment,
|
|
5
|
+
ScrollAlignmentOptions,
|
|
6
|
+
ScrollDetails,
|
|
7
|
+
VirtualScrollProps,
|
|
8
|
+
} from '../composables/useVirtualScroll.js';
|
|
9
|
+
|
|
10
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
11
|
+
|
|
12
|
+
import { useVirtualScroll } from '../composables/useVirtualScroll.js';
|
|
13
|
+
import { getPaddingX, getPaddingY } from '../utils/scroll.js';
|
|
14
|
+
|
|
15
|
+
export interface Props<T = unknown> {
|
|
16
|
+
/** Array of items to be virtualized. */
|
|
17
|
+
items: T[];
|
|
18
|
+
/** Fixed size of each item or a function that returns the size of an item. Pass 0, null or undefined for dynamic size detection. */
|
|
19
|
+
itemSize?: number | ((item: T, index: number) => number) | null;
|
|
20
|
+
/** Direction of the scroll: 'vertical', 'horizontal', or 'both' (grid). */
|
|
21
|
+
direction?: 'vertical' | 'horizontal' | 'both';
|
|
22
|
+
/** Number of items to render before the visible viewport. */
|
|
23
|
+
bufferBefore?: number;
|
|
24
|
+
/** Number of items to render after the visible viewport. */
|
|
25
|
+
bufferAfter?: number;
|
|
26
|
+
/** The scrollable container element or window. If not provided, the host element is used. */
|
|
27
|
+
container?: HTMLElement | Window | null;
|
|
28
|
+
/** Range of items to render for SSR. */
|
|
29
|
+
ssrRange?: {
|
|
30
|
+
start: number;
|
|
31
|
+
end: number;
|
|
32
|
+
colStart?: number;
|
|
33
|
+
colEnd?: number;
|
|
34
|
+
};
|
|
35
|
+
/** Number of columns for bidirectional (grid) scroll. */
|
|
36
|
+
columnCount?: number;
|
|
37
|
+
/** Fixed width of columns or an array/function for column widths. Pass 0, null or undefined for dynamic width. */
|
|
38
|
+
columnWidth?: number | number[] | ((index: number) => number) | null;
|
|
39
|
+
/** The HTML tag to use for the container. */
|
|
40
|
+
containerTag?: string;
|
|
41
|
+
/** The HTML tag to use for the items wrapper. */
|
|
42
|
+
wrapperTag?: string;
|
|
43
|
+
/** The HTML tag to use for each item. */
|
|
44
|
+
itemTag?: string;
|
|
45
|
+
/** Padding at the start of the scroll container. */
|
|
46
|
+
scrollPaddingStart?: number | { x?: number; y?: number; };
|
|
47
|
+
/** Padding at the end of the scroll container. */
|
|
48
|
+
scrollPaddingEnd?: number | { x?: number; y?: number; };
|
|
49
|
+
/** Whether the header slot content is sticky and should be accounted for in scroll padding. If true, header size is automatically measured. */
|
|
50
|
+
stickyHeader?: boolean;
|
|
51
|
+
/** Whether the footer slot content is sticky and should be accounted for in scroll padding. If true, footer size is automatically measured. */
|
|
52
|
+
stickyFooter?: boolean;
|
|
53
|
+
/** Gap between items in pixels (vertical). */
|
|
54
|
+
gap?: number;
|
|
55
|
+
/** Gap between columns in pixels (horizontal/grid). */
|
|
56
|
+
columnGap?: number;
|
|
57
|
+
/** Indices of items that should stick to the top/start. Supports iOS-style pushing effect. */
|
|
58
|
+
stickyIndices?: number[];
|
|
59
|
+
/** Distance from the end of the scrollable area to trigger 'load' event in pixels. */
|
|
60
|
+
loadDistance?: number;
|
|
61
|
+
/** Whether items are currently being loaded. Prevents multiple 'load' events and shows 'loading' slot. */
|
|
62
|
+
loading?: boolean;
|
|
63
|
+
/** Whether to automatically restore scroll position when items are prepended to the list. */
|
|
64
|
+
restoreScrollOnPrepend?: boolean;
|
|
65
|
+
/** Initial scroll index to jump to on mount. */
|
|
66
|
+
initialScrollIndex?: number;
|
|
67
|
+
/** Alignment for the initial scroll index. */
|
|
68
|
+
initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions;
|
|
69
|
+
/** Default size for items before they are measured. */
|
|
70
|
+
defaultItemSize?: number;
|
|
71
|
+
/** Default width for columns before they are measured. */
|
|
72
|
+
defaultColumnWidth?: number;
|
|
73
|
+
/** Whether to show debug information (buffers and offsets). */
|
|
74
|
+
debug?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const props = withDefaults(defineProps<Props<T>>(), {
|
|
78
|
+
direction: 'vertical',
|
|
79
|
+
bufferBefore: 5,
|
|
80
|
+
bufferAfter: 5,
|
|
81
|
+
columnCount: 0,
|
|
82
|
+
containerTag: 'div',
|
|
83
|
+
wrapperTag: 'div',
|
|
84
|
+
itemTag: 'div',
|
|
85
|
+
scrollPaddingStart: 0,
|
|
86
|
+
scrollPaddingEnd: 0,
|
|
87
|
+
stickyHeader: false,
|
|
88
|
+
stickyFooter: false,
|
|
89
|
+
gap: 0,
|
|
90
|
+
columnGap: 0,
|
|
91
|
+
stickyIndices: () => [],
|
|
92
|
+
loadDistance: 50,
|
|
93
|
+
loading: false,
|
|
94
|
+
restoreScrollOnPrepend: false,
|
|
95
|
+
debug: false,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const emit = defineEmits<{
|
|
99
|
+
(e: 'scroll', details: ScrollDetails<T>): void;
|
|
100
|
+
(e: 'load', direction: 'vertical' | 'horizontal'): void;
|
|
101
|
+
(e: 'visibleRangeChange', range: { start: number; end: number; colStart: number; colEnd: number; }): void;
|
|
102
|
+
}>();
|
|
103
|
+
|
|
104
|
+
const isDebug = computed(() => props.debug);
|
|
105
|
+
|
|
106
|
+
const hostRef = ref<HTMLElement | null>(null);
|
|
107
|
+
const wrapperRef = ref<HTMLElement | null>(null);
|
|
108
|
+
const headerRef = ref<HTMLElement | null>(null);
|
|
109
|
+
const footerRef = ref<HTMLElement | null>(null);
|
|
110
|
+
const itemRefs = new Map<number, HTMLElement>();
|
|
111
|
+
|
|
112
|
+
const measuredPaddingStart = ref(0);
|
|
113
|
+
const measuredPaddingEnd = ref(0);
|
|
114
|
+
|
|
115
|
+
const isHeaderFooterInsideContainer = computed(() => {
|
|
116
|
+
const container = props.container === undefined
|
|
117
|
+
? hostRef.value
|
|
118
|
+
: props.container;
|
|
119
|
+
|
|
120
|
+
return container === hostRef.value
|
|
121
|
+
|| (typeof window !== 'undefined' && (container === window || container === null));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const virtualScrollProps = computed(() => {
|
|
125
|
+
const pStart = props.scrollPaddingStart;
|
|
126
|
+
const pEnd = props.scrollPaddingEnd;
|
|
127
|
+
|
|
128
|
+
const startX = typeof pStart === 'object'
|
|
129
|
+
? (pStart.x || 0)
|
|
130
|
+
: (props.direction === 'horizontal' ? (pStart || 0) : 0);
|
|
131
|
+
const startY = typeof pStart === 'object'
|
|
132
|
+
? (pStart.y || 0)
|
|
133
|
+
: (props.direction !== 'horizontal' ? (pStart || 0) : 0);
|
|
134
|
+
|
|
135
|
+
const endX = typeof pEnd === 'object'
|
|
136
|
+
? (pEnd.x || 0)
|
|
137
|
+
: (props.direction === 'horizontal' ? (pEnd || 0) : 0);
|
|
138
|
+
const endY = typeof pEnd === 'object'
|
|
139
|
+
? (pEnd.y || 0)
|
|
140
|
+
: (props.direction !== 'horizontal' ? (pEnd || 0) : 0);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
items: props.items,
|
|
144
|
+
itemSize: props.itemSize,
|
|
145
|
+
direction: props.direction,
|
|
146
|
+
bufferBefore: props.bufferBefore,
|
|
147
|
+
bufferAfter: props.bufferAfter,
|
|
148
|
+
container: props.container === undefined
|
|
149
|
+
? hostRef.value
|
|
150
|
+
: props.container,
|
|
151
|
+
hostElement: wrapperRef.value,
|
|
152
|
+
ssrRange: props.ssrRange,
|
|
153
|
+
columnCount: props.columnCount,
|
|
154
|
+
columnWidth: props.columnWidth,
|
|
155
|
+
scrollPaddingStart: {
|
|
156
|
+
x: startX,
|
|
157
|
+
y: startY + (props.stickyHeader && isHeaderFooterInsideContainer.value ? measuredPaddingStart.value : 0),
|
|
158
|
+
},
|
|
159
|
+
scrollPaddingEnd: {
|
|
160
|
+
x: endX,
|
|
161
|
+
y: endY + (props.stickyFooter && isHeaderFooterInsideContainer.value ? measuredPaddingEnd.value : 0),
|
|
162
|
+
},
|
|
163
|
+
gap: props.gap,
|
|
164
|
+
columnGap: props.columnGap,
|
|
165
|
+
stickyIndices: props.stickyIndices,
|
|
166
|
+
loadDistance: props.loadDistance,
|
|
167
|
+
loading: props.loading,
|
|
168
|
+
restoreScrollOnPrepend: props.restoreScrollOnPrepend,
|
|
169
|
+
initialScrollIndex: props.initialScrollIndex,
|
|
170
|
+
initialScrollAlign: props.initialScrollAlign,
|
|
171
|
+
defaultItemSize: props.defaultItemSize,
|
|
172
|
+
defaultColumnWidth: props.defaultColumnWidth,
|
|
173
|
+
debug: isDebug.value,
|
|
174
|
+
} as VirtualScrollProps<T>;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const {
|
|
178
|
+
isHydrated,
|
|
179
|
+
columnRange,
|
|
180
|
+
renderedItems,
|
|
181
|
+
scrollDetails,
|
|
182
|
+
totalHeight,
|
|
183
|
+
totalWidth,
|
|
184
|
+
getColumnWidth,
|
|
185
|
+
scrollToIndex,
|
|
186
|
+
scrollToOffset,
|
|
187
|
+
updateHostOffset,
|
|
188
|
+
updateItemSizes,
|
|
189
|
+
refresh,
|
|
190
|
+
stopProgrammaticScroll,
|
|
191
|
+
} = useVirtualScroll(virtualScrollProps);
|
|
192
|
+
|
|
193
|
+
// Watch for scroll details and emit event
|
|
194
|
+
watch(scrollDetails, (details, oldDetails) => {
|
|
195
|
+
if (!isHydrated.value) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
emit('scroll', details);
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
!oldDetails
|
|
202
|
+
|| details.range.start !== oldDetails.range.start
|
|
203
|
+
|| details.range.end !== oldDetails.range.end
|
|
204
|
+
|| details.columnRange.start !== oldDetails.columnRange.start
|
|
205
|
+
|| details.columnRange.end !== oldDetails.columnRange.end
|
|
206
|
+
) {
|
|
207
|
+
emit('visibleRangeChange', {
|
|
208
|
+
start: details.range.start,
|
|
209
|
+
end: details.range.end,
|
|
210
|
+
colStart: details.columnRange.start,
|
|
211
|
+
colEnd: details.columnRange.end,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (props.loading) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// vertical or both
|
|
220
|
+
if (props.direction !== 'horizontal') {
|
|
221
|
+
const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
|
|
222
|
+
if (remaining <= props.loadDistance) {
|
|
223
|
+
emit('load', 'vertical');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// horizontal or both
|
|
227
|
+
if (props.direction !== 'vertical') {
|
|
228
|
+
const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
|
|
229
|
+
if (remaining <= props.loadDistance) {
|
|
230
|
+
emit('load', 'horizontal');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
watch(isHydrated, (hydrated) => {
|
|
236
|
+
if (hydrated) {
|
|
237
|
+
emit('visibleRangeChange', {
|
|
238
|
+
start: scrollDetails.value.range.start,
|
|
239
|
+
end: scrollDetails.value.range.end,
|
|
240
|
+
colStart: scrollDetails.value.columnRange.start,
|
|
241
|
+
colEnd: scrollDetails.value.columnRange.end,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}, { once: true });
|
|
245
|
+
|
|
246
|
+
const hostResizeObserver = typeof window === 'undefined'
|
|
247
|
+
? null
|
|
248
|
+
: new ResizeObserver(updateHostOffset);
|
|
249
|
+
|
|
250
|
+
const itemResizeObserver = typeof window === 'undefined'
|
|
251
|
+
? null
|
|
252
|
+
: new ResizeObserver((entries) => {
|
|
253
|
+
const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
|
|
254
|
+
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
const target = entry.target as HTMLElement;
|
|
257
|
+
const index = Number(target.dataset.index);
|
|
258
|
+
const colIndex = target.dataset.colIndex;
|
|
259
|
+
|
|
260
|
+
if (colIndex !== undefined) {
|
|
261
|
+
// It's a cell measurement. row index is not strictly needed for column width.
|
|
262
|
+
// We use -1 as a placeholder for row index if it's a cell measurement.
|
|
263
|
+
updates.push({ index: -1, inlineSize: 0, blockSize: 0, element: target });
|
|
264
|
+
} else if (!Number.isNaN(index)) {
|
|
265
|
+
let inlineSize = entry.contentRect.width;
|
|
266
|
+
let blockSize = entry.contentRect.height;
|
|
267
|
+
|
|
268
|
+
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
|
269
|
+
inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
|
|
270
|
+
blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
|
|
271
|
+
} else {
|
|
272
|
+
// Fallback for older browsers or if borderBoxSize is missing
|
|
273
|
+
inlineSize = target.offsetWidth;
|
|
274
|
+
blockSize = target.offsetHeight;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updates.push({ index, inlineSize, blockSize, element: target });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (updates.length > 0) {
|
|
282
|
+
updateItemSizes(updates);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const extraResizeObserver = typeof window === 'undefined'
|
|
287
|
+
? null
|
|
288
|
+
: new ResizeObserver(() => {
|
|
289
|
+
measuredPaddingStart.value = headerRef.value?.offsetHeight || 0;
|
|
290
|
+
measuredPaddingEnd.value = footerRef.value?.offsetHeight || 0;
|
|
291
|
+
updateHostOffset();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
watch(headerRef, (newEl, oldEl) => {
|
|
295
|
+
if (oldEl) {
|
|
296
|
+
extraResizeObserver?.unobserve(oldEl);
|
|
297
|
+
}
|
|
298
|
+
if (newEl) {
|
|
299
|
+
extraResizeObserver?.observe(newEl);
|
|
300
|
+
}
|
|
301
|
+
}, { immediate: true });
|
|
302
|
+
|
|
303
|
+
watch(footerRef, (newEl, oldEl) => {
|
|
304
|
+
if (oldEl) {
|
|
305
|
+
extraResizeObserver?.unobserve(oldEl);
|
|
306
|
+
}
|
|
307
|
+
if (newEl) {
|
|
308
|
+
extraResizeObserver?.observe(newEl);
|
|
309
|
+
}
|
|
310
|
+
}, { immediate: true });
|
|
311
|
+
|
|
312
|
+
const firstRenderedIndex = computed(() => renderedItems.value[ 0 ]?.index);
|
|
313
|
+
|
|
314
|
+
watch(firstRenderedIndex, (newIdx, oldIdx) => {
|
|
315
|
+
if (props.direction === 'both') {
|
|
316
|
+
if (oldIdx !== undefined) {
|
|
317
|
+
const oldEl = itemRefs.get(oldIdx);
|
|
318
|
+
if (oldEl) {
|
|
319
|
+
oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (newIdx !== undefined) {
|
|
323
|
+
const newEl = itemRefs.get(newIdx);
|
|
324
|
+
if (newEl) {
|
|
325
|
+
newEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}, { flush: 'post' });
|
|
330
|
+
|
|
331
|
+
onMounted(() => {
|
|
332
|
+
if (hostRef.value) {
|
|
333
|
+
hostResizeObserver?.observe(hostRef.value);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Re-observe items that were set before observer was ready
|
|
337
|
+
for (const el of itemRefs.values()) {
|
|
338
|
+
itemResizeObserver?.observe(el);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Observe cells of the first rendered item
|
|
342
|
+
if (firstRenderedIndex.value !== undefined) {
|
|
343
|
+
const el = itemRefs.get(firstRenderedIndex.value);
|
|
344
|
+
if (el) {
|
|
345
|
+
el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
watch([ hostRef, wrapperRef ], ([ newHost ], [ oldHost ]) => {
|
|
351
|
+
if (oldHost) {
|
|
352
|
+
hostResizeObserver?.unobserve(oldHost);
|
|
353
|
+
}
|
|
354
|
+
if (newHost) {
|
|
355
|
+
hostResizeObserver?.observe(newHost);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
function setItemRef(el: unknown, index: number) {
|
|
360
|
+
if (el) {
|
|
361
|
+
itemRefs.set(index, el as HTMLElement);
|
|
362
|
+
itemResizeObserver?.observe(el as HTMLElement);
|
|
363
|
+
} else {
|
|
364
|
+
const oldEl = itemRefs.get(index);
|
|
365
|
+
if (oldEl) {
|
|
366
|
+
itemResizeObserver?.unobserve(oldEl);
|
|
367
|
+
itemRefs.delete(index);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
373
|
+
stopProgrammaticScroll();
|
|
374
|
+
const { viewportSize, scrollOffset } = scrollDetails.value;
|
|
375
|
+
const isHorizontal = props.direction !== 'vertical';
|
|
376
|
+
const isVertical = props.direction !== 'horizontal';
|
|
377
|
+
|
|
378
|
+
if (event.key === 'Home') {
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
scrollToIndex(0, 0, 'start');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (event.key === 'End') {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
const lastItemIndex = props.items.length - 1;
|
|
386
|
+
const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
|
|
387
|
+
|
|
388
|
+
if (isHorizontal && isVertical) {
|
|
389
|
+
scrollToIndex(lastItemIndex, lastColIndex, 'end');
|
|
390
|
+
} else {
|
|
391
|
+
scrollToIndex(0, lastItemIndex, 'end');
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (event.key === 'ArrowUp') {
|
|
396
|
+
event.preventDefault();
|
|
397
|
+
scrollToOffset(null, scrollOffset.y - 40);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (event.key === 'ArrowDown') {
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
scrollToOffset(null, scrollOffset.y + 40);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (event.key === 'ArrowLeft') {
|
|
406
|
+
event.preventDefault();
|
|
407
|
+
scrollToOffset(scrollOffset.x - 40, null);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (event.key === 'ArrowRight') {
|
|
411
|
+
event.preventDefault();
|
|
412
|
+
scrollToOffset(scrollOffset.x + 40, null);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (event.key === 'PageUp') {
|
|
416
|
+
event.preventDefault();
|
|
417
|
+
scrollToOffset(
|
|
418
|
+
isHorizontal ? scrollOffset.x - viewportSize.width : null,
|
|
419
|
+
isVertical ? scrollOffset.y - viewportSize.height : null,
|
|
420
|
+
);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (event.key === 'PageDown') {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
scrollToOffset(
|
|
426
|
+
isHorizontal ? scrollOffset.x + viewportSize.width : null,
|
|
427
|
+
isVertical ? scrollOffset.y + viewportSize.height : null,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
onUnmounted(() => {
|
|
433
|
+
hostResizeObserver?.disconnect();
|
|
434
|
+
itemResizeObserver?.disconnect();
|
|
435
|
+
extraResizeObserver?.disconnect();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const isWindowContainer = computed(() => {
|
|
439
|
+
const c = props.container;
|
|
440
|
+
if (
|
|
441
|
+
c === null
|
|
442
|
+
// window
|
|
443
|
+
|| (typeof window !== 'undefined' && c === window)
|
|
444
|
+
) {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// body
|
|
449
|
+
if (c && typeof c === 'object' && 'tagName' in c) {
|
|
450
|
+
return (c as HTMLElement).tagName === 'BODY';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return false;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const containerStyle = computed(() => {
|
|
457
|
+
if (isWindowContainer.value) {
|
|
458
|
+
return {
|
|
459
|
+
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (props.containerTag === 'table') {
|
|
464
|
+
return {
|
|
465
|
+
minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
471
|
+
};
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const wrapperStyle = computed(() => ({
|
|
475
|
+
inlineSize: props.direction === 'vertical' ? '100%' : `${ totalWidth.value }px`,
|
|
476
|
+
blockSize: props.direction === 'horizontal' ? '100%' : `${ totalHeight.value }px`,
|
|
477
|
+
}));
|
|
478
|
+
|
|
479
|
+
const loadingStyle = computed(() => {
|
|
480
|
+
const isHorizontal = props.direction === 'horizontal';
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
display: isHorizontal ? 'inline-block' : 'block',
|
|
484
|
+
...(isHorizontal ? { blockSize: '100%', verticalAlign: 'top' } : { inlineSize: '100%' }),
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const spacerStyle = computed(() => ({
|
|
489
|
+
inlineSize: props.direction === 'vertical' ? '1px' : `${ totalWidth.value }px`,
|
|
490
|
+
blockSize: props.direction === 'horizontal' ? '1px' : `${ totalHeight.value }px`,
|
|
491
|
+
}));
|
|
492
|
+
|
|
493
|
+
function getItemStyle(item: RenderedItem<T>) {
|
|
494
|
+
const isVertical = props.direction === 'vertical';
|
|
495
|
+
const isHorizontal = props.direction === 'horizontal';
|
|
496
|
+
const isBoth = props.direction === 'both';
|
|
497
|
+
const isDynamic = props.itemSize === undefined || props.itemSize === null || props.itemSize === 0;
|
|
498
|
+
|
|
499
|
+
const style: Record<string, string | number | undefined> = {
|
|
500
|
+
blockSize: isHorizontal ? '100%' : (!isDynamic ? `${ item.size.height }px` : 'auto'),
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
if (isVertical && props.containerTag === 'table') {
|
|
504
|
+
style.minInlineSize = '100%';
|
|
505
|
+
} else {
|
|
506
|
+
style.inlineSize = isVertical ? '100%' : (!isDynamic ? `${ item.size.width }px` : 'auto');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (isDynamic) {
|
|
510
|
+
if (!isVertical) {
|
|
511
|
+
style.minInlineSize = `${ item.size.width }px`;
|
|
512
|
+
}
|
|
513
|
+
if (!isHorizontal) {
|
|
514
|
+
style.minBlockSize = `${ item.size.height }px`;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (isHydrated.value) {
|
|
519
|
+
if (item.isStickyActive) {
|
|
520
|
+
if (isVertical || isBoth) {
|
|
521
|
+
style.insetBlockStart = `${ getPaddingY(props.scrollPaddingStart, props.direction) }px`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (isHorizontal || isBoth) {
|
|
525
|
+
style.insetInlineStart = `${ getPaddingX(props.scrollPaddingStart, props.direction) }px`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
style.transform = `translate(${ item.stickyOffset.x }px, ${ item.stickyOffset.y }px)`;
|
|
529
|
+
} else {
|
|
530
|
+
style.transform = `translate(${ item.offset.x }px, ${ item.offset.y }px)`;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return style;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
defineExpose({
|
|
538
|
+
scrollDetails,
|
|
539
|
+
columnRange,
|
|
540
|
+
getColumnWidth,
|
|
541
|
+
scrollToIndex,
|
|
542
|
+
scrollToOffset,
|
|
543
|
+
refresh,
|
|
544
|
+
stopProgrammaticScroll,
|
|
545
|
+
});
|
|
546
|
+
</script>
|
|
547
|
+
|
|
548
|
+
<template>
|
|
549
|
+
<component
|
|
550
|
+
:is="containerTag"
|
|
551
|
+
ref="hostRef"
|
|
552
|
+
class="virtual-scroll-container"
|
|
553
|
+
:class="[
|
|
554
|
+
`virtual-scroll--${ direction }`,
|
|
555
|
+
{
|
|
556
|
+
'virtual-scroll--hydrated': isHydrated,
|
|
557
|
+
'virtual-scroll--window': isWindowContainer,
|
|
558
|
+
'virtual-scroll--table': containerTag === 'table',
|
|
559
|
+
},
|
|
560
|
+
]"
|
|
561
|
+
:style="containerStyle"
|
|
562
|
+
tabindex="0"
|
|
563
|
+
@keydown="handleKeyDown"
|
|
564
|
+
@wheel.passive="stopProgrammaticScroll"
|
|
565
|
+
@pointerdown.passive="stopProgrammaticScroll"
|
|
566
|
+
@touchstart.passive="stopProgrammaticScroll"
|
|
567
|
+
>
|
|
568
|
+
<component
|
|
569
|
+
:is="containerTag === 'table' ? 'thead' : 'div'"
|
|
570
|
+
v-if="$slots.header"
|
|
571
|
+
ref="headerRef"
|
|
572
|
+
class="virtual-scroll-header"
|
|
573
|
+
:class="{ 'virtual-scroll--sticky': stickyHeader }"
|
|
574
|
+
>
|
|
575
|
+
<slot name="header" />
|
|
576
|
+
</component>
|
|
577
|
+
|
|
578
|
+
<component
|
|
579
|
+
:is="wrapperTag"
|
|
580
|
+
ref="wrapperRef"
|
|
581
|
+
class="virtual-scroll-wrapper"
|
|
582
|
+
:style="wrapperStyle"
|
|
583
|
+
>
|
|
584
|
+
<!-- Phantom element to push scroll height -->
|
|
585
|
+
<component
|
|
586
|
+
:is="itemTag"
|
|
587
|
+
v-if="containerTag === 'table'"
|
|
588
|
+
class="virtual-scroll-spacer"
|
|
589
|
+
:style="spacerStyle"
|
|
590
|
+
>
|
|
591
|
+
<td style="padding: 0; border: none; block-size: inherit;" />
|
|
592
|
+
</component>
|
|
593
|
+
|
|
594
|
+
<component
|
|
595
|
+
:is="itemTag"
|
|
596
|
+
v-for="renderedItem in renderedItems"
|
|
597
|
+
:key="renderedItem.index"
|
|
598
|
+
:ref="(el: unknown) => setItemRef(el, renderedItem.index)"
|
|
599
|
+
:data-index="renderedItem.index"
|
|
600
|
+
class="virtual-scroll-item"
|
|
601
|
+
:class="{
|
|
602
|
+
'virtual-scroll--sticky': renderedItem.isStickyActive,
|
|
603
|
+
'virtual-scroll--debug': isDebug,
|
|
604
|
+
}"
|
|
605
|
+
:style="getItemStyle(renderedItem)"
|
|
606
|
+
>
|
|
607
|
+
<slot
|
|
608
|
+
name="item"
|
|
609
|
+
:item="renderedItem.item"
|
|
610
|
+
:index="renderedItem.index"
|
|
611
|
+
:column-range="columnRange"
|
|
612
|
+
:get-column-width="getColumnWidth"
|
|
613
|
+
:is-sticky="renderedItem.isSticky"
|
|
614
|
+
:is-sticky-active="renderedItem.isStickyActive"
|
|
615
|
+
/>
|
|
616
|
+
<div v-if="isDebug" class="virtual-scroll-debug-info">
|
|
617
|
+
#{{ renderedItem.index }} ({{ Math.round(renderedItem.offset.x) }}, {{ Math.round(renderedItem.offset.y) }})
|
|
618
|
+
</div>
|
|
619
|
+
</component>
|
|
620
|
+
</component>
|
|
621
|
+
|
|
622
|
+
<div
|
|
623
|
+
v-if="loading && $slots.loading"
|
|
624
|
+
class="virtual-scroll-loading"
|
|
625
|
+
:style="loadingStyle"
|
|
626
|
+
>
|
|
627
|
+
<slot name="loading" />
|
|
628
|
+
</div>
|
|
629
|
+
|
|
630
|
+
<component
|
|
631
|
+
:is="containerTag === 'table' ? 'tfoot' : 'div'"
|
|
632
|
+
v-if="$slots.footer"
|
|
633
|
+
ref="footerRef"
|
|
634
|
+
class="virtual-scroll-footer"
|
|
635
|
+
:class="{ 'virtual-scroll--sticky': stickyFooter }"
|
|
636
|
+
>
|
|
637
|
+
<slot name="footer" />
|
|
638
|
+
</component>
|
|
639
|
+
</component>
|
|
640
|
+
</template>
|
|
641
|
+
|
|
642
|
+
<style scoped>
|
|
643
|
+
.virtual-scroll-container {
|
|
644
|
+
position: relative;
|
|
645
|
+
block-size: 100%;
|
|
646
|
+
inline-size: 100%;
|
|
647
|
+
outline-offset: 1px;
|
|
648
|
+
|
|
649
|
+
&:not(.virtual-scroll--window) {
|
|
650
|
+
overflow: auto;
|
|
651
|
+
overscroll-behavior: contain;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
&.virtual-scroll--table {
|
|
655
|
+
display: block;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.virtual-scroll--horizontal {
|
|
660
|
+
white-space: nowrap;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.virtual-scroll-wrapper {
|
|
664
|
+
contain: layout;
|
|
665
|
+
position: relative;
|
|
666
|
+
|
|
667
|
+
:where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
|
|
668
|
+
position: absolute;
|
|
669
|
+
inset-block-start: 0;
|
|
670
|
+
inset-inline-start: 0;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
.virtual-scroll-item {
|
|
675
|
+
box-sizing: border-box;
|
|
676
|
+
will-change: transform;
|
|
677
|
+
|
|
678
|
+
&:where(.virtual-scroll--debug) {
|
|
679
|
+
outline: 1px dashed rgba(255, 0, 0, 0.5);
|
|
680
|
+
background-color: rgba(255, 0, 0, 0.05);
|
|
681
|
+
|
|
682
|
+
&:where(:hover) {
|
|
683
|
+
background-color: rgba(255, 0, 0, 0.1);
|
|
684
|
+
z-index: 100;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.virtual-scroll-debug-info {
|
|
690
|
+
position: absolute;
|
|
691
|
+
inset-block-start: 2px;
|
|
692
|
+
inset-inline-end: 2px;
|
|
693
|
+
background: rgba(0, 0, 0, 0.7);
|
|
694
|
+
color: white;
|
|
695
|
+
font-size: 10px;
|
|
696
|
+
padding: 2px 4px;
|
|
697
|
+
border-radius: 4px;
|
|
698
|
+
pointer-events: none;
|
|
699
|
+
z-index: 100;
|
|
700
|
+
font-family: monospace;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.virtual-scroll-spacer {
|
|
704
|
+
pointer-events: none;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.virtual-scroll-header,
|
|
708
|
+
.virtual-scroll-footer {
|
|
709
|
+
position: relative;
|
|
710
|
+
z-index: 20;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.virtual-scroll--sticky {
|
|
714
|
+
position: sticky;
|
|
715
|
+
|
|
716
|
+
&:where(.virtual-scroll-header) {
|
|
717
|
+
inset-block-start: 0;
|
|
718
|
+
inset-inline-start: 0;
|
|
719
|
+
min-inline-size: 100%;
|
|
720
|
+
box-sizing: border-box;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
&:where(.virtual-scroll-footer) {
|
|
724
|
+
inset-block-end: 0;
|
|
725
|
+
inset-inline-start: 0;
|
|
726
|
+
min-inline-size: 100%;
|
|
727
|
+
box-sizing: border-box;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
&:where(.virtual-scroll-item) {
|
|
731
|
+
z-index: 10;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
:is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
|
|
736
|
+
display: inline-flex;
|
|
737
|
+
min-inline-size: 100%;
|
|
738
|
+
& > :deep(tr) {
|
|
739
|
+
display: inline-flex;
|
|
740
|
+
min-inline-size: 100%;
|
|
741
|
+
|
|
742
|
+
& > :is(td, th) {
|
|
743
|
+
display: inline-block;
|
|
744
|
+
align-items: center;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
</style>
|