@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.
- package/README.md +182 -88
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +100 -35
- package/dist/index.js +1 -823
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +902 -0
- package/dist/index.mjs.map +1 -0
- package/dist/virtual-scroll.css +2 -0
- package/package.json +9 -6
- package/src/components/VirtualScroll.test.ts +397 -329
- package/src/components/VirtualScroll.vue +107 -25
- package/src/composables/useVirtualScroll.test.ts +1029 -255
- package/src/composables/useVirtualScroll.ts +176 -88
- package/src/utils/fenwick-tree.test.ts +80 -65
- package/dist/index.css +0 -2
|
@@ -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 {
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
389
|
-
|
|
455
|
+
if (isHorizontal) {
|
|
456
|
+
if (isVertical) {
|
|
457
|
+
scrollToIndex(lastItemIndex, lastColIndex, 'end');
|
|
458
|
+
} else {
|
|
459
|
+
scrollToIndex(0, lastItemIndex, 'end');
|
|
460
|
+
}
|
|
390
461
|
} else {
|
|
391
|
-
scrollToIndex(
|
|
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 -
|
|
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 +
|
|
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 -
|
|
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 +
|
|
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 =
|
|
582
|
+
style.minInlineSize = '1px';
|
|
512
583
|
}
|
|
513
584
|
if (!isHorizontal) {
|
|
514
|
-
style.minBlockSize =
|
|
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':
|
|
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="
|
|
570
|
-
v-if="
|
|
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="
|
|
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 &&
|
|
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="
|
|
632
|
-
v-if="
|
|
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
|
|