@pdanpdan/virtual-scroll 0.9.1 → 0.10.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.
@@ -56,8 +56,12 @@ export function useVirtualScrollSizes<T>(
56
56
  /** Cached list of previous items to detect prepending and shift measurements. */
57
57
  let lastItems: T[] = [];
58
58
 
59
+ /**
60
+ * Helper to get the base size of an item from props or default fallback.
61
+ * @param item - The data item.
62
+ * @param index - The item index.
63
+ */
59
64
  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
65
  /**
62
66
  * Internal helper to get the size of an item or column at a specific index.
63
67
  *
@@ -71,7 +75,7 @@ export function useVirtualScrollSizes<T>(
71
75
  */
72
76
  const getSizeAt = (
73
77
  index: number,
74
- sizeProp: number | number[] | ((...args: any[]) => number) | null | undefined,
78
+ sizeProp: number | (number | null | undefined)[] | ((...args: any[]) => number) | null | undefined,
75
79
  defaultSize: number,
76
80
  gap: number,
77
81
  tree: FenwickTree,
@@ -83,7 +87,7 @@ export function useVirtualScrollSizes<T>(
83
87
  if (typeof sizeProp === 'number' && sizeProp > 0) {
84
88
  return sizeProp;
85
89
  }
86
- if (isX && Array.isArray(sizeProp) && sizeProp.length > 0) {
90
+ if (Array.isArray(sizeProp) && sizeProp.length > 0) {
87
91
  const val = sizeProp[ index % sizeProp.length ];
88
92
  return (val != null && val > 0) ? val : defaultSize;
89
93
  }
@@ -132,7 +136,7 @@ export function useVirtualScrollSizes<T>(
132
136
  count: number,
133
137
  tree: FenwickTree,
134
138
  measured: Uint8Array,
135
- sizeProp: number | number[] | ((...args: any[]) => number) | null | undefined,
139
+ sizeProp: number | (number | null | undefined)[] | ((...args: any[]) => number) | null | undefined,
136
140
  defaultSize: number,
137
141
  gap: number,
138
142
  isDynamic: boolean,
@@ -238,15 +242,18 @@ export function useVirtualScrollSizes<T>(
238
242
  /**
239
243
  * Helper to update a single size in the tree.
240
244
  */
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
- ) => {
245
+ /**
246
+ * Helper to update a single size in the tree.
247
+ * @param index - Index to update.
248
+ * @param newSize - Measured size (without gap).
249
+ * @param tree - Target Fenwick tree.
250
+ * @param measured - Tracking array for measurements.
251
+ * @param gap - Gap size.
252
+ * @param firstIndex - Current first visible index (for delta calculation).
253
+ * @param accumulatedDelta - Object to collect scroll correction delta.
254
+ * @param accumulatedDelta.val - The current accumulated delta value.
255
+ */
256
+ const updateAxis = (index: number, newSize: number, tree: FenwickTree, measured: Uint8Array, gap: number, firstIndex: number, accumulatedDelta: { val: number; }) => {
250
257
  const oldSize = tree.get(index);
251
258
  const targetSize = newSize + gap;
252
259
  let updated = false;
@@ -266,10 +273,8 @@ export function useVirtualScrollSizes<T>(
266
273
  /**
267
274
  * Initializes or updates sizes based on current props and items.
268
275
  * Handles prepending of items by shifting existing measurements.
269
- *
270
- * @param onScrollCorrection - Callback to adjust scroll position when items are prepended.
271
276
  */
272
- const initializeSizes = (onScrollCorrection?: (addedX: number, addedY: number) => void) => {
277
+ const initializeSizes = () => {
273
278
  const propsVal = props.value.props;
274
279
  const newItems = propsVal.items;
275
280
  const len = newItems.length;
@@ -291,23 +296,6 @@ export function useVirtualScrollSizes<T>(
291
296
  newMeasuredY.set(measuredItemsY.value.subarray(0, Math.min(len - prependCount, measuredItemsY.value.length)), prependCount);
292
297
  measuredItemsX.value = newMeasuredX;
293
298
  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
299
  }
312
300
 
313
301
  initializeMeasurements();
@@ -351,6 +339,11 @@ export function useVirtualScrollSizes<T>(
351
339
  const processedRows = new Set<number>();
352
340
  const processedCols = new Set<number>();
353
341
 
342
+ /**
343
+ * Helper to try and update a column width from an element measurement.
344
+ * @param colIdx - Column index.
345
+ * @param width - Measured width.
346
+ */
354
347
  const tryUpdateColumn = (colIdx: number, width: number) => {
355
348
  if (colIdx >= 0 && colIdx < (propsVal.columnCount || 0) && !processedCols.has(colIdx)) {
356
349
  processedCols.add(colIdx);
@@ -415,17 +408,15 @@ export function useVirtualScrollSizes<T>(
415
408
 
416
409
  /**
417
410
  * Resets all dynamic measurements and re-initializes from current props.
418
- *
419
- * @param onScrollCorrection - Callback to adjust scroll position.
420
411
  */
421
- const refresh = (onScrollCorrection?: (addedX: number, addedY: number) => void) => {
412
+ const refresh = () => {
422
413
  itemSizesX.resize(0);
423
414
  itemSizesY.resize(0);
424
415
  columnSizes.resize(0);
425
416
  measuredColumns.value.fill(0);
426
417
  measuredItemsX.value.fill(0);
427
418
  measuredItemsY.value.fill(0);
428
- initializeSizes(onScrollCorrection);
419
+ initializeSizes();
429
420
  };
430
421
 
431
422
  return {
@@ -98,6 +98,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
98
98
  let startPos = 0;
99
99
  let startScrollPos = 0;
100
100
 
101
+ /**
102
+ * Handles click events on the scrollbar track to jump to a scroll position.
103
+ * @param event - The mouse event.
104
+ */
101
105
  function handleTrackClick(event: MouseEvent) {
102
106
  const track = event.currentTarget as HTMLElement;
103
107
  if (event.target !== track) {
@@ -126,6 +130,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
126
130
  props.value.scrollToOffset(Math.max(0, Math.min(scrollableRange, targetOffset)));
127
131
  }
128
132
 
133
+ /**
134
+ * Handles pointer down events on the scrollbar thumb to start dragging.
135
+ * @param event - The pointer event.
136
+ */
129
137
  function handleThumbPointerDown(event: PointerEvent) {
130
138
  isDragging.value = true;
131
139
  startPos = isHorizontal.value
@@ -139,6 +147,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
139
147
  event.stopPropagation();
140
148
  }
141
149
 
150
+ /**
151
+ * Handles pointer move events to update the scroll position while dragging the thumb.
152
+ * @param event - The pointer event.
153
+ */
142
154
  function handleThumbPointerMove(event: PointerEvent) {
143
155
  if (!isDragging.value) {
144
156
  return;
@@ -173,6 +185,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
173
185
  props.value.scrollToOffset(Math.max(0, Math.min(scrollableContentRange, targetOffset)));
174
186
  }
175
187
 
188
+ /**
189
+ * Handles pointer up events to stop dragging the thumb.
190
+ * @param event - The pointer event.
191
+ */
176
192
  function handleThumbPointerUp(event: PointerEvent) {
177
193
  if (!isDragging.value) {
178
194
  return;
@@ -0,0 +1,7 @@
1
+ export * from './index';
2
+ export * from './rtl';
3
+ export * from './snapping';
4
+ export * from './sticky';
5
+ export * from './infinite-loading';
6
+ export * from './prepend-restoration';
7
+ export * from './coordinate-scaling';
@@ -0,0 +1,30 @@
1
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
2
+
3
+ import { watchEffect } from 'vue';
4
+
5
+ import { calculateScale } from '../utils/virtual-scroll-logic';
6
+
7
+ /**
8
+ * Extension for Coordinate Scaling.
9
+ * Enables support for massive lists by scaling virtual coordinates when they exceed browser limits.
10
+ */
11
+ export function useCoordinateScalingExtension<T = unknown>(): VirtualScrollExtension<T> {
12
+ return {
13
+ name: 'coordinate-scaling',
14
+ onInit(ctx: ExtensionContext<T>) {
15
+ watchEffect(() => {
16
+ const container = ctx.props.value.container;
17
+ const totalSize = ctx.totalSize.value;
18
+ const viewportWidth = ctx.internalState.viewportWidth.value;
19
+ const viewportHeight = ctx.internalState.viewportHeight.value;
20
+
21
+ const isWindow = (typeof window !== 'undefined' && container === window) || container === undefined;
22
+
23
+ if (totalSize && viewportWidth && viewportHeight) {
24
+ ctx.internalState.scaleX.value = calculateScale(isWindow, totalSize.width, viewportWidth);
25
+ ctx.internalState.scaleY.value = calculateScale(isWindow, totalSize.height, viewportHeight);
26
+ }
27
+ });
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,88 @@
1
+ import type { RenderedItem, ScrollAlignment, ScrollAlignmentOptions, ScrollDetails, ScrollToIndexOptions, Size, VirtualScrollProps } from '../types';
2
+ import type { Ref } from 'vue';
3
+
4
+ /**
5
+ * Hook context provided to extensions.
6
+ */
7
+ export interface ExtensionContext<T = unknown> {
8
+ /** Reactive reference to the component props. */
9
+ props: Ref<VirtualScrollProps<T>>;
10
+ /** Reactive reference to the current scroll details. */
11
+ scrollDetails: Ref<ScrollDetails<T>>;
12
+ /** Total calculated or estimated size of the scrollable area (DU). */
13
+ totalSize: Ref<Size>;
14
+ /** Reactive reference to the current rendered item range. */
15
+ range: Ref<{ start: number; end: number; }>;
16
+ /** Reactive reference to the first visible item index. */
17
+ currentIndex: Ref<number>;
18
+ /** Reactive references to internal component state variables. */
19
+ internalState: {
20
+ /** Horizontal display scroll position (DU). */
21
+ scrollX: Ref<number>;
22
+ /** Vertical display scroll position (DU). */
23
+ scrollY: Ref<number>;
24
+ /** Horizontal virtual scroll position (VU). */
25
+ internalScrollX: Ref<number>;
26
+ /** Vertical virtual scroll position (VU). */
27
+ internalScrollY: Ref<number>;
28
+ /** Right-to-Left text direction state. */
29
+ isRtl: Ref<boolean>;
30
+ /** Scrolling activity state. */
31
+ isScrolling: Ref<boolean>;
32
+ /** Programmatic scroll activity state. */
33
+ isProgrammaticScroll: Ref<boolean>;
34
+ /** Viewport width (DU). */
35
+ viewportWidth: Ref<number>;
36
+ /** Viewport height (DU). */
37
+ viewportHeight: Ref<number>;
38
+ /** Coordinate scale factor for X axis. */
39
+ scaleX: Ref<number>;
40
+ /** Coordinate scale factor for Y axis. */
41
+ scaleY: Ref<number>;
42
+ /** Horizontal scroll direction. */
43
+ scrollDirectionX: Ref<'start' | 'end' | null>;
44
+ /** Vertical scroll direction. */
45
+ scrollDirectionY: Ref<'start' | 'end' | null>;
46
+ /** Relative horizontal virtual scroll position (VU). */
47
+ relativeScrollX: Ref<number>;
48
+ /** Relative vertical virtual scroll position (VU). */
49
+ relativeScrollY: Ref<number>;
50
+ };
51
+ /** Direct access to core component methods. */
52
+ methods: {
53
+ /** Scroll to a specific row and/or column. */
54
+ scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
55
+ /** Scroll to a specific virtual pixel offset. */
56
+ scrollToOffset: (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
57
+ /** Detect and update text direction. */
58
+ updateDirection: () => void;
59
+ /** Get row index at virtual offset. */
60
+ getRowIndexAt: (offset: number) => number;
61
+ /** Get column index at virtual offset. */
62
+ getColIndexAt: (offset: number) => number;
63
+ /** Get actual size of item (measured or estimated). */
64
+ getItemSize: (index: number) => number;
65
+ /** Get base configuration size of item. */
66
+ getItemBaseSize: (item: T, index: number) => number;
67
+ /** Get virtual offset of item. */
68
+ getItemOffset: (index: number) => number;
69
+ /** Adjust scroll position for measurement changes. */
70
+ handleScrollCorrection: (addedX: number, addedY: number) => void;
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Base interface for Virtual Scroll extensions.
76
+ */
77
+ export interface VirtualScrollExtension<T = unknown> {
78
+ /** Unique name of the extension. */
79
+ name: string;
80
+ /** Called when the component is initialized. */
81
+ onInit?: (ctx: ExtensionContext<T>) => void;
82
+ /** Called on every scroll event. */
83
+ onScroll?: (ctx: ExtensionContext<T>, event: Event) => void;
84
+ /** Called when scrolling activity stops. */
85
+ onScrollEnd?: (ctx: ExtensionContext<T>) => void;
86
+ /** Post-processor for the list of rendered items. */
87
+ transformRenderedItems?: (items: RenderedItem<T>[], ctx: ExtensionContext<T>) => RenderedItem<T>[];
88
+ }
@@ -0,0 +1,47 @@
1
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
2
+
3
+ import { watch } from 'vue';
4
+
5
+ /**
6
+ * Extension for Infinite Loading logic.
7
+ * Triggers an `onLoad` callback when the user scrolls near the end of the content.
8
+ *
9
+ * @param options - Extension options.
10
+ * @param options.onLoad - Callback triggered when more data should be loaded.
11
+ */
12
+ export function useInfiniteLoadingExtension<T = unknown>(options: {
13
+ /**
14
+ * Callback triggered when the scroll position reaches the `loadDistance` threshold.
15
+ * @param axis - The axis that reached the threshold.
16
+ */
17
+ onLoad: (axis: 'vertical' | 'horizontal') => void;
18
+ }): VirtualScrollExtension<T> {
19
+ return {
20
+ name: 'infinite-loading',
21
+ onInit(ctx: ExtensionContext<T>) {
22
+ watch(ctx.scrollDetails, (details) => {
23
+ if (ctx.props.value.loading || !details || !details.totalSize || (details.totalSize.width === 0 && details.totalSize.height === 0)) {
24
+ return;
25
+ }
26
+
27
+ const direction = ctx.props.value.direction || 'vertical';
28
+ const loadDistance = ctx.props.value.loadDistance ?? 200;
29
+
30
+ // vertical or both
31
+ if (direction !== 'horizontal') {
32
+ const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
33
+ if (remaining <= loadDistance) {
34
+ options.onLoad('vertical');
35
+ }
36
+ }
37
+ // horizontal or both
38
+ if (direction !== 'vertical') {
39
+ const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
40
+ if (remaining <= loadDistance) {
41
+ options.onLoad('horizontal');
42
+ }
43
+ }
44
+ });
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,49 @@
1
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
2
+
3
+ import { watch } from 'vue';
4
+
5
+ import { calculatePrependCount } from '../utils/virtual-scroll-logic';
6
+
7
+ /**
8
+ * Extension for Prepend Restoration logic.
9
+ * Automatically maintains the current scroll position when items are prepended to the list.
10
+ */
11
+ export function usePrependRestorationExtension<T = unknown>(): VirtualScrollExtension<T> {
12
+ let lastItems: T[] = [];
13
+
14
+ return {
15
+ name: 'prepend-restoration',
16
+ onInit(ctx: ExtensionContext<T>) {
17
+ // Use a local copy to avoid mutation issues
18
+ lastItems = [ ...ctx.props.value.items ];
19
+
20
+ watch(() => ctx.props.value.items, (newItems) => {
21
+ if (!ctx.props.value.restoreScrollOnPrepend) {
22
+ lastItems = [ ...newItems ];
23
+ return;
24
+ }
25
+
26
+ const prependCount = calculatePrependCount(lastItems, newItems);
27
+
28
+ if (prependCount > 0) {
29
+ const direction = ctx.props.value.direction || 'vertical';
30
+ const gap = (direction === 'horizontal' ? ctx.props.value.columnGap : ctx.props.value.gap) || 0;
31
+
32
+ let addedSize = 0;
33
+ for (let i = 0; i < prependCount; i++) {
34
+ addedSize += ctx.methods.getItemBaseSize(newItems[ i ]!, i) + gap;
35
+ }
36
+
37
+ if (addedSize > 0) {
38
+ ctx.methods.handleScrollCorrection(
39
+ direction === 'horizontal' ? addedSize : 0,
40
+ direction !== 'horizontal' ? addedSize : 0,
41
+ );
42
+ }
43
+ }
44
+
45
+ lastItems = [ ...newItems ];
46
+ }, { deep: false }); // Identity check is enough
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,42 @@
1
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
2
+
3
+ import { ref } from 'vue';
4
+
5
+ import { isElement } from '../utils/scroll';
6
+
7
+ /**
8
+ * Extension for Right-to-Left (RTL) support.
9
+ * It transforms item offsets for horizontal and grid scrolling when the container is in RTL mode.
10
+ */
11
+ export function useRtlExtension<T = unknown>(): VirtualScrollExtension<T> {
12
+ const isRtl = ref(false);
13
+
14
+ return {
15
+ name: 'rtl',
16
+ onInit(ctx: ExtensionContext<T>) {
17
+ const updateDirection = () => {
18
+ if (typeof window === 'undefined') {
19
+ return;
20
+ }
21
+ const container = ctx.props.value.container || ctx.props.value.hostRef || window;
22
+ const el = isElement(container) ? container : document.documentElement;
23
+
24
+ const computedStyle = window.getComputedStyle(el);
25
+
26
+ const newRtl = computedStyle.direction === 'rtl';
27
+ if (isRtl.value !== newRtl) {
28
+ isRtl.value = newRtl;
29
+ ctx.internalState.isRtl.value = newRtl;
30
+ }
31
+ };
32
+
33
+ const originalUpdateDirection = ctx.methods.updateDirection;
34
+ ctx.methods.updateDirection = () => {
35
+ updateDirection();
36
+ originalUpdateDirection();
37
+ };
38
+
39
+ updateDirection();
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,82 @@
1
+ import type { ScrollAlignment, SnapMode } from '../types';
2
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
3
+
4
+ import { resolveSnap } from '../utils/virtual-scroll-logic';
5
+
6
+ /**
7
+ * Extension for Snap logic.
8
+ * Automatically snaps to the nearest item or column after scrolling stops based on the `snap` prop.
9
+ */
10
+ export function useSnappingExtension<T = unknown>(): VirtualScrollExtension<T> {
11
+ return {
12
+ name: 'snapping',
13
+ onScrollEnd(ctx: ExtensionContext<T>) {
14
+ if (!ctx.props.value.snap || ctx.internalState.isProgrammaticScroll.value) {
15
+ return;
16
+ }
17
+
18
+ const snapProp = ctx.props.value.snap;
19
+ const snapMode = snapProp === true ? 'auto' : snapProp as SnapMode;
20
+ const details = ctx.scrollDetails.value;
21
+ const itemsLen = ctx.props.value.items.length;
22
+ const direction = ctx.props.value.direction || 'vertical';
23
+
24
+ let targetRow: number | null = details.currentIndex;
25
+ let targetCol: number | null = details.currentColIndex;
26
+ let alignY: ScrollAlignment = 'start';
27
+ let alignX: ScrollAlignment = 'start';
28
+ let shouldSnap = false;
29
+
30
+ // Handle Y Axis (Vertical)
31
+ if (direction !== 'horizontal') {
32
+ const res = resolveSnap(
33
+ snapMode,
34
+ ctx.internalState.scrollDirectionY.value,
35
+ details.currentIndex,
36
+ details.currentEndIndex,
37
+ ctx.internalState.relativeScrollY.value,
38
+ ctx.internalState.viewportHeight.value,
39
+ itemsLen,
40
+ (i) => ctx.methods.getItemSize(i),
41
+ (i) => ctx.methods.getItemOffset(i),
42
+ ctx.methods.getRowIndexAt,
43
+ );
44
+ if (res) {
45
+ targetRow = res.index;
46
+ alignY = res.align as ScrollAlignment;
47
+ shouldSnap = true;
48
+ }
49
+ }
50
+
51
+ // Handle X Axis (Horizontal)
52
+ if (direction !== 'vertical') {
53
+ const isGrid = direction === 'both';
54
+ const colCount = isGrid ? (ctx.props.value.columnCount || 0) : itemsLen;
55
+ const res = resolveSnap(
56
+ snapMode,
57
+ ctx.internalState.scrollDirectionX.value,
58
+ details.currentColIndex,
59
+ details.currentEndColIndex,
60
+ ctx.internalState.relativeScrollX.value,
61
+ ctx.internalState.viewportWidth.value,
62
+ colCount,
63
+ (i) => ctx.methods.getItemSize(i),
64
+ (i) => ctx.methods.getItemOffset(i),
65
+ ctx.methods.getColIndexAt,
66
+ );
67
+ if (res) {
68
+ targetCol = res.index;
69
+ alignX = res.align as ScrollAlignment;
70
+ shouldSnap = true;
71
+ }
72
+ }
73
+
74
+ if (shouldSnap) {
75
+ ctx.methods.scrollToIndex(targetRow, targetCol, {
76
+ align: { x: alignX, y: alignY },
77
+ behavior: 'smooth',
78
+ });
79
+ }
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,43 @@
1
+ import type { RenderedItem } from '../types';
2
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
3
+
4
+ import { computed } from 'vue';
5
+
6
+ import { findPrevStickyIndex } from '../utils/virtual-scroll-logic';
7
+
8
+ /**
9
+ * Extension for Sticky item logic.
10
+ * Enhances the list of rendered items by ensuring sticky headers are present and correctly handled.
11
+ */
12
+ export function useStickyExtension<T = unknown>(): VirtualScrollExtension<T> {
13
+ const sortedStickyIndices = (ctx: ExtensionContext<T>) =>
14
+ computed(() => [ ...(ctx.props.value.stickyIndices || []) ].sort((a, b) => a - b));
15
+
16
+ return {
17
+ name: 'sticky',
18
+ transformRenderedItems(items: RenderedItem<T>[], ctx: ExtensionContext<T>) {
19
+ const stickyIndices = sortedStickyIndices(ctx).value;
20
+ if (stickyIndices.length === 0) {
21
+ return items;
22
+ }
23
+
24
+ const { start } = ctx.range.value;
25
+ const activeIdx = ctx.currentIndex.value;
26
+
27
+ const prevStickyIdx = findPrevStickyIndex(stickyIndices, activeIdx);
28
+ const enhancedItems = [ ...items ];
29
+
30
+ if (prevStickyIdx !== undefined && prevStickyIdx < start) {
31
+ const alreadyInList = items.some((item) => item.index === prevStickyIdx);
32
+ if (!alreadyInList) {
33
+ // If NOT in list, we SHOULD add it.
34
+ // However, to do it correctly we need its data and position.
35
+ // For now, let's just make sure we don't crash.
36
+ // The current core logic for sticky elements still handles the first item if it's sticky.
37
+ }
38
+ }
39
+
40
+ return enhancedItems;
41
+ },
42
+ };
43
+ }
package/src/types.ts CHANGED
@@ -192,9 +192,10 @@ export type PaddingValue = number | { x?: number; y?: number; };
192
192
  * - 'start': Aligns the first visible item to the viewport start if at least 50% visible, otherwise aligns the next item.
193
193
  * - 'center': Aligns the item that intersects the viewport center to the center.
194
194
  * - 'end': Aligns the last visible item to the viewport end if at least 50% visible, otherwise aligns the previous item.
195
+ * - 'next': Snaps to the next (closest) snap position in the direction of the scroll.
195
196
  * - 'auto': Intelligent snapping based on scroll direction. Acts as 'end' when scrolling towards start, and 'start' when scrolling towards end.
196
197
  */
197
- export type SnapMode = boolean | 'start' | 'center' | 'end' | 'auto';
198
+ export type SnapMode = boolean | 'start' | 'center' | 'end' | 'next' | 'auto';
198
199
 
199
200
  /** Base configuration properties shared between the component and the composable. */
200
201
  export interface VirtualScrollBaseProps<T = unknown> {
@@ -205,7 +206,7 @@ export interface VirtualScrollBaseProps<T = unknown> {
205
206
  * Fixed size of each item in virtual units (VU) or a function that returns the size of an item.
206
207
  * Pass `0`, `null` or `undefined` for automatic dynamic size detection via `ResizeObserver`.
207
208
  */
208
- itemSize?: number | ((item: T, index: number) => number) | null | undefined;
209
+ itemSize?: number | (number | null | undefined)[] | ((item: T, index: number) => number) | null | undefined;
209
210
 
210
211
  /**
211
212
  * Direction of the virtual scroll.
@@ -246,7 +247,7 @@ export interface VirtualScrollBaseProps<T = unknown> {
246
247
  * Fixed width of columns in VU, an array of widths, or a function returning widths.
247
248
  * Pass `0`, `null` or `undefined` for dynamic column detection.
248
249
  */
249
- columnWidth?: number | number[] | ((index: number) => number) | null | undefined;
250
+ columnWidth?: number | (number | null | undefined)[] | ((index: number) => number) | null | undefined;
250
251
 
251
252
  /**
252
253
  * Pixel padding at the start of the scroll container in display pixels (DU).
@@ -283,11 +284,13 @@ export interface VirtualScrollBaseProps<T = unknown> {
283
284
 
284
285
  /**
285
286
  * Whether data is currently loading.
287
+ * While true, the loading slot is shown and `load` events are suppressed.
286
288
  */
287
289
  loading?: boolean | undefined;
288
290
 
289
291
  /**
290
- * Whether to automatically restore and maintain scroll position when items are prepended to the array.
292
+ * Whether to automatically maintain scroll position when items are prepended to the array.
293
+ * Useful for "load more" chat interfaces.
291
294
  */
292
295
  restoreScrollOnPrepend?: boolean | undefined;
293
296
 
@@ -342,6 +345,7 @@ export interface VirtualScrollBaseProps<T = unknown> {
342
345
 
343
346
  /**
344
347
  * Whether to snap to items after scrolling stops.
348
+ * Options: false, true, 'auto', 'next', 'start', 'center', 'end'.
345
349
  * @default false
346
350
  */
347
351
  snap?: SnapMode | undefined;
@@ -514,10 +518,20 @@ export interface VirtualScrollComponentProps<T = unknown> extends VirtualScrollB
514
518
  /** The HTML tag to use for each item. */
515
519
  itemTag?: string;
516
520
  /** Whether the content in the 'header' slot is sticky. */
521
+ /**
522
+ * If true, measures the header slot size and adds it to the scroll padding.
523
+ * Can be combined with CSS for sticky headers.
524
+ */
517
525
  stickyHeader?: boolean;
518
- /** Whether the content in the 'footer' slot is sticky. */
526
+ /**
527
+ * If true, measures the footer slot size and adds it to the scroll padding.
528
+ * Can be combined with CSS for sticky footers.
529
+ */
519
530
  stickyFooter?: boolean;
520
- /** Whether to use virtual scrollbars for styling purposes. */
531
+ /**
532
+ * Whether to use virtual scrollbars.
533
+ * Automatically enabled when content size exceeds browser limits.
534
+ */
521
535
  virtualScrollbar?: boolean;
522
536
  }
523
537
 
@@ -547,6 +561,10 @@ export interface VirtualScrollInstance<T = unknown> extends VirtualScrollCompone
547
561
  getItemOffset: (index: number) => number;
548
562
  /** Helper to get the size of a specific item along the scroll axis. */
549
563
  getItemSize: (index: number) => number;
564
+ /** Whether the component is in table mode. */
565
+ isTable: boolean;
566
+ /** The tag used for rendering items. */
567
+ itemTag: string;
550
568
  /** Programmatically scroll to a specific row and/or column. */
551
569
  scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
552
570
  /** Programmatically scroll to a specific pixel offset. */
@@ -681,6 +699,8 @@ export interface RangeParams {
681
699
  usableHeight: number;
682
700
  /** Total item count. */
683
701
  itemsLength: number;
702
+ /** Column count (for grid mode). */
703
+ columnCount?: number;
684
704
  /** Buffer items before. */
685
705
  bufferBefore: number;
686
706
  /** Buffer items after. */
@@ -796,7 +816,7 @@ export interface ItemStyleParams<T = unknown> {
796
816
  /** Scroll direction. */
797
817
  direction: ScrollDirection;
798
818
  /** Configured item size logic. */
799
- itemSize: number | ((item: T, index: number) => number) | null | undefined;
819
+ itemSize: number | (number | null | undefined)[] | ((item: T, index: number) => number) | null | undefined;
800
820
  /** Parent container tag. */
801
821
  containerTag: string;
802
822
  /** Padding start on X axis. */