|
2 | 2 | <!-- table --> |
3 | 3 | <div class="relative shadow-listTableShadow dark:shadow-darkListTableShadow overflow-auto " |
4 | 4 | :class="{'rounded-default': !noRoundings}" |
| 5 | + :style="isVirtualScrollEnabled ? { maxHeight: `${containerHeight}px` } : {}" |
| 6 | + @scroll="handleScroll" |
| 7 | + ref="containerRef" |
5 | 8 | > |
6 | 9 | <!-- skelet loader --> |
7 | 10 | <div role="status" v-if="!resource || !resource.columns" |
|
82 | 85 |
|
83 | 86 | </td> |
84 | 87 | </tr> |
85 | | - |
| 88 | + <!-- Top spacer(virtual scroll) --> |
| 89 | + <tr v-if="isVirtualScrollEnabled && spacerHeight > 0"> |
| 90 | + <td :colspan="resource?.columns.length + 2" :style="{ height: `${spacerHeight}px` }"></td> |
| 91 | + </tr> |
86 | 92 | <component |
87 | | - v-else |
88 | | - v-for="(row, rowI) in rows" |
| 93 | + v-for="(row, rowI) in rowsToRender" |
89 | 94 | :is="tableRowReplaceInjection ? getCustomComponent(tableRowReplaceInjection) : 'tr'" |
90 | 95 | :key="`row_${row._primaryKeyValue}`" |
91 | 96 | :record="row" |
|
95 | 100 | @click="onClick($event, row)" |
96 | 101 | ref="rowRefs" |
97 | 102 | class="list-table-body-row bg-lightListTable dark:bg-darkListTable border-lightListBorder dark:border-gray-700 hover:bg-lightListTableRowHover dark:hover:bg-darkListTableRowHover" |
98 | | - :class="{'border-b': rowI !== rows.length - 1, 'cursor-pointer': row._clickUrl !== null}" |
| 103 | + :class="{'border-b': rowI !== rowsToRender.length - 1, 'cursor-pointer': row._clickUrl !== null}" |
| 104 | + @mounted="(el: any) => updateRowHeight(`row_${row._primaryKeyValue}`, el.offsetHeight)" |
99 | 105 | > |
100 | 106 | <td class="w-4 p-4 cursor-default sticky-column bg-lightListTable dark:bg-darkListTable" @click="(e)=>e.stopPropagation()"> |
101 | 107 | <Checkbox |
|
225 | 231 |
|
226 | 232 | </td> |
227 | 233 | </component> |
| 234 | + <!-- Bottom spacer(virtual scroll) --> |
| 235 | + <tr v-if="isVirtualScrollEnabled && totalHeight > 0"> |
| 236 | + <td :colspan="resource?.columns.length + 2" |
| 237 | + :style="{ height: `${Math.max(0, totalHeight - (endIndex + 1) * (props.itemHeight || 52.5))}px` }"> |
| 238 | + </td> |
| 239 | + </tr> |
228 | 240 | </tbody> |
229 | 241 | </table> |
230 | 242 | </div> |
@@ -359,10 +371,23 @@ const props = defineProps<{ |
359 | 371 | noRoundings?: boolean, |
360 | 372 | customActionsInjection?: any[], |
361 | 373 | tableBodyStartInjection?: any[], |
| 374 | + containerHeight?: number, |
| 375 | + itemHeight?: number, |
| 376 | + bufferSize?: number, |
362 | 377 | customActionIconsThreeDotsMenuItems?: any[] |
363 | 378 | tableRowReplaceInjection?: AdminForthComponentDeclarationFull, |
| 379 | + isVirtualScrollEnabled: boolean |
364 | 380 | }>(); |
365 | 381 |
|
| 382 | +//select between all rows or rows, that should be rendered in virtual scroll |
| 383 | +const rowsToRender = computed(() => { |
| 384 | + if (!props.isVirtualScrollEnabled) { |
| 385 | + return props.rows || []; |
| 386 | + } else { |
| 387 | + return visibleRows.value; |
| 388 | + } |
| 389 | +}); |
| 390 | +
|
366 | 391 | // emits, update page |
367 | 392 | const emits = defineEmits([ |
368 | 393 | 'update:page', |
@@ -628,6 +653,119 @@ function validatePageInput() { |
628 | 653 | page.value = validPage; |
629 | 654 | pageInput.value = validPage.toString(); |
630 | 655 | } |
| 656 | +/* |
| 657 | +*___________________________________________________________________ |
| 658 | +* | |
| 659 | +* Virtual Scroll Implementation | |
| 660 | +*___________________________________________________________________| |
| 661 | +*/ |
| 662 | +// Add throttle utility |
| 663 | +const throttle = (fn: Function, delay: number) => { |
| 664 | + let lastCall = 0; |
| 665 | + return (...args: any[]) => { |
| 666 | + const now = Date.now(); |
| 667 | + if (now - lastCall >= delay) { |
| 668 | + lastCall = now; |
| 669 | + fn(...args); |
| 670 | + } |
| 671 | + }; |
| 672 | +}; |
| 673 | +// Virtual scroll state |
| 674 | +const containerRef = ref<HTMLElement | null>(null); |
| 675 | +const scrollTop = ref(0); |
| 676 | +const visibleRows = ref<any[]>([]); |
| 677 | +const startIndex = ref(0); |
| 678 | +const endIndex = ref(0); |
| 679 | +const totalHeight = ref(0); |
| 680 | +const spacerHeight = ref(0); |
| 681 | +const rowHeightsMap = ref<{[key: string]: number}>({}); |
| 682 | +const rowPositions = ref<number[]>([]); |
| 683 | +// Calculate row positions based on heights |
| 684 | +const calculateRowPositions = () => { |
| 685 | + if (!props.rows) return; |
| 686 | + |
| 687 | + let currentPosition = 0; |
| 688 | + rowPositions.value = props.rows.map((row) => { |
| 689 | + const height = rowHeightsMap.value[`row_${row._primaryKeyValue}`] || props.itemHeight || 52.5; |
| 690 | + const position = currentPosition; |
| 691 | + currentPosition += height; |
| 692 | + return position; |
| 693 | + }); |
| 694 | + totalHeight.value = currentPosition; |
| 695 | +}; |
| 696 | +// Calculate visible rows based on scroll position |
| 697 | +const calculateVisibleRows = () => { |
| 698 | + if (!props.rows?.length) { |
| 699 | + visibleRows.value = props.rows || []; |
| 700 | + return; |
| 701 | + } |
| 702 | + const buffer = props.bufferSize || 5; |
| 703 | + const containerHeight = props.containerHeight || 900; |
| 704 | + |
| 705 | + // For single item or small datasets, show all rows |
| 706 | + if (props.rows.length <= buffer * 2 + 1) { |
| 707 | + startIndex.value = 0; |
| 708 | + endIndex.value = props.rows.length - 1; |
| 709 | + visibleRows.value = props.rows; |
| 710 | + spacerHeight.value = 0; |
| 711 | + return; |
| 712 | + } |
| 713 | + |
| 714 | + // Binary search for start index |
| 715 | + let low = 0; |
| 716 | + let high = rowPositions.value.length - 1; |
| 717 | + const targetPosition = scrollTop.value; |
| 718 | + |
| 719 | + while (low <= high) { |
| 720 | + const mid = Math.floor((low + high) / 2); |
| 721 | + if (rowPositions.value[mid] <= targetPosition) { |
| 722 | + low = mid + 1; |
| 723 | + } else { |
| 724 | + high = mid - 1; |
| 725 | + } |
| 726 | + } |
| 727 | + |
| 728 | + const newStartIndex = Math.max(0, low - 1 - buffer); |
| 729 | + const newEndIndex = Math.min( |
| 730 | + props.rows.length - 1, |
| 731 | + newStartIndex + Math.ceil(containerHeight / (props.itemHeight || 52.5)) + buffer * 2 |
| 732 | + ); |
| 733 | + // Ensure at least one row is visible |
| 734 | + if (newEndIndex < newStartIndex) { |
| 735 | + startIndex.value = 0; |
| 736 | + endIndex.value = Math.min(props.rows.length - 1, Math.ceil(containerHeight / (props.itemHeight || 52.5))); |
| 737 | + } else { |
| 738 | + startIndex.value = newStartIndex; |
| 739 | + endIndex.value = newEndIndex; |
| 740 | + } |
| 741 | + |
| 742 | + visibleRows.value = props.rows.slice(startIndex.value, endIndex.value + 1); |
| 743 | + spacerHeight.value = startIndex.value > 0 ? rowPositions.value[startIndex.value - 1] : 0; |
| 744 | +}; |
| 745 | +// Throttled scroll handler |
| 746 | +const handleScroll = throttle((e: Event) => { |
| 747 | + if (!props.isVirtualScrollEnabled) return; |
| 748 | + const target = e.target as HTMLElement; |
| 749 | + scrollTop.value = target.scrollTop; |
| 750 | + calculateVisibleRows(); |
| 751 | +}, 16); |
| 752 | +// Update row height when it changes |
| 753 | +const updateRowHeight = (rowId: string, height: number) => { |
| 754 | + if (!props.isVirtualScrollEnabled) return; |
| 755 | + if (rowHeightsMap.value[rowId] !== height) { |
| 756 | + rowHeightsMap.value[rowId] = height; |
| 757 | + calculateRowPositions(); |
| 758 | + calculateVisibleRows(); |
| 759 | + } |
| 760 | +}; |
| 761 | +// Watch for changes in rows |
| 762 | +watch(() => props.rows, () => { |
| 763 | + if (props.rows) { |
| 764 | + calculateRowPositions(); |
| 765 | + calculateVisibleRows(); |
| 766 | + } |
| 767 | +}, { immediate: true }); |
| 768 | +
|
631 | 769 |
|
632 | 770 | </script> |
633 | 771 |
|
|
0 commit comments