@pdanpdan/virtual-scroll 0.8.0 → 0.9.1
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 +104 -11
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +299 -32
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +954 -898
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +1 -1
- package/src/components/VirtualScroll.vue +301 -209
- package/src/components/VirtualScrollbar.vue +8 -8
- package/src/composables/useVirtualScroll.ts +277 -524
- package/src/composables/useVirtualScrollSizes.ts +459 -0
- package/src/composables/useVirtualScrollbar.ts +30 -35
- package/src/index.ts +1 -0
- package/src/types.ts +25 -2
- package/src/utils/scroll.ts +14 -14
- package/src/utils/virtual-scroll-logic.ts +316 -3
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import type { VirtualScrollProps } from '../types';
|
|
2
|
+
import type { MaybeRefOrGetter } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { computed, ref, shallowRef, toValue } from 'vue';
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_COLUMN_WIDTH } from '../types';
|
|
7
|
+
import { FenwickTree } from '../utils/fenwick-tree';
|
|
8
|
+
import { calculatePrependCount } from '../utils/virtual-scroll-logic';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration properties for the `useVirtualScrollSizes` composable.
|
|
12
|
+
*/
|
|
13
|
+
export interface UseVirtualScrollSizesProps<T> {
|
|
14
|
+
/** Reactive reference to the virtual scroll configuration. */
|
|
15
|
+
props: VirtualScrollProps<T>;
|
|
16
|
+
/** Whether items have dynamic heights/widths. */
|
|
17
|
+
isDynamicItemSize: boolean;
|
|
18
|
+
/** Whether columns have dynamic widths. */
|
|
19
|
+
isDynamicColumnWidth: boolean;
|
|
20
|
+
/** Fallback size for items before they are measured. */
|
|
21
|
+
defaultSize: number;
|
|
22
|
+
/** Fixed item size if applicable. */
|
|
23
|
+
fixedItemSize: number | null;
|
|
24
|
+
/** Scroll direction. */
|
|
25
|
+
direction: 'vertical' | 'horizontal' | 'both';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Composable for managing item and column sizes using Fenwick Trees.
|
|
30
|
+
* Handles prefix sum calculations, size updates, and scroll correction adjustments.
|
|
31
|
+
*/
|
|
32
|
+
export function useVirtualScrollSizes<T>(
|
|
33
|
+
propsInput: MaybeRefOrGetter<UseVirtualScrollSizesProps<T>>,
|
|
34
|
+
) {
|
|
35
|
+
const props = computed(() => toValue(propsInput));
|
|
36
|
+
|
|
37
|
+
/** Fenwick Tree for item widths (horizontal mode). */
|
|
38
|
+
const itemSizesX = new FenwickTree(props.value.props.items?.length || 0);
|
|
39
|
+
/** Fenwick Tree for item heights (vertical/both mode). */
|
|
40
|
+
const itemSizesY = new FenwickTree(props.value.props.items?.length || 0);
|
|
41
|
+
/** Fenwick Tree for column widths (grid mode). */
|
|
42
|
+
const columnSizes = new FenwickTree(props.value.props.columnCount || 0);
|
|
43
|
+
|
|
44
|
+
/** Track which columns have been measured (Uint8Array for memory efficiency). */
|
|
45
|
+
const measuredColumns = shallowRef(new Uint8Array(0));
|
|
46
|
+
/** Track which item widths have been measured. */
|
|
47
|
+
const measuredItemsX = shallowRef(new Uint8Array(0));
|
|
48
|
+
/** Track which item heights have been measured. */
|
|
49
|
+
const measuredItemsY = shallowRef(new Uint8Array(0));
|
|
50
|
+
|
|
51
|
+
/** Reactive flag to trigger re-computations when trees update. */
|
|
52
|
+
const treeUpdateFlag = ref(0);
|
|
53
|
+
/** Whether the initial sizes have been calculated. */
|
|
54
|
+
const sizesInitialized = ref(false);
|
|
55
|
+
|
|
56
|
+
/** Cached list of previous items to detect prepending and shift measurements. */
|
|
57
|
+
let lastItems: T[] = [];
|
|
58
|
+
|
|
59
|
+
const getItemBaseSize = (item: T, index: number) => (typeof props.value.props.itemSize === 'function' ? (props.value.props.itemSize as (item: T, index: number) => number)(item, index) : props.value.defaultSize);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Internal helper to get the size of an item or column at a specific index.
|
|
63
|
+
*
|
|
64
|
+
* @param index - The item/column index.
|
|
65
|
+
* @param sizeProp - The size property from props (number, array, or function).
|
|
66
|
+
* @param defaultSize - Fallback size.
|
|
67
|
+
* @param gap - Spacing between items.
|
|
68
|
+
* @param tree - FenwickTree for this axis.
|
|
69
|
+
* @param isX - True for horizontal axis.
|
|
70
|
+
* @returns The calculated size in VU.
|
|
71
|
+
*/
|
|
72
|
+
const getSizeAt = (
|
|
73
|
+
index: number,
|
|
74
|
+
sizeProp: number | number[] | ((...args: any[]) => number) | null | undefined,
|
|
75
|
+
defaultSize: number,
|
|
76
|
+
gap: number,
|
|
77
|
+
tree: FenwickTree,
|
|
78
|
+
isX: boolean,
|
|
79
|
+
) => {
|
|
80
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
81
|
+
treeUpdateFlag.value;
|
|
82
|
+
|
|
83
|
+
if (typeof sizeProp === 'number' && sizeProp > 0) {
|
|
84
|
+
return sizeProp;
|
|
85
|
+
}
|
|
86
|
+
if (isX && Array.isArray(sizeProp) && sizeProp.length > 0) {
|
|
87
|
+
const val = sizeProp[ index % sizeProp.length ];
|
|
88
|
+
return (val != null && val > 0) ? val : defaultSize;
|
|
89
|
+
}
|
|
90
|
+
if (typeof sizeProp === 'function') {
|
|
91
|
+
const item = props.value.props.items[ index ];
|
|
92
|
+
return (isX && props.value.direction !== 'both') || !isX
|
|
93
|
+
? (item !== undefined ? sizeProp(item, index) : defaultSize)
|
|
94
|
+
: (sizeProp as (i: number) => number)(index);
|
|
95
|
+
}
|
|
96
|
+
const val = tree.get(index);
|
|
97
|
+
return val > 0 ? val - gap : defaultSize;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resizes internal arrays and Fenwick Trees while preserving existing measurements.
|
|
102
|
+
*
|
|
103
|
+
* @param len - New item count.
|
|
104
|
+
* @param colCount - New column count.
|
|
105
|
+
*/
|
|
106
|
+
const resizeMeasurements = (len: number, colCount: number) => {
|
|
107
|
+
itemSizesX.resize(len);
|
|
108
|
+
itemSizesY.resize(len);
|
|
109
|
+
columnSizes.resize(colCount);
|
|
110
|
+
|
|
111
|
+
if (measuredItemsX.value.length !== len) {
|
|
112
|
+
const newMeasuredX = new Uint8Array(len);
|
|
113
|
+
newMeasuredX.set(measuredItemsX.value.subarray(0, Math.min(len, measuredItemsX.value.length)));
|
|
114
|
+
measuredItemsX.value = newMeasuredX;
|
|
115
|
+
}
|
|
116
|
+
if (measuredItemsY.value.length !== len) {
|
|
117
|
+
const newMeasuredY = new Uint8Array(len);
|
|
118
|
+
newMeasuredY.set(measuredItemsY.value.subarray(0, Math.min(len, measuredItemsY.value.length)));
|
|
119
|
+
measuredItemsY.value = newMeasuredY;
|
|
120
|
+
}
|
|
121
|
+
if (measuredColumns.value.length !== colCount) {
|
|
122
|
+
const newMeasuredCols = new Uint8Array(colCount);
|
|
123
|
+
newMeasuredCols.set(measuredColumns.value.subarray(0, Math.min(colCount, measuredColumns.value.length)));
|
|
124
|
+
measuredColumns.value = newMeasuredCols;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Helper to initialize measurements for a single axis.
|
|
130
|
+
*/
|
|
131
|
+
const initializeAxis = (
|
|
132
|
+
count: number,
|
|
133
|
+
tree: FenwickTree,
|
|
134
|
+
measured: Uint8Array,
|
|
135
|
+
sizeProp: number | number[] | ((...args: any[]) => number) | null | undefined,
|
|
136
|
+
defaultSize: number,
|
|
137
|
+
gap: number,
|
|
138
|
+
isDynamic: boolean,
|
|
139
|
+
isX: boolean,
|
|
140
|
+
shouldReset: boolean,
|
|
141
|
+
) => {
|
|
142
|
+
let needsRebuild = false;
|
|
143
|
+
|
|
144
|
+
if (shouldReset) {
|
|
145
|
+
for (let i = 0; i < count; i++) {
|
|
146
|
+
if (tree.get(i) !== 0) {
|
|
147
|
+
tree.set(i, 0);
|
|
148
|
+
measured[ i ] = 0;
|
|
149
|
+
needsRebuild = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return needsRebuild;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < count; i++) {
|
|
156
|
+
const current = tree.get(i);
|
|
157
|
+
const isMeasured = measured[ i ] === 1;
|
|
158
|
+
|
|
159
|
+
if (!isDynamic || (!isMeasured && current === 0)) {
|
|
160
|
+
const baseSize = getSizeAt(i, sizeProp, defaultSize, gap, tree, isX) + gap;
|
|
161
|
+
|
|
162
|
+
if (Math.abs(current - baseSize) > 0.5) {
|
|
163
|
+
tree.set(i, baseSize);
|
|
164
|
+
measured[ i ] = isDynamic ? 0 : 1;
|
|
165
|
+
needsRebuild = true;
|
|
166
|
+
} else if (!isDynamic) {
|
|
167
|
+
measured[ i ] = 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return needsRebuild;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Initializes prefix sum trees from props (fixed sizes, width arrays, or functions).
|
|
176
|
+
*/
|
|
177
|
+
const initializeMeasurements = () => {
|
|
178
|
+
const propsVal = props.value.props;
|
|
179
|
+
const len = propsVal.items.length;
|
|
180
|
+
const colCount = propsVal.columnCount || 0;
|
|
181
|
+
const gap = propsVal.gap || 0;
|
|
182
|
+
const columnGap = propsVal.columnGap || 0;
|
|
183
|
+
const cw = propsVal.columnWidth;
|
|
184
|
+
const itemSize = propsVal.itemSize;
|
|
185
|
+
const defaultColWidth = propsVal.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
|
|
186
|
+
const defaultItemSize = propsVal.defaultItemSize || props.value.defaultSize;
|
|
187
|
+
|
|
188
|
+
// Initialize columns
|
|
189
|
+
const colNeedsRebuild = initializeAxis(
|
|
190
|
+
colCount,
|
|
191
|
+
columnSizes,
|
|
192
|
+
measuredColumns.value,
|
|
193
|
+
cw,
|
|
194
|
+
defaultColWidth,
|
|
195
|
+
columnGap,
|
|
196
|
+
props.value.isDynamicColumnWidth,
|
|
197
|
+
true,
|
|
198
|
+
false,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Initialize items X
|
|
202
|
+
const itemsXNeedsRebuild = initializeAxis(
|
|
203
|
+
len,
|
|
204
|
+
itemSizesX,
|
|
205
|
+
measuredItemsX.value,
|
|
206
|
+
itemSize,
|
|
207
|
+
defaultItemSize,
|
|
208
|
+
columnGap,
|
|
209
|
+
props.value.isDynamicItemSize,
|
|
210
|
+
true,
|
|
211
|
+
props.value.direction !== 'horizontal',
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Initialize items Y
|
|
215
|
+
const itemsYNeedsRebuild = initializeAxis(
|
|
216
|
+
len,
|
|
217
|
+
itemSizesY,
|
|
218
|
+
measuredItemsY.value,
|
|
219
|
+
itemSize,
|
|
220
|
+
defaultItemSize,
|
|
221
|
+
gap,
|
|
222
|
+
props.value.isDynamicItemSize,
|
|
223
|
+
false,
|
|
224
|
+
props.value.direction === 'horizontal',
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (colNeedsRebuild) {
|
|
228
|
+
columnSizes.rebuild();
|
|
229
|
+
}
|
|
230
|
+
if (itemsXNeedsRebuild) {
|
|
231
|
+
itemSizesX.rebuild();
|
|
232
|
+
}
|
|
233
|
+
if (itemsYNeedsRebuild) {
|
|
234
|
+
itemSizesY.rebuild();
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Helper to update a single size in the tree.
|
|
240
|
+
*/
|
|
241
|
+
const updateAxis = (
|
|
242
|
+
index: number,
|
|
243
|
+
newSize: number,
|
|
244
|
+
tree: FenwickTree,
|
|
245
|
+
measured: Uint8Array,
|
|
246
|
+
gap: number,
|
|
247
|
+
firstIndex: number,
|
|
248
|
+
accumulatedDelta: { val: number; },
|
|
249
|
+
) => {
|
|
250
|
+
const oldSize = tree.get(index);
|
|
251
|
+
const targetSize = newSize + gap;
|
|
252
|
+
let updated = false;
|
|
253
|
+
|
|
254
|
+
if (!measured[ index ] || Math.abs(targetSize - oldSize) > 0.1) {
|
|
255
|
+
const d = targetSize - oldSize;
|
|
256
|
+
tree.update(index, d);
|
|
257
|
+
measured[ index ] = 1;
|
|
258
|
+
updated = true;
|
|
259
|
+
if (index < firstIndex && oldSize > 0) {
|
|
260
|
+
accumulatedDelta.val += d;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return updated;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Initializes or updates sizes based on current props and items.
|
|
268
|
+
* Handles prepending of items by shifting existing measurements.
|
|
269
|
+
*
|
|
270
|
+
* @param onScrollCorrection - Callback to adjust scroll position when items are prepended.
|
|
271
|
+
*/
|
|
272
|
+
const initializeSizes = (onScrollCorrection?: (addedX: number, addedY: number) => void) => {
|
|
273
|
+
const propsVal = props.value.props;
|
|
274
|
+
const newItems = propsVal.items;
|
|
275
|
+
const len = newItems.length;
|
|
276
|
+
const colCount = propsVal.columnCount || 0;
|
|
277
|
+
|
|
278
|
+
resizeMeasurements(len, colCount);
|
|
279
|
+
|
|
280
|
+
const prependCount = propsVal.restoreScrollOnPrepend
|
|
281
|
+
? calculatePrependCount(lastItems, newItems)
|
|
282
|
+
: 0;
|
|
283
|
+
|
|
284
|
+
if (prependCount > 0) {
|
|
285
|
+
itemSizesX.shift(prependCount);
|
|
286
|
+
itemSizesY.shift(prependCount);
|
|
287
|
+
|
|
288
|
+
const newMeasuredX = new Uint8Array(len);
|
|
289
|
+
const newMeasuredY = new Uint8Array(len);
|
|
290
|
+
newMeasuredX.set(measuredItemsX.value.subarray(0, Math.min(len - prependCount, measuredItemsX.value.length)), prependCount);
|
|
291
|
+
newMeasuredY.set(measuredItemsY.value.subarray(0, Math.min(len - prependCount, measuredItemsY.value.length)), prependCount);
|
|
292
|
+
measuredItemsX.value = newMeasuredX;
|
|
293
|
+
measuredItemsY.value = newMeasuredY;
|
|
294
|
+
|
|
295
|
+
// Calculate added size
|
|
296
|
+
const gap = propsVal.gap || 0;
|
|
297
|
+
const columnGap = propsVal.columnGap || 0;
|
|
298
|
+
let addedX = 0;
|
|
299
|
+
let addedY = 0;
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < prependCount; i++) {
|
|
302
|
+
const size = getItemBaseSize(newItems[ i ] as T, i);
|
|
303
|
+
if (props.value.direction === 'horizontal') {
|
|
304
|
+
addedX += size + columnGap;
|
|
305
|
+
} else { addedY += size + gap; }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if ((addedX > 0 || addedY > 0) && onScrollCorrection) {
|
|
309
|
+
onScrollCorrection(addedX, addedY);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
initializeMeasurements();
|
|
314
|
+
|
|
315
|
+
lastItems = [ ...newItems ];
|
|
316
|
+
sizesInitialized.value = true;
|
|
317
|
+
treeUpdateFlag.value++;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Updates the size of multiple items in the Fenwick tree.
|
|
322
|
+
*
|
|
323
|
+
* @param updates - Array of updates.
|
|
324
|
+
* @param getRowIndexAt - Helper to get row index at offset (for scroll correction check).
|
|
325
|
+
* @param getColIndexAt - Helper to get col index at offset.
|
|
326
|
+
* @param relativeScrollX - Current relative scroll X.
|
|
327
|
+
* @param relativeScrollY - Current relative scroll Y.
|
|
328
|
+
* @param onScrollCorrection - Callback to adjust scroll position.
|
|
329
|
+
*/
|
|
330
|
+
const updateItemSizes = (
|
|
331
|
+
updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>,
|
|
332
|
+
getRowIndexAt: (offset: number) => number,
|
|
333
|
+
getColIndexAt: (offset: number) => number,
|
|
334
|
+
relativeScrollX: number,
|
|
335
|
+
relativeScrollY: number,
|
|
336
|
+
onScrollCorrection: (deltaX: number, deltaY: number) => void,
|
|
337
|
+
) => {
|
|
338
|
+
let needUpdate = false;
|
|
339
|
+
const deltaX = { val: 0 };
|
|
340
|
+
const deltaY = { val: 0 };
|
|
341
|
+
const propsVal = props.value.props;
|
|
342
|
+
const gap = propsVal.gap || 0;
|
|
343
|
+
const columnGap = propsVal.columnGap || 0;
|
|
344
|
+
|
|
345
|
+
const firstRowIndex = getRowIndexAt(props.value.direction === 'horizontal' ? relativeScrollX : relativeScrollY);
|
|
346
|
+
const firstColIndex = getColIndexAt(relativeScrollX);
|
|
347
|
+
|
|
348
|
+
const isHorizontalMode = props.value.direction === 'horizontal';
|
|
349
|
+
const isBothMode = props.value.direction === 'both';
|
|
350
|
+
|
|
351
|
+
const processedRows = new Set<number>();
|
|
352
|
+
const processedCols = new Set<number>();
|
|
353
|
+
|
|
354
|
+
const tryUpdateColumn = (colIdx: number, width: number) => {
|
|
355
|
+
if (colIdx >= 0 && colIdx < (propsVal.columnCount || 0) && !processedCols.has(colIdx)) {
|
|
356
|
+
processedCols.add(colIdx);
|
|
357
|
+
if (updateAxis(colIdx, width, columnSizes, measuredColumns.value, columnGap, firstColIndex, deltaX)) {
|
|
358
|
+
needUpdate = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
for (const { index, inlineSize, blockSize, element } of updates) {
|
|
364
|
+
// Ignore 0-size measurements as they usually indicate hidden/detached elements
|
|
365
|
+
if (inlineSize <= 0 && blockSize <= 0) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const isMeasurable = props.value.isDynamicItemSize || typeof propsVal.itemSize === 'function';
|
|
370
|
+
if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
|
|
371
|
+
processedRows.add(index);
|
|
372
|
+
if (isHorizontalMode && inlineSize > 0) {
|
|
373
|
+
if (updateAxis(index, inlineSize, itemSizesX, measuredItemsX.value, columnGap, firstRowIndex, deltaX)) {
|
|
374
|
+
needUpdate = true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (!isHorizontalMode) {
|
|
378
|
+
if (updateAxis(index, blockSize, itemSizesY, measuredItemsY.value, gap, firstRowIndex, deltaY)) {
|
|
379
|
+
needUpdate = true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Dynamic column width measurement
|
|
385
|
+
const isColMeasurable = props.value.isDynamicColumnWidth || typeof propsVal.columnWidth === 'function';
|
|
386
|
+
if (
|
|
387
|
+
isBothMode
|
|
388
|
+
&& element
|
|
389
|
+
&& propsVal.columnCount
|
|
390
|
+
&& isColMeasurable
|
|
391
|
+
&& (inlineSize > 0 || element.dataset.colIndex === undefined)
|
|
392
|
+
) {
|
|
393
|
+
const colIndexAttr = element.dataset.colIndex;
|
|
394
|
+
if (colIndexAttr != null) {
|
|
395
|
+
tryUpdateColumn(Number.parseInt(colIndexAttr, 10), inlineSize);
|
|
396
|
+
} else {
|
|
397
|
+
// If the element is a row, try to find cells with data-col-index
|
|
398
|
+
const cells = Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
|
|
399
|
+
|
|
400
|
+
for (const child of cells) {
|
|
401
|
+
const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
|
|
402
|
+
tryUpdateColumn(colIndex, child.getBoundingClientRect().width);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (needUpdate) {
|
|
409
|
+
treeUpdateFlag.value++;
|
|
410
|
+
if (deltaX.val !== 0 || deltaY.val !== 0) {
|
|
411
|
+
onScrollCorrection(deltaX.val, deltaY.val);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Resets all dynamic measurements and re-initializes from current props.
|
|
418
|
+
*
|
|
419
|
+
* @param onScrollCorrection - Callback to adjust scroll position.
|
|
420
|
+
*/
|
|
421
|
+
const refresh = (onScrollCorrection?: (addedX: number, addedY: number) => void) => {
|
|
422
|
+
itemSizesX.resize(0);
|
|
423
|
+
itemSizesY.resize(0);
|
|
424
|
+
columnSizes.resize(0);
|
|
425
|
+
measuredColumns.value.fill(0);
|
|
426
|
+
measuredItemsX.value.fill(0);
|
|
427
|
+
measuredItemsY.value.fill(0);
|
|
428
|
+
initializeSizes(onScrollCorrection);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
/** Fenwick Tree for horizontal item sizes. */
|
|
433
|
+
itemSizesX,
|
|
434
|
+
/** Fenwick Tree for vertical item sizes. */
|
|
435
|
+
itemSizesY,
|
|
436
|
+
/** Fenwick Tree for column widths. */
|
|
437
|
+
columnSizes,
|
|
438
|
+
/** Measured item widths. */
|
|
439
|
+
measuredItemsX,
|
|
440
|
+
/** Measured item heights. */
|
|
441
|
+
measuredItemsY,
|
|
442
|
+
/** Measured column widths. */
|
|
443
|
+
measuredColumns,
|
|
444
|
+
/** Flag that updates when any tree changes. */
|
|
445
|
+
treeUpdateFlag,
|
|
446
|
+
/** Whether sizes have been initialized. */
|
|
447
|
+
sizesInitialized,
|
|
448
|
+
/** Base size of an item from props. */
|
|
449
|
+
getItemBaseSize,
|
|
450
|
+
/** Helper to get current size at index. */
|
|
451
|
+
getSizeAt,
|
|
452
|
+
/** Initialize or update sizes from props. */
|
|
453
|
+
initializeSizes,
|
|
454
|
+
/** Update sizes of multiple items from measurements. */
|
|
455
|
+
updateItemSizes,
|
|
456
|
+
/** Reset all measurements. */
|
|
457
|
+
refresh,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
@@ -11,61 +11,56 @@ import { computed, getCurrentInstance, onUnmounted, ref, toValue } from 'vue';
|
|
|
11
11
|
/** Configuration properties for the `useVirtualScrollbar` composable. */
|
|
12
12
|
export interface UseVirtualScrollbarProps {
|
|
13
13
|
/** The axis for this scrollbar. */
|
|
14
|
-
axis:
|
|
14
|
+
axis: ScrollAxis;
|
|
15
15
|
/** Total size of the scrollable content area in display pixels (DU). */
|
|
16
|
-
totalSize:
|
|
16
|
+
totalSize: number;
|
|
17
17
|
/** Current scroll position in display pixels (DU). */
|
|
18
|
-
position:
|
|
18
|
+
position: number;
|
|
19
19
|
/** Viewport size in display pixels (DU). */
|
|
20
|
-
viewportSize:
|
|
20
|
+
viewportSize: number;
|
|
21
21
|
/**
|
|
22
22
|
* Function to scroll to a specific display pixel offset (DU) on this axis.
|
|
23
23
|
* @param offset - The display pixel offset to scroll to.
|
|
24
24
|
*/
|
|
25
25
|
scrollToOffset: (offset: number) => void;
|
|
26
26
|
/** The ID of the container element this scrollbar controls. */
|
|
27
|
-
containerId?:
|
|
27
|
+
containerId?: string | undefined;
|
|
28
28
|
/** Whether the scrollbar is in Right-to-Left (RTL) mode. */
|
|
29
|
-
isRtl?:
|
|
29
|
+
isRtl?: boolean;
|
|
30
30
|
/** Accessible label for the scrollbar. */
|
|
31
|
-
ariaLabel?:
|
|
31
|
+
ariaLabel?: string | undefined;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Composable for virtual scrollbar logic.
|
|
36
36
|
* Provides attributes and event listeners for track and thumb elements.
|
|
37
37
|
*
|
|
38
|
-
* @param
|
|
38
|
+
* @param propsInput - Configuration properties.
|
|
39
39
|
*/
|
|
40
|
-
export function useVirtualScrollbar(
|
|
41
|
-
const
|
|
42
|
-
const totalSize = computed(() => toValue(props.totalSize));
|
|
43
|
-
const position = computed(() => toValue(props.position));
|
|
44
|
-
const viewportSize = computed(() => toValue(props.viewportSize));
|
|
45
|
-
const containerId = computed(() => toValue(props.containerId));
|
|
46
|
-
const isRtl = computed(() => !!toValue(props.isRtl));
|
|
40
|
+
export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrollbarProps>) {
|
|
41
|
+
const props = computed(() => toValue(propsInput));
|
|
47
42
|
|
|
48
|
-
const isHorizontal = computed(() =>
|
|
43
|
+
const isHorizontal = computed(() => props.value.axis === 'horizontal');
|
|
49
44
|
|
|
50
45
|
const viewportPercent = computed(() => {
|
|
51
|
-
if (
|
|
46
|
+
if (props.value.totalSize <= 0) {
|
|
52
47
|
return 0;
|
|
53
48
|
}
|
|
54
|
-
return Math.min(1,
|
|
49
|
+
return Math.min(1, props.value.viewportSize / props.value.totalSize);
|
|
55
50
|
});
|
|
56
51
|
|
|
57
52
|
const positionPercent = computed(() => {
|
|
58
|
-
const scrollableRange =
|
|
53
|
+
const scrollableRange = props.value.totalSize - props.value.viewportSize;
|
|
59
54
|
if (scrollableRange <= 0) {
|
|
60
55
|
return 0;
|
|
61
56
|
}
|
|
62
|
-
return Math.max(0, Math.min(1,
|
|
57
|
+
return Math.max(0, Math.min(1, props.value.position / scrollableRange));
|
|
63
58
|
});
|
|
64
59
|
|
|
65
60
|
const thumbSizePercent = computed(() => {
|
|
66
61
|
// Minimum thumb size in pixels (32px for better touch targets and visibility)
|
|
67
62
|
const minThumbSize = 32;
|
|
68
|
-
const minPercent =
|
|
63
|
+
const minPercent = props.value.viewportSize > 0 ? (minThumbSize / props.value.viewportSize) : 0.1;
|
|
69
64
|
return Math.max(Math.min(minPercent, 0.1), viewportPercent.value) * 100;
|
|
70
65
|
});
|
|
71
66
|
/** Calculated thumb position as a percentage of the track size (0 to 100). */
|
|
@@ -87,7 +82,7 @@ export function useVirtualScrollbar(props: UseVirtualScrollbarProps) {
|
|
|
87
82
|
|
|
88
83
|
/** Reactive style object for the scrollbar track. */
|
|
89
84
|
const trackStyle = computed(() => {
|
|
90
|
-
const displayViewportSize =
|
|
85
|
+
const displayViewportSize = props.value.viewportSize;
|
|
91
86
|
const scrollbarGap = 'var(--vs-scrollbar-has-cross-gap, var(--vsi-scrollbar-has-cross-gap, 0)) * var(--vs-scrollbar-cross-gap, var(--vsi-scrollbar-size, 8px))';
|
|
92
87
|
|
|
93
88
|
return isHorizontal.value
|
|
@@ -114,29 +109,29 @@ export function useVirtualScrollbar(props: UseVirtualScrollbarProps) {
|
|
|
114
109
|
let clickPos = 0;
|
|
115
110
|
|
|
116
111
|
if (isHorizontal.value) {
|
|
117
|
-
clickPos =
|
|
112
|
+
clickPos = props.value.isRtl ? rect.right - event.clientX : event.clientX - rect.left;
|
|
118
113
|
} else {
|
|
119
114
|
clickPos = event.clientY - rect.top;
|
|
120
115
|
}
|
|
121
116
|
|
|
122
117
|
const thumbSize = (thumbSizePercent.value / 100) * trackSize;
|
|
123
118
|
const targetPercent = (clickPos - thumbSize / 2) / (trackSize - thumbSize);
|
|
124
|
-
const scrollableRange =
|
|
119
|
+
const scrollableRange = props.value.totalSize - props.value.viewportSize;
|
|
125
120
|
|
|
126
121
|
let targetOffset = targetPercent * scrollableRange;
|
|
127
122
|
if (targetOffset > scrollableRange - 1) {
|
|
128
123
|
targetOffset = scrollableRange;
|
|
129
124
|
}
|
|
130
125
|
|
|
131
|
-
props.scrollToOffset(Math.max(0, Math.min(scrollableRange, targetOffset)));
|
|
126
|
+
props.value.scrollToOffset(Math.max(0, Math.min(scrollableRange, targetOffset)));
|
|
132
127
|
}
|
|
133
128
|
|
|
134
129
|
function handleThumbPointerDown(event: PointerEvent) {
|
|
135
130
|
isDragging.value = true;
|
|
136
131
|
startPos = isHorizontal.value
|
|
137
|
-
? (
|
|
132
|
+
? (props.value.isRtl ? -event.clientX : event.clientX)
|
|
138
133
|
: event.clientY;
|
|
139
|
-
startScrollPos =
|
|
134
|
+
startScrollPos = props.value.position;
|
|
140
135
|
|
|
141
136
|
const thumb = event.currentTarget as HTMLElement;
|
|
142
137
|
thumb.setPointerCapture(event.pointerId);
|
|
@@ -156,7 +151,7 @@ export function useVirtualScrollbar(props: UseVirtualScrollbarProps) {
|
|
|
156
151
|
}
|
|
157
152
|
|
|
158
153
|
const currentPos = isHorizontal.value
|
|
159
|
-
? (
|
|
154
|
+
? (props.value.isRtl ? -event.clientX : event.clientX)
|
|
160
155
|
: event.clientY;
|
|
161
156
|
const delta = currentPos - startPos;
|
|
162
157
|
const rect = track.getBoundingClientRect();
|
|
@@ -168,14 +163,14 @@ export function useVirtualScrollbar(props: UseVirtualScrollbarProps) {
|
|
|
168
163
|
return;
|
|
169
164
|
}
|
|
170
165
|
|
|
171
|
-
const scrollableContentRange =
|
|
166
|
+
const scrollableContentRange = props.value.totalSize - props.value.viewportSize;
|
|
172
167
|
let targetOffset = startScrollPos + (delta / scrollableTrackRange) * scrollableContentRange;
|
|
173
168
|
|
|
174
169
|
if (targetOffset > scrollableContentRange - 1) {
|
|
175
170
|
targetOffset = scrollableContentRange;
|
|
176
171
|
}
|
|
177
172
|
|
|
178
|
-
props.scrollToOffset(Math.max(0, Math.min(scrollableContentRange, targetOffset)));
|
|
173
|
+
props.value.scrollToOffset(Math.max(0, Math.min(scrollableContentRange, targetOffset)));
|
|
179
174
|
}
|
|
180
175
|
|
|
181
176
|
function handleThumbPointerUp(event: PointerEvent) {
|
|
@@ -199,12 +194,12 @@ export function useVirtualScrollbar(props: UseVirtualScrollbarProps) {
|
|
|
199
194
|
],
|
|
200
195
|
style: trackStyle.value,
|
|
201
196
|
role: 'scrollbar',
|
|
202
|
-
'aria-label':
|
|
203
|
-
'aria-orientation':
|
|
204
|
-
'aria-valuenow': Math.round(
|
|
197
|
+
'aria-label': props.value.ariaLabel,
|
|
198
|
+
'aria-orientation': props.value.axis,
|
|
199
|
+
'aria-valuenow': Math.round(props.value.position),
|
|
205
200
|
'aria-valuemin': 0,
|
|
206
|
-
'aria-valuemax': Math.round(
|
|
207
|
-
'aria-controls':
|
|
201
|
+
'aria-valuemax': Math.round(props.value.totalSize - props.value.viewportSize),
|
|
202
|
+
'aria-controls': props.value.containerId,
|
|
208
203
|
tabindex: -1,
|
|
209
204
|
onMousedown: handleTrackClick,
|
|
210
205
|
}));
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { default as VirtualScroll } from './components/VirtualScroll.vue';
|
|
|
9
9
|
export { default as VirtualScrollbar } from './components/VirtualScrollbar.vue';
|
|
10
10
|
export * from './composables/useVirtualScroll';
|
|
11
11
|
export * from './composables/useVirtualScrollbar';
|
|
12
|
+
export * from './composables/useVirtualScrollSizes';
|
|
12
13
|
export * from './types';
|
|
13
14
|
export * from './utils/fenwick-tree';
|
|
14
15
|
export * from './utils/scroll';
|
package/src/types.ts
CHANGED
|
@@ -185,6 +185,17 @@ export interface SSRRange {
|
|
|
185
185
|
/** Pixel padding configuration in display pixels (DU). */
|
|
186
186
|
export type PaddingValue = number | { x?: number; y?: number; };
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Snap mode for automatic alignment after scrolling stops.
|
|
190
|
+
* - `false`: No snapping.
|
|
191
|
+
* - `true`: Same as 'auto'.
|
|
192
|
+
* - 'start': Aligns the first visible item to the viewport start if at least 50% visible, otherwise aligns the next item.
|
|
193
|
+
* - 'center': Aligns the item that intersects the viewport center to the center.
|
|
194
|
+
* - 'end': Aligns the last visible item to the viewport end if at least 50% visible, otherwise aligns the previous item.
|
|
195
|
+
* - 'auto': Intelligent snapping based on scroll direction. Acts as 'end' when scrolling towards start, and 'start' when scrolling towards end.
|
|
196
|
+
*/
|
|
197
|
+
export type SnapMode = boolean | 'start' | 'center' | 'end' | 'auto';
|
|
198
|
+
|
|
188
199
|
/** Base configuration properties shared between the component and the composable. */
|
|
189
200
|
export interface VirtualScrollBaseProps<T = unknown> {
|
|
190
201
|
/** Array of data items to virtualize. */
|
|
@@ -254,7 +265,7 @@ export interface VirtualScrollBaseProps<T = unknown> {
|
|
|
254
265
|
gap?: number | undefined;
|
|
255
266
|
|
|
256
267
|
/**
|
|
257
|
-
* Gap between columns in VU.
|
|
268
|
+
* Gap between columns in virtual units (VU).
|
|
258
269
|
* Applied in horizontal and bidirectional grid modes.
|
|
259
270
|
*/
|
|
260
271
|
columnGap?: number | undefined;
|
|
@@ -328,6 +339,12 @@ export interface VirtualScrollBaseProps<T = unknown> {
|
|
|
328
339
|
* Set to 'none' or 'presentation' to disable automatic role assignment on the wrapper.
|
|
329
340
|
*/
|
|
330
341
|
itemRole?: string | undefined;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Whether to snap to items after scrolling stops.
|
|
345
|
+
* @default false
|
|
346
|
+
*/
|
|
347
|
+
snap?: SnapMode | undefined;
|
|
331
348
|
}
|
|
332
349
|
|
|
333
350
|
/** Configuration properties for the `useVirtualScroll` composable. */
|
|
@@ -516,6 +533,12 @@ export interface VirtualScrollInstance<T = unknown> extends VirtualScrollCompone
|
|
|
516
533
|
getRowHeight: (index: number) => number;
|
|
517
534
|
/** Helper to get ARIA attributes for a cell. */
|
|
518
535
|
getCellAriaProps: (colIndex: number) => Record<string, string | number | undefined>;
|
|
536
|
+
/** Helper to get ARIA attributes for an item. */
|
|
537
|
+
getItemAriaProps: (index: number) => Record<string, string | number | undefined>;
|
|
538
|
+
/** The ARIA role of the items wrapper. */
|
|
539
|
+
wrapperRole: string | null;
|
|
540
|
+
/** The ARIA role of each cell. */
|
|
541
|
+
cellRole: string | null;
|
|
519
542
|
/** Helper to get the virtual offset of a specific row. */
|
|
520
543
|
getRowOffset: (index: number) => number;
|
|
521
544
|
/** Helper to get the virtual offset of a specific column. */
|
|
@@ -525,7 +548,7 @@ export interface VirtualScrollInstance<T = unknown> extends VirtualScrollCompone
|
|
|
525
548
|
/** Helper to get the size of a specific item along the scroll axis. */
|
|
526
549
|
getItemSize: (index: number) => number;
|
|
527
550
|
/** Programmatically scroll to a specific row and/or column. */
|
|
528
|
-
scrollToIndex: (rowIndex
|
|
551
|
+
scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
|
|
529
552
|
/** Programmatically scroll to a specific pixel offset. */
|
|
530
553
|
scrollToOffset: (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
|
|
531
554
|
/** Resets all dynamic measurements and re-initializes from props. */
|