@pdanpdan/virtual-scroll 0.2.0 → 0.3.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.
@@ -6,10 +6,14 @@ import type {
6
6
  ScrollDetails,
7
7
  VirtualScrollProps,
8
8
  } from '../composables/useVirtualScroll';
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 {
14
+ DEFAULT_ITEM_SIZE,
15
+ useVirtualScroll,
16
+ } from '../composables/useVirtualScroll';
13
17
  import { getPaddingX, getPaddingY } from '../utils/scroll';
14
18
 
15
19
  export interface Props<T = unknown> {
@@ -89,7 +93,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
89
93
  gap: 0,
90
94
  columnGap: 0,
91
95
  stickyIndices: () => [],
92
- loadDistance: 50,
96
+ loadDistance: 200,
93
97
  loading: false,
94
98
  restoreScrollOnPrepend: false,
95
99
  debug: false,
@@ -101,7 +105,29 @@ const emit = defineEmits<{
101
105
  (e: 'visibleRangeChange', range: { start: number; end: number; colStart: number; colEnd: number; }): void;
102
106
  }>();
103
107
 
104
- const isDebug = computed(() => props.debug);
108
+ const slots = defineSlots<{
109
+ /** Content rendered at the top of the scrollable area. Can be made sticky. */
110
+ header?: (props: Record<string, never>) => VNodeChild;
111
+ /** Slot for rendering each individual item. */
112
+ item?: (props: {
113
+ /** The data item being rendered. */
114
+ item: T;
115
+ /** The index of the item in the items array. */
116
+ 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. */
120
+ getColumnWidth: (index: number) => number;
121
+ /** Whether this item is configured to be sticky. */
122
+ isSticky?: boolean | undefined;
123
+ /** Whether this item is currently in a sticky state. */
124
+ isStickyActive?: boolean | undefined;
125
+ }) => VNodeChild;
126
+ /** Content shown when `loading` prop is true. */
127
+ loading?: (props: Record<string, never>) => VNodeChild;
128
+ /** Content rendered at the bottom of the scrollable area. Can be made sticky. */
129
+ footer?: (props: Record<string, never>) => VNodeChild;
130
+ }>();
105
131
 
106
132
  const hostRef = ref<HTMLElement | null>(null);
107
133
  const wrapperRef = ref<HTMLElement | null>(null);
@@ -125,6 +151,7 @@ const virtualScrollProps = computed(() => {
125
151
  const pStart = props.scrollPaddingStart;
126
152
  const pEnd = props.scrollPaddingEnd;
127
153
 
154
+ /* v8 ignore start -- @preserve */
128
155
  const startX = typeof pStart === 'object'
129
156
  ? (pStart.x || 0)
130
157
  : (props.direction === 'horizontal' ? (pStart || 0) : 0);
@@ -138,6 +165,7 @@ const virtualScrollProps = computed(() => {
138
165
  const endY = typeof pEnd === 'object'
139
166
  ? (pEnd.y || 0)
140
167
  : (props.direction !== 'horizontal' ? (pEnd || 0) : 0);
168
+ /* v8 ignore stop -- @preserve */
141
169
 
142
170
  return {
143
171
  items: props.items,
@@ -170,7 +198,7 @@ const virtualScrollProps = computed(() => {
170
198
  initialScrollAlign: props.initialScrollAlign,
171
199
  defaultItemSize: props.defaultItemSize,
172
200
  defaultColumnWidth: props.defaultColumnWidth,
173
- debug: isDebug.value,
201
+ debug: props.debug,
174
202
  } as VirtualScrollProps<T>;
175
203
  });
176
204
 
@@ -186,10 +214,37 @@ const {
186
214
  scrollToOffset,
187
215
  updateHostOffset,
188
216
  updateItemSizes,
189
- refresh,
217
+ refresh: coreRefresh,
190
218
  stopProgrammaticScroll,
191
219
  } = useVirtualScroll(virtualScrollProps);
192
220
 
221
+ /**
222
+ * Resets all dynamic measurements and re-initializes from props.
223
+ * Also triggers manual re-measurement of all currently rendered items.
224
+ */
225
+ function refresh() {
226
+ coreRefresh();
227
+ nextTick(() => {
228
+ const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
229
+
230
+ for (const [ index, el ] of itemRefs.entries()) {
231
+ /* v8 ignore else -- @preserve */
232
+ if (el) {
233
+ updates.push({
234
+ index,
235
+ inlineSize: el.offsetWidth,
236
+ blockSize: el.offsetHeight,
237
+ element: el,
238
+ });
239
+ }
240
+ }
241
+
242
+ if (updates.length > 0) {
243
+ updateItemSizes(updates);
244
+ }
245
+ });
246
+ }
247
+
193
248
  // Watch for scroll details and emit event
194
249
  watch(scrollDetails, (details, oldDetails) => {
195
250
  if (!isHydrated.value) {
@@ -233,6 +288,7 @@ watch(scrollDetails, (details, oldDetails) => {
233
288
  });
234
289
 
235
290
  watch(isHydrated, (hydrated) => {
291
+ /* v8 ignore else -- @preserve */
236
292
  if (hydrated) {
237
293
  emit('visibleRangeChange', {
238
294
  start: scrollDetails.value.range.start,
@@ -243,10 +299,12 @@ watch(isHydrated, (hydrated) => {
243
299
  }
244
300
  }, { once: true });
245
301
 
302
+ /* v8 ignore next 2 -- @preserve */
246
303
  const hostResizeObserver = typeof window === 'undefined'
247
304
  ? null
248
305
  : new ResizeObserver(updateHostOffset);
249
306
 
307
+ /* v8 ignore next 2 -- @preserve */
250
308
  const itemResizeObserver = typeof window === 'undefined'
251
309
  ? null
252
310
  : new ResizeObserver((entries) => {
@@ -278,11 +336,13 @@ const itemResizeObserver = typeof window === 'undefined'
278
336
  }
279
337
  }
280
338
 
339
+ /* v8 ignore else -- @preserve */
281
340
  if (updates.length > 0) {
282
341
  updateItemSizes(updates);
283
342
  }
284
343
  });
285
344
 
345
+ /* v8 ignore next 2 -- @preserve */
286
346
  const extraResizeObserver = typeof window === 'undefined'
287
347
  ? null
288
348
  : new ResizeObserver(() => {
@@ -292,6 +352,7 @@ const extraResizeObserver = typeof window === 'undefined'
292
352
  });
293
353
 
294
354
  watch(headerRef, (newEl, oldEl) => {
355
+ /* v8 ignore if -- @preserve */
295
356
  if (oldEl) {
296
357
  extraResizeObserver?.unobserve(oldEl);
297
358
  }
@@ -301,6 +362,7 @@ watch(headerRef, (newEl, oldEl) => {
301
362
  }, { immediate: true });
302
363
 
303
364
  watch(footerRef, (newEl, oldEl) => {
365
+ /* v8 ignore if -- @preserve */
304
366
  if (oldEl) {
305
367
  extraResizeObserver?.unobserve(oldEl);
306
368
  }
@@ -310,9 +372,9 @@ watch(footerRef, (newEl, oldEl) => {
310
372
  }, { immediate: true });
311
373
 
312
374
  const firstRenderedIndex = computed(() => renderedItems.value[ 0 ]?.index);
313
-
314
375
  watch(firstRenderedIndex, (newIdx, oldIdx) => {
315
376
  if (props.direction === 'both') {
377
+ /* v8 ignore else -- @preserve */
316
378
  if (oldIdx !== undefined) {
317
379
  const oldEl = itemRefs.get(oldIdx);
318
380
  if (oldEl) {
@@ -321,6 +383,7 @@ watch(firstRenderedIndex, (newIdx, oldIdx) => {
321
383
  }
322
384
  if (newIdx !== undefined) {
323
385
  const newEl = itemRefs.get(newIdx);
386
+ /* v8 ignore else -- @preserve */
324
387
  if (newEl) {
325
388
  newEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
326
389
  }
@@ -329,6 +392,7 @@ watch(firstRenderedIndex, (newIdx, oldIdx) => {
329
392
  }, { flush: 'post' });
330
393
 
331
394
  onMounted(() => {
395
+ /* v8 ignore else -- @preserve */
332
396
  if (hostRef.value) {
333
397
  hostResizeObserver?.observe(hostRef.value);
334
398
  }
@@ -339,8 +403,10 @@ onMounted(() => {
339
403
  }
340
404
 
341
405
  // Observe cells of the first rendered item
406
+ /* v8 ignore else -- @preserve */
342
407
  if (firstRenderedIndex.value !== undefined) {
343
408
  const el = itemRefs.get(firstRenderedIndex.value);
409
+ /* v8 ignore else -- @preserve */
344
410
  if (el) {
345
411
  el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
346
412
  }
@@ -362,6 +428,7 @@ function setItemRef(el: unknown, index: number) {
362
428
  itemResizeObserver?.observe(el as HTMLElement);
363
429
  } else {
364
430
  const oldEl = itemRefs.get(index);
431
+ /* v8 ignore else -- @preserve */
365
432
  if (oldEl) {
366
433
  itemResizeObserver?.unobserve(oldEl);
367
434
  itemRefs.delete(index);
@@ -385,37 +452,41 @@ function handleKeyDown(event: KeyboardEvent) {
385
452
  const lastItemIndex = props.items.length - 1;
386
453
  const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
387
454
 
388
- if (isHorizontal && isVertical) {
389
- scrollToIndex(lastItemIndex, lastColIndex, 'end');
455
+ if (isHorizontal) {
456
+ if (isVertical) {
457
+ scrollToIndex(lastItemIndex, lastColIndex, 'end');
458
+ } else {
459
+ scrollToIndex(0, lastItemIndex, 'end');
460
+ }
390
461
  } else {
391
- scrollToIndex(0, lastItemIndex, 'end');
462
+ scrollToIndex(lastItemIndex, 0, 'end');
392
463
  }
393
464
  return;
394
465
  }
395
466
  if (event.key === 'ArrowUp') {
396
467
  event.preventDefault();
397
- scrollToOffset(null, scrollOffset.y - 40);
468
+ scrollToOffset(null, scrollOffset.y - DEFAULT_ITEM_SIZE);
398
469
  return;
399
470
  }
400
471
  if (event.key === 'ArrowDown') {
401
472
  event.preventDefault();
402
- scrollToOffset(null, scrollOffset.y + 40);
473
+ scrollToOffset(null, scrollOffset.y + DEFAULT_ITEM_SIZE);
403
474
  return;
404
475
  }
405
476
  if (event.key === 'ArrowLeft') {
406
477
  event.preventDefault();
407
- scrollToOffset(scrollOffset.x - 40, null);
478
+ scrollToOffset(scrollOffset.x - DEFAULT_ITEM_SIZE, null);
408
479
  return;
409
480
  }
410
481
  if (event.key === 'ArrowRight') {
411
482
  event.preventDefault();
412
- scrollToOffset(scrollOffset.x + 40, null);
483
+ scrollToOffset(scrollOffset.x + DEFAULT_ITEM_SIZE, null);
413
484
  return;
414
485
  }
415
486
  if (event.key === 'PageUp') {
416
487
  event.preventDefault();
417
488
  scrollToOffset(
418
- isHorizontal ? scrollOffset.x - viewportSize.width : null,
489
+ !isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
419
490
  isVertical ? scrollOffset.y - viewportSize.height : null,
420
491
  );
421
492
  return;
@@ -423,7 +494,7 @@ function handleKeyDown(event: KeyboardEvent) {
423
494
  if (event.key === 'PageDown') {
424
495
  event.preventDefault();
425
496
  scrollToOffset(
426
- isHorizontal ? scrollOffset.x + viewportSize.width : null,
497
+ !isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
427
498
  isVertical ? scrollOffset.y + viewportSize.height : null,
428
499
  );
429
500
  }
@@ -508,10 +579,10 @@ function getItemStyle(item: RenderedItem<T>) {
508
579
 
509
580
  if (isDynamic) {
510
581
  if (!isVertical) {
511
- style.minInlineSize = `${ item.size.width }px`;
582
+ style.minInlineSize = '1px';
512
583
  }
513
584
  if (!isHorizontal) {
514
- style.minBlockSize = `${ item.size.height }px`;
585
+ style.minBlockSize = '1px';
515
586
  }
516
587
  }
517
588
 
@@ -534,6 +605,11 @@ function getItemStyle(item: RenderedItem<T>) {
534
605
  return style;
535
606
  }
536
607
 
608
+ const isDebug = computed(() => props.debug);
609
+ const isTable = computed(() => props.containerTag === 'table');
610
+ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
611
+ const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
612
+
537
613
  defineExpose({
538
614
  scrollDetails,
539
615
  columnRange,
@@ -555,7 +631,7 @@ defineExpose({
555
631
  {
556
632
  'virtual-scroll--hydrated': isHydrated,
557
633
  'virtual-scroll--window': isWindowContainer,
558
- 'virtual-scroll--table': containerTag === 'table',
634
+ 'virtual-scroll--table': isTable,
559
635
  },
560
636
  ]"
561
637
  :style="containerStyle"
@@ -565,15 +641,17 @@ defineExpose({
565
641
  @pointerdown.passive="stopProgrammaticScroll"
566
642
  @touchstart.passive="stopProgrammaticScroll"
567
643
  >
644
+ <!-- v8 ignore start -->
568
645
  <component
569
- :is="containerTag === 'table' ? 'thead' : 'div'"
570
- v-if="$slots.header"
646
+ :is="headerTag"
647
+ v-if="slots.header"
571
648
  ref="headerRef"
572
649
  class="virtual-scroll-header"
573
650
  :class="{ 'virtual-scroll--sticky': stickyHeader }"
574
651
  >
575
652
  <slot name="header" />
576
653
  </component>
654
+ <!-- v8 ignore stop -->
577
655
 
578
656
  <component
579
657
  :is="wrapperTag"
@@ -582,14 +660,16 @@ defineExpose({
582
660
  :style="wrapperStyle"
583
661
  >
584
662
  <!-- Phantom element to push scroll height -->
663
+ <!-- v8 ignore start -->
585
664
  <component
586
665
  :is="itemTag"
587
- v-if="containerTag === 'table'"
666
+ v-if="isTable"
588
667
  class="virtual-scroll-spacer"
589
668
  :style="spacerStyle"
590
669
  >
591
670
  <td style="padding: 0; border: none; block-size: inherit;" />
592
671
  </component>
672
+ <!-- v8 ignore stop -->
593
673
 
594
674
  <component
595
675
  :is="itemTag"
@@ -619,8 +699,9 @@ defineExpose({
619
699
  </component>
620
700
  </component>
621
701
 
702
+ <!-- v8 ignore start -->
622
703
  <div
623
- v-if="loading && $slots.loading"
704
+ v-if="loading && slots.loading"
624
705
  class="virtual-scroll-loading"
625
706
  :style="loadingStyle"
626
707
  >
@@ -628,14 +709,15 @@ defineExpose({
628
709
  </div>
629
710
 
630
711
  <component
631
- :is="containerTag === 'table' ? 'tfoot' : 'div'"
632
- v-if="$slots.footer"
712
+ :is="footerTag"
713
+ v-if="slots.footer"
633
714
  ref="footerRef"
634
715
  class="virtual-scroll-footer"
635
716
  :class="{ 'virtual-scroll--sticky': stickyFooter }"
636
717
  >
637
718
  <slot name="footer" />
638
719
  </component>
720
+ <!-- v8 ignore stop -->
639
721
  </component>
640
722
  </template>
641
723