feat(VirtualList): add estimated total size calculation
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user