@humanspeak/svelte-virtual-list 0.3.6 → 0.3.9

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.
@@ -92,6 +92,26 @@ import { type SvelteVirtualListProps, type SvelteVirtualListScrollOptions } from
92
92
  declare function $$render<TItem = unknown>(): {
93
93
  props: SvelteVirtualListProps<TItem>;
94
94
  exports: {
95
+ /**
96
+ * Runs a batch of updates with scroll corrections coalesced until the batch completes.
97
+ *
98
+ * Use this method when making multiple changes to the items array to prevent
99
+ * intermediate scroll corrections. The scroll position reconciliation is deferred
100
+ * until the batch exits, ensuring smooth visual updates.
101
+ *
102
+ * @param {() => void} fn - The function containing batch updates to execute.
103
+ * @returns {void}
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * // Add multiple items without intermediate scroll corrections
108
+ * list.runInBatch(() => {
109
+ * items.push(newItem1);
110
+ * items.push(newItem2);
111
+ * items.push(newItem3);
112
+ * });
113
+ * ```
114
+ */ runInBatch: (fn: () => void) => void;
95
115
  /**
96
116
  * Scrolls the virtual list to the item at the given index.
97
117
  *
@@ -159,6 +179,26 @@ declare class __sveltets_Render<TItem = unknown> {
159
179
  slots(): ReturnType<typeof $$render<TItem>>['slots'];
160
180
  bindings(): "";
161
181
  exports(): {
182
+ /**
183
+ * Runs a batch of updates with scroll corrections coalesced until the batch completes.
184
+ *
185
+ * Use this method when making multiple changes to the items array to prevent
186
+ * intermediate scroll corrections. The scroll position reconciliation is deferred
187
+ * until the batch exits, ensuring smooth visual updates.
188
+ *
189
+ * @param {() => void} fn - The function containing batch updates to execute.
190
+ * @returns {void}
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * // Add multiple items without intermediate scroll corrections
195
+ * list.runInBatch(() => {
196
+ * items.push(newItem1);
197
+ * items.push(newItem2);
198
+ * items.push(newItem3);
199
+ * });
200
+ * ```
201
+ */ runInBatch: (fn: () => void) => void;
162
202
  /**
163
203
  * Scrolls the virtual list to the item at the given index.
164
204
  *
@@ -1,13 +1,91 @@
1
+ /**
2
+ * Scheduler that coalesces recompute requests to the next animation frame.
3
+ *
4
+ * This class provides efficient batching of recompute operations by scheduling
5
+ * them to run on the next animation frame in browser environments. In non-browser
6
+ * or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
7
+ *
8
+ * Key features:
9
+ * - Coalesces multiple schedule() calls into a single recompute
10
+ * - Supports temporary blocking during critical sections
11
+ * - Handles nested block/unblock calls with depth tracking
12
+ * - Environment-aware: uses RAF in browsers, setTimeout in tests
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const scheduler = new RecomputeScheduler(() => {
17
+ * console.log('Recomputing derived state');
18
+ * });
19
+ *
20
+ * // Multiple calls within the same frame are coalesced
21
+ * scheduler.schedule();
22
+ * scheduler.schedule();
23
+ * scheduler.schedule(); // Only one recompute will run
24
+ *
25
+ * // Block during critical sections
26
+ * scheduler.block();
27
+ * scheduler.schedule(); // Marked as pending, won't run yet
28
+ * scheduler.unblock(); // Runs immediately if pending
29
+ * ```
30
+ *
31
+ * @class
32
+ */
1
33
  export declare class RecomputeScheduler {
34
+ /** Callback function to execute on recompute. */
2
35
  private onRecompute;
36
+ /** Whether a recompute is currently scheduled. */
3
37
  private isScheduled;
38
+ /** Whether a recompute is pending due to blocking. */
4
39
  private isPending;
40
+ /** Current nesting depth of block() calls. */
5
41
  private blockDepth;
42
+ /** ID of the pending setTimeout (non-browser fallback). */
6
43
  private timeoutId;
44
+ /** ID of the pending requestAnimationFrame. */
7
45
  private rafId;
46
+ /**
47
+ * Creates a new RecomputeScheduler instance.
48
+ *
49
+ * @param {() => void} onRecompute - Callback function to execute when recompute runs.
50
+ */
8
51
  constructor(onRecompute: () => void);
52
+ /**
53
+ * Schedules a recompute for the next animation frame.
54
+ *
55
+ * If the scheduler is blocked, the request is marked as pending and will
56
+ * execute when unblocked. Multiple calls while a recompute is already
57
+ * scheduled are coalesced into a single execution.
58
+ *
59
+ * @returns {void}
60
+ */
9
61
  schedule: () => void;
62
+ /**
63
+ * Temporarily blocks recompute execution.
64
+ *
65
+ * Cancels any in-flight timers and marks any pending recompute request.
66
+ * Block calls can be nested; the scheduler remains blocked until all
67
+ * corresponding unblock() calls are made.
68
+ *
69
+ * @returns {void}
70
+ */
10
71
  block: () => void;
72
+ /**
73
+ * Unblocks the scheduler and runs pending recompute if any.
74
+ *
75
+ * Decrements the block depth counter. When the depth reaches zero and
76
+ * a recompute was pending, it executes immediately (synchronously).
77
+ * Guards against underflow if unblock is called without matching block.
78
+ *
79
+ * @returns {void}
80
+ */
11
81
  unblock: () => void;
82
+ /**
83
+ * Cancels any scheduled or pending recompute.
84
+ *
85
+ * Clears all timers (setTimeout and RAF) and resets the scheduled
86
+ * and pending flags. Does not affect the block depth.
87
+ *
88
+ * @returns {void}
89
+ */
12
90
  cancel: () => void;
13
91
  }
@@ -1,19 +1,65 @@
1
- // RecomputeScheduler
2
- // -------------------
3
- // Coalesces recompute requests to the next animation frame in the browser.
4
- // Falls back to setTimeout(0) in non-browser/jsdom to preserve deterministic tests.
5
- // Supports temporary blocking to delay recomputation during critical sections.
1
+ /**
2
+ * Scheduler that coalesces recompute requests to the next animation frame.
3
+ *
4
+ * This class provides efficient batching of recompute operations by scheduling
5
+ * them to run on the next animation frame in browser environments. In non-browser
6
+ * or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
7
+ *
8
+ * Key features:
9
+ * - Coalesces multiple schedule() calls into a single recompute
10
+ * - Supports temporary blocking during critical sections
11
+ * - Handles nested block/unblock calls with depth tracking
12
+ * - Environment-aware: uses RAF in browsers, setTimeout in tests
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const scheduler = new RecomputeScheduler(() => {
17
+ * console.log('Recomputing derived state');
18
+ * });
19
+ *
20
+ * // Multiple calls within the same frame are coalesced
21
+ * scheduler.schedule();
22
+ * scheduler.schedule();
23
+ * scheduler.schedule(); // Only one recompute will run
24
+ *
25
+ * // Block during critical sections
26
+ * scheduler.block();
27
+ * scheduler.schedule(); // Marked as pending, won't run yet
28
+ * scheduler.unblock(); // Runs immediately if pending
29
+ * ```
30
+ *
31
+ * @class
32
+ */
6
33
  export class RecomputeScheduler {
34
+ /** Callback function to execute on recompute. */
7
35
  onRecompute;
36
+ /** Whether a recompute is currently scheduled. */
8
37
  isScheduled = false;
38
+ /** Whether a recompute is pending due to blocking. */
9
39
  isPending = false;
40
+ /** Current nesting depth of block() calls. */
10
41
  blockDepth = 0;
42
+ /** ID of the pending setTimeout (non-browser fallback). */
11
43
  timeoutId = null;
44
+ /** ID of the pending requestAnimationFrame. */
12
45
  rafId = null;
46
+ /**
47
+ * Creates a new RecomputeScheduler instance.
48
+ *
49
+ * @param {() => void} onRecompute - Callback function to execute when recompute runs.
50
+ */
13
51
  constructor(onRecompute) {
14
52
  this.onRecompute = onRecompute;
15
53
  }
16
- // Request a recompute. If blocked, mark as pending; otherwise schedule for next frame.
54
+ /**
55
+ * Schedules a recompute for the next animation frame.
56
+ *
57
+ * If the scheduler is blocked, the request is marked as pending and will
58
+ * execute when unblocked. Multiple calls while a recompute is already
59
+ * scheduled are coalesced into a single execution.
60
+ *
61
+ * @returns {void}
62
+ */
17
63
  schedule = () => {
18
64
  if (this.blockDepth > 0) {
19
65
  this.isPending = true;
@@ -45,7 +91,15 @@ export class RecomputeScheduler {
45
91
  this.onRecompute();
46
92
  });
47
93
  };
48
- // Temporarily block recomputes; any in-flight timers are canceled and a recompute is marked pending.
94
+ /**
95
+ * Temporarily blocks recompute execution.
96
+ *
97
+ * Cancels any in-flight timers and marks any pending recompute request.
98
+ * Block calls can be nested; the scheduler remains blocked until all
99
+ * corresponding unblock() calls are made.
100
+ *
101
+ * @returns {void}
102
+ */
49
103
  block = () => {
50
104
  this.blockDepth += 1;
51
105
  if (this.timeoutId) {
@@ -61,7 +115,15 @@ export class RecomputeScheduler {
61
115
  this.isPending = true;
62
116
  }
63
117
  };
64
- // Unblock and run recompute immediately if one was pending.
118
+ /**
119
+ * Unblocks the scheduler and runs pending recompute if any.
120
+ *
121
+ * Decrements the block depth counter. When the depth reaches zero and
122
+ * a recompute was pending, it executes immediately (synchronously).
123
+ * Guards against underflow if unblock is called without matching block.
124
+ *
125
+ * @returns {void}
126
+ */
65
127
  unblock = () => {
66
128
  if (this.blockDepth === 0)
67
129
  return;
@@ -71,7 +133,14 @@ export class RecomputeScheduler {
71
133
  this.onRecompute();
72
134
  }
73
135
  };
74
- // Cancel any scheduled recompute and clear pending state.
136
+ /**
137
+ * Cancels any scheduled or pending recompute.
138
+ *
139
+ * Clears all timers (setTimeout and RAF) and resets the scheduled
140
+ * and pending flags. Does not affect the block depth.
141
+ *
142
+ * @returns {void}
143
+ */
75
144
  cancel = () => {
76
145
  if (this.timeoutId) {
77
146
  clearTimeout(this.timeoutId);
package/dist/types.d.ts CHANGED
@@ -63,6 +63,21 @@ export type SvelteVirtualListProps<TItem = any> = {
63
63
  * CSS class to apply to the scrollable viewport element.
64
64
  */
65
65
  viewportClass?: string;
66
+ /**
67
+ * Callback when more data is needed. Supports sync and async functions.
68
+ * Called when the user scrolls near the end of the list (based on loadMoreThreshold).
69
+ */
70
+ onLoadMore?: () => void | Promise<void>;
71
+ /**
72
+ * Number of items from the end to trigger onLoadMore.
73
+ * @default 20
74
+ */
75
+ loadMoreThreshold?: number;
76
+ /**
77
+ * Set to false when all data has been loaded to stop triggering onLoadMore.
78
+ * @default true
79
+ */
80
+ hasMore?: boolean;
66
81
  };
67
82
  /**
68
83
  * Debug information provided by the virtual list during rendering.
@@ -1,12 +1,37 @@
1
1
  /**
2
- * Utility functions for detecting significant height changes in virtual list items
2
+ * Utility functions for detecting significant height changes in virtual list items.
3
+ *
4
+ * @fileoverview Provides height change detection utilities for virtual list optimization.
5
+ * These functions help determine when item height changes are significant enough to
6
+ * trigger recalculations, preventing unnecessary updates for sub-pixel variations.
3
7
  */
4
8
  /**
5
- * Checks if a height change is significant enough to warrant marking an item as dirty
6
- * @param itemIndex - The index of the item
7
- * @param newHeight - The new measured height
8
- * @param heightCache - Existing height cache to compare against
9
- * @param marginOfError - Height difference threshold (default: 1px)
10
- * @returns true if the height change is significant
9
+ * Checks if a height change is significant enough to warrant marking an item as dirty.
10
+ *
11
+ * This function compares the new measured height against the cached height for an item
12
+ * and determines if the difference exceeds the specified margin of error. Items with
13
+ * no previous measurement are always considered significant.
14
+ *
15
+ * @param {number} itemIndex - The index of the item in the virtual list.
16
+ * @param {number} newHeight - The new measured height in pixels.
17
+ * @param {Record<number, number>} heightCache - Cache of previously measured item heights.
18
+ * @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
19
+ * smaller than this value are considered insignificant.
20
+ * @returns {boolean} Returns true if the height change exceeds the margin of error
21
+ * or if this is the first measurement for the item.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const heightCache = { 0: 40, 1: 50 };
26
+ *
27
+ * // First-time measurement (no cache entry)
28
+ * isSignificantHeightChange(2, 45, heightCache); // true
29
+ *
30
+ * // Significant change (exceeds 1px threshold)
31
+ * isSignificantHeightChange(0, 45, heightCache); // true
32
+ *
33
+ * // Insignificant change (within 1px threshold)
34
+ * isSignificantHeightChange(0, 40.5, heightCache); // false
35
+ * ```
11
36
  */
12
37
  export declare const isSignificantHeightChange: (itemIndex: number, newHeight: number, heightCache: Record<number, number>, marginOfError?: number) => boolean;
@@ -1,13 +1,38 @@
1
1
  /**
2
- * Utility functions for detecting significant height changes in virtual list items
2
+ * Utility functions for detecting significant height changes in virtual list items.
3
+ *
4
+ * @fileoverview Provides height change detection utilities for virtual list optimization.
5
+ * These functions help determine when item height changes are significant enough to
6
+ * trigger recalculations, preventing unnecessary updates for sub-pixel variations.
3
7
  */
4
8
  /**
5
- * Checks if a height change is significant enough to warrant marking an item as dirty
6
- * @param itemIndex - The index of the item
7
- * @param newHeight - The new measured height
8
- * @param heightCache - Existing height cache to compare against
9
- * @param marginOfError - Height difference threshold (default: 1px)
10
- * @returns true if the height change is significant
9
+ * Checks if a height change is significant enough to warrant marking an item as dirty.
10
+ *
11
+ * This function compares the new measured height against the cached height for an item
12
+ * and determines if the difference exceeds the specified margin of error. Items with
13
+ * no previous measurement are always considered significant.
14
+ *
15
+ * @param {number} itemIndex - The index of the item in the virtual list.
16
+ * @param {number} newHeight - The new measured height in pixels.
17
+ * @param {Record<number, number>} heightCache - Cache of previously measured item heights.
18
+ * @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
19
+ * smaller than this value are considered insignificant.
20
+ * @returns {boolean} Returns true if the height change exceeds the margin of error
21
+ * or if this is the first measurement for the item.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const heightCache = { 0: 40, 1: 50 };
26
+ *
27
+ * // First-time measurement (no cache entry)
28
+ * isSignificantHeightChange(2, 45, heightCache); // true
29
+ *
30
+ * // Significant change (exceeds 1px threshold)
31
+ * isSignificantHeightChange(0, 45, heightCache); // true
32
+ *
33
+ * // Insignificant change (within 1px threshold)
34
+ * isSignificantHeightChange(0, 40.5, heightCache); // false
35
+ * ```
11
36
  */
12
37
  export const isSignificantHeightChange = (itemIndex, newHeight, heightCache, marginOfError = 1) => {
13
38
  const previousHeight = heightCache[itemIndex];
@@ -1,4 +1,39 @@
1
1
  import type { SvelteVirtualListMode, SvelteVirtualListScrollAlign } from '../types.js';
2
+ /**
3
+ * Calculates the scroll target for aligning an item to a specific edge.
4
+ *
5
+ * This helper consolidates the shared alignment logic between bottomToTop
6
+ * and topToBottom scroll calculations, reducing code duplication.
7
+ *
8
+ * @param {number} itemTop - The top position of the item in pixels
9
+ * @param {number} itemBottom - The bottom position of the item in pixels
10
+ * @param {number} scrollTop - Current scroll position in pixels
11
+ * @param {number} viewportHeight - Height of the viewport in pixels
12
+ * @param {'top' | 'bottom' | 'nearest'} align - The alignment mode
13
+ * @returns {number | null} The scroll target position, or null if item is already visible (for 'nearest')
14
+ */
15
+ export declare const alignToEdge: (itemTop: number, itemBottom: number, scrollTop: number, viewportHeight: number, align: "top" | "bottom" | "nearest") => number | null;
16
+ /**
17
+ * Calculates the scroll target for aligning a visible item to its nearest edge.
18
+ *
19
+ * Unlike alignToEdge with 'nearest', this always returns a scroll position
20
+ * even when the item is visible. Used for 'auto' alignment mode when item
21
+ * is within the visible range.
22
+ *
23
+ * @param {number} itemTop - The top position of the item in pixels
24
+ * @param {number} itemBottom - The bottom position of the item in pixels
25
+ * @param {number} scrollTop - Current scroll position in pixels
26
+ * @param {number} viewportHeight - Height of the viewport in pixels
27
+ * @returns {number} The scroll target position aligned to nearest edge
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * // For a visible item, align to whichever edge is closer
32
+ * const scrollTarget = alignVisibleToNearestEdge(400, 450, 200, 400)
33
+ * viewportElement.scrollTo({ top: scrollTarget })
34
+ * ```
35
+ */
36
+ export declare const alignVisibleToNearestEdge: (itemTop: number, itemBottom: number, scrollTop: number, viewportHeight: number) => number;
2
37
  /**
3
38
  * Parameters for calculating scroll target position
4
39
  */
@@ -1,4 +1,66 @@
1
- import { getScrollOffsetForIndex } from './virtualList.js';
1
+ import { clampValue, getScrollOffsetForIndex } from './virtualList.js';
2
+ /**
3
+ * Calculates the scroll target for aligning an item to a specific edge.
4
+ *
5
+ * This helper consolidates the shared alignment logic between bottomToTop
6
+ * and topToBottom scroll calculations, reducing code duplication.
7
+ *
8
+ * @param {number} itemTop - The top position of the item in pixels
9
+ * @param {number} itemBottom - The bottom position of the item in pixels
10
+ * @param {number} scrollTop - Current scroll position in pixels
11
+ * @param {number} viewportHeight - Height of the viewport in pixels
12
+ * @param {'top' | 'bottom' | 'nearest'} align - The alignment mode
13
+ * @returns {number | null} The scroll target position, or null if item is already visible (for 'nearest')
14
+ */
15
+ export const alignToEdge = (itemTop, itemBottom, scrollTop, viewportHeight, align) => {
16
+ if (align === 'top') {
17
+ return itemTop;
18
+ }
19
+ if (align === 'bottom') {
20
+ return clampValue(itemBottom - viewportHeight, 0, Infinity);
21
+ }
22
+ // 'nearest' alignment
23
+ const viewportBottom = scrollTop + viewportHeight;
24
+ const isVisible = itemTop < viewportBottom && itemBottom > scrollTop;
25
+ if (isVisible) {
26
+ // Already visible, no scroll needed
27
+ return null;
28
+ }
29
+ // Not visible - align to nearest edge
30
+ const distanceToTop = Math.abs(scrollTop - itemTop);
31
+ const distanceToBottom = Math.abs(viewportBottom - itemBottom);
32
+ return distanceToTop < distanceToBottom
33
+ ? itemTop
34
+ : clampValue(itemBottom - viewportHeight, 0, Infinity);
35
+ };
36
+ /**
37
+ * Calculates the scroll target for aligning a visible item to its nearest edge.
38
+ *
39
+ * Unlike alignToEdge with 'nearest', this always returns a scroll position
40
+ * even when the item is visible. Used for 'auto' alignment mode when item
41
+ * is within the visible range.
42
+ *
43
+ * @param {number} itemTop - The top position of the item in pixels
44
+ * @param {number} itemBottom - The bottom position of the item in pixels
45
+ * @param {number} scrollTop - Current scroll position in pixels
46
+ * @param {number} viewportHeight - Height of the viewport in pixels
47
+ * @returns {number} The scroll target position aligned to nearest edge
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * // For a visible item, align to whichever edge is closer
52
+ * const scrollTarget = alignVisibleToNearestEdge(400, 450, 200, 400)
53
+ * viewportElement.scrollTo({ top: scrollTarget })
54
+ * ```
55
+ */
56
+ export const alignVisibleToNearestEdge = (itemTop, itemBottom, scrollTop, viewportHeight) => {
57
+ const viewportBottom = scrollTop + viewportHeight;
58
+ const distanceToTop = Math.abs(scrollTop - itemTop);
59
+ const distanceToBottom = Math.abs(viewportBottom - itemBottom);
60
+ return distanceToTop < distanceToBottom
61
+ ? itemTop
62
+ : clampValue(itemBottom - viewportHeight, 0, Infinity);
63
+ };
2
64
  /**
3
65
  * Calculates the target scroll position for scrolling to a specific item index.
4
66
  *
@@ -58,7 +120,15 @@ export const calculateScrollTarget = (params) => {
58
120
  }
59
121
  };
60
122
  /**
61
- * Calculates scroll target for bottom-to-top mode
123
+ * Calculates the target scroll position for bottom-to-top mode.
124
+ *
125
+ * In bottom-to-top mode, items are rendered from the bottom of the viewport upward,
126
+ * which requires different scroll calculations than the standard top-to-bottom mode.
127
+ * This function handles the coordinate system translation and alignment logic.
128
+ *
129
+ * @param {BottomToTopScrollParams} params - Parameters for scroll calculation.
130
+ * @returns {number | null} The target scroll position in pixels, or null if no
131
+ * scroll is needed (item already visible with 'nearest' alignment).
62
132
  */
63
133
  const calculateBottomToTopScrollTarget = (params) => {
64
134
  const { align, targetIndex, itemsLength, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
@@ -66,102 +136,60 @@ const calculateBottomToTopScrollTarget = (params) => {
66
136
  const totalHeight = getScrollOffsetForIndex(heightCache, calculatedItemHeight, itemsLength);
67
137
  const itemOffset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
68
138
  const itemHeight = calculatedItemHeight;
139
+ // Calculate item boundaries in bottomToTop coordinate space
140
+ const itemTop = totalHeight - (itemOffset + itemHeight);
141
+ const itemBottom = totalHeight - itemOffset;
69
142
  if (align === 'auto') {
70
143
  // If item is above the viewport, align to top
71
144
  if (targetIndex < firstVisibleIndex) {
72
- return Math.max(0, totalHeight - (itemOffset + itemHeight));
145
+ return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
73
146
  }
74
147
  else if (targetIndex > lastVisibleIndex - 1) {
75
148
  // In bottomToTop, "below" means higher indices that need HIGHER scrollTop
76
- return Math.max(0, totalHeight - itemOffset - height);
149
+ return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
77
150
  }
78
151
  else {
79
- const itemTop = totalHeight - (itemOffset + itemHeight);
80
- const itemBottom = totalHeight - itemOffset;
81
- const distanceToTop = Math.abs(scrollTop - itemTop);
82
- const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
83
- return distanceToTop < distanceToBottom ? itemTop : Math.max(0, itemBottom - height);
152
+ // Item is visible - align to nearest edge (always returns a value)
153
+ return alignVisibleToNearestEdge(itemTop, itemBottom, scrollTop, height);
84
154
  }
85
155
  }
86
- else if (align === 'top') {
87
- return Math.max(0, totalHeight - (itemOffset + itemHeight));
88
- }
89
- else if (align === 'bottom') {
90
- return Math.max(0, totalHeight - itemOffset - height);
91
- }
92
- else if (align === 'nearest') {
93
- const itemTop = totalHeight - (itemOffset + itemHeight);
94
- const itemBottom = totalHeight - itemOffset;
95
- if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
96
- // Not visible, align to nearest edge
97
- const distanceToTop = Math.abs(scrollTop - itemTop);
98
- const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
99
- return distanceToTop < distanceToBottom ? itemTop : Math.max(0, itemBottom - height);
100
- }
101
- else {
102
- // Already visible, do nothing
103
- return null;
104
- }
156
+ if (align === 'top' || align === 'bottom' || align === 'nearest') {
157
+ return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
105
158
  }
106
159
  return null;
107
160
  };
108
161
  /**
109
- * Calculates scroll target for top-to-bottom mode
162
+ * Calculates the target scroll position for top-to-bottom mode.
163
+ *
164
+ * This is the standard scroll mode where items are rendered from the top of the
165
+ * viewport downward. The function calculates the optimal scroll position based
166
+ * on the alignment option and current viewport state.
167
+ *
168
+ * @param {TopToBottomScrollParams} params - Parameters for scroll calculation.
169
+ * @returns {number | null} The target scroll position in pixels, or null if no
170
+ * scroll is needed (item already visible with 'nearest' alignment).
110
171
  */
111
172
  const calculateTopToBottomScrollTarget = (params) => {
112
173
  const { align, targetIndex, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
174
+ // Calculate item boundaries
175
+ const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
176
+ const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
113
177
  if (align === 'auto') {
114
178
  // If item is above the viewport, align to top
115
179
  if (targetIndex < firstVisibleIndex) {
116
- const scrollTarget = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
117
- return scrollTarget;
180
+ return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
118
181
  }
119
182
  // If item is below the viewport, align to bottom
120
183
  else if (targetIndex > lastVisibleIndex - 1) {
121
- const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
122
- const scrollTarget = Math.max(0, itemBottom - height);
123
- return scrollTarget;
184
+ return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
124
185
  }
125
186
  else {
126
- // Item is visible but not aligned: align to nearest edge
127
- const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
128
- const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
129
- const distanceToTop = Math.abs(scrollTop - itemTop);
130
- const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
131
- if (distanceToTop < distanceToBottom) {
132
- return itemTop;
133
- }
134
- else {
135
- return Math.max(0, itemBottom - height);
136
- }
187
+ // Item is visible - align to nearest edge (always returns a value)
188
+ return alignVisibleToNearestEdge(itemTop, itemBottom, scrollTop, height);
137
189
  }
138
190
  }
139
- else if (align === 'top') {
140
- const scrollTarget = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
141
- return scrollTarget;
142
- }
143
- else if (align === 'bottom') {
144
- const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
145
- return Math.max(0, itemBottom - height);
146
- }
147
- else if (align === 'nearest') {
148
- const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
149
- const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
150
- if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
151
- // Not visible, align to nearest edge
152
- const distanceToTop = Math.abs(scrollTop - itemTop);
153
- const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
154
- if (distanceToTop < distanceToBottom) {
155
- return itemTop;
156
- }
157
- else {
158
- return Math.max(0, itemBottom - height);
159
- }
160
- }
161
- else {
162
- // Already visible, do nothing
163
- return null;
164
- }
191
+ if (align === 'top' || align === 'bottom' || align === 'nearest') {
192
+ return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
165
193
  }
166
194
  return null;
167
195
  };