@pdanpdan/virtual-scroll 0.2.1 → 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,72 +5,207 @@ import type {
5
5
  ScrollAlignmentOptions,
6
6
  ScrollDetails,
7
7
  VirtualScrollProps,
8
- } from '../composables/useVirtualScroll';
8
+ } from '../types';
9
+ import type { VNodeChild } from 'vue';
9
10
 
10
- import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
11
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
11
12
 
12
- import { useVirtualScroll } from '../composables/useVirtualScroll';
13
- import { getPaddingX, getPaddingY } from '../utils/scroll';
13
+ import {
14
+ DEFAULT_ITEM_SIZE,
15
+ useVirtualScroll,
16
+ } from '../composables/useVirtualScroll';
17
+ import { isWindowLike } from '../utils/scroll';
18
+ import { calculateItemStyle } from '../utils/virtual-scroll-logic';
14
19
 
15
20
  export interface Props<T = unknown> {
16
- /** Array of items to be virtualized. */
21
+ /**
22
+ * Array of items to be virtualized.
23
+ * Required.
24
+ */
17
25
  items: T[];
18
- /** 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
+ */
19
32
  itemSize?: number | ((item: T, index: number) => number) | null;
20
- /** 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
+ */
21
41
  direction?: 'vertical' | 'horizontal' | 'both';
22
- /** 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
+ */
23
48
  bufferBefore?: number;
24
- /** 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
+ */
25
54
  bufferAfter?: number;
26
- /** 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
+ */
27
61
  container?: HTMLElement | Window | null;
28
- /** 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
+ */
29
68
  ssrRange?: {
69
+ /** First row index to render. */
30
70
  start: number;
71
+ /** Last row index to render (exclusive). */
31
72
  end: number;
73
+ /** First column index to render (for grid mode). */
32
74
  colStart?: number;
75
+ /** Last column index to render (exclusive, for grid mode). */
33
76
  colEnd?: number;
34
77
  };
35
- /** 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
+ */
36
84
  columnCount?: number;
37
- /** 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
+ */
38
92
  columnWidth?: number | number[] | ((index: number) => number) | null;
39
- /** The HTML tag to use for the container. */
93
+
94
+ /**
95
+ * The HTML tag to use for the root container.
96
+ * @default 'div'
97
+ */
40
98
  containerTag?: string;
41
- /** 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
+ */
42
105
  wrapperTag?: string;
43
- /** 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
+ */
44
112
  itemTag?: string;
45
- /** 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
+ */
46
119
  scrollPaddingStart?: number | { x?: number; y?: number; };
47
- /** 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
+ */
48
125
  scrollPaddingEnd?: number | { x?: number; y?: number; };
49
- /** 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
+ */
50
132
  stickyHeader?: boolean;
51
- /** 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
+ */
52
138
  stickyFooter?: boolean;
53
- /** 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
+ */
54
144
  gap?: number;
55
- /** 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
+ */
56
150
  columnGap?: number;
57
- /** 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
+ */
58
157
  stickyIndices?: number[];
59
- /** 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
+ */
60
163
  loadDistance?: number;
61
- /** 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
+ */
62
170
  loading?: boolean;
63
- /** 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
+ */
64
177
  restoreScrollOnPrepend?: boolean;
65
- /** Initial scroll index to jump to on mount. */
178
+
179
+ /**
180
+ * Initial scroll index to jump to immediately after mount.
181
+ */
66
182
  initialScrollIndex?: number;
67
- /** Alignment for the initial scroll index. */
183
+
184
+ /**
185
+ * Alignment for the initial scroll index.
186
+ * @default 'start'
187
+ * @see ScrollAlignment
188
+ */
68
189
  initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions;
69
- /** 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
+ */
70
196
  defaultItemSize?: number;
71
- /** 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
+ */
72
203
  defaultColumnWidth?: number;
73
- /** 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
+ */
74
209
  debug?: boolean;
75
210
  }
