@pdanpdan/virtual-scroll 0.3.0 → 0.4.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.
@@ -5,7 +5,7 @@ import type {
5
5
  ScrollAlignmentOptions,
6
6
  ScrollDetails,
7
7
  VirtualScrollProps,
8
- } from '../composables/useVirtualScroll';
8
+ } from '../types';
9
9
  import type { VNodeChild } from 'vue';
10
10
 
11
11
  import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
@@ -14,67 +14,198 @@ import {
14
14
  DEFAULT_ITEM_SIZE,
15
15
  useVirtualScroll,
16
16
  } from '../composables/useVirtualScroll';
17
- import { getPaddingX, getPaddingY } from '../utils/scroll';
17
+ import { isWindowLike } from '../utils/scroll';
18
+ import { calculateItemStyle } from '../utils/virtual-scroll-logic';
18
19
 
19
20
  export interface Props<T = unknown> {
20
- /** Array of items to be virtualized. */
21
+ /**
22
+ * Array of items to be virtualized.
23
+ * Required.
24
+ */
21
25
  items: T[];
22
- /** Fixed size of each item or a function that returns the size of an item. Pass 0, null or undefined for dynamic size detection. */
26
+
27
+ /**
28
+ * Fixed size of each item (in pixels) or a function that returns the size of an item.
29
+ * Pass 0, null or undefined for dynamic size detection via ResizeObserver.
30
+ * @default 40
31
+ */
23
32
  itemSize?: number | ((item: T, index: number) => number) | null;
24
- /** Direction of the scroll: 'vertical', 'horizontal', or 'both' (grid). */
33
+
34
+ /**
35
+ * Direction of the scroll.
36
+ * - 'vertical': Standard vertical list.
37
+ * - 'horizontal': Standard horizontal list.
38
+ * - 'both': Grid mode virtualizing both rows and columns.
39
+ * @default 'vertical'
40
+ */
25
41
  direction?: 'vertical' | 'horizontal' | 'both';
26
- /** Number of items to render before the visible viewport. */
42
+
43
+ /**
44
+ * Number of items to render before the visible viewport.
45
+ * Useful for smoother scrolling and keyboard navigation.
46
+ * @default 5
47
+ */
27
48
  bufferBefore?: number;
28
- /** Number of items to render after the visible viewport. */
49
+
50
+ /**
51
+ * Number of items to render after the visible viewport.
52
+ * @default 5
53
+ */
29
54
  bufferAfter?: number;
30
- /** The scrollable container element or window. If not provided, the host element is used. */
55
+
56
+ /**
57
+ * The scrollable container element or window.
58
+ * If not provided, the host element (root of VirtualScroll) is used.
59
+ * @default hostRef
60
+ */
31
61
  container?: HTMLElement | Window | null;
32
- /** Range of items to render for SSR. */
62
+
63
+ /**
64
+ * Range of items to render during Server-Side Rendering.
65
+ * When provided, these items will be rendered in-flow before hydration.
66
+ * @see SSRRange
67
+ */
33
68
  ssrRange?: {
69
+ /** First row index to render. */
34
70
  start: number;
71
+ /** Last row index to render (exclusive). */
35
72
  end: number;
73
+ /** First column index to render (for grid mode). */
36
74
  colStart?: number;
75
+ /** Last column index to render (exclusive, for grid mode). */
37
76
  colEnd?: number;
38
77
  };
39
- /** Number of columns for bidirectional (grid) scroll. */
78
+
79
+ /**
80
+ * Number of columns for bidirectional (grid) scroll.
81
+ * Only applicable when direction="both".
82
+ * @default 0
83
+ */
40
84
  columnCount?: number;
41
- /** Fixed width of columns or an array/function for column widths. Pass 0, null or undefined for dynamic width. */
85
+
86
+ /**
87
+ * Fixed width of columns (in pixels), an array of widths, or a function for column widths.
88
+ * Pass 0, null or undefined for dynamic width detection via ResizeObserver.
89
+ * Only applicable when direction="both".
90
+ * @default 100
91
+ */
42
92
  columnWidth?: number | number[] | ((index: number) => number) | null;
43
- /** The HTML tag to use for the container. */
93
+
94
+ /**
95
+ * The HTML tag to use for the root container.
96
+ * @default 'div'
97
+ */
44
98
  containerTag?: string;
45
- /** The HTML tag to use for the items wrapper. */
99
+
100
+ /**
101
+ * The HTML tag to use for the items wrapper.
102
+ * Useful for <table> integration (e.g. 'tbody').
103
+ * @default 'div'
104
+ */
46
105
  wrapperTag?: string;
47
- /** The HTML tag to use for each item. */
106
+
107
+ /**
108
+ * The HTML tag to use for each item.
109
+ * Useful for <table> integration (e.g. 'tr').
110
+ * @default 'div'
111
+ */
48
112
  itemTag?: string;
49
- /** Padding at the start of the scroll container. */
113
+
114
+ /**
115
+ * Additional padding at the start of the scroll container (top or left).
116
+ * Can be a number (applied to current direction) or an object with x/y.
117
+ * @default 0
118
+ */
50
119
  scrollPaddingStart?: number | { x?: number; y?: number; };
51
- /** Padding at the end of the scroll container. */
120
+
121
+ /**
122
+ * Additional padding at the end of the scroll container (bottom or right).
123
+ * @default 0
124
+ */
52
125
  scrollPaddingEnd?: number | { x?: number; y?: number; };
53
- /** Whether the header slot content is sticky and should be accounted for in scroll padding. If true, header size is automatically measured. */
126
+
127
+ /**
128
+ * Whether the content in the 'header' slot is sticky.
129
+ * If true, the header size is measured and accounted for in scroll padding.
130
+ * @default false
131
+ */
54
132
  stickyHeader?: boolean;
55
- /** Whether the footer slot content is sticky and should be accounted for in scroll padding. If true, footer size is automatically measured. */
133
+
134
+ /**
135
+ * Whether the content in the 'footer' slot is sticky.
136
+ * @default false
137
+ */
56
138
  stickyFooter?: boolean;
57
- /** Gap between items in pixels (vertical). */
139
+
140
+ /**
141
+ * Gap between items in pixels (vertical gap in vertical/grid mode, horizontal gap in horizontal mode).
142
+ * @default 0
143
+ */
58
144
  gap?: number;
59
- /** Gap between columns in pixels (horizontal/grid). */
145
+
146
+ /**
147
+ * Gap between columns in pixels. Only applicable when direction="both" or "horizontal".
148
+ * @default 0
149
+ */
60
150
  columnGap?: number;
61
- /** Indices of items that should stick to the top/start. Supports iOS-style pushing effect. */
151
+
152
+ /**
153
+ * Indices of items that should stick to the top/start of the viewport.
154
+ * Supports iOS-style pushing effect where the next sticky item pushes the previous one.
155
+ * @default []
156
+ */
62
157
  stickyIndices?: number[];
63
- /** Distance from the end of the scrollable area to trigger 'load' event in pixels. */
158
+
159
+ /**
160
+ * Distance from the end of the scrollable area (in pixels) to trigger the 'load' event.
161
+ * @default 200
162
+ */
64
163
  loadDistance?: number;
65
- /** Whether items are currently being loaded. Prevents multiple 'load' events and shows 'loading' slot. */
164
+
165
+ /**
166
+ * Whether items are currently being loaded.
167
+ * Prevents multiple 'load' events from triggering and shows the 'loading' slot.
168
+ * @default false
169
+ */
66
170
  loading?: boolean;
67
- /** Whether to automatically restore scroll position when items are prepended to the list. */
171
+
172
+ /**
173
+ * Whether to automatically restore and maintain scroll position when items are prepended to the list.
174
+ * Perfect for chat applications.
175
+ * @default false
176
+ */
68
177
  restoreScrollOnPrepend?: boolean;
69
- /** Initial scroll index to jump to on mount. */
178
+
179
+ /**
180
+ * Initial scroll index to jump to immediately after mount.
181
+ */
70
182
  initialScrollIndex?: number;
71
- /** Alignment for the initial scroll index. */
183
+
184
+ /**
185
+ * Alignment for the initial scroll index.
186
+ * @default 'start'
187
+ * @see ScrollAlignment
188
+ */
72
189
  initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions;
73
- /** Default size for items before they are measured. */
190
+
191
+ /**
192
+ * Default size for items before they are measured by ResizeObserver.
193
+ * Only used when itemSize is dynamic.
194
+ * @default 40
195
+ */
74
196
  defaultItemSize?: number;
75
- /** Default width for columns before they are measured. */
197
+
198
+ /**
199
+ * Default width for columns before they are measured by ResizeObserver.
200
+ * Only used when columnWidth is dynamic.
201
+ * @default 100
202
+ */
76
203
  defaultColumnWidth?: number;
77
- /** Whether to show debug information (buffers and offsets). */
204
+
205
+ /**
206
+ * Whether to show debug information (visible offsets and indices) over items.
207
+ * @default false
208
+ */
78
209
  debug?: boolean;
79
210
  }
