@pdanpdan/virtual-scroll 0.7.0 → 0.9.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.
@@ -0,0 +1,448 @@
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
+ * Initializes prefix sum trees from props (fixed sizes, width arrays, or functions).
130
+ */
131
+ const initializeMeasurements = () => {
132
+ const propsVal = props.value.props;
133
+ const newItems = propsVal.items;
134
+ const len = newItems.length;
135
+ const colCount = propsVal.columnCount || 0;
136
+ const gap = propsVal.gap || 0;
137
+ const columnGap = propsVal.columnGap || 0;
138
+ const cw = propsVal.columnWidth;
139
+
140
+ let colNeedsRebuild = false;
141
+ let itemsNeedRebuild = false;
142
+
143
+ // Initialize columns
144
+ if (colCount > 0) {
145
+ for (let i = 0; i < colCount; i++) {
146
+ const currentW = columnSizes.get(i);
147
+ const isMeasured = measuredColumns.value[ i ] === 1;
148
+
149
+ if (!props.value.isDynamicColumnWidth || (!isMeasured && currentW === 0)) {
150
+ let baseWidth = 0;
151
+ if (typeof cw === 'number' && cw > 0) {
152
+ baseWidth = cw;
153
+ } else if (Array.isArray(cw) && cw.length > 0) {
154
+ baseWidth = cw[ i % cw.length ] || propsVal.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
155
+ } else if (typeof cw === 'function') {
156
+ baseWidth = cw(i);
157
+ } else {
158
+ baseWidth = propsVal.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
159
+ }
160
+
161
+ const targetW = baseWidth + columnGap;
162
+ if (Math.abs(currentW - targetW) > 0.5) {
163
+ columnSizes.set(i, targetW);
164
+ measuredColumns.value[ i ] = props.value.isDynamicColumnWidth ? 0 : 1;
165
+ colNeedsRebuild = true;
166
+ } else if (!props.value.isDynamicColumnWidth) {
167
+ measuredColumns.value[ i ] = 1;
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ // Initialize items
174
+ for (let i = 0; i < len; i++) {
175
+ const item = propsVal.items[ i ];
176
+ const currentX = itemSizesX.get(i);
177
+ const currentY = itemSizesY.get(i);
178
+ const isMeasuredX = measuredItemsX.value[ i ] === 1;
179
+ const isMeasuredY = measuredItemsY.value[ i ] === 1;
180
+
181
+ if (props.value.direction === 'horizontal') {
182
+ if (!props.value.isDynamicItemSize || (!isMeasuredX && currentX === 0)) {
183
+ const baseSize = getItemBaseSize(item as T, i);
184
+ const targetX = baseSize + columnGap;
185
+ if (Math.abs(currentX - targetX) > 0.5) {
186
+ itemSizesX.set(i, targetX);
187
+ measuredItemsX.value[ i ] = props.value.isDynamicItemSize ? 0 : 1;
188
+ itemsNeedRebuild = true;
189
+ } else if (!props.value.isDynamicItemSize) {
190
+ measuredItemsX.value[ i ] = 1;
191
+ }
192
+ }
193
+ } else if (currentX !== 0) {
194
+ itemSizesX.set(i, 0);
195
+ measuredItemsX.value[ i ] = 0;
196
+ itemsNeedRebuild = true;
197
+ }
198
+
199
+ if (props.value.direction !== 'horizontal') {
200
+ if (!props.value.isDynamicItemSize || (!isMeasuredY && currentY === 0)) {
201
+ const baseSize = getItemBaseSize(item as T, i);
202
+ const targetY = baseSize + gap;
203
+ if (Math.abs(currentY - targetY) > 0.5) {
204
+ itemSizesY.set(i, targetY);
205
+ measuredItemsY.value[ i ] = props.value.isDynamicItemSize ? 0 : 1;
206
+ itemsNeedRebuild = true;
207
+ } else if (!props.value.isDynamicItemSize) {
208
+ measuredItemsY.value[ i ] = 1;
209
+ }
210
+ }
211
+ } else if (currentY !== 0) {
212
+ itemSizesY.set(i, 0);
213
+ measuredItemsY.value[ i ] = 0;
214
+ itemsNeedRebuild = true;
215
+ }
216
+ }
217
+
218
+ if (colNeedsRebuild) {
219
+ columnSizes.rebuild();
220
+ }
221
+ if (itemsNeedRebuild) {
222
+ itemSizesX.rebuild();
223
+ itemSizesY.rebuild();
224
+ }
225
+ };
226
+
227
+ /**
228
+ * Initializes or updates sizes based on current props and items.
229
+ * Handles prepending of items by shifting existing measurements.
230
+ *
231
+ * @param onScrollCorrection - Callback to adjust scroll position when items are prepended.
232
+ */
233
+ const initializeSizes = (onScrollCorrection?: (addedX: number, addedY: number) => void) => {
234
+ const propsVal = props.value.props;
235
+ const newItems = propsVal.items;
236
+ const len = newItems.length;
237
+ const colCount = propsVal.columnCount || 0;
238
+
239
+ resizeMeasurements(len, colCount);
240
+
241
+ const prependCount = propsVal.restoreScrollOnPrepend
242
+ ? calculatePrependCount(lastItems, newItems)
243
+ : 0;
244
+
245
+ if (prependCount > 0) {
246
+ itemSizesX.shift(prependCount);
247
+ itemSizesY.shift(prependCount);
248
+
249
+ const newMeasuredX = new Uint8Array(len);
250
+ const newMeasuredY = new Uint8Array(len);
251
+ newMeasuredX.set(measuredItemsX.value.subarray(0, Math.min(len - prependCount, measuredItemsX.value.length)), prependCount);
252
+ newMeasuredY.set(measuredItemsY.value.subarray(0, Math.min(len - prependCount, measuredItemsY.value.length)), prependCount);
253
+ measuredItemsX.value = newMeasuredX;
254
+ measuredItemsY.value = newMeasuredY;
255
+
256
+ // Calculate added size
257
+ const gap = propsVal.gap || 0;
258
+ const columnGap = propsVal.columnGap || 0;
259
+ let addedX = 0;
260
+ let addedY = 0;
261
+
262
+ for (let i = 0; i < prependCount; i++) {
263
+ const size = getItemBaseSize(newItems[ i ] as T, i);
264
+ if (props.value.direction === 'horizontal') {
265
+ addedX += size + columnGap;
266
+ } else { addedY += size + gap; }
267
+ }
268
+
269
+ if ((addedX > 0 || addedY > 0) && onScrollCorrection) {
270
+ onScrollCorrection(addedX, addedY);
271
+ }
272
+ }
273
+
274
+ initializeMeasurements();
275
+
276
+ lastItems = [ ...newItems ];
277
+ sizesInitialized.value = true;
278
+ treeUpdateFlag.value++;
279
+ };
280
+
281
+ /**
282
+ * Updates the size of multiple items in the Fenwick tree.
283
+ *
284
+ * @param updates - Array of updates.
285
+ * @param getRowIndexAt - Helper to get row index at offset (for scroll correction check).
286
+ * @param getColIndexAt - Helper to get col index at offset.
287
+ * @param relativeScrollX - Current relative scroll X.
288
+ * @param relativeScrollY - Current relative scroll Y.
289
+ * @param onScrollCorrection - Callback to adjust scroll position.
290
+ */
291
+ const updateItemSizes = (
292
+ updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>,
293
+ getRowIndexAt: (offset: number) => number,
294
+ getColIndexAt: (offset: number) => number,
295
+ relativeScrollX: number,
296
+ relativeScrollY: number,
297
+ onScrollCorrection: (deltaX: number, deltaY: number) => void,
298
+ ) => {
299
+ let needUpdate = false;
300
+ let deltaX = 0;
301
+ let deltaY = 0;
302
+ const propsVal = props.value.props;
303
+ const gap = propsVal.gap || 0;
304
+ const columnGap = propsVal.columnGap || 0;
305
+
306
+ const firstRowIndex = getRowIndexAt(props.value.direction === 'horizontal' ? relativeScrollX : relativeScrollY);
307
+ const firstColIndex = getColIndexAt(relativeScrollX);
308
+
309
+ const isHorizontalMode = props.value.direction === 'horizontal';
310
+ const isBothMode = props.value.direction === 'both';
311
+
312
+ const processedRows = new Set<number>();
313
+ const processedCols = new Set<number>();
314
+
315
+ const tryUpdateColumn = (colIdx: number, width: number) => {
316
+ if (colIdx >= 0 && colIdx < (propsVal.columnCount || 0) && !processedCols.has(colIdx)) {
317
+ processedCols.add(colIdx);
318
+ const oldW = columnSizes.get(colIdx);
319
+ const targetW = width + columnGap;
320
+
321
+ if (!measuredColumns.value[ colIdx ] || Math.abs(oldW - targetW) > 0.1) {
322
+ const d = targetW - oldW;
323
+ if (Math.abs(d) > 0.1) {
324
+ columnSizes.update(colIdx, d);
325
+ needUpdate = true;
326
+ if (colIdx < firstColIndex && oldW > 0) {
327
+ deltaX += d;
328
+ }
329
+ }
330
+ measuredColumns.value[ colIdx ] = 1;
331
+ }
332
+ }
333
+ };
334
+
335
+ for (const { index, inlineSize, blockSize, element } of updates) {
336
+ // Ignore 0-size measurements as they usually indicate hidden/detached elements
337
+ if (inlineSize <= 0 && blockSize <= 0) {
338
+ continue;
339
+ }
340
+
341
+ const isMeasurable = props.value.isDynamicItemSize || typeof propsVal.itemSize === 'function';
342
+ if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
343
+ processedRows.add(index);
344
+ if (isHorizontalMode && inlineSize > 0) {
345
+ const oldWidth = itemSizesX.get(index);
346
+ const targetWidth = inlineSize + columnGap;
347
+ if (!measuredItemsX.value[ index ] || Math.abs(targetWidth - oldWidth) > 0.1) {
348
+ const d = targetWidth - oldWidth;
349
+ itemSizesX.update(index, d);
350
+ measuredItemsX.value[ index ] = 1;
351
+ needUpdate = true;
352
+ if (index < firstRowIndex && oldWidth > 0) {
353
+ deltaX += d;
354
+ }
355
+ }
356
+ }
357
+ if (!isHorizontalMode) {
358
+ const oldHeight = itemSizesY.get(index);
359
+ const targetHeight = blockSize + gap;
360
+
361
+ if (!measuredItemsY.value[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
362
+ const d = targetHeight - oldHeight;
363
+ itemSizesY.update(index, d);
364
+ measuredItemsY.value[ index ] = 1;
365
+ needUpdate = true;
366
+ if (index < firstRowIndex && oldHeight > 0) {
367
+ deltaY += d;
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ // Dynamic column width measurement
374
+ const isColMeasurable = props.value.isDynamicColumnWidth || typeof propsVal.columnWidth === 'function';
375
+ if (
376
+ isBothMode
377
+ && element
378
+ && propsVal.columnCount
379
+ && isColMeasurable
380
+ && (inlineSize > 0 || element.dataset.colIndex === undefined)
381
+ ) {
382
+ const colIndexAttr = element.dataset.colIndex;
383
+ if (colIndexAttr != null) {
384
+ tryUpdateColumn(Number.parseInt(colIndexAttr, 10), inlineSize);
385
+ } else {
386
+ // If the element is a row, try to find cells with data-col-index
387
+ const cells = Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
388
+
389
+ for (const child of cells) {
390
+ const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
391
+ tryUpdateColumn(colIndex, child.getBoundingClientRect().width);
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ if (needUpdate) {
398
+ treeUpdateFlag.value++;
399
+ if (deltaX !== 0 || deltaY !== 0) {
400
+ onScrollCorrection(deltaX, deltaY);
401
+ }
402
+ }
403
+ };
404
+
405
+ /**
406
+ * Resets all dynamic measurements and re-initializes from current props.
407
+ *
408
+ * @param onScrollCorrection - Callback to adjust scroll position.
409
+ */
410
+ const refresh = (onScrollCorrection?: (addedX: number, addedY: number) => void) => {
411
+ itemSizesX.resize(0);
412
+ itemSizesY.resize(0);
413
+ columnSizes.resize(0);
414
+ measuredColumns.value.fill(0);
415
+ measuredItemsX.value.fill(0);
416
+ measuredItemsY.value.fill(0);
417
+ initializeSizes(onScrollCorrection);
418
+ };
419
+
420
+ return {
421
+ /** Fenwick Tree for horizontal item sizes. */
422
+ itemSizesX,
423
+ /** Fenwick Tree for vertical item sizes. */
424
+ itemSizesY,
425
+ /** Fenwick Tree for column widths. */
426
+ columnSizes,
427
+ /** Measured item widths. */
428
+ measuredItemsX,
429
+ /** Measured item heights. */
430
+ measuredItemsY,
431
+ /** Measured column widths. */
432
+ measuredColumns,
433
+ /** Flag that updates when any tree changes. */
434
+ treeUpdateFlag,
435
+ /** Whether sizes have been initialized. */
436
+ sizesInitialized,
437
+ /** Base size of an item from props. */
438
+ getItemBaseSize,
439
+ /** Helper to get current size at index. */
440
+ getSizeAt,
441
+ /** Initialize or update sizes from props. */
442
+ initializeSizes,
443
+ /** Update sizes of multiple items from measurements. */
444
+ updateItemSizes,
445
+ /** Reset all measurements. */
446
+ refresh,
447
+ };
448
+ }
@@ -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: MaybeRefOrGetter<ScrollAxis>;
14
+ axis: ScrollAxis;
15
15
  /** Total size of the scrollable content area in display pixels (DU). */
16
- totalSize: MaybeRefOrGetter<number>;
16
+ totalSize: number;
17
17
  /** Current scroll position in display pixels (DU). */
18
- position: MaybeRefOrGetter<number>;
18
+ position: number;
19
19
  /** Viewport size in display pixels (DU). */
20
- viewportSize: MaybeRefOrGetter<number>;
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?: MaybeRefOrGetter<string | undefined>;
27
+ containerId?: string | undefined;
28
28
  /** Whether the scrollbar is in Right-to-Left (RTL) mode. */
29
- isRtl?: MaybeRefOrGetter<boolean>;
29
+ isRtl?: boolean;
30
30
  /** Accessible label for the scrollbar. */
31
- ariaLabel?: MaybeRefOrGetter<string | undefined>;
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 props - Configuration properties.
38
+ * @param propsInput - Configuration properties.
39
39
  */
40
- export function useVirtualScrollbar(props: UseVirtualScrollbarProps) {
41
- const axis = computed(() => toValue(props.axis));
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(() => axis.value === 'horizontal');
43
+ const isHorizontal = computed(() => props.value.axis === 'horizontal');
49
44
 
50
45
  const viewportPercent = computed(() => {
51
- if (totalSize.value <= 0) {
46
+ if (props.value.totalSize <= 0) {
52
47
  return 0;
53
48
  }
54
- return Math.min(1, viewportSize.value / totalSize.value);
49
+ return Math.min(1, props.value.viewportSize / props.value.totalSize);
55
50
  });
56
51
 
57
52
  const positionPercent = computed(() => {
58
- const scrollableRange = totalSize.value - viewportSize.value;
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, position.value / scrollableRange));
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 = viewportSize.value > 0 ? (minThumbSize / viewportSize.value) : 0.1;
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 = viewportSize.value;
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 = isRtl.value ? rect.right - event.clientX : event.clientX - rect.left;
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 = totalSize.value - viewportSize.value;
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
- ? (isRtl.value ? -event.clientX : event.clientX)
132
+ ? (props.value.isRtl ? -event.clientX : event.clientX)
138
133
  : event.clientY;
139
- startScrollPos = position.value;
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
- ? (isRtl.value ? -event.clientX : event.clientX)
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 = totalSize.value - viewportSize.value;
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': toValue(props.ariaLabel),
203
- 'aria-orientation': axis.value,
204
- 'aria-valuenow': Math.round(position.value),
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(totalSize.value - viewportSize.value),
207
- 'aria-controls': containerId.value,
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: number | null | undefined, colIndex: number | null | undefined, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
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. */