76
211
 
@@ -89,7 +224,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
89
224
  gap: 0,
90
225
  columnGap: 0,
91
226
  stickyIndices: () => [],
92
- loadDistance: 50,
227
+ loadDistance: 200,
93
228
  loading: false,
94
229
  restoreScrollOnPrepend: false,
95
230
  debug: false,
@@ -101,7 +236,58 @@ const emit = defineEmits<{
101
236
  (e: 'visibleRangeChange', range: { start: number; end: number; colStart: number; colEnd: number; }): void;
102
237
  }>();
103
238
 
104
- const isDebug = computed(() => props.debug);
239
+ const slots = defineSlots<{
240
+ /**
241
+ * Content rendered at the top of the scrollable area.
242
+ * Can be made sticky using the `stickyHeader` prop.
243
+ */
244
+ header?: (props: Record<string, never>) => VNodeChild;
245
+
246
+ /**
247
+ * Scoped slot for rendering each individual item.
248
+ */
249
+ item?: (props: {
250
+ /** The original data item from the `items` array. */
251
+ item: T;
252
+ /** The original index of the item in the `items` array. */
253
+ index: number;
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
+ */
272
+ getColumnWidth: (index: number) => number;
273
+ /** Whether this item is configured to be sticky via `stickyIndices`. */
274
+ isSticky?: boolean | undefined;
275
+ /** Whether this item is currently in a sticky state (stuck at the top/start). */
276
+ isStickyActive?: boolean | undefined;
277
+ }) => VNodeChild;
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
+ */
283
+ loading?: (props: Record<string, never>) => VNodeChild;
284
+
285
+ /**
286
+ * Content rendered at the bottom of the scrollable area.
287
+ * Can be made sticky using the `stickyFooter` prop.
288
+ */
289
+ footer?: (props: Record<string, never>) => VNodeChild;
290
+ }>();
105
291
 
106
292
  const hostRef = ref<HTMLElement | null>(null);
107
293
  const wrapperRef = ref<HTMLElement | null>(null);