80
211
 
@@ -106,26 +237,55 @@ const emit = defineEmits<{
106
237
  }>();
107
238
 
108
239
  const slots = defineSlots<{
109
- /** Content rendered at the top of the scrollable area. Can be made sticky. */
240
+ /**
241
+ * Content rendered at the top of the scrollable area.
242
+ * Can be made sticky using the `stickyHeader` prop.
243
+ */
110
244
  header?: (props: Record<string, never>) => VNodeChild;
111
- /** Slot for rendering each individual item. */
245
+
246
+ /**
247
+ * Scoped slot for rendering each individual item.
248
+ */
112
249
  item?: (props: {
113
- /** The data item being rendered. */
250
+ /** The original data item from the `items` array. */
114
251
  item: T;
115
- /** The index of the item in the items array. */
252
+ /** The original index of the item in the `items` array. */
116
253
  index: number;
117
- /** The current visible range of columns (for grid mode). */
118
- columnRange: { start: number; end: number; padStart: number; padEnd: number; };
119
- /** Function to get the width of a specific column. */
254
+ /**
255
+ * Information about the current visible range of columns (for grid mode).
256
+ * @see ColumnRange
257
+ */
258
+ columnRange: {
259
+ /** Index of the first rendered column. */
260
+ start: number;
261
+ /** Index of the last rendered column (exclusive). */
262
+ end: number;
263
+ /** Pixel offset from the start of the row to the first rendered cell. */
264
+ padStart: number;
265
+ /** Pixel offset from the last rendered cell to the end of the row. */
266
+ padEnd: number;
267
+ };
268
+ /**
269
+ * Helper function to get the width of a specific column.
270
+ * Useful for setting consistent widths in grid mode.
271
+ */
120
272
  getColumnWidth: (index: number) => number;
121
- /** Whether this item is configured to be sticky. */
273
+ /** Whether this item is configured to be sticky via `stickyIndices`. */
122
274
  isSticky?: boolean | undefined;
123
- /** Whether this item is currently in a sticky state. */
275
+ /** Whether this item is currently in a sticky state (stuck at the top/start). */
124
276
  isStickyActive?: boolean | undefined;
125
277
  }) => VNodeChild;
126
- /** Content shown when `loading` prop is true. */
278
+
279
+ /**
280
+ * Content shown at the end of the list when the `loading` prop is true.
281
+ * Also prevents additional 'load' events from triggering while visible.
282
+ */
127
283
  loading?: (props: Record<string, never>) => VNodeChild;
128
- /** Content rendered at the bottom of the scrollable area. Can be made sticky. */
284
+
285
+ /**
286
+ * Content rendered at the bottom of the scrollable area.
287
+ * Can be made sticky using the `stickyFooter` prop.
288
+ */
129
289
  footer?: (props: Record<string, never>) => VNodeChild;
130
290
  }>();
