@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
|
@@ -5,72 +5,207 @@ import type {
|
|
|
5
5
|
ScrollAlignmentOptions,
|
|
6
6
|
ScrollDetails,
|
|
7
7
|
VirtualScrollProps,
|
|
8
|
-
} from '../
|
|
8
|
+
} from '../types';
|
|
9
|
+
import type { VNodeChild } from 'vue';
|
|
9
10
|
|
|
10
|
-
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
11
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_ITEM_SIZE,
|
|
15
|
+
useVirtualScroll,
|
|
16
|
+
} from '../composables/useVirtualScroll';
|
|
17
|
+
import { isWindowLike } from '../utils/scroll';
|
|
18
|
+
import { calculateItemStyle } from '../utils/virtual-scroll-logic';
|
|
14
19
|
|
|
15
20
|
export interface Props<T = unknown> {
|
|
16
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Array of items to be virtualized.
|
|
23
|
+
* Required.
|
|
24
|
+
*/
|
|
17
25
|
items: T[];
|
|
18
|
-
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fixed size of each item (in pixels) or a function that returns the size of an item.
|
|
29
|
+
* Pass 0, null or undefined for dynamic size detection via ResizeObserver.
|
|
30
|
+
* @default 40
|
|
31
|
+
*/
|
|
19
32
|
itemSize?: number | ((item: T, index: number) => number) | null;
|
|
20
|
-
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Direction of the scroll.
|
|
36
|
+
* - 'vertical': Standard vertical list.
|
|
37
|
+
* - 'horizontal': Standard horizontal list.
|
|
38
|
+
* - 'both': Grid mode virtualizing both rows and columns.
|
|
39
|
+
* @default 'vertical'
|
|
40
|
+
*/
|
|
21
41
|
direction?: 'vertical' | 'horizontal' | 'both';
|
|
22
|
-
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Number of items to render before the visible viewport.
|
|
45
|
+
* Useful for smoother scrolling and keyboard navigation.
|
|
46
|
+
* @default 5
|
|
47
|
+
*/
|
|
23
48
|
bufferBefore?: number;
|
|
24
|
-
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Number of items to render after the visible viewport.
|
|
52
|
+
* @default 5
|
|
53
|
+
*/
|
|
25
54
|
bufferAfter?: number;
|
|
26
|
-
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The scrollable container element or window.
|
|
58
|
+
* If not provided, the host element (root of VirtualScroll) is used.
|
|
59
|
+
* @default hostRef
|
|
60
|
+
*/
|
|
27
61
|
container?: HTMLElement | Window | null;
|
|
28
|
-
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Range of items to render during Server-Side Rendering.
|
|
65
|
+
* When provided, these items will be rendered in-flow before hydration.
|
|
66
|
+
* @see SSRRange
|
|
67
|
+
*/
|
|
29
68
|
ssrRange?: {
|
|
69
|
+
/** First row index to render. */
|
|
30
70
|
start: number;
|
|
71
|
+
/** Last row index to render (exclusive). */
|
|
31
72
|
end: number;
|
|
73
|
+
/** First column index to render (for grid mode). */
|
|
32
74
|
colStart?: number;
|
|
75
|
+
/** Last column index to render (exclusive, for grid mode). */
|
|
33
76
|
colEnd?: number;
|
|
34
77
|
};
|
|
35
|
-
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Number of columns for bidirectional (grid) scroll.
|
|
81
|
+
* Only applicable when direction="both".
|
|
82
|
+
* @default 0
|
|
83
|
+
*/
|
|
36
84
|
columnCount?: number;
|
|
37
|
-
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fixed width of columns (in pixels), an array of widths, or a function for column widths.
|
|
88
|
+
* Pass 0, null or undefined for dynamic width detection via ResizeObserver.
|
|
89
|
+
* Only applicable when direction="both".
|
|
90
|
+
* @default 100
|
|
91
|
+
*/
|
|
38
92
|
columnWidth?: number | number[] | ((index: number) => number) | null;
|
|
39
|
-
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The HTML tag to use for the root container.
|
|
96
|
+
* @default 'div'
|
|
97
|
+
*/
|
|
40
98
|
containerTag?: string;
|
|
41
|
-
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The HTML tag to use for the items wrapper.
|
|
102
|
+
* Useful for <table> integration (e.g. 'tbody').
|
|
103
|
+
* @default 'div'
|
|
104
|
+
*/
|
|
42
105
|
wrapperTag?: string;
|
|
43
|
-
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The HTML tag to use for each item.
|
|
109
|
+
* Useful for <table> integration (e.g. 'tr').
|
|
110
|
+
* @default 'div'
|
|
111
|
+
*/
|
|
44
112
|
itemTag?: string;
|
|
45
|
-
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Additional padding at the start of the scroll container (top or left).
|
|
116
|
+
* Can be a number (applied to current direction) or an object with x/y.
|
|
117
|
+
* @default 0
|
|
118
|
+
*/
|
|
46
119
|
scrollPaddingStart?: number | { x?: number; y?: number; };
|
|
47
|
-
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Additional padding at the end of the scroll container (bottom or right).
|
|
123
|
+
* @default 0
|
|
124
|
+
*/
|
|
48
125
|
scrollPaddingEnd?: number | { x?: number; y?: number; };
|
|
49
|
-
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether the content in the 'header' slot is sticky.
|
|
129
|
+
* If true, the header size is measured and accounted for in scroll padding.
|
|
130
|
+
* @default false
|
|
131
|
+
*/
|
|
50
132
|
stickyHeader?: boolean;
|
|
51
|
-
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether the content in the 'footer' slot is sticky.
|
|
136
|
+
* @default false
|
|
137
|
+
*/
|
|
52
138
|
stickyFooter?: boolean;
|
|
53
|
-
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Gap between items in pixels (vertical gap in vertical/grid mode, horizontal gap in horizontal mode).
|
|
142
|
+
* @default 0
|
|
143
|
+
*/
|
|
54
144
|
gap?: number;
|
|
55
|
-
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Gap between columns in pixels. Only applicable when direction="both" or "horizontal".
|
|
148
|
+
* @default 0
|
|
149
|
+
*/
|
|
56
150
|
columnGap?: number;
|
|
57
|
-
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Indices of items that should stick to the top/start of the viewport.
|
|
154
|
+
* Supports iOS-style pushing effect where the next sticky item pushes the previous one.
|
|
155
|
+
* @default []
|
|
156
|
+
*/
|
|
58
157
|
stickyIndices?: number[];
|
|
59
|
-
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Distance from the end of the scrollable area (in pixels) to trigger the 'load' event.
|
|
161
|
+
* @default 200
|
|
162
|
+
*/
|
|
60
163
|
loadDistance?: number;
|
|
61
|
-
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether items are currently being loaded.
|
|
167
|
+
* Prevents multiple 'load' events from triggering and shows the 'loading' slot.
|
|
168
|
+
* @default false
|
|
169
|
+
*/
|
|
62
170
|
loading?: boolean;
|
|
63
|
-
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether to automatically restore and maintain scroll position when items are prepended to the list.
|
|
174
|
+
* Perfect for chat applications.
|
|
175
|
+
* @default false
|
|
176
|
+
*/
|
|
64
177
|
restoreScrollOnPrepend?: boolean;
|
|
65
|
-
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Initial scroll index to jump to immediately after mount.
|
|
181
|
+
*/
|
|
66
182
|
initialScrollIndex?: number;
|
|
67
|
-
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Alignment for the initial scroll index.
|
|
186
|
+
* @default 'start'
|
|
187
|
+
* @see ScrollAlignment
|
|
188
|
+
*/
|
|
68
189
|
initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions;
|
|
69
|
-
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Default size for items before they are measured by ResizeObserver.
|
|
193
|
+
* Only used when itemSize is dynamic.
|
|
194
|
+
* @default 40
|
|
195
|
+
*/
|
|
70
196
|
defaultItemSize?: number;
|
|
71
|
-
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Default width for columns before they are measured by ResizeObserver.
|
|
200
|
+
* Only used when columnWidth is dynamic.
|
|
201
|
+
* @default 100
|
|
202
|
+
*/
|
|
72
203
|
defaultColumnWidth?: number;
|
|
73
|
-
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether to show debug information (visible offsets and indices) over items.
|
|
207
|
+
* @default false
|
|
208
|
+
*/
|
|
74
209
|
debug?: boolean;
|
|
75
210
|
}
|
|
76
211
|
|
|
@@ -89,7 +224,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
89
224
|
gap: 0,
|
|
90
225
|
columnGap: 0,
|
|
91
226
|
stickyIndices: () => [],
|
|
92
|
-
loadDistance:
|
|
227
|
+
loadDistance: 200,
|
|
93
228
|
loading: false,
|
|
94
229
|
restoreScrollOnPrepend: false,
|
|
95
230
|
debug: false,
|
|
@@ -101,7 +236,58 @@ const emit = defineEmits<{
|
|
|
101
236
|
(e: 'visibleRangeChange', range: { start: number; end: number; colStart: number; colEnd: number; }): void;
|
|
102
237
|
}>();
|
|
103
238
|
|
|
104
|
-
const
|
|
239
|
+
const slots = defineSlots<{
|
|
240
|
+
/**
|
|
241
|
+
* Content rendered at the top of the scrollable area.
|
|
242
|
+
* Can be made sticky using the `stickyHeader` prop.
|
|
243
|
+
*/
|
|
244
|
+
header?: (props: Record<string, never>) => VNodeChild;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Scoped slot for rendering each individual item.
|
|
248
|
+
*/
|
|
249
|
+
item?: (props: {
|
|
250
|
+
/** The original data item from the `items` array. */
|
|
251
|
+
item: T;
|
|
252
|
+
/** The original index of the item in the `items` array. */
|
|
253
|
+
index: number;
|
|
254
|
+
/**
|
|
255
|
+
* Information about the current visible range of columns (for grid mode).
|
|
256
|
+
* @see ColumnRange
|
|
257
|
+
*/
|
|
258
|
+
columnRange: {
|
|
259
|
+
/** Index of the first rendered column. */
|
|
260
|
+
start: number;
|
|
261
|
+
/** Index of the last rendered column (exclusive). */
|
|
262
|
+
end: number;
|
|
263
|
+
/** Pixel offset from the start of the row to the first rendered cell. */
|
|
264
|
+
padStart: number;
|
|
265
|
+
/** Pixel offset from the last rendered cell to the end of the row. */
|
|
266
|
+
padEnd: number;
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Helper function to get the width of a specific column.
|
|
270
|
+
* Useful for setting consistent widths in grid mode.
|
|
271
|
+
*/
|
|
272
|
+
getColumnWidth: (index: number) => number;
|
|
273
|
+
/** Whether this item is configured to be sticky via `stickyIndices`. */
|
|
274
|
+
isSticky?: boolean | undefined;
|
|
275
|
+
/** Whether this item is currently in a sticky state (stuck at the top/start). */
|
|
276
|
+
isStickyActive?: boolean | undefined;
|
|
277
|
+
}) => VNodeChild;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Content shown at the end of the list when the `loading` prop is true.
|
|
281
|
+
* Also prevents additional 'load' events from triggering while visible.
|
|
282
|
+
*/
|
|
283
|
+
loading?: (props: Record<string, never>) => VNodeChild;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Content rendered at the bottom of the scrollable area.
|
|
287
|
+
* Can be made sticky using the `stickyFooter` prop.
|
|
288
|
+
*/
|
|
289
|
+
footer?: (props: Record<string, never>) => VNodeChild;
|
|
290
|
+
}>();
|
|
105
291
|
|
|
106
292
|
const hostRef = ref<HTMLElement | null>(null);
|
|
107
293
|
const wrapperRef = ref<HTMLElement | null>(null);
|
|
@@ -125,21 +311,23 @@ const virtualScrollProps = computed(() => {
|
|
|
125
311
|
const pStart = props.scrollPaddingStart;
|
|
126
312
|
const pEnd = props.scrollPaddingEnd;
|
|
127
313
|
|
|
128
|
-
/*
|
|
314
|
+
/* Trigger re-evaluation on items array mutations */
|
|
315
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
316
|
+
props.items.length;
|
|
317
|
+
|
|
129
318
|
const startX = typeof pStart === 'object'
|
|
130
319
|
? (pStart.x || 0)
|
|
131
|
-
: (props.direction === 'horizontal' ? (pStart || 0) : 0);
|
|
320
|
+
: ((props.direction === 'horizontal' || props.direction === 'both') ? (pStart || 0) : 0);
|
|
132
321
|
const startY = typeof pStart === 'object'
|
|
133
322
|
? (pStart.y || 0)
|
|
134
|
-
: (props.direction
|
|
323
|
+
: ((props.direction === 'vertical' || props.direction === 'both') ? (pStart || 0) : 0);
|
|
135
324
|
|
|
136
325
|
const endX = typeof pEnd === 'object'
|
|
137
326
|
? (pEnd.x || 0)
|
|
138
|
-
: (props.direction === 'horizontal' ? (pEnd || 0) : 0);
|
|
327
|
+
: ((props.direction === 'horizontal' || props.direction === 'both') ? (pEnd || 0) : 0);
|
|
139
328
|
const endY = typeof pEnd === 'object'
|
|
140
329
|
? (pEnd.y || 0)
|
|
141
|
-
: (props.direction
|
|
142
|
-
/* v8 ignore stop -- @preserve */
|
|
330
|
+
: ((props.direction === 'vertical' || props.direction === 'both') ? (pEnd || 0) : 0);
|
|
143
331
|
|
|
144
332
|
return {
|
|
145
333
|
items: props.items,
|
|
@@ -172,7 +360,7 @@ const virtualScrollProps = computed(() => {
|
|
|
172
360
|
initialScrollAlign: props.initialScrollAlign,
|
|
173
361
|
defaultItemSize: props.defaultItemSize,
|
|
174
362
|
defaultColumnWidth: props.defaultColumnWidth,
|
|
175
|
-
debug:
|
|
363
|
+
debug: props.debug,
|
|
176
364
|
} as VirtualScrollProps<T>;
|
|
177
365
|
});
|
|
178
366
|
|
|
@@ -188,10 +376,36 @@ const {
|
|
|
188
376
|
scrollToOffset,
|
|
189
377
|
updateHostOffset,
|
|
190
378
|
updateItemSizes,
|
|
191
|
-
refresh,
|
|
379
|
+
refresh: coreRefresh,
|
|
192
380
|
stopProgrammaticScroll,
|
|
193
381
|
} = useVirtualScroll(virtualScrollProps);
|
|
194
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Resets all dynamic measurements and re-initializes from props.
|
|
385
|
+
* Also triggers manual re-measurement of all currently rendered items.
|
|
386
|
+
*/
|
|
387
|
+
function refresh() {
|
|
388
|
+
coreRefresh();
|
|
389
|
+
nextTick(() => {
|
|
390
|
+
const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
|
|
391
|
+
|
|
392
|
+
for (const [ index, el ] of itemRefs.entries()) {
|
|
393
|
+
if (el) {
|
|
394
|
+
updates.push({
|
|
395
|
+
index,
|
|
396
|
+
inlineSize: el.offsetWidth,
|
|
397
|
+
blockSize: el.offsetHeight,
|
|
398
|
+
element: el,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (updates.length > 0) {
|
|
404
|
+
updateItemSizes(updates);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
195
409
|
// Watch for scroll details and emit event
|
|
196
410
|
watch(scrollDetails, (details, oldDetails) => {
|
|
197
411
|
if (!isHydrated.value) {
|
|
@@ -235,7 +449,6 @@ watch(scrollDetails, (details, oldDetails) => {
|
|
|
235
449
|
});
|
|
236
450
|
|
|
237
451
|
watch(isHydrated, (hydrated) => {
|
|
238
|
-
/* v8 ignore else -- @preserve */
|
|
239
452
|
if (hydrated) {
|
|
240
453
|
emit('visibleRangeChange', {
|
|
241
454
|
start: scrollDetails.value.range.start,
|
|
@@ -246,12 +459,10 @@ watch(isHydrated, (hydrated) => {
|
|
|
246
459
|
}
|
|
247
460
|
}, { once: true });
|
|
248
461
|
|
|
249
|
-
/* v8 ignore next 2 -- @preserve */
|
|
250
462
|
const hostResizeObserver = typeof window === 'undefined'
|
|
251
463
|
? null
|
|
252
464
|
: new ResizeObserver(updateHostOffset);
|
|
253
465
|
|
|
254
|
-
/* v8 ignore next 2 -- @preserve */
|
|
255
466
|
const itemResizeObserver = typeof window === 'undefined'
|
|
256
467
|
? null
|
|
257
468
|
: new ResizeObserver((entries) => {
|
|
@@ -262,34 +473,32 @@ const itemResizeObserver = typeof window === 'undefined'
|
|
|
262
473
|
const index = Number(target.dataset.index);
|
|
263
474
|
const colIndex = target.dataset.colIndex;
|
|
264
475
|
|
|
476
|
+
let inlineSize = entry.contentRect.width;
|
|
477
|
+
let blockSize = entry.contentRect.height;
|
|
478
|
+
|
|
479
|
+
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
|
480
|
+
inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
|
|
481
|
+
blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
|
|
482
|
+
} else {
|
|
483
|
+
// Fallback for older browsers or if borderBoxSize is missing
|
|
484
|
+
inlineSize = target.offsetWidth;
|
|
485
|
+
blockSize = target.offsetHeight;
|
|
486
|
+
}
|
|
487
|
+
|
|
265
488
|
if (colIndex !== undefined) {
|
|
266
489
|
// It's a cell measurement. row index is not strictly needed for column width.
|
|
267
490
|
// We use -1 as a placeholder for row index if it's a cell measurement.
|
|
268
|
-
updates.push({ index: -1, inlineSize
|
|
491
|
+
updates.push({ index: -1, inlineSize, blockSize, element: target });
|
|
269
492
|
} else if (!Number.isNaN(index)) {
|
|
270
|
-
let inlineSize = entry.contentRect.width;
|
|
271
|
-
let blockSize = entry.contentRect.height;
|
|
272
|
-
|
|
273
|
-
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
|
274
|
-
inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
|
|
275
|
-
blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
|
|
276
|
-
} else {
|
|
277
|
-
// Fallback for older browsers or if borderBoxSize is missing
|
|
278
|
-
inlineSize = target.offsetWidth;
|
|
279
|
-
blockSize = target.offsetHeight;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
493
|
updates.push({ index, inlineSize, blockSize, element: target });
|
|
283
494
|
}
|
|
284
495
|
}
|
|
285
496
|
|
|
286
|
-
/* v8 ignore else -- @preserve */
|
|
287
497
|
if (updates.length > 0) {
|
|
288
498
|
updateItemSizes(updates);
|
|
289
499
|
}
|
|
290
500
|
});
|
|
291
501
|
|
|
292
|
-
/* v8 ignore next 2 -- @preserve */
|
|
293
502
|
const extraResizeObserver = typeof window === 'undefined'
|
|
294
503
|
? null
|
|
295
504
|
: new ResizeObserver(() => {
|
|
@@ -299,7 +508,6 @@ const extraResizeObserver = typeof window === 'undefined'
|
|
|
299
508
|
});
|
|
300
509
|
|
|
301
510
|
watch(headerRef, (newEl, oldEl) => {
|
|
302
|
-
/* v8 ignore if -- @preserve */
|
|
303
511
|
if (oldEl) {
|
|
304
512
|
extraResizeObserver?.unobserve(oldEl);
|
|
305
513
|
}
|
|
@@ -309,7 +517,6 @@ watch(headerRef, (newEl, oldEl) => {
|
|
|
309
517
|
}, { immediate: true });
|
|
310
518
|
|
|
311
519
|
watch(footerRef, (newEl, oldEl) => {
|
|
312
|
-
/* v8 ignore if -- @preserve */
|
|
313
520
|
if (oldEl) {
|
|
314
521
|
extraResizeObserver?.unobserve(oldEl);
|
|
315
522
|
}
|
|
@@ -318,28 +525,7 @@ watch(footerRef, (newEl, oldEl) => {
|
|
|
318
525
|
}
|
|
319
526
|
}, { immediate: true });
|
|
320
527
|
|
|
321
|
-
const firstRenderedIndex = computed(() => renderedItems.value[ 0 ]?.index);
|
|
322
|
-
watch(firstRenderedIndex, (newIdx, oldIdx) => {
|
|
323
|
-
if (props.direction === 'both') {
|
|
324
|
-
/* v8 ignore else -- @preserve */
|
|
325
|
-
if (oldIdx !== undefined) {
|
|
326
|
-
const oldEl = itemRefs.get(oldIdx);
|
|
327
|
-
if (oldEl) {
|
|
328
|
-
oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (newIdx !== undefined) {
|
|
332
|
-
const newEl = itemRefs.get(newIdx);
|
|
333
|
-
/* v8 ignore else -- @preserve */
|
|
334
|
-
if (newEl) {
|
|
335
|
-
newEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}, { flush: 'post' });
|
|
340
|
-
|
|
341
528
|
onMounted(() => {
|
|
342
|
-
/* v8 ignore else -- @preserve */
|
|
343
529
|
if (hostRef.value) {
|
|
344
530
|
hostResizeObserver?.observe(hostRef.value);
|
|
345
531
|
}
|
|
@@ -347,14 +533,7 @@ onMounted(() => {
|
|
|
347
533
|
// Re-observe items that were set before observer was ready
|
|
348
534
|
for (const el of itemRefs.values()) {
|
|
349
535
|
itemResizeObserver?.observe(el);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// Observe cells of the first rendered item
|
|
353
|
-
/* v8 ignore else -- @preserve */
|
|
354
|
-
if (firstRenderedIndex.value !== undefined) {
|
|
355
|
-
const el = itemRefs.get(firstRenderedIndex.value);
|
|
356
|
-
/* v8 ignore else -- @preserve */
|
|
357
|
-
if (el) {
|
|
536
|
+
if (props.direction === 'both') {
|
|
358
537
|
el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
359
538
|
}
|
|
360
539
|
}
|
|
@@ -373,77 +552,86 @@ function setItemRef(el: unknown, index: number) {
|
|
|
373
552
|
if (el) {
|
|
374
553
|
itemRefs.set(index, el as HTMLElement);
|
|
375
554
|
itemResizeObserver?.observe(el as HTMLElement);
|
|
555
|
+
|
|
556
|
+
if (props.direction === 'both') {
|
|
557
|
+
(el as HTMLElement).querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
558
|
+
}
|
|
376
559
|
} else {
|
|
377
560
|
const oldEl = itemRefs.get(index);
|
|
378
|
-
/* v8 ignore else -- @preserve */
|
|
379
561
|
if (oldEl) {
|
|
380
562
|
itemResizeObserver?.unobserve(oldEl);
|
|
563
|
+
if (props.direction === 'both') {
|
|
564
|
+
oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
|
|
565
|
+
}
|
|
381
566
|
itemRefs.delete(index);
|
|
382
567
|
}
|
|
383
568
|
}
|
|
384
569
|
}
|
|
385
570
|
|
|
386
571
|
function handleKeyDown(event: KeyboardEvent) {
|
|
387
|
-
stopProgrammaticScroll();
|
|
388
572
|
const { viewportSize, scrollOffset } = scrollDetails.value;
|
|
389
573
|
const isHorizontal = props.direction !== 'vertical';
|
|
390
574
|
const isVertical = props.direction !== 'horizontal';
|
|
391
575
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
576
|
+
switch (event.key) {
|
|
577
|
+
case 'Home':
|
|
578
|
+
event.preventDefault();
|
|
579
|
+
stopProgrammaticScroll();
|
|
580
|
+
scrollToIndex(0, 0, 'start');
|
|
581
|
+
break;
|
|
582
|
+
case 'End': {
|
|
583
|
+
event.preventDefault();
|
|
584
|
+
stopProgrammaticScroll();
|
|
585
|
+
const lastItemIndex = props.items.length - 1;
|
|
586
|
+
const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
|
|
587
|
+
|
|
588
|
+
if (isHorizontal) {
|
|
589
|
+
if (isVertical) {
|
|
590
|
+
scrollToIndex(lastItemIndex, lastColIndex, 'end');
|
|
591
|
+
} else {
|
|
592
|
+
scrollToIndex(0, lastItemIndex, 'end');
|
|
593
|
+
}
|
|
405
594
|
} else {
|
|
406
|
-
scrollToIndex(
|
|
595
|
+
scrollToIndex(lastItemIndex, 0, 'end');
|
|
407
596
|
}
|
|
408
|
-
|
|
409
|
-
scrollToIndex(lastItemIndex, 0, 'end');
|
|
597
|
+
break;
|
|
410
598
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
599
|
+
case 'ArrowUp':
|
|
600
|
+
event.preventDefault();
|
|
601
|
+
stopProgrammaticScroll();
|
|
602
|
+
scrollToOffset(null, scrollOffset.y - DEFAULT_ITEM_SIZE);
|
|
603
|
+
break;
|
|
604
|
+
case 'ArrowDown':
|
|
605
|
+
event.preventDefault();
|
|
606
|
+
stopProgrammaticScroll();
|
|
607
|
+
scrollToOffset(null, scrollOffset.y + DEFAULT_ITEM_SIZE);
|
|
608
|
+
break;
|
|
609
|
+
case 'ArrowLeft':
|
|
610
|
+
event.preventDefault();
|
|
611
|
+
stopProgrammaticScroll();
|
|
612
|
+
scrollToOffset(scrollOffset.x - DEFAULT_ITEM_SIZE, null);
|
|
613
|
+
break;
|
|
614
|
+
case 'ArrowRight':
|
|
615
|
+
event.preventDefault();
|
|
616
|
+
stopProgrammaticScroll();
|
|
617
|
+
scrollToOffset(scrollOffset.x + DEFAULT_ITEM_SIZE, null);
|
|
618
|
+
break;
|
|
619
|
+
case 'PageUp':
|
|
620
|
+
event.preventDefault();
|
|
621
|
+
stopProgrammaticScroll();
|
|
622
|
+
scrollToOffset(
|
|
623
|
+
!isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
|
|
624
|
+
isVertical ? scrollOffset.y - viewportSize.height : null,
|
|
625
|
+
);
|
|
626
|
+
break;
|
|
627
|
+
case 'PageDown':
|
|
628
|
+
event.preventDefault();
|
|
629
|
+
stopProgrammaticScroll();
|
|
630
|
+
scrollToOffset(
|
|
631
|
+
!isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
|
|
632
|
+
isVertical ? scrollOffset.y + viewportSize.height : null,
|
|
633
|
+
);
|
|
634
|
+
break;
|
|
447
635
|
}
|
|
448
636
|
}
|
|
449
637
|
|
|
@@ -453,23 +641,7 @@ onUnmounted(() => {
|
|
|
453
641
|
extraResizeObserver?.disconnect();
|
|
454
642
|
});
|
|
455
643
|
|
|
456
|
-
const isWindowContainer = computed(() =>
|
|
457
|
-
const c = props.container;
|
|
458
|
-
if (
|
|
459
|
-
c === null
|
|
460
|
-
// window
|
|
461
|
-
|| (typeof window !== 'undefined' && c === window)
|
|
462
|
-
) {
|
|
463
|
-
return true;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// body
|
|
467
|
-
if (c && typeof c === 'object' && 'tagName' in c) {
|
|
468
|
-
return (c as HTMLElement).tagName === 'BODY';
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return false;
|
|
472
|
-
});
|
|
644
|
+
const isWindowContainer = computed(() => isWindowLike(props.container));
|
|
473
645
|
|
|
474
646
|
const containerStyle = computed(() => {
|
|
475
647
|
if (isWindowContainer.value) {
|
|
@@ -509,56 +681,76 @@ const spacerStyle = computed(() => ({
|
|
|
509
681
|
}));
|
|
510
682
|
|
|
511
683
|
function getItemStyle(item: RenderedItem<T>) {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (isVertical && props.containerTag === 'table') {
|
|
522
|
-
style.minInlineSize = '100%';
|
|
523
|
-
} else {
|
|
524
|
-
style.inlineSize = isVertical ? '100%' : (!isDynamic ? `${ item.size.width }px` : 'auto');
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
if (isDynamic) {
|
|
528
|
-
if (!isVertical) {
|
|
529
|
-
style.minInlineSize = `${ item.size.width }px`;
|
|
530
|
-
}
|
|
531
|
-
if (!isHorizontal) {
|
|
532
|
-
style.minBlockSize = `${ item.size.height }px`;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (isHydrated.value) {
|
|
537
|
-
if (item.isStickyActive) {
|
|
538
|
-
if (isVertical || isBoth) {
|
|
539
|
-
style.insetBlockStart = `${ getPaddingY(props.scrollPaddingStart, props.direction) }px`;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (isHorizontal || isBoth) {
|
|
543
|
-
style.insetInlineStart = `${ getPaddingX(props.scrollPaddingStart, props.direction) }px`;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
style.transform = `translate(${ item.stickyOffset.x }px, ${ item.stickyOffset.y }px)`;
|
|
547
|
-
} else {
|
|
548
|
-
style.transform = `translate(${ item.offset.x }px, ${ item.offset.y }px)`;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
return style;
|
|
684
|
+
return calculateItemStyle({
|
|
685
|
+
containerTag: props.containerTag,
|
|
686
|
+
direction: props.direction,
|
|
687
|
+
isHydrated: isHydrated.value,
|
|
688
|
+
item,
|
|
689
|
+
itemSize: props.itemSize,
|
|
690
|
+
paddingStartX: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x,
|
|
691
|
+
paddingStartY: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y,
|
|
692
|
+
});
|
|
553
693
|
}
|
|
554
694
|
|
|
695
|
+
const isDebug = computed(() => props.debug);
|
|
696
|
+
const isTable = computed(() => props.containerTag === 'table');
|
|
697
|
+
const headerTag = computed(() => isTable.value ? 'thead' : 'div');
|
|
698
|
+
const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
|
|
699
|
+
|
|
555
700
|
defineExpose({
|
|
701
|
+
/**
|
|
702
|
+
* Detailed information about the current scroll state.
|
|
703
|
+
* @see ScrollDetails
|
|
704
|
+
* @see useVirtualScroll
|
|
705
|
+
*/
|
|
556
706
|
scrollDetails,
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Information about the current visible range of columns.
|
|
710
|
+
* @see ColumnRange
|
|
711
|
+
* @see useVirtualScroll
|
|
712
|
+
*/
|
|
557
713
|
columnRange,
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Helper to get the width of a specific column.
|
|
717
|
+
* @param index - The column index.
|
|
718
|
+
* @see useVirtualScroll
|
|
719
|
+
*/
|
|
558
720
|
getColumnWidth,
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Programmatically scroll to a specific row and/or column.
|
|
724
|
+
*
|
|
725
|
+
* @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
|
|
726
|
+
* @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
|
|
727
|
+
* @param options - Alignment and behavior options. Defaults to { align: 'auto', behavior: 'auto' }.
|
|
728
|
+
* @see ScrollAlignment
|
|
729
|
+
* @see ScrollToIndexOptions
|
|
730
|
+
* @see useVirtualScroll
|
|
731
|
+
*/
|
|
559
732
|
scrollToIndex,
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Programmatically scroll to a specific pixel offset.
|
|
736
|
+
*
|
|
737
|
+
* @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
|
|
738
|
+
* @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
|
|
739
|
+
* @param options - Scroll options (behavior). Defaults to { behavior: 'auto' }.
|
|
740
|
+
* @see useVirtualScroll
|
|
741
|
+
*/
|
|
560
742
|
scrollToOffset,
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Resets all dynamic measurements and re-initializes from props.
|
|
746
|
+
* @see useVirtualScroll
|
|
747
|
+
*/
|
|
561
748
|
refresh,
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Immediately stops any currently active smooth scroll animation and clears pending corrections.
|
|
752
|
+
* @see useVirtualScroll
|
|
753
|
+
*/
|
|
562
754
|
stopProgrammaticScroll,
|
|
563
755
|
});
|
|
564
756
|
</script>
|
|
@@ -573,7 +765,7 @@ defineExpose({
|
|
|
573
765
|
{
|
|
574
766
|
'virtual-scroll--hydrated': isHydrated,
|
|
575
767
|
'virtual-scroll--window': isWindowContainer,
|
|
576
|
-
'virtual-scroll--table':
|
|
768
|
+
'virtual-scroll--table': isTable,
|
|
577
769
|
},
|
|
578
770
|
]"
|
|
579
771
|
:style="containerStyle"
|
|
@@ -584,8 +776,8 @@ defineExpose({
|
|
|
584
776
|
@touchstart.passive="stopProgrammaticScroll"
|
|
585
777
|
>
|
|
586
778
|
<component
|
|
587
|
-
:is="
|
|
588
|
-
v-if="
|
|
779
|
+
:is="headerTag"
|
|
780
|
+
v-if="slots.header"
|
|
589
781
|
ref="headerRef"
|
|
590
782
|
class="virtual-scroll-header"
|
|
591
783
|
:class="{ 'virtual-scroll--sticky': stickyHeader }"
|
|
@@ -602,7 +794,7 @@ defineExpose({
|
|
|
602
794
|
<!-- Phantom element to push scroll height -->
|
|
603
795
|
<component
|
|
604
796
|
:is="itemTag"
|
|
605
|
-
v-if="
|
|
797
|
+
v-if="isTable"
|
|
606
798
|
class="virtual-scroll-spacer"
|
|
607
799
|
:style="spacerStyle"
|
|
608
800
|
>
|
|
@@ -638,7 +830,7 @@ defineExpose({
|
|
|
638
830
|
</component>
|
|
639
831
|
|
|
640
832
|
<div
|
|
641
|
-
v-if="loading &&
|
|
833
|
+
v-if="loading && slots.loading"
|
|
642
834
|
class="virtual-scroll-loading"
|
|
643
835
|
:style="loadingStyle"
|
|
644
836
|
>
|
|
@@ -646,8 +838,8 @@ defineExpose({
|
|
|
646
838
|
</div>
|
|
647
839
|
|
|
648
840
|
<component
|
|
649
|
-
:is="
|
|
650
|
-
v-if="
|
|
841
|
+
:is="footerTag"
|
|
842
|
+
v-if="slots.footer"
|
|
651
843
|
ref="footerRef"
|
|
652
844
|
class="virtual-scroll-footer"
|
|
653
845
|
:class="{ 'virtual-scroll--sticky': stickyFooter }"
|
|
@@ -690,6 +882,7 @@ defineExpose({
|
|
|
690
882
|
}
|
|
691
883
|
|
|
692
884
|
.virtual-scroll-item {
|
|
885
|
+
display: grid;
|
|
693
886
|
box-sizing: border-box;
|
|
694
887
|
will-change: transform;
|
|
695
888
|
|