@@ -125,21 +311,23 @@ const virtualScrollProps = computed(() => {
125
311
  const pStart = props.scrollPaddingStart;
126
312
  const pEnd = props.scrollPaddingEnd;
127
313
 
128
- /* 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
+
129
318
  const startX = typeof pStart === 'object'
130
319
  ? (pStart.x || 0)
131
- : (props.direction === 'horizontal' ? (pStart || 0) : 0);
320
+ : ((props.direction === 'horizontal' || props.direction === 'both') ? (pStart || 0) : 0);
132
321
  const startY = typeof pStart === 'object'
133
322
  ? (pStart.y || 0)
134
- : (props.direction !== 'horizontal' ? (pStart || 0) : 0);
323
+ : ((props.direction === 'vertical' || props.direction === 'both') ? (pStart || 0) : 0);
135
324
 
136
325
  const endX = typeof pEnd === 'object'
137
326
  ? (pEnd.x || 0)
138
- : (props.direction === 'horizontal' ? (pEnd || 0) : 0);
327
+ : ((props.direction === 'horizontal' || props.direction === 'both') ? (pEnd || 0) : 0);
139
328
  const endY = typeof pEnd === 'object'
140
329
  ? (pEnd.y || 0)
141
- : (props.direction !== 'horizontal' ? (pEnd || 0) : 0);
142
- /* v8 ignore stop -- @preserve */
330
+ : ((props.direction === 'vertical' || props.direction === 'both') ? (pEnd || 0) : 0);
143
331
 
144
332
  return {
145
333
  items: props.items,
@@ -172,7 +360,7 @@ const virtualScrollProps = computed(() => {
172
360
  initialScrollAlign: props.initialScrollAlign,
173
361
  defaultItemSize: props.defaultItemSize,
174
362
  defaultColumnWidth: props.defaultColumnWidth,
175
- debug: isDebug.value,
363
+ debug: props.debug,
176
364
  } as VirtualScrollProps<T>;
177
365
  });
178
366
 
@@ -188,10 +376,36 @@ const {
188
376
  scrollToOffset,
189
377
  updateHostOffset,
190
378
  updateItemSizes,
191
- refresh,
379
+ refresh: coreRefresh,
192
380
  stopProgrammaticScroll,
193
381
  } = useVirtualScroll(virtualScrollProps);
194
382
 
383
+ /**
384
+ * Resets all dynamic measurements and re-initializes from props.
385
+ * Also triggers manual re-measurement of all currently rendered items.
386
+ */
387
+ function refresh() {
388
+ coreRefresh();
389
+ nextTick(() => {
390
+ const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
391
+
392
+ for (const [ index, el ] of itemRefs.entries()) {
393
+ if (el) {
394
+ updates.push({
395
+ index,
396
+ inlineSize: el.offsetWidth,
397
+ blockSize: el.offsetHeight,
398
+ element: el,
399
+ });
400
+ }
401
+ }
402
+
403
+ if (updates.length > 0) {
404
+ updateItemSizes(updates);
405
+ }
406
+ });
407
+ }
408
+
195
409
  // Watch for scroll details and emit event
196
410
  watch(scrollDetails, (details, oldDetails) => {
197
411
  if (!isHydrated.value) {
@@ -235,7 +449,6 @@ watch(scrollDetails, (details, oldDetails) => {
235
449
  });
236
450
 
237
451
  watch(isHydrated, (hydrated) => {
238
- /* v8 ignore else -- @preserve */
239
452
  if (hydrated) {
240
453
  emit('visibleRangeChange', {
241
454
  start: scrollDetails.value.range.start,
@@ -246,12 +459,10 @@ watch(isHydrated, (hydrated) => {
246
459
  }
247
460
  }, { once: true });
248
461
 
249
- /* v8 ignore next 2 -- @preserve */
250
462
  const hostResizeObserver = typeof window === 'undefined'
251
463
  ? null
252
464
  : new ResizeObserver(updateHostOffset);
253
465
 
254
- /* v8 ignore next 2 -- @preserve */
255
466
  const itemResizeObserver = typeof window === 'undefined'
256
467
  ? null
257
468
  : new ResizeObserver((entries) => {
@@ -262,34 +473,32 @@ const itemResizeObserver = typeof window === 'undefined'
262
473
  const index = Number(target.dataset.index);
263
474
  const colIndex = target.dataset.colIndex;
264
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
+
265
488
  if (colIndex !== undefined) {
266
489
  // It's a cell measurement. row index is not strictly needed for column width.
267
490
  // We use -1 as a placeholder for row index if it's a cell measurement.
268
- updates.push({ index: -1, inlineSize: 0, blockSize: 0, element: target });
491
+ updates.push({ index: -1, inlineSize, blockSize, element: target });
269
492
  } else if (!Number.isNaN(index)) {
270
- let inlineSize = entry.contentRect.width;
271
- let blockSize = entry.contentRect.height;
272
-
273
- if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
274
- inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
275
- blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
276
- } else {
277
- // Fallback for older browsers or if borderBoxSize is missing
278
- inlineSize = target.offsetWidth;
279
- blockSize = target.offsetHeight;
280
- }
281
-
282
493
  updates.push({ index, inlineSize, blockSize, element: target });
283
494
  }
284
495
  }
285
496
 
286
- /* v8 ignore else -- @preserve */
287
497
  if (updates.length > 0) {
288
498
  updateItemSizes(updates);
289
499
  }
290
500
  });
291
501
 
292
- /* v8 ignore next 2 -- @preserve */
293
502
  const extraResizeObserver = typeof window === 'undefined'
294
503
  ? null
295
504
  : new ResizeObserver(() => {
@@ -299,7 +508,6 @@ const extraResizeObserver = typeof window === 'undefined'
299
508
  });
300
509
 
301
510
  watch(headerRef, (newEl, oldEl) => {
302
- /* v8 ignore if -- @preserve */
303
511
  if (oldEl) {
304
512
  extraResizeObserver?.unobserve(oldEl);
305
513
  }
@@ -309,7 +517,6 @@ watch(headerRef, (newEl, oldEl) => {
309
517
  }, { immediate: true });
310
518
 
311
519
  watch(footerRef, (newEl, oldEl) => {
312
- /* v8 ignore if -- @preserve */
313
520
  if (oldEl) {
314
521
  extraResizeObserver?.unobserve(oldEl);
315
522
  }
@@ -318,28 +525,7 @@ watch(footerRef, (newEl, oldEl) => {
318
525
  }
319
526
  }, { immediate: true });
320
527
 
321
- const firstRenderedIndex = computed(() => renderedItems.value[ 0 ]?.index);
322
- watch(firstRenderedIndex, (newIdx, oldIdx) => {
323
- if (props.direction === 'both') {
324
- /* v8 ignore else -- @preserve */
325
- if (oldIdx !== undefined) {
326
- const oldEl = itemRefs.get(oldIdx);
327
- if (oldEl) {
328
- oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
329
- }
330
- }
331
- if (newIdx !== undefined) {
332
- const newEl = itemRefs.get(newIdx);
333
- /* v8 ignore else -- @preserve */
334
- if (newEl) {
335
- newEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
336
- }
337
- }
338
- }
339
- }, { flush: 'post' });
340
-
341
528
  onMounted(() => {
342
- /* v8 ignore else -- @preserve */
343
529
  if (hostRef.value) {
344
530
  hostResizeObserver?.observe(hostRef.value);
345
531
  }
@@ -347,14 +533,7 @@ onMounted(() => {
347
533
  // Re-observe items that were set before observer was ready
348
534
  for (const el of itemRefs.values()) {
349
535
  itemResizeObserver?.observe(el);
350
- }
351
-
352
- // Observe cells of the first rendered item
353
- /* v8 ignore else -- @preserve */
354
- if (firstRenderedIndex.value !== undefined) {
355
- const el = itemRefs.get(firstRenderedIndex.value);
356
- /* v8 ignore else -- @preserve */
357
- if (el) {
536
+ if (props.direction === 'both') {
358
537
  el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
359
538
  }
360
539
  }
@@ -373,77 +552,86 @@ function setItemRef(el: unknown, index: number) {
373
552
  if (el) {
374
553
  itemRefs.set(index, el as HTMLElement);
375
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
+ }
376
559
  } else {
377
560
  const oldEl = itemRefs.get(index);
378
- /* v8 ignore else -- @preserve */
379
561
  if (oldEl) {
380
562
  itemResizeObserver?.unobserve(oldEl);
563
+ if (props.direction === 'both') {
564
+ oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
565
+ }
381
566
  itemRefs.delete(index);
382
567
  }
383
568
  }
384
569
  }
385
570
 
386
571
  function handleKeyDown(event: KeyboardEvent) {
387
- stopProgrammaticScroll();
388
572
  const { viewportSize, scrollOffset } = scrollDetails.value;
389
573
  const isHorizontal = props.direction !== 'vertical';
390
574
  const isVertical = props.direction !== 'horizontal';
391
575
 
392
- if (event.key === 'Home') {
393
- event.preventDefault();
394
- scrollToIndex(0, 0, 'start');
395
- return;
396
- }
397
- if (event.key === 'End') {
398
- event.preventDefault();
399
- const lastItemIndex = props.items.length - 1;
400
- const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
401
-
402
- if (isHorizontal) {
403
- if (isVertical) {
404
- 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
+ }
405
594
  } else {
406
- scrollToIndex(0, lastItemIndex, 'end');
595
+ scrollToIndex(lastItemIndex, 0, 'end');
407
596
  }
408
- } else {
409
- scrollToIndex(lastItemIndex, 0, 'end');
597
+ break;
410
598
  }
411
- return;
412
- }
413
- if (event.key === 'ArrowUp') {
414
- event.preventDefault();
415
- scrollToOffset(null, scrollOffset.y - 40);
416
- return;
417
- }
418
- if (event.key === 'ArrowDown') {
419
- event.preventDefault();
420
- scrollToOffset(null, scrollOffset.y + 40);
421
- return;
422
- }
423
- if (event.key === 'ArrowLeft') {
424
- event.preventDefault();
425
- scrollToOffset(scrollOffset.x - 40, null);
426
- return;
427
- }
428
- if (event.key === 'ArrowRight') {
429
- event.preventDefault();
430
- scrollToOffset(scrollOffset.x + 40, null);
431
- return;
432
- }
433
- if (event.key === 'PageUp') {
434
- event.preventDefault();
435
- scrollToOffset(
436
- !isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
437
- isVertical ? scrollOffset.y - viewportSize.height : null,
438
- );
439
- return;
440
- }
441
- if (event.key === 'PageDown') {
442
- event.preventDefault();
443
- scrollToOffset(
444
- !isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
445
- isVertical ? scrollOffset.y + viewportSize.height : null,
446
- );
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;
447
635
  }
448
636
  }
449
637
 
@@ -453,23 +641,7 @@ onUnmounted(() => {
453
641
  extraResizeObserver?.disconnect();
454
642
  });
455
643
 
456
- const isWindowContainer = computed(() => {
457
- const c = props.container;
458
- if (
459
- c === null
460
- // window
461
- || (typeof window !== 'undefined' && c === window)
462
- ) {
463
- return true;
464
- }
465
-
466
- // body
467
- if (c && typeof c === 'object' && 'tagName' in c) {
468
- return (c as HTMLElement).tagName === 'BODY';
469
- }
470
-
471
- return false;
472
- });
644
+ const isWindowContainer = computed(() => isWindowLike(props.container));
473
645
 
474
646
  const containerStyle = computed(() => {
475
647
  if (isWindowContainer.value) {
@@ -509,56 +681,76 @@ const spacerStyle = computed(() => ({
509
681
  }));
510
682
 
511
683
  function getItemStyle(item: RenderedItem<T>) {
512
- const isVertical = props.direction === 'vertical';
513
- const isHorizontal = props.direction === 'horizontal';
514
- const isBoth = props.direction === 'both';
515
- const isDynamic = props.itemSize === undefined || props.itemSize === null || props.itemSize === 0;
516
-
517
- const style: Record<string, string | number | undefined> = {
518
- blockSize: isHorizontal ? '100%' : (!isDynamic ? `${ item.size.height }px` : 'auto'),
519
- };
520
-
521
- if (isVertical && props.containerTag === 'table') {
522
- style.minInlineSize = '100%';
523
- } else {
524
- style.inlineSize = isVertical ? '100%' : (!isDynamic ? `${ item.size.width }px` : 'auto');
525
- }
526
-
527
- if (isDynamic) {
528
- if (!isVertical) {
529
- style.minInlineSize = `${ item.size.width }px`;
530
- }
531
- if (!isHorizontal) {
532
- style.minBlockSize = `${ item.size.height }px`;
533
- }
534
- }
535
-
536
- if (isHydrated.value) {
537
- if (item.isStickyActive) {
538
- if (isVertical || isBoth) {
539
- style.insetBlockStart = `${ getPaddingY(props.scrollPaddingStart, props.direction) }px`;
540
- }
541
-
542
- if (isHorizontal || isBoth) {
543
- style.insetInlineStart = `${ getPaddingX(props.scrollPaddingStart, props.direction) }px`;
544
- }
545
-
546
- style.transform = `translate(${ item.stickyOffset.x }px, ${ item.stickyOffset.y }px)`;
547
- } else {
548
- style.transform = `translate(${ item.offset.x }px, ${ item.offset.y }px)`;
549
- }
550
- }
551
-
552
- 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
+ });
553
693
  }
554
694
 
695
+ const isDebug = computed(() => props.debug);
696
+ const isTable = computed(() => props.containerTag === 'table');
697
+ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
698
+ const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
699
+
555
700
  defineExpose({
701
+ /**
702
+ * Detailed information about the current scroll state.
703
+ * @see ScrollDetails
704
+ * @see useVirtualScroll
705
+ */
556
706
  scrollDetails,
707
+
708
+ /**
709
+ * Information about the current visible range of columns.
710
+ * @see ColumnRange
711
+ * @see useVirtualScroll
712
+ */
557
713
  columnRange,
714
+
715
+ /**
716
+ * Helper to get the width of a specific column.
717
+ * @param index - The column index.
718
+ * @see useVirtualScroll
719
+ */
558
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
+ */
559
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
+ */
560
742
  scrollToOffset,
743
+
744
+ /**
745
+ * Resets all dynamic measurements and re-initializes from props.
746
+ * @see useVirtualScroll
747
+ */
561
748
  refresh,
749
+
750
+ /**
751
+ * Immediately stops any currently active smooth scroll animation and clears pending corrections.
752
+ * @see useVirtualScroll
753
+ */
562
754
  stopProgrammaticScroll,
563
755
  });
564
756
  </script>
@@ -573,7 +765,7 @@ defineExpose({
573
765
  {
574
766
  'virtual-scroll--hydrated': isHydrated,
575
767
  'virtual-scroll--window': isWindowContainer,
576
- 'virtual-scroll--table': containerTag === 'table',
768
+ 'virtual-scroll--table': isTable,
577
769
  },
578
770
  ]"
579
771
  :style="containerStyle"
@@ -584,8 +776,8 @@ defineExpose({
584
776
  @touchstart.passive="stopProgrammaticScroll"
585
777
  >
586
778
  <component
587
- :is="containerTag === 'table' ? 'thead' : 'div'"
588
- v-if="$slots.header"
779
+ :is="headerTag"
780
+ v-if="slots.header"
589
781
  ref="headerRef"
590
782
  class="virtual-scroll-header"
591
783
  :class="{ 'virtual-scroll--sticky': stickyHeader }"
@@ -602,7 +794,7 @@ defineExpose({
602
794
  <!-- Phantom element to push scroll height -->
603
795
  <component
604
796
  :is="itemTag"
605
- v-if="containerTag === 'table'"
797
+ v-if="isTable"
606
798
  class="virtual-scroll-spacer"
607
799
  :style="spacerStyle"
608
800
  >
@@ -638,7 +830,7 @@ defineExpose({
638
830
  </component>
639
831
 
640
832
  <div
641
- v-if="loading && $slots.loading"
833
+ v-if="loading && slots.loading"
642
834
  class="virtual-scroll-loading"
643
835
  :style="loadingStyle"
644
836
  >
@@ -646,8 +838,8 @@ defineExpose({
646
838
  </div>
647
839
 
648
840
  <component
649
- :is="containerTag === 'table' ? 'tfoot' : 'div'"
650
- v-if="$slots.footer"
841
+ :is="footerTag"
842
+ v-if="slots.footer"
651
843
  ref="footerRef"
652
844
  class="virtual-scroll-footer"
653
845
  :class="{ 'virtual-scroll--sticky': stickyFooter }"
@@ -690,6 +882,7 @@ defineExpose({
690
882
  }
691
883
 
692
884
  .virtual-scroll-item {
885
+ display: grid;
693
886
  box-sizing: border-box;
694
887
  will-change: transform;
695
888