131
291
 
@@ -151,21 +311,23 @@ const virtualScrollProps = computed(() => {
151
311
  const pStart = props.scrollPaddingStart;
152
312
  const pEnd = props.scrollPaddingEnd;
153
313
 
154
- /* v8 ignore start -- @preserve */
314
+ /* Trigger re-evaluation on items array mutations */
315
+ // eslint-disable-next-line ts/no-unused-expressions
316
+ props.items.length;
317
+
155
318
  const startX = typeof pStart === 'object'
156
319
  ? (pStart.x || 0)
157
- : (props.direction === 'horizontal' ? (pStart || 0) : 0);
320
+ : ((props.direction === 'horizontal' || props.direction === 'both') ? (pStart || 0) : 0);
158
321
  const startY = typeof pStart === 'object'
159
322
  ? (pStart.y || 0)
160
- : (props.direction !== 'horizontal' ? (pStart || 0) : 0);
323
+ : ((props.direction === 'vertical' || props.direction === 'both') ? (pStart || 0) : 0);
161
324
 
162
325
  const endX = typeof pEnd === 'object'
163
326
  ? (pEnd.x || 0)
164
- : (props.direction === 'horizontal' ? (pEnd || 0) : 0);
327
+ : ((props.direction === 'horizontal' || props.direction === 'both') ? (pEnd || 0) : 0);
165
328
  const endY = typeof pEnd === 'object'
166
329
  ? (pEnd.y || 0)
167
- : (props.direction !== 'horizontal' ? (pEnd || 0) : 0);
168
- /* v8 ignore stop -- @preserve */
330
+ : ((props.direction === 'vertical' || props.direction === 'both') ? (pEnd || 0) : 0);
169
331
 
170
332
  return {
171
333
  items: props.items,
@@ -228,7 +390,6 @@ function refresh() {
228
390
  const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
229
391
 
230
392
  for (const [ index, el ] of itemRefs.entries()) {
231
- /* v8 ignore else -- @preserve */
232
393
  if (el) {
233
394
  updates.push({
234
395
  index,
@@ -288,7 +449,6 @@ watch(scrollDetails, (details, oldDetails) => {
288
449
  });
289
450
 
290
451
  watch(isHydrated, (hydrated) => {
291
- /* v8 ignore else -- @preserve */
292
452
  if (hydrated) {
293
453
  emit('visibleRangeChange', {
294
454
  start: scrollDetails.value.range.start,
@@ -299,12 +459,10 @@ watch(isHydrated, (hydrated) => {
299
459
  }
300
460
  }, { once: true });
301
461
 
302
- /* v8 ignore next 2 -- @preserve */
303
462
  const hostResizeObserver = typeof window === 'undefined'
304
463
  ? null
305
464
  : new ResizeObserver(updateHostOffset);
306
465
 
307
- /* v8 ignore next 2 -- @preserve */
308
466
  const itemResizeObserver = typeof window === 'undefined'
309
467
  ? null
310
468
  : new ResizeObserver((entries) => {
@@ -315,34 +473,32 @@ const itemResizeObserver = typeof window === 'undefined'
315
473
  const index = Number(target.dataset.index);
316
474
  const colIndex = target.dataset.colIndex;
317
475
 
476
+ let inlineSize = entry.contentRect.width;
477
+ let blockSize = entry.contentRect.height;
478
+
479
+ if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
480
+ inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
481
+ blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
482
+ } else {
483
+ // Fallback for older browsers or if borderBoxSize is missing
484
+ inlineSize = target.offsetWidth;
485
+ blockSize = target.offsetHeight;
486
+ }
487
+
318
488
  if (colIndex !== undefined) {
319
489
  // It's a cell measurement. row index is not strictly needed for column width.
320
490
  // We use -1 as a placeholder for row index if it's a cell measurement.
321
- updates.push({ index: -1, inlineSize: 0, blockSize: 0, element: target });
491
+ updates.push({ index: -1, inlineSize, blockSize, element: target });
322
492
  } else if (!Number.isNaN(index)) {
323
- let inlineSize = entry.contentRect.width;
324
- let blockSize = entry.contentRect.height;
325
-
326
- if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
327
- inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
328
- blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
329
- } else {
330
- // Fallback for older browsers or if borderBoxSize is missing
331
- inlineSize = target.offsetWidth;
332
- blockSize = target.offsetHeight;
333
- }
334
-
335
493
  updates.push({ index, inlineSize, blockSize, element: target });
336
494
  }
337
495
  }
338
496
 
339
- /* v8 ignore else -- @preserve */
340
497
  if (updates.length > 0) {
341
498
  updateItemSizes(updates);
342
499
  }
343
500
  });
344
501
 
345
- /* v8 ignore next 2 -- @preserve */
346
502
  const extraResizeObserver = typeof window === 'undefined'
347
503
  ? null
348
504
  : new ResizeObserver(() => {
@@ -352,7 +508,6 @@ const extraResizeObserver = typeof window === 'undefined'
352
508
  });
353
509
 
354
510
  watch(headerRef, (newEl, oldEl) => {
355
- /* v8 ignore if -- @preserve */
356
511
  if (oldEl) {
357
512
  extraResizeObserver?.unobserve(oldEl);
358
513
  }
@@ -362,7 +517,6 @@ watch(headerRef, (newEl, oldEl) => {
362
517
  }, { immediate: true });
363
518
 
364
519
  watch(footerRef, (newEl, oldEl) => {
365
- /* v8 ignore if -- @preserve */
366
520
  if (oldEl) {
367
521
  extraResizeObserver?.unobserve(oldEl);
368
522
  }
@@ -371,28 +525,7 @@ watch(footerRef, (newEl, oldEl) => {
371
525
  }
372
526
  }, { immediate: true });
373
527
 
374
- const firstRenderedIndex = computed(() => renderedItems.value[ 0 ]?.index);
375
- watch(firstRenderedIndex, (newIdx, oldIdx) => {
376
- if (props.direction === 'both') {
377
- /* v8 ignore else -- @preserve */
378
- if (oldIdx !== undefined) {
379
- const oldEl = itemRefs.get(oldIdx);
380
- if (oldEl) {
381
- oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
382
- }
383
- }
384
- if (newIdx !== undefined) {
385
- const newEl = itemRefs.get(newIdx);
386
- /* v8 ignore else -- @preserve */
387
- if (newEl) {
388
- newEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
389
- }
390
- }
391
- }
392
- }, { flush: 'post' });
393
-
394
528
  onMounted(() => {
395
- /* v8 ignore else -- @preserve */
396
529
  if (hostRef.value) {
397
530
  hostResizeObserver?.observe(hostRef.value);
398
531
  }
@@ -400,14 +533,7 @@ onMounted(() => {
400
533
  // Re-observe items that were set before observer was ready
401
534
  for (const el of itemRefs.values()) {
402
535
  itemResizeObserver?.observe(el);
403
- }
404
-
405
- // Observe cells of the first rendered item
406
- /* v8 ignore else -- @preserve */
407
- if (firstRenderedIndex.value !== undefined) {
408
- const el = itemRefs.get(firstRenderedIndex.value);
409
- /* v8 ignore else -- @preserve */
410
- if (el) {
536
+ if (props.direction === 'both') {
411
537
  el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
412
538
  }
413
539
  }
@@ -426,77 +552,86 @@ function setItemRef(el: unknown, index: number) {
426
552
  if (el) {
427
553
  itemRefs.set(index, el as HTMLElement);
428
554
  itemResizeObserver?.observe(el as HTMLElement);
555
+
556
+ if (props.direction === 'both') {
557
+ (el as HTMLElement).querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
558
+ }
429
559
  } else {
430
560
  const oldEl = itemRefs.get(index);
431
- /* v8 ignore else -- @preserve */
432
561
  if (oldEl) {
433
562
  itemResizeObserver?.unobserve(oldEl);
563
+ if (props.direction === 'both') {
564
+ oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
565
+ }
434
566
  itemRefs.delete(index);
435
567
  }
436
568
  }
437
569
  }
438
570
 
439
571
  function handleKeyDown(event: KeyboardEvent) {
440
- stopProgrammaticScroll();
441
572
  const { viewportSize, scrollOffset } = scrollDetails.value;
442
573
  const isHorizontal = props.direction !== 'vertical';
443
574
  const isVertical = props.direction !== 'horizontal';
444
575
 
445
- if (event.key === 'Home') {
446
- event.preventDefault();
447
- scrollToIndex(0, 0, 'start');
448
- return;
449
- }
450
- if (event.key === 'End') {
451
- event.preventDefault();
452
- const lastItemIndex = props.items.length - 1;
453
- const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
454
-
455
- if (isHorizontal) {
456
- if (isVertical) {
457
- scrollToIndex(lastItemIndex, lastColIndex, 'end');
576
+ switch (event.key) {
577
+ case 'Home':
578
+ event.preventDefault();
579
+ stopProgrammaticScroll();
580
+ scrollToIndex(0, 0, 'start');
581
+ break;
582
+ case 'End': {
583
+ event.preventDefault();
584
+ stopProgrammaticScroll();
585
+ const lastItemIndex = props.items.length - 1;
586
+ const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
587
+
588
+ if (isHorizontal) {
589
+ if (isVertical) {
590
+ scrollToIndex(lastItemIndex, lastColIndex, 'end');
591
+ } else {
592
+ scrollToIndex(0, lastItemIndex, 'end');
593
+ }
458
594
  } else {
459
- scrollToIndex(0, lastItemIndex, 'end');
595
+ scrollToIndex(lastItemIndex, 0, 'end');
460
596
  }
461
- } else {
462
- scrollToIndex(lastItemIndex, 0, 'end');
597
+ break;
463
598
  }
464
- return;
465
- }
466
- if (event.key === 'ArrowUp') {
467
- event.preventDefault();
468
- scrollToOffset(null, scrollOffset.y - DEFAULT_ITEM_SIZE);
469
- return;
470
- }
471
- if (event.key === 'ArrowDown') {
472
- event.preventDefault();
473
- scrollToOffset(null, scrollOffset.y + DEFAULT_ITEM_SIZE);
474
- return;
475
- }
476
- if (event.key === 'ArrowLeft') {
477
- event.preventDefault();
478
- scrollToOffset(scrollOffset.x - DEFAULT_ITEM_SIZE, null);
479
- return;
480
- }
481
- if (event.key === 'ArrowRight') {
482
- event.preventDefault();
483
- scrollToOffset(scrollOffset.x + DEFAULT_ITEM_SIZE, null);
484
- return;
485
- }
486
- if (event.key === 'PageUp') {
487
- event.preventDefault();
488
- scrollToOffset(
489
- !isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
490
- isVertical ? scrollOffset.y - viewportSize.height : null,
491
- );
492
- return;
493
- }
494
- if (event.key === 'PageDown') {
495
- event.preventDefault();
496
- scrollToOffset(
497
- !isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
498
- isVertical ? scrollOffset.y + viewportSize.height : null,
499
- );
599
+ case 'ArrowUp':
600
+ event.preventDefault();
601
+ stopProgrammaticScroll();
602
+ scrollToOffset(null, scrollOffset.y - DEFAULT_ITEM_SIZE);
603
+ break;
604
+ case 'ArrowDown':
605
+ event.preventDefault();
606
+ stopProgrammaticScroll();
607
+ scrollToOffset(null, scrollOffset.y + DEFAULT_ITEM_SIZE);
608
+ break;
609
+ case 'ArrowLeft':
610
+ event.preventDefault();
611
+ stopProgrammaticScroll();
612
+ scrollToOffset(scrollOffset.x - DEFAULT_ITEM_SIZE, null);
613
+ break;
614
+ case 'ArrowRight':
615
+ event.preventDefault();
616
+ stopProgrammaticScroll();
617
+ scrollToOffset(scrollOffset.x + DEFAULT_ITEM_SIZE, null);
618
+ break;
619
+ case 'PageUp':
620
+ event.preventDefault();
621
+ stopProgrammaticScroll();
622
+ scrollToOffset(
623
+ !isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
624
+ isVertical ? scrollOffset.y - viewportSize.height : null,
625
+ );
626
+ break;
627
+ case 'PageDown':
628
+ event.preventDefault();
629
+ stopProgrammaticScroll();
630
+ scrollToOffset(
631
+ !isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
632
+ isVertical ? scrollOffset.y + viewportSize.height : null,
633
+ );
634
+ break;
500
635
  }
501
636
  }
502
637
 
@@ -506,23 +641,7 @@ onUnmounted(() => {
506
641
  extraResizeObserver?.disconnect();
507
642
  });
508
643
 
509
- const isWindowContainer = computed(() => {
510
- const c = props.container;
511
- if (
512
- c === null
513
- // window
514
- || (typeof window !== 'undefined' && c === window)
515
- ) {
516
- return true;
517
- }
518
-
519
- // body
520
- if (c && typeof c === 'object' && 'tagName' in c) {
521
- return (c as HTMLElement).tagName === 'BODY';
522
- }
523
-
524
- return false;
525
- });
644
+ const isWindowContainer = computed(() => isWindowLike(props.container));
526
645
 
527
646
  const containerStyle = computed(() => {
528
647
  if (isWindowContainer.value) {
@@ -562,47 +681,15 @@ const spacerStyle = computed(() => ({
562
681
  }));
563
682
 
564
683
  function getItemStyle(item: RenderedItem<T>) {
565
- const isVertical = props.direction === 'vertical';
566
- const isHorizontal = props.direction === 'horizontal';
567
- const isBoth = props.direction === 'both';
568
- const isDynamic = props.itemSize === undefined || props.itemSize === null || props.itemSize === 0;
569
-
570
- const style: Record<string, string | number | undefined> = {
571
- blockSize: isHorizontal ? '100%' : (!isDynamic ? `${ item.size.height }px` : 'auto'),
572
- };
573
-
574
- if (isVertical && props.containerTag === 'table') {
575
- style.minInlineSize = '100%';
576
- } else {
577
- style.inlineSize = isVertical ? '100%' : (!isDynamic ? `${ item.size.width }px` : 'auto');
578
- }
579
-
580
- if (isDynamic) {
581
- if (!isVertical) {
582
- style.minInlineSize = '1px';
583
- }
584
- if (!isHorizontal) {
585
- style.minBlockSize = '1px';
586
- }
587
- }
588
-
589
- if (isHydrated.value) {
590
- if (item.isStickyActive) {
591
- if (isVertical || isBoth) {
592
- style.insetBlockStart = `${ getPaddingY(props.scrollPaddingStart, props.direction) }px`;
593
- }
594
-
595
- if (isHorizontal || isBoth) {
596
- style.insetInlineStart = `${ getPaddingX(props.scrollPaddingStart, props.direction) }px`;
597
- }
598
-
599
- style.transform = `translate(${ item.stickyOffset.x }px, ${ item.stickyOffset.y }px)`;
600
- } else {
601
- style.transform = `translate(${ item.offset.x }px, ${ item.offset.y }px)`;
602
- }
603
- }
604
-
605
- return style;
684
+ return calculateItemStyle({
685
+ containerTag: props.containerTag,
686
+ direction: props.direction,
687
+ isHydrated: isHydrated.value,
688
+ item,
689
+ itemSize: props.itemSize,
690
+ paddingStartX: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x,
691
+ paddingStartY: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y,
692
+ });
606
693
  }
607
694
 
608
695
  const isDebug = computed(() => props.debug);
@@ -611,12 +698,59 @@ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
611
698
  const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
612
699
 
613
700
  defineExpose({
701
+ /**
702
+ * Detailed information about the current scroll state.
703
+ * @see ScrollDetails
704
+ * @see useVirtualScroll
705
+ */
614
706
  scrollDetails,
707
+
708
+ /**
709
+ * Information about the current visible range of columns.
710
+ * @see ColumnRange
711
+ * @see useVirtualScroll
712
+ */
615
713
  columnRange,
714
+
715
+ /**
716
+ * Helper to get the width of a specific column.
717
+ * @param index - The column index.
718
+ * @see useVirtualScroll
719
+ */
616
720
  getColumnWidth,
721
+
722
+ /**
723
+ * Programmatically scroll to a specific row and/or column.
724
+ *
725
+ * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
726
+ * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
727
+ * @param options - Alignment and behavior options. Defaults to { align: 'auto', behavior: 'auto' }.
728
+ * @see ScrollAlignment
729
+ * @see ScrollToIndexOptions
730
+ * @see useVirtualScroll
731
+ */
617
732
  scrollToIndex,
733
+
734
+ /**
735
+ * Programmatically scroll to a specific pixel offset.
736
+ *
737
+ * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
738
+ * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
739
+ * @param options - Scroll options (behavior). Defaults to { behavior: 'auto' }.
740
+ * @see useVirtualScroll
741
+ */
618
742
  scrollToOffset,
743
+
744
+ /**
745
+ * Resets all dynamic measurements and re-initializes from props.
746
+ * @see useVirtualScroll
747
+ */
619
748
  refresh,
749
+
750
+ /**
751
+ * Immediately stops any currently active smooth scroll animation and clears pending corrections.
752
+ * @see useVirtualScroll
753
+ */
620
754
  stopProgrammaticScroll,
621
755
  });
622
756
  </script>
@@ -641,7 +775,6 @@ defineExpose({
641
775
  @pointerdown.passive="stopProgrammaticScroll"
642
776
  @touchstart.passive="stopProgrammaticScroll"
643
777
  >
644
- <!-- v8 ignore start -->
645
778
  <component
646
779
  :is="headerTag"
647
780
  v-if="slots.header"
@@ -651,7 +784,6 @@ defineExpose({
651
784
  >
652
785
  <slot name="header" />
653
786
  </component>
654
- <!-- v8 ignore stop -->
655
787
 
656
788
  <component
657
789
  :is="wrapperTag"
@@ -660,7 +792,6 @@ defineExpose({
660
792
  :style="wrapperStyle"
661
793
  >
662
794
  <!-- Phantom element to push scroll height -->
663
- <!-- v8 ignore start -->
664
795
  <component
665
796
  :is="itemTag"
666
797
  v-if="isTable"
@@ -669,7 +800,6 @@ defineExpose({
669
800
  >
670
801
  <td style="padding: 0; border: none; block-size: inherit;" />
671
802
  </component>
672
- <!-- v8 ignore stop -->
673
803
 
674
804
  <component
675
805
  :is="itemTag"
@@ -699,7 +829,6 @@ defineExpose({
699
829
  </component>
700
830
  </component>
701
831
 
702
- <!-- v8 ignore start -->
703
832
  <div
704
833
  v-if="loading && slots.loading"
705
834
  class="virtual-scroll-loading"
@@ -717,7 +846,6 @@ defineExpose({
717
846
  >
718
847
  <slot name="footer" />
719
848
  </component>
720
- <!-- v8 ignore stop -->
721
849
  </component>
722
850
  </template>
723
851
 
@@ -754,6 +882,7 @@ defineExpose({
754
882
  }
755
883
 
756
884
  .virtual-scroll-item {
885
+ display: grid;
757
886
  box-sizing: border-box;
758
887
  will-change: transform;
759
888