feat(VirtualList): add estimated total size calculation

This commit is contained in:
Ilia Mashkov
2026-02-16 14:15:19 +03:00
parent bee529dff8
commit 4d57f2084c

View File

@@ -6,12 +6,11 @@
- Keyboard navigation (ArrowUp/Down, Home, End) - Keyboard navigation (ArrowUp/Down, Home, End)
- Fixed or dynamic item heights - Fixed or dynamic item heights
- ARIA listbox/option pattern with single tab stop - ARIA listbox/option pattern with single tab stop
- Custom shadcn ScrollArea scrollbar - Native browser scroll
--> -->
<script lang="ts" generics="T"> <script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib'; import { createVirtualizer } from '$shared/lib';
import { throttle } from '$shared/lib/utils'; import { throttle } from '$shared/lib/utils';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@@ -136,10 +135,13 @@ let {
isLoading = false, isLoading = false,
}: Props = $props(); }: Props = $props();
// Reference to the ScrollArea viewport element for attaching the virtualizer // Reference to the scroll container element for attaching the virtualizer
let viewportRef = $state<HTMLElement | null>(null); let viewportRef = $state<HTMLElement | null>(null);
// Use items.length for count to keep existing item positions stable
// But calculate a separate totalSize for scrollbar that accounts for unloaded items
const virtualizer = createVirtualizer(() => ({ const virtualizer = createVirtualizer(() => ({
// Only virtualize loaded items - this keeps positions stable when new items load
count: items.length, count: items.length,
data: items, data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
@@ -147,6 +149,34 @@ const virtualizer = createVirtualizer(() => ({
useWindowScroll, useWindowScroll,
})); }));
// Calculate total size including unloaded items for proper scrollbar sizing
// Use estimateSize() for items that haven't been loaded yet
const estimatedTotalSize = $derived.by(() => {
if (total === items.length) {
// No unloaded items, use virtualizer's totalSize
return virtualizer.totalSize;
}
// Start with the virtualized (loaded) items size
const loadedSize = virtualizer.totalSize;
// Add estimated size for unloaded items
const unloadedCount = total - items.length;
if (unloadedCount <= 0) return loadedSize;
// Estimate the size of unloaded items
// Get the average size of loaded items, or use the estimateSize function
const estimateFn = typeof itemHeight === 'function' ? itemHeight : () => itemHeight;
// Use estimateSize for unloaded items (index from items.length to total - 1)
let unloadedSize = 0;
for (let i = items.length; i < total; i++) {
unloadedSize += estimateFn(i);
}
return loadedSize + unloadedSize;
});
// Attach virtualizer.container action to the viewport when it becomes available // Attach virtualizer.container action to the viewport when it becomes available
$effect(() => { $effect(() => {
if (viewportRef) { if (viewportRef) {
@@ -170,7 +200,8 @@ $effect(() => {
$effect(() => { $effect(() => {
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items) // Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
if (virtualizer.items.length > 0 && onNearBottom) { // Only trigger if container has sufficient height to avoid false positives
if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1]; const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
// Compare against loaded items length, not total // Compare against loaded items length, not total
const itemsRemaining = items.length - lastVisibleItem.index; const itemsRemaining = items.length - lastVisibleItem.index;
@@ -184,7 +215,7 @@ $effect(() => {
{#if useWindowScroll} {#if useWindowScroll}
<div class={cn('relative w-full', className)} bind:this={viewportRef}> <div class={cn('relative w-full', className)} bind:this={viewportRef}>
<div style:height="{virtualizer.totalSize}px" class="relative w-full"> <div style:height="{estimatedTotalSize}px" class="relative w-full">
{#each virtualizer.items as item (item.key)} {#each virtualizer.items as item (item.key)}
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
@@ -195,7 +226,7 @@ $effect(() => {
> >
{#if item.index < items.length} {#if item.index < items.length}
{@render children({ {@render children({
// TODO: Fix indenation rule for this case // TODO: Fix indentation rule for this case
item: items[item.index], item: items[item.index],
index: item.index, index: item.index,
isFullyVisible: item.isFullyVisible, isFullyVisible: item.isFullyVisible,
@@ -208,16 +239,16 @@ $effect(() => {
</div> </div>
</div> </div>
{:else} {:else}
<ScrollArea <div
bind:viewportRef bind:this={viewportRef}
class={cn( class={cn(
'relative rounded-md bg-background', 'relative overflow-y-auto overflow-x-hidden',
'h-150 w-full', 'rounded-md bg-background',
'w-full min-h-[200px]',
className, className,
)} )}
orientation="vertical"
> >
<div style:height="{virtualizer.totalSize}px" class="relative w-full"> <div style:height="{estimatedTotalSize}px" class="relative w-full">
{#each virtualizer.items as item (item.key)} {#each virtualizer.items as item (item.key)}
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
@@ -227,7 +258,7 @@ $effect(() => {
> >
{#if item.index < items.length} {#if item.index < items.length}
{@render children({ {@render children({
// TODO: Fix indenation rule for this case // TODO: Fix indentation rule for this case
item: items[item.index], item: items[item.index],
index: item.index, index: item.index,
isFullyVisible: item.isFullyVisible, isFullyVisible: item.isFullyVisible,
@@ -238,5 +269,5 @@ $effect(() => {
</div> </div>
{/each} {/each}
</div> </div>
</ScrollArea> </div>
{/if} {/if}