@pdanpdan/virtual-scroll 0.4.0 → 0.6.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.
@@ -12,43 +12,461 @@ import type {
12
12
  TotalSizeParams,
13
13
  } from '../types';
14
14
 
15
- import { isScrollToIndexOptions } from './scroll';
15
+ import { BROWSER_MAX_SIZE, isScrollToIndexOptions } from './scroll';
16
+
17
+ // --- Internal Helper Types ---
18
+
19
+ interface GenericRangeParams {
20
+ scrollPos: number;
21
+ containerSize: number;
22
+ count: number;
23
+ bufferBefore: number;
24
+ bufferAfter: number;
25
+ gap: number;
26
+ fixedSize: number | null;
27
+ findLowerBound: (offset: number) => number;
28
+ query: (index: number) => number;
29
+ }
30
+
31
+ interface AxisAlignmentParams {
32
+ align: ScrollAlignment;
33
+ targetPos: number;
34
+ itemSize: number;
35
+ scrollPos: number;
36
+ viewSize: number;
37
+ stickyOffsetStart: number;
38
+ stickyOffsetEnd: number;
39
+ }
40
+
41
+ // --- Internal Helpers ---
42
+
43
+ /**
44
+ * Generic range calculation for a single axis (row or column).
45
+ *
46
+ * @param params - Range parameters.
47
+ * @param params.scrollPos - Virtual scroll position.
48
+ * @param params.containerSize - Usable viewport size.
49
+ * @param params.count - Total item count.
50
+ * @param params.bufferBefore - Buffer items before.
51
+ * @param params.bufferAfter - Buffer items after.
52
+ * @param params.gap - Item gap.
53
+ * @param params.fixedSize - Fixed item size.
54
+ * @param params.findLowerBound - Binary search for index.
55
+ * @param params.query - Prefix sum for index.
56
+ * @returns Start and end indices.
57
+ */
58
+ function calculateGenericRange({
59
+ scrollPos,
60
+ containerSize,
61
+ count,
62
+ bufferBefore,
63
+ bufferAfter,
64
+ gap,
65
+ fixedSize,
66
+ findLowerBound,
67
+ query,
68
+ }: GenericRangeParams) {
69
+ let start = 0;
70
+ let end = count;
71
+ const endOffset = scrollPos + containerSize;
72
+
73
+ if (fixedSize !== null) {
74
+ const step = fixedSize + gap;
75
+ start = Math.floor(scrollPos / step);
76
+ end = Math.ceil(endOffset / step);
77
+ } else {
78
+ start = findLowerBound(scrollPos);
79
+ end = findLowerBound(endOffset);
80
+ if (end < count && query(end) < endOffset) {
81
+ end++;
82
+ }
83
+ }
84
+
85
+ return {
86
+ start: Math.max(0, start - bufferBefore),
87
+ end: Math.min(count, end + bufferAfter),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Binary search for the next sticky index after the current index.
93
+ *
94
+ * @param stickyIndices - Sorted array of sticky indices.
95
+ * @param index - Current index.
96
+ * @returns Next sticky index or undefined.
97
+ */
98
+ function findNextStickyIndex(stickyIndices: number[], index: number): number | undefined {
99
+ let low = 0;
100
+ let high = stickyIndices.length - 1;
101
+ let nextStickyIdx: number | undefined;
102
+
103
+ while (low <= high) {
104
+ const mid = (low + high) >>> 1;
105
+ if (stickyIndices[ mid ]! > index) {
106
+ nextStickyIdx = stickyIndices[ mid ];
107
+ high = mid - 1;
108
+ } else {
109
+ low = mid + 1;
110
+ }
111
+ }
112
+ return nextStickyIdx;
113
+ }
114
+
115
+ /**
116
+ * Binary search for the previous sticky index before the current index.
117
+ *
118
+ * @param stickyIndices - Sorted array of sticky indices.
119
+ * @param index - Current index.
120
+ * @returns Previous sticky index or undefined.
121
+ */
122
+ export function findPrevStickyIndex(stickyIndices: number[], index: number): number | undefined {
123
+ let low = 0;
124
+ let high = stickyIndices.length - 1;
125
+ let prevStickyIdx: number | undefined;
126
+
127
+ while (low <= high) {
128
+ const mid = (low + high) >>> 1;
129
+ if (stickyIndices[ mid ]! < index) {
130
+ prevStickyIdx = stickyIndices[ mid ];
131
+ low = mid + 1;
132
+ } else {
133
+ high = mid - 1;
134
+ }
135
+ }
136
+ return prevStickyIdx;
137
+ }
138
+
139
+ /**
140
+ * Generic alignment calculation for a single axis.
141
+ *
142
+ * @param params - Alignment parameters.
143
+ * @param params.align - Desired alignment.
144
+ * @param params.targetPos - Virtual item position.
145
+ * @param params.itemSize - Virtual item size.
146
+ * @param params.scrollPos - Virtual scroll position.
147
+ * @param params.viewSize - Full viewport size.
148
+ * @param params.stickyOffsetStart - Dynamic sticky offset at start.
149
+ * @param params.stickyOffsetEnd - Sticky offset at end.
150
+ * @returns Target scroll position and effective alignment.
151
+ */
152
+ function calculateAxisAlignment({
153
+ align,
154
+ targetPos,
155
+ itemSize,
156
+ scrollPos,
157
+ viewSize,
158
+ stickyOffsetStart,
159
+ stickyOffsetEnd,
160
+ }: AxisAlignmentParams) {
161
+ const targetStart = targetPos - stickyOffsetStart;
162
+ const targetEnd = targetPos - (viewSize - stickyOffsetEnd - itemSize);
163
+
164
+ if (align === 'start') {
165
+ return { target: targetStart, effectiveAlign: 'start' as const };
166
+ }
167
+ if (align === 'center') {
168
+ return {
169
+ target: targetPos - stickyOffsetStart - (viewSize - stickyOffsetStart - stickyOffsetEnd - itemSize) / 2,
170
+ effectiveAlign: 'center' as const,
171
+ };
172
+ }
173
+ if (align === 'end') {
174
+ return { target: targetEnd, effectiveAlign: 'end' as const };
175
+ }
176
+
177
+ if (isItemVisible(targetPos, itemSize, scrollPos, viewSize, stickyOffsetStart, stickyOffsetEnd)) {
178
+ return { target: scrollPos, effectiveAlign: 'auto' as const };
179
+ }
180
+
181
+ const usableSize = viewSize - stickyOffsetStart - stickyOffsetEnd;
182
+
183
+ if (itemSize <= usableSize) {
184
+ return targetPos < scrollPos + stickyOffsetStart
185
+ ? {
186
+ target: targetStart,
187
+ effectiveAlign: 'start' as const,
188
+ }
189
+ : {
190
+ target: targetEnd,
191
+ effectiveAlign: 'end' as const,
192
+ };
193
+ }
194
+
195
+ return Math.abs(targetStart - scrollPos) < Math.abs(targetEnd - scrollPos)
196
+ ? {
197
+ target: targetStart,
198
+ effectiveAlign: 'start' as const,
199
+ }
200
+ : {
201
+ target: targetEnd,
202
+ effectiveAlign: 'end' as const,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Helper to calculate total size for a single axis.
208
+ *
209
+ * @param count - Item count.
210
+ * @param fixedSize - Fixed size if any.
211
+ * @param gap - Gap size.
212
+ * @param query - Prefix sum resolver.
213
+ * @returns Total size.
214
+ */
215
+ function calculateAxisSize(
216
+ count: number,
217
+ fixedSize: number | null,
218
+ gap: number,
219
+ query: (index: number) => number,
220
+ ): number {
221
+ if (count <= 0) {
222
+ return 0;
223
+ }
224
+ if (fixedSize !== null) {
225
+ return Math.max(0, count * (fixedSize + gap) - gap);
226
+ }
227
+ return Math.max(0, query(count) - gap);
228
+ }
229
+
230
+ /**
231
+ * Helper to calculate target scroll position for a single axis.
232
+ *
233
+ * @param params - Axis target parameters.
234
+ * @param params.index - Row/column index.
235
+ * @param params.align - Desired alignment.
236
+ * @param params.viewSize - Full viewport size.
237
+ * @param params.scrollPos - Virtual scroll position.
238
+ * @param params.fixedSize - Fixed item size.
239
+ * @param params.gap - Item gap.
240
+ * @param params.query - Prefix sum resolver.
241
+ * @param params.getSize - Item size resolver.
242
+ * @param params.stickyIndices - Sticky indices.
243
+ * @param params.stickyStart - Sticky start element size.
244
+ * @param params.stickyEnd - Sticky end element size.
245
+ * @returns Target position, item size and effective alignment.
246
+ */
247
+ function calculateAxisTarget({
248
+ index,
249
+ align,
250
+ viewSize,
251
+ scrollPos,
252
+ fixedSize,
253
+ gap,
254
+ query,
255
+ getSize,
256
+ stickyIndices,
257
+ stickyStart,
258
+ stickyEnd = 0,
259
+ }: {
260
+ index: number;
261
+ align: ScrollAlignment;
262
+ viewSize: number;
263
+ scrollPos: number;
264
+ fixedSize: number | null;
265
+ gap: number;
266
+ query: (idx: number) => number;
267
+ getSize: (idx: number) => number;
268
+ stickyIndices?: number[] | undefined;
269
+ stickyStart: number;
270
+ stickyEnd?: number;
271
+ }) {
272
+ let stickyOffsetStart = stickyStart;
273
+ if (stickyIndices && stickyIndices.length > 0) {
274
+ const activeStickyIdx = findPrevStickyIndex(stickyIndices, index);
275
+ if (activeStickyIdx !== undefined) {
276
+ stickyOffsetStart += calculateAxisSize(1, fixedSize, 0, () => getSize(activeStickyIdx));
277
+ }
278
+ }
279
+
280
+ const itemPos = (fixedSize !== null ? index * (fixedSize + gap) : query(index));
281
+ const itemSize = fixedSize !== null ? fixedSize : getSize(index) - gap;
282
+
283
+ const { target, effectiveAlign } = calculateAxisAlignment({
284
+ align,
285
+ targetPos: itemPos,
286
+ itemSize,
287
+ scrollPos,
288
+ viewSize,
289
+ stickyOffsetStart,
290
+ stickyOffsetEnd: stickyEnd,
291
+ });
292
+
293
+ return { target, itemSize, effectiveAlign };
294
+ }
295
+
296
+ /**
297
+ * Helper to calculate sticky state for a single axis.
298
+ *
299
+ * @param scrollPos - Virtual scroll position.
300
+ * @param originalPos - Original virtual item position.
301
+ * @param size - Virtual item size.
302
+ * @param index - Item index.
303
+ * @param stickyIndices - All sticky indices.
304
+ * @param getNextStickyPos - Resolver for the next sticky item's position.
305
+ * @returns Sticky state for this axis.
306
+ */
307
+ function calculateAxisSticky(
308
+ scrollPos: number,
309
+ originalPos: number,
310
+ size: number,
311
+ index: number,
312
+ stickyIndices: number[],
313
+ getNextStickyPos: (idx: number) => number,
314
+ ) {
315
+ if (scrollPos <= originalPos) {
316
+ return { isActive: false, offset: 0 };
317
+ }
318
+
319
+ const nextStickyIdx = findNextStickyIndex(stickyIndices, index);
320
+ if (nextStickyIdx === undefined) {
321
+ return { isActive: true, offset: 0 };
322
+ }
323
+
324
+ const nextStickyPos = getNextStickyPos(nextStickyIdx);
325
+ if (scrollPos >= nextStickyPos) {
326
+ return { isActive: false, offset: 0 };
327
+ }
328
+
329
+ return {
330
+ isActive: true,
331
+ offset: Math.max(0, Math.min(size, nextStickyPos - scrollPos)) - size,
332
+ };
333
+ }
334
+
335
+ // --- Exported Functions ---
336
+
337
+ /**
338
+ * Determines if an item is visible within the usable viewport.
339
+ *
340
+ * @param itemPos - Virtual start position of the item (VU).
341
+ * @param itemSize - Virtual size of the item (VU).
342
+ * @param scrollPos - Virtual scroll position (VU).
343
+ * @param viewSize - Full size of the viewport (VU).
344
+ * @param stickyOffsetStart - Dynamic offset from sticky items at start (VU).
345
+ * @param stickyOffsetEnd - Offset from sticky items at end (VU).
346
+ * @returns True if visible.
347
+ */
348
+ export function isItemVisible(
349
+ itemPos: number,
350
+ itemSize: number,
351
+ scrollPos: number,
352
+ viewSize: number,
353
+ stickyOffsetStart: number = 0,
354
+ stickyOffsetEnd: number = 0,
355
+ ): boolean {
356
+ const usableStart = scrollPos + stickyOffsetStart;
357
+ const usableEnd = scrollPos + viewSize - stickyOffsetEnd;
358
+ const usableSize = viewSize - stickyOffsetStart - stickyOffsetEnd;
359
+
360
+ if (itemSize <= usableSize) {
361
+ return itemPos >= usableStart - 0.5 && (itemPos + itemSize) <= usableEnd + 0.5;
362
+ }
363
+ return itemPos <= usableStart + 0.5 && (itemPos + itemSize) >= usableEnd - 0.5;
364
+ }
365
+
366
+ /**
367
+ * Maps a display scroll position to a virtual content position.
368
+ *
369
+ * @param displayPos - Display pixel position (DU).
370
+ * @param hostOffset - Offset of the host element in display pixels (DU).
371
+ * @param scale - Coordinate scaling factor (VU/DU).
372
+ * @returns Virtual content position (VU).
373
+ */
374
+ export function displayToVirtual(displayPos: number, hostOffset: number, scale: number): number {
375
+ return (displayPos - hostOffset) * scale;
376
+ }
377
+
378
+ /**
379
+ * Maps a virtual content position to a display scroll position.
380
+ *
381
+ * @param virtualPos - Virtual content position (VU).
382
+ * @param hostOffset - Offset of the host element in display pixels (DU).
383
+ * @param scale - Coordinate scaling factor (VU/DU).
384
+ * @returns Display pixel position (DU).
385
+ */
386
+ export function virtualToDisplay(virtualPos: number, hostOffset: number, scale: number): number {
387
+ return virtualPos / scale + hostOffset;
388
+ }
16
389
 
17
390
  /**
18
391
  * Calculates the target scroll position (relative to content) for a given row/column index and alignment.
19
392
  *
20
- * @param params - The parameters for calculation.
21
- * @returns The target X and Y positions and item dimensions.
393
+ * @param params - Scroll target parameters.
394
+ * @param params.rowIndex - Row index to target.
395
+ * @param params.colIndex - Column index to target.
396
+ * @param params.options - Scroll options including alignment.
397
+ * @param params.direction - Current scroll direction.
398
+ * @param params.viewportWidth - Full viewport width (DU).
399
+ * @param params.viewportHeight - Full viewport height (DU).
400
+ * @param params.totalWidth - Total estimated width (VU).
401
+ * @param params.totalHeight - Total estimated height (VU).
402
+ * @param params.gap - Item gap (VU).
403
+ * @param params.columnGap - Column gap (VU).
404
+ * @param params.fixedSize - Fixed item size (VU).
405
+ * @param params.fixedWidth - Fixed column width (VU).
406
+ * @param params.relativeScrollX - Current relative X scroll (VU).
407
+ * @param params.relativeScrollY - Current relative Y scroll (VU).
408
+ * @param params.getItemSizeY - Resolver for item height (VU).
409
+ * @param params.getItemSizeX - Resolver for item width (VU).
410
+ * @param params.getItemQueryY - Prefix sum resolver for item height (VU).
411
+ * @param params.getItemQueryX - Prefix sum resolver for item width (VU).
412
+ * @param params.getColumnSize - Resolver for column size (VU).
413
+ * @param params.getColumnQuery - Prefix sum resolver for column width (VU).
414
+ * @param params.scaleX - Coordinate scaling factor for X axis.
415
+ * @param params.scaleY - Coordinate scaling factor for Y axis.
416
+ * @param params.hostOffsetX - Display pixels offset of items wrapper on X axis (DU).
417
+ * @param params.hostOffsetY - Display pixels offset of items wrapper on Y axis (DU).
418
+ * @param params.flowPaddingStartX - Display pixels padding at flow start on X axis (DU).
419
+ * @param params.flowPaddingStartY - Display pixels padding at flow start on Y axis (DU).
420
+ * @param params.paddingStartX - Display pixels padding at scroll start on X axis (DU).
421
+ * @param params.paddingStartY - Display pixels padding at scroll start on Y axis (DU).
422
+ * @param params.paddingEndX - Display pixels padding at scroll end on X axis (DU).
423
+ * @param params.paddingEndY - Display pixels padding at scroll end on Y axis (DU).
424
+ * @param params.stickyIndices - List of sticky indices.
425
+ * @param params.stickyStartX - Sticky start offset on X axis (DU).
426
+ * @param params.stickyStartY - Sticky start offset on Y axis (DU).
427
+ * @param params.stickyEndX - Sticky end offset on X axis (DU).
428
+ * @param params.stickyEndY - Sticky end offset on Y axis (DU).
429
+ * @returns The target X and Y positions (VU) and item dimensions (VU).
22
430
  * @see ScrollTargetParams
23
431
  * @see ScrollTargetResult
24
432
  */
25
- export function calculateScrollTarget(params: ScrollTargetParams): ScrollTargetResult {
26
- const {
27
- rowIndex,
28
- colIndex,
29
- options,
30
- itemsLength,
31
- columnCount,
32
- direction,
33
- usableWidth,
34
- usableHeight,
35
- totalWidth,
36
- totalHeight,
37
- gap,
38
- columnGap,
39
- fixedSize,
40
- fixedWidth,
41
- relativeScrollX,
42
- relativeScrollY,
43
- getItemSizeY,
44
- getItemSizeX,
45
- getItemQueryY,
46
- getItemQueryX,
47
- getColumnSize,
48
- getColumnQuery,
49
- stickyIndices,
50
- } = params;
51
-
433
+ export function calculateScrollTarget({
434
+ rowIndex,
435
+ colIndex,
436
+ options,
437
+ direction,
438
+ viewportWidth,
439
+ viewportHeight,
440
+ totalWidth,
441
+ totalHeight,
442
+ gap,
443
+ columnGap,
444
+ fixedSize,
445
+ fixedWidth,
446
+ relativeScrollX,
447
+ relativeScrollY,
448
+ getItemSizeY,
449
+ getItemSizeX,
450
+ getItemQueryY,
451
+ getItemQueryX,
452
+ getColumnSize,
453
+ getColumnQuery,
454
+ scaleX,
455
+ scaleY,
456
+ hostOffsetX,
457
+ hostOffsetY,
458
+ stickyIndices,
459
+ stickyStartX = 0,
460
+ stickyStartY = 0,
461
+ stickyEndX = 0,
462
+ stickyEndY = 0,
463
+ flowPaddingStartX = 0,
464
+ flowPaddingStartY = 0,
465
+ paddingStartX = 0,
466
+ paddingStartY = 0,
467
+ paddingEndX = 0,
468
+ paddingEndY = 0,
469
+ }: ScrollTargetParams): ScrollTargetResult {
52
470
  let align: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions | undefined;
53
471
 
54
472
  if (isScrollToIndexOptions(options)) {
@@ -57,167 +475,74 @@ export function calculateScrollTarget(params: ScrollTargetParams): ScrollTargetR
57
475
  align = options as ScrollAlignment | ScrollAlignmentOptions;
58
476
  }
59
477
 
60
- const alignX = (typeof align === 'object' ? align.x : align) || 'auto';
61
- const alignY = (typeof align === 'object' ? align.y : align) || 'auto';
62
-
63
- const isVertical = direction === 'vertical' || direction === 'both';
64
- const isHorizontal = direction === 'horizontal' || direction === 'both';
478
+ const alignX = (align && typeof align === 'object' ? align.x : align) || 'auto';
479
+ const alignY = (align && typeof align === 'object' ? align.y : align) || 'auto';
65
480
 
66
481
  let targetX = relativeScrollX;
67
482
  let targetY = relativeScrollY;
68
483
  let itemWidth = 0;
69
484
  let itemHeight = 0;
70
- let effectiveAlignX: ScrollAlignment = alignX === 'auto' ? 'auto' : alignX;
71
- let effectiveAlignY: ScrollAlignment = alignY === 'auto' ? 'auto' : alignY;
485
+ let effectiveAlignX: ScrollAlignment = 'auto';
486
+ let effectiveAlignY: ScrollAlignment = 'auto';
72
487
 
73
- // Y calculation
74
- if (rowIndex != null) {
75
- let stickyOffsetY = 0;
76
- if (isVertical && stickyIndices && stickyIndices.length > 0) {
77
- let activeStickyIdx: number | undefined;
78
- let low = 0;
79
- let high = stickyIndices.length - 1;
80
- while (low <= high) {
81
- const mid = (low + high) >>> 1;
82
- if (stickyIndices[ mid ]! < rowIndex) {
83
- activeStickyIdx = stickyIndices[ mid ];
84
- low = mid + 1;
85
- } else {
86
- high = mid - 1;
87
- }
88
- }
488
+ // Clamp to valid range
489
+ const rWidth = scaleX === 1 ? totalWidth : BROWSER_MAX_SIZE;
490
+ const rHeight = scaleY === 1 ? totalHeight : BROWSER_MAX_SIZE;
89
491
 
90
- if (activeStickyIdx !== undefined) {
91
- stickyOffsetY = fixedSize !== null ? fixedSize : getItemSizeY(activeStickyIdx) - gap;
92
- }
93
- }
492
+ const maxDisplayX = Math.max(0, hostOffsetX + rWidth - viewportWidth);
493
+ const maxDisplayY = Math.max(0, hostOffsetY + rHeight - viewportHeight);
94
494
 
95
- let itemY = 0;
96
- if (rowIndex >= itemsLength) {
97
- itemY = totalHeight;
98
- itemHeight = 0;
99
- } else {
100
- itemY = fixedSize !== null ? rowIndex * (fixedSize + gap) : getItemQueryY(rowIndex);
101
- itemHeight = fixedSize !== null ? fixedSize : getItemSizeY(rowIndex) - gap;
102
- }
495
+ // maxTarget should be in virtual internalScroll coordinates
496
+ const maxTargetX = (maxDisplayX - hostOffsetX) * scaleX;
497
+ const maxTargetY = (maxDisplayY - hostOffsetY) * scaleY;
103
498
 
104
- // Apply Y Alignment
105
- if (alignY === 'start') {
106
- targetY = itemY - stickyOffsetY;
107
- } else if (alignY === 'center') {
108
- targetY = itemY - (usableHeight - itemHeight) / 2;
109
- } else if (alignY === 'end') {
110
- targetY = itemY - (usableHeight - itemHeight);
111
- } else {
112
- // Auto alignment: stay if visible, otherwise align to nearest edge (minimal movement)
113
- const isVisibleY = itemHeight <= (usableHeight - stickyOffsetY)
114
- ? (itemY >= relativeScrollY + stickyOffsetY - 0.5 && (itemY + itemHeight) <= (relativeScrollY + usableHeight + 0.5))
115
- : (itemY <= relativeScrollY + stickyOffsetY + 0.5 && (itemY + itemHeight) >= (relativeScrollY + usableHeight - 0.5));
116
-
117
- if (!isVisibleY) {
118
- const targetStart = itemY - stickyOffsetY;
119
- const targetEnd = itemY - (usableHeight - itemHeight);
120
-
121
- if (itemHeight <= usableHeight - stickyOffsetY) {
122
- if (itemY < relativeScrollY + stickyOffsetY) {
123
- targetY = targetStart;
124
- effectiveAlignY = 'start';
125
- } else {
126
- targetY = targetEnd;
127
- effectiveAlignY = 'end';
128
- }
129
- } else {
130
- // Large item: minimal movement
131
- if (Math.abs(targetStart - relativeScrollY) < Math.abs(targetEnd - relativeScrollY)) {
132
- targetY = targetStart;
133
- effectiveAlignY = 'start';
134
- } else {
135
- targetY = targetEnd;
136
- effectiveAlignY = 'end';
137
- }
138
- }
139
- }
140
- }
499
+ const itemsStartVirtualX = flowPaddingStartX + stickyStartX + paddingStartX;
500
+ const itemsStartVirtualY = flowPaddingStartY + stickyStartY + paddingStartY;
501
+
502
+ // Y calculation
503
+ if (rowIndex != null) {
504
+ const res = calculateAxisTarget({
505
+ index: rowIndex,
506
+ align: alignY as ScrollAlignment,
507
+ viewSize: viewportHeight,
508
+ scrollPos: relativeScrollY,
509
+ fixedSize,
510
+ gap,
511
+ query: getItemQueryY,
512
+ getSize: getItemSizeY,
513
+ stickyIndices,
514
+ stickyStart: stickyStartY + paddingStartY,
515
+ stickyEnd: stickyEndY + paddingEndY,
516
+ });
517
+ targetY = res.target + itemsStartVirtualY;
518
+ itemHeight = res.itemSize;
519
+ effectiveAlignY = res.effectiveAlign;
141
520
  }
142
521
 
143
522
  // X calculation
144
523
  if (colIndex != null) {
145
- let stickyOffsetX = 0;
146
- if (isHorizontal && stickyIndices && stickyIndices.length > 0 && (direction === 'horizontal' || direction === 'both')) {
147
- let activeStickyIdx: number | undefined;
148
- let low = 0;
149
- let high = stickyIndices.length - 1;
150
- while (low <= high) {
151
- const mid = (low + high) >>> 1;
152
- if (stickyIndices[ mid ]! < colIndex) {
153
- activeStickyIdx = stickyIndices[ mid ];
154
- low = mid + 1;
155
- } else {
156
- high = mid - 1;
157
- }
158
- }
159
-
160
- if (activeStickyIdx !== undefined) {
161
- stickyOffsetX = direction === 'horizontal'
162
- ? (fixedSize !== null ? fixedSize : getItemSizeX(activeStickyIdx) - columnGap)
163
- : (fixedWidth !== null ? fixedWidth : getColumnSize(activeStickyIdx) - columnGap);
164
- }
165
- }
166
-
167
- let itemX = 0;
168
- if (colIndex >= columnCount && columnCount > 0) {
169
- itemX = totalWidth;
170
- itemWidth = 0;
171
- } else if (direction === 'horizontal') {
172
- itemX = fixedSize !== null ? colIndex * (fixedSize + columnGap) : getItemQueryX(colIndex);
173
- itemWidth = fixedSize !== null ? fixedSize : getItemSizeX(colIndex) - columnGap;
174
- } else {
175
- itemX = getColumnQuery(colIndex);
176
- itemWidth = getColumnSize(colIndex) - columnGap;
177
- }
178
-
179
- // Apply X Alignment
180
- if (alignX === 'start') {
181
- targetX = itemX - stickyOffsetX;
182
- } else if (alignX === 'center') {
183
- targetX = itemX - (usableWidth - itemWidth) / 2;
184
- } else if (alignX === 'end') {
185
- targetX = itemX - (usableWidth - itemWidth);
186
- } else {
187
- // Auto alignment: stay if visible, otherwise align to nearest edge (minimal movement)
188
- const isVisibleX = itemWidth <= (usableWidth - stickyOffsetX)
189
- ? (itemX >= relativeScrollX + stickyOffsetX - 0.5 && (itemX + itemWidth) <= (relativeScrollX + usableWidth + 0.5))
190
- : (itemX <= relativeScrollX + stickyOffsetX + 0.5 && (itemX + itemWidth) >= (relativeScrollX + usableWidth - 0.5));
191
-
192
- if (!isVisibleX) {
193
- const targetStart = itemX - stickyOffsetX;
194
- const targetEnd = itemX - (usableWidth - itemWidth);
195
-
196
- if (itemWidth <= usableWidth - stickyOffsetX) {
197
- if (itemX < relativeScrollX + stickyOffsetX) {
198
- targetX = targetStart;
199
- effectiveAlignX = 'start';
200
- } else {
201
- targetX = targetEnd;
202
- effectiveAlignX = 'end';
203
- }
204
- } else {
205
- // Large item: minimal movement
206
- if (Math.abs(targetStart - relativeScrollX) < Math.abs(targetEnd - relativeScrollX)) {
207
- targetX = targetStart;
208
- effectiveAlignX = 'start';
209
- } else {
210
- targetX = targetEnd;
211
- effectiveAlignX = 'end';
212
- }
213
- }
214
- }
215
- }
524
+ const isGrid = direction === 'both';
525
+ const isHorizontal = direction === 'horizontal';
526
+ const res = calculateAxisTarget({
527
+ index: colIndex,
528
+ align: alignX as ScrollAlignment,
529
+ viewSize: viewportWidth,
530
+ scrollPos: relativeScrollX,
531
+ fixedSize: isGrid ? fixedWidth : fixedSize,
532
+ gap: (isGrid || isHorizontal) ? columnGap : gap,
533
+ query: isGrid ? getColumnQuery : getItemQueryX,
534
+ getSize: isGrid ? getColumnSize : getItemSizeX,
535
+ stickyIndices,
536
+ stickyStart: stickyStartX + paddingStartX,
537
+ stickyEnd: stickyEndX + paddingEndX,
538
+ });
539
+ targetX = res.target + itemsStartVirtualX;
540
+ itemWidth = res.itemSize;
541
+ effectiveAlignX = res.effectiveAlign;
216
542
  }
217
543
 
218
- // Clamp to valid range
219
- targetX = Math.max(0, Math.min(targetX, Math.max(0, totalWidth - usableWidth)));
220
- targetY = Math.max(0, Math.min(targetY, Math.max(0, totalHeight - usableHeight)));
544
+ targetX = Math.max(0, Math.min(targetX, maxTargetX));
545
+ targetY = Math.max(0, Math.min(targetY, maxTargetY));
221
546
 
222
547
  return { targetX, targetY, itemWidth, itemHeight, effectiveAlignX, effectiveAlignY };
223
548
  }
@@ -225,248 +550,240 @@ export function calculateScrollTarget(params: ScrollTargetParams): ScrollTargetR
225
550
  /**
226
551
  * Calculates the range of items to render based on scroll position and viewport size.
227
552
  *
228
- * @param params - The parameters for calculation.
553
+ * @param params - Range parameters.
554
+ * @param params.direction - Scroll direction.
555
+ * @param params.relativeScrollX - Virtual horizontal position (VU).
556
+ * @param params.relativeScrollY - Virtual vertical position (VU).
557
+ * @param params.usableWidth - Usable viewport width (VU).
558
+ * @param params.usableHeight - Usable viewport height (VU).
559
+ * @param params.itemsLength - Total item count.
560
+ * @param params.bufferBefore - Buffer items before.
561
+ * @param params.bufferAfter - Buffer items after.
562
+ * @param params.gap - Item gap (VU).
563
+ * @param params.columnGap - Column gap (VU).
564
+ * @param params.fixedSize - Fixed item size (VU).
565
+ * @param params.findLowerBoundY - Resolver for vertical index.
566
+ * @param params.findLowerBoundX - Resolver for horizontal index.
567
+ * @param params.queryY - Resolver for vertical offset (VU).
568
+ * @param params.queryX - Resolver for horizontal offset (VU).
229
569
  * @returns The start and end indices of the items to render.
230
570
  * @see RangeParams
231
571
  */
232
- export function calculateRange(params: RangeParams) {
233
- const {
234
- direction,
235
- relativeScrollX,
236
- relativeScrollY,
237
- usableWidth,
238
- usableHeight,
239
- itemsLength,
572
+ export function calculateRange({
573
+ direction,
574
+ relativeScrollX,
575
+ relativeScrollY,
576
+ usableWidth,
577
+ usableHeight,
578
+ itemsLength,
579
+ bufferBefore,
580
+ bufferAfter,
581
+ gap,
582
+ columnGap,
583
+ fixedSize,
584
+ findLowerBoundY,
585
+ findLowerBoundX,
586
+ queryY,
587
+ queryX,
588
+ }: RangeParams) {
589
+ const isVertical = direction === 'vertical' || direction === 'both';
590
+
591
+ return calculateGenericRange({
592
+ scrollPos: isVertical ? relativeScrollY : relativeScrollX,
593
+ containerSize: isVertical ? usableHeight : usableWidth,
594
+ count: itemsLength,
240
595
  bufferBefore,
241
596
  bufferAfter,
242
- gap,
243
- columnGap,
597
+ gap: isVertical ? gap : columnGap,
244
598
  fixedSize,
245
- findLowerBoundY,
246
- findLowerBoundX,
247
- queryY,
248
- queryX,
249
- } = params;
250
-
251
- const isVertical = direction === 'vertical' || direction === 'both';
252
-
253
- let start = 0;
254
- let end = itemsLength;
255
-
256
- if (isVertical) {
257
- if (fixedSize !== null) {
258
- start = Math.floor(relativeScrollY / (fixedSize + gap));
259
- end = Math.ceil((relativeScrollY + usableHeight) / (fixedSize + gap));
260
- } else {
261
- start = findLowerBoundY(relativeScrollY);
262
- const targetY = relativeScrollY + usableHeight;
263
- end = findLowerBoundY(targetY);
264
- if (end < itemsLength && queryY(end) < targetY) {
265
- end++;
266
- }
267
- }
268
- } else {
269
- if (fixedSize !== null) {
270
- start = Math.floor(relativeScrollX / (fixedSize + columnGap));
271
- end = Math.ceil((relativeScrollX + usableWidth) / (fixedSize + columnGap));
272
- } else {
273
- start = findLowerBoundX(relativeScrollX);
274
- const targetX = relativeScrollX + usableWidth;
275
- end = findLowerBoundX(targetX);
276
- if (end < itemsLength && queryX(end) < targetX) {
277
- end++;
278
- }
279
- }
280
- }
281
-
282
- return {
283
- start: Math.max(0, start - bufferBefore),
284
- end: Math.min(itemsLength, end + bufferAfter),
285
- };
599
+ findLowerBound: isVertical ? findLowerBoundY : findLowerBoundX,
600
+ query: isVertical ? queryY : queryX,
601
+ });
286
602
  }
287
603
 
288
604
  /**
289
605
  * Calculates the range of columns to render for bidirectional scroll.
290
606
  *
291
- * @param params - The parameters for calculation.
292
- * @returns The start and end indices and paddings for columns.
607
+ * @param params - Column range parameters.
608
+ * @param params.columnCount - Total column count.
609
+ * @param params.relativeScrollX - Virtual horizontal position (VU).
610
+ * @param params.usableWidth - Usable viewport width (VU).
611
+ * @param params.colBuffer - Column buffer size.
612
+ * @param params.fixedWidth - Fixed column width (VU).
613
+ * @param params.columnGap - Column gap (VU).
614
+ * @param params.findLowerBound - Resolver for column index.
615
+ * @param params.query - Resolver for column offset (VU).
616
+ * @param params.totalColsQuery - Resolver for total width (VU).
617
+ * @returns The start and end indices and paddings for columns (VU).
293
618
  * @see ColumnRangeParams
294
619
  * @see ColumnRange
295
620
  */
296
- export function calculateColumnRange(params: ColumnRangeParams) {
297
- const {
298
- columnCount,
299
- relativeScrollX,
300
- usableWidth,
301
- colBuffer,
302
- fixedWidth,
303
- columnGap,
304
- findLowerBound,
305
- query,
306
- totalColsQuery,
307
- } = params;
308
-
621
+ export function calculateColumnRange({
622
+ columnCount,
623
+ relativeScrollX,
624
+ usableWidth,
625
+ colBuffer,
626
+ fixedWidth,
627
+ columnGap,
628
+ findLowerBound,
629
+ query,
630
+ totalColsQuery,
631
+ }: ColumnRangeParams) {
309
632
  if (!columnCount) {
310
633
  return { start: 0, end: 0, padStart: 0, padEnd: 0 };
311
634
  }
312
635
 
313
- let start = 0;
314
- let end = columnCount;
315
-
316
- if (fixedWidth !== null) {
317
- start = Math.floor(relativeScrollX / (fixedWidth + columnGap));
318
- end = Math.ceil((relativeScrollX + usableWidth) / (fixedWidth + columnGap));
319
- } else {
320
- start = findLowerBound(relativeScrollX);
321
- let currentX = query(start);
322
- let i = start;
323
- while (i < columnCount && currentX < relativeScrollX + usableWidth) {
324
- currentX = query(++i);
325
- }
326
- end = i;
327
- }
636
+ const { start, end } = calculateGenericRange({
637
+ scrollPos: relativeScrollX,
638
+ containerSize: usableWidth,
639
+ count: columnCount,
640
+ bufferBefore: colBuffer,
641
+ bufferAfter: colBuffer,
642
+ gap: columnGap,
643
+ fixedSize: fixedWidth,
644
+ findLowerBound,
645
+ query,
646
+ });
328
647
 
329
- // Add buffer of columns
330
- const safeStart = Math.max(0, start - colBuffer);
331
- const safeEnd = Math.min(columnCount, end + colBuffer);
648
+ const safeStart = start;
649
+ const safeEnd = end;
332
650
 
333
651
  const padStart = fixedWidth !== null ? safeStart * (fixedWidth + columnGap) : query(safeStart);
334
652
  const totalWidth = fixedWidth !== null ? columnCount * (fixedWidth + columnGap) - columnGap : Math.max(0, totalColsQuery() - columnGap);
335
653
 
336
- const renderedEnd = fixedWidth !== null
337
- ? (safeEnd * (fixedWidth + columnGap) - (safeEnd >= columnCount ? columnGap : 0))
338
- : (query(safeEnd) - (safeEnd >= columnCount ? columnGap : 0));
654
+ const contentEnd = fixedWidth !== null
655
+ ? (safeEnd * (fixedWidth + columnGap) - (safeEnd > 0 ? columnGap : 0))
656
+ : (query(safeEnd) - (safeEnd > 0 ? columnGap : 0));
339
657
 
340
658
  return {
341
659
  start: safeStart,
342
660
  end: safeEnd,
343
661
  padStart,
344
- padEnd: Math.max(0, totalWidth - renderedEnd),
662
+ padEnd: Math.max(0, totalWidth - contentEnd),
345
663
  };
346
664
  }
347
665
 
348
666
  /**
349
667
  * Calculates the sticky state and offset for a single item.
350
668
  *
351
- * @param params - The parameters for calculation.
352
- * @returns Sticky state and offset.
669
+ * @param params - Sticky item parameters.
670
+ * @param params.index - Item index.
671
+ * @param params.isSticky - If configured as sticky.
672
+ * @param params.direction - Scroll direction.
673
+ * @param params.relativeScrollX - Virtual horizontal position (VU).
674
+ * @param params.relativeScrollY - Virtual vertical position (VU).
675
+ * @param params.originalX - Virtual original X position (VU).
676
+ * @param params.originalY - Virtual original Y position (VU).
677
+ * @param params.width - Virtual item width (VU).
678
+ * @param params.height - Virtual item height (VU).
679
+ * @param params.stickyIndices - All sticky indices.
680
+ * @param params.fixedSize - Fixed item size (VU).
681
+ * @param params.gap - Item gap (VU).
682
+ * @param params.columnGap - Column gap (VU).
683
+ * @param params.getItemQueryY - Resolver for vertical offset (VU).
684
+ * @param params.getItemQueryX - Resolver for horizontal offset (VU).
685
+ * @returns Sticky state and offset (VU).
353
686
  * @see StickyParams
354
687
  */
355
- export function calculateStickyItem(params: StickyParams) {
356
- const {
357
- index,
358
- isSticky,
359
- direction,
360
- relativeScrollX,
361
- relativeScrollY,
362
- originalX,
363
- originalY,
364
- width,
365
- height,
366
- stickyIndices,
367
- fixedSize,
368
- fixedWidth,
369
- gap,
370
- columnGap,
371
- getItemQueryY,
372
- getItemQueryX,
373
- } = params;
374
-
375
- let isStickyActive = false;
688
+ export function calculateStickyItem({
689
+ index,
690
+ isSticky,
691
+ direction,
692
+ relativeScrollX,
693
+ relativeScrollY,
694
+ originalX,
695
+ originalY,
696
+ width,
697
+ height,
698
+ stickyIndices,
699
+ fixedSize,
700
+ gap,
701
+ columnGap,
702
+ getItemQueryY,
703
+ getItemQueryX,
704
+ }: StickyParams) {
705
+ let isStickyActiveX = false;
706
+ let isStickyActiveY = false;
376
707
  const stickyOffset = { x: 0, y: 0 };
377
708
 
378
709
  if (!isSticky) {
379
- return { isStickyActive, stickyOffset };
710
+ return { isStickyActiveX, isStickyActiveY, isStickyActive: false, stickyOffset };
380
711
  }
381
712
 
713
+ // Y Axis (Sticky Rows)
382
714
  if (direction === 'vertical' || direction === 'both') {
383
- if (relativeScrollY > originalY) {
384
- // Check if next sticky item pushes this one
385
- let nextStickyIdx: number | undefined;
386
- let low = 0;
387
- let high = stickyIndices.length - 1;
388
- while (low <= high) {
389
- const mid = (low + high) >>> 1;
390
- if (stickyIndices[ mid ]! > index) {
391
- nextStickyIdx = stickyIndices[ mid ];
392
- high = mid - 1;
393
- } else {
394
- low = mid + 1;
395
- }
396
- }
397
-
398
- if (nextStickyIdx !== undefined) {
399
- const nextStickyY = fixedSize !== null ? nextStickyIdx * (fixedSize + gap) : getItemQueryY(nextStickyIdx);
400
- if (relativeScrollY >= nextStickyY) {
401
- isStickyActive = false;
402
- } else {
403
- isStickyActive = true;
404
- stickyOffset.y = Math.max(0, Math.min(height, nextStickyY - relativeScrollY)) - height;
405
- }
406
- } else {
407
- isStickyActive = true;
408
- }
409
- }
715
+ const res = calculateAxisSticky(
716
+ relativeScrollY,
717
+ originalY,
718
+ height,
719
+ index,
720
+ stickyIndices,
721
+ (nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + gap) : getItemQueryY(nextIdx)),
722
+ );
723
+ isStickyActiveY = res.isActive;
724
+ stickyOffset.y = res.offset;
410
725
  }
411
726
 
412
- if (direction === 'horizontal' || (direction === 'both' && !isStickyActive)) {
413
- if (relativeScrollX > originalX) {
414
- let nextStickyIdx: number | undefined;
415
- let low = 0;
416
- let high = stickyIndices.length - 1;
417
- while (low <= high) {
418
- const mid = (low + high) >>> 1;
419
- if (stickyIndices[ mid ]! > index) {
420
- nextStickyIdx = stickyIndices[ mid ];
421
- high = mid - 1;
422
- } else {
423
- low = mid + 1;
424
- }
425
- }
426
-
427
- if (nextStickyIdx !== undefined) {
428
- const nextStickyX = direction === 'horizontal'
429
- ? (fixedSize !== null ? nextStickyIdx * (fixedSize + columnGap) : getItemQueryX(nextStickyIdx))
430
- : (fixedWidth !== null ? nextStickyIdx * (fixedWidth + columnGap) : getItemQueryX(nextStickyIdx));
431
-
432
- if (relativeScrollX >= nextStickyX) {
433
- isStickyActive = false;
434
- } else {
435
- isStickyActive = true;
436
- stickyOffset.x = Math.max(0, Math.min(width, nextStickyX - relativeScrollX)) - width;
437
- }
438
- } else {
439
- isStickyActive = true;
440
- }
727
+ // X Axis (Sticky Columns / Items)
728
+ if (direction === 'horizontal') {
729
+ const res = calculateAxisSticky(
730
+ relativeScrollX,
731
+ originalX,
732
+ width,
733
+ index,
734
+ stickyIndices,
735
+ (nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + columnGap) : getItemQueryX(nextIdx)),
736
+ );
737
+
738
+ if (res.isActive) {
739
+ isStickyActiveX = true;
740
+ stickyOffset.x = res.offset;
441
741
  }
442
742
  }
443
743
 
444
- return { isStickyActive, stickyOffset };
744
+ return {
745
+ isStickyActiveX,
746
+ isStickyActiveY,
747
+ isStickyActive: isStickyActiveX || isStickyActiveY,
748
+ stickyOffset,
749
+ };
445
750
  }
446
751
 
447
752
  /**
448
753
  * Calculates the position and size of a single item.
449
754
  *
450
- * @param params - The parameters for calculation.
451
- * @returns Item position and size.
755
+ * @param params - Item position parameters.
756
+ * @param params.index - Item index.
757
+ * @param params.direction - Scroll direction.
758
+ * @param params.fixedSize - Fixed item size (VU).
759
+ * @param params.gap - Item gap (VU).
760
+ * @param params.columnGap - Column gap (VU).
761
+ * @param params.usableWidth - Usable viewport width (VU).
762
+ * @param params.usableHeight - Usable viewport height (VU).
763
+ * @param params.totalWidth - Total estimated width (VU).
764
+ * @param params.queryY - Resolver for vertical offset (VU).
765
+ * @param params.queryX - Resolver for horizontal offset (VU).
766
+ * @param params.getSizeY - Resolver for height (VU).
767
+ * @param params.getSizeX - Resolver for width (VU).
768
+ * @param params.columnRange - Current column range (for grid mode).
769
+ * @returns Item position and size (VU).
452
770
  * @see ItemPositionParams
453
771
  */
454
- export function calculateItemPosition(params: ItemPositionParams) {
455
- const {
456
- index,
457
- direction,
458
- fixedSize,
459
- gap,
460
- columnGap,
461
- usableWidth,
462
- usableHeight,
463
- totalWidth,
464
- queryY,
465
- queryX,
466
- getSizeY,
467
- getSizeX,
468
- } = params;
469
-
772
+ export function calculateItemPosition({
773
+ index,
774
+ direction,
775
+ fixedSize,
776
+ gap,
777
+ columnGap,
778
+ usableWidth,
779
+ usableHeight,
780
+ totalWidth,
781
+ queryY,
782
+ queryX,
783
+ getSizeY,
784
+ getSizeX,
785
+ columnRange,
786
+ }: ItemPositionParams) {
470
787
  let x = 0;
471
788
  let y = 0;
472
789
  let width = 0;
@@ -476,9 +793,13 @@ export function calculateItemPosition(params: ItemPositionParams) {
476
793
  x = fixedSize !== null ? index * (fixedSize + columnGap) : queryX(index);
477
794
  width = fixedSize !== null ? fixedSize : getSizeX(index) - columnGap;
478
795
  height = usableHeight;
796
+ } else if (direction === 'both' && columnRange) {
797
+ y = fixedSize !== null ? index * (fixedSize + gap) : queryY(index);
798
+ height = fixedSize !== null ? fixedSize : getSizeY(index) - gap;
799
+ x = columnRange.padStart;
800
+ width = Math.max(0, totalWidth - columnRange.padStart - columnRange.padEnd);
479
801
  } else {
480
- // vertical or both
481
- y = (direction === 'vertical' || direction === 'both') && fixedSize !== null ? index * (fixedSize + gap) : queryY(index);
802
+ y = fixedSize !== null ? index * (fixedSize + gap) : queryY(index);
482
803
  height = fixedSize !== null ? fixedSize : getSizeY(index) - gap;
483
804
  width = direction === 'both' ? totalWidth : usableWidth;
484
805
  }
@@ -489,21 +810,28 @@ export function calculateItemPosition(params: ItemPositionParams) {
489
810
  /**
490
811
  * Calculates the style object for a rendered item.
491
812
  *
492
- * @param params - The parameters for calculation.
813
+ * @param params - Item style parameters.
814
+ * @param params.item - Rendered item state.
815
+ * @param params.direction - Scroll direction.
816
+ * @param params.itemSize - Virtual item size (VU).
817
+ * @param params.containerTag - Container HTML tag.
818
+ * @param params.paddingStartX - Horizontal virtual padding (DU).
819
+ * @param params.paddingStartY - Vertical virtual padding (DU).
820
+ * @param params.isHydrated - If mounted and hydrated.
821
+ * @param params.isRtl - If in RTL mode.
493
822
  * @returns Style object.
494
823
  * @see ItemStyleParams
495
824
  */
496
- export function calculateItemStyle<T = unknown>(params: ItemStyleParams<T>) {
497
- const {
498
- item,
499
- direction,
500
- itemSize,
501
- containerTag,
502
- paddingStartX,
503
- paddingStartY,
504
- isHydrated,
505
- } = params;
506
-
825
+ export function calculateItemStyle<T = unknown>({
826
+ item,
827
+ direction,
828
+ itemSize,
829
+ containerTag,
830
+ paddingStartX,
831
+ paddingStartY,
832
+ isHydrated,
833
+ isRtl,
834
+ }: ItemStyleParams<T>) {
507
835
  const isVertical = direction === 'vertical';
508
836
  const isHorizontal = direction === 'horizontal';
509
837
  const isBoth = direction === 'both';
@@ -529,18 +857,20 @@ export function calculateItemStyle<T = unknown>(params: ItemStyleParams<T>) {
529
857
  }
530
858
 
531
859
  if (isHydrated) {
532
- if (item.isStickyActive) {
533
- if (isVertical || isBoth) {
534
- style.insetBlockStart = `${ paddingStartY }px`;
535
- }
536
-
537
- if (isHorizontal || isBoth) {
538
- style.insetInlineStart = `${ paddingStartX }px`;
539
- }
540
-
541
- style.transform = `translate(${ item.stickyOffset.x }px, ${ item.stickyOffset.y }px)`;
860
+ const isStickingVertically = item.isStickyActiveY ?? (item.isStickyActive && (isVertical || isBoth));
861
+ const isStickingHorizontally = item.isStickyActiveX ?? (item.isStickyActive && isHorizontal);
862
+
863
+ const tx = isRtl
864
+ ? -(isStickingHorizontally ? item.stickyOffset.x : item.offset.x)
865
+ : (isStickingHorizontally ? item.stickyOffset.x : item.offset.x);
866
+ const ty = isStickingVertically ? item.stickyOffset.y : item.offset.y;
867
+
868
+ if (item.isStickyActive || item.isStickyActiveX || item.isStickyActiveY) {
869
+ style.insetBlockStart = isStickingVertically ? `${ paddingStartY }px` : 'auto';
870
+ style.insetInlineStart = isStickingHorizontally ? `${ paddingStartX }px` : 'auto';
871
+ style.transform = `translate(${ tx }px, ${ ty }px)`;
542
872
  } else {
543
- style.transform = `translate(${ item.offset.x }px, ${ item.offset.y }px)`;
873
+ style.transform = `translate(${ tx }px, ${ item.offset.y }px)`;
544
874
  }
545
875
  }
546
876
 
@@ -550,56 +880,55 @@ export function calculateItemStyle<T = unknown>(params: ItemStyleParams<T>) {
550
880
  /**
551
881
  * Calculates the total width and height of the virtualized content.
552
882
  *
553
- * @param params - The parameters for calculation.
554
- * @returns Total width and height.
883
+ * @param params - Total size parameters.
884
+ * @param params.direction - Scroll direction.
885
+ * @param params.itemsLength - Total item count.
886
+ * @param params.columnCount - Column count.
887
+ * @param params.fixedSize - Fixed item size (VU).
888
+ * @param params.fixedWidth - Fixed column width (VU).
889
+ * @param params.gap - Item gap (VU).
890
+ * @param params.columnGap - Column gap (VU).
891
+ * @param params.usableWidth - Usable viewport width (VU).
892
+ * @param params.usableHeight - Usable viewport height (VU).
893
+ * @param params.queryY - Resolver for vertical offset (VU).
894
+ * @param params.queryX - Resolver for horizontal offset (VU).
895
+ * @param params.queryColumn - Resolver for column offset (VU).
896
+ * @returns Total width and height (VU).
555
897
  * @see TotalSizeParams
556
898
  */
557
- export function calculateTotalSize(params: TotalSizeParams) {
558
- const {
559
- direction,
560
- itemsLength,
561
- columnCount,
562
- fixedSize,
563
- fixedWidth,
564
- gap,
565
- columnGap,
566
- usableWidth,
567
- usableHeight,
568
- queryY,
569
- queryX,
570
- queryColumn,
571
- } = params;
899
+ export function calculateTotalSize({
900
+ direction,
901
+ itemsLength,
902
+ columnCount,
903
+ fixedSize,
904
+ fixedWidth,
905
+ gap,
906
+ columnGap,
907
+ usableWidth,
908
+ usableHeight,
909
+ queryY,
910
+ queryX,
911
+ queryColumn,
912
+ }: TotalSizeParams) {
913
+ const isBoth = direction === 'both';
914
+ const isHorizontal = direction === 'horizontal';
572
915
 
573
916
  let width = 0;
574
917
  let height = 0;
575
918
 
576
- if (direction === 'both') {
577
- if (columnCount > 0) {
578
- width = fixedWidth !== null ? columnCount * (fixedWidth + columnGap) - columnGap : Math.max(0, queryColumn(columnCount) - columnGap);
579
- }
580
- if (fixedSize !== null) {
581
- height = Math.max(0, itemsLength * (fixedSize + gap) - (itemsLength > 0 ? gap : 0));
582
- } else {
583
- height = Math.max(0, queryY(itemsLength) - (itemsLength > 0 ? gap : 0));
584
- }
585
- width = Math.max(width, usableWidth);
586
- height = Math.max(height, usableHeight);
587
- } else if (direction === 'horizontal') {
588
- if (fixedSize !== null) {
589
- width = Math.max(0, itemsLength * (fixedSize + columnGap) - (itemsLength > 0 ? columnGap : 0));
590
- } else {
591
- width = Math.max(0, queryX(itemsLength) - (itemsLength > 0 ? columnGap : 0));
592
- }
919
+ if (isBoth) {
920
+ width = calculateAxisSize(columnCount, fixedWidth, columnGap, queryColumn);
921
+ height = calculateAxisSize(itemsLength, fixedSize, gap, queryY);
922
+ } else if (isHorizontal) {
923
+ width = calculateAxisSize(itemsLength, fixedSize, columnGap, queryX);
593
924
  height = usableHeight;
594
925
  } else {
595
- // vertical
596
926
  width = usableWidth;
597
- if (fixedSize !== null) {
598
- height = Math.max(0, itemsLength * (fixedSize + gap) - (itemsLength > 0 ? gap : 0));
599
- } else {
600
- height = Math.max(0, queryY(itemsLength) - (itemsLength > 0 ? gap : 0));
601
- }
927
+ height = calculateAxisSize(itemsLength, fixedSize, gap, queryY);
602
928
  }
603
929
 
604
- return { width, height };
930
+ return {
931
+ width: isBoth ? Math.max(width, usableWidth) : width,
932
+ height: isBoth ? Math.max(height, usableHeight) : height,
933
+ };
605
934
  }