feat(VirtualList): add different layout support

This commit is contained in:
Ilia Mashkov
2026-02-27 13:00:03 +03:00
parent 44bbac4695
commit fb6cd495d3

View File

@@ -51,6 +51,16 @@ interface Props {
* (follows shadcn convention for className prop) * (follows shadcn convention for className prop)
*/ */
class?: string; class?: string;
/**
* Number of columns for grid layout.
* @default 1
*/
columns?: number;
/**
* Gap between items in pixels.
* @default 0
*/
gap?: number;
/** /**
* An optional callback that will be called for each new set of loaded items * An optional callback that will be called for each new set of loaded items
* @param items - Loaded items * @param items - Loaded items
@@ -131,18 +141,26 @@ let {
children, children,
useWindowScroll = false, useWindowScroll = false,
isLoading = false, isLoading = false,
columns = 1,
gap = 0,
}: Props = $props(); }: Props = $props();
// Reference to the scroll container 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);
// Calculate row-based counts for grid layout
const rowCount = $derived(Math.ceil(items.length / columns));
const totalRows = $derived(Math.ceil(total / columns));
// Use items.length for count to keep existing item positions stable // Use items.length for count to keep existing item positions stable
// But calculate a separate totalSize for scrollbar that accounts for unloaded items // 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 // Only virtualize loaded items - this keeps positions stable when new items load
count: items.length, count: rowCount,
data: items, data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function'
? (index: number) => itemHeight(index) + gap
: () => itemHeight + gap,
overscan, overscan,
useWindowScroll, useWindowScroll,
})); }));
@@ -150,25 +168,26 @@ const virtualizer = createVirtualizer(() => ({
// Calculate total size including unloaded items for proper scrollbar sizing // Calculate total size including unloaded items for proper scrollbar sizing
// Use estimateSize() for items that haven't been loaded yet // Use estimateSize() for items that haven't been loaded yet
const estimatedTotalSize = $derived.by(() => { const estimatedTotalSize = $derived.by(() => {
if (total === items.length) { if (totalRows === rowCount) {
// No unloaded items, use virtualizer's totalSize // No unloaded items, use virtualizer's totalSize
return virtualizer.totalSize; return virtualizer.totalSize;
} }
// Start with the virtualized (loaded) items size // Start with the virtualized (loaded) rows size
const loadedSize = virtualizer.totalSize; const loadedSize = virtualizer.totalSize;
// Add estimated size for unloaded items // Add estimated size for unloaded rows
const unloadedCount = total - items.length; const unloadedRows = totalRows - rowCount;
if (unloadedCount <= 0) return loadedSize; if (unloadedRows <= 0) return loadedSize;
// Estimate the size of unloaded items // Estimate the size of unloaded rows
// Get the average size of loaded items, or use the estimateSize function const estimateFn = typeof itemHeight === 'function'
const estimateFn = typeof itemHeight === 'function' ? itemHeight : () => itemHeight; ? (index: number) => itemHeight(index * columns) + gap
: () => itemHeight + gap;
// Use estimateSize for unloaded items (index from items.length to total - 1) // Use estimateSize for unloaded rows (index from rowCount to totalRows - 1)
let unloadedSize = 0; let unloadedSize = 0;
for (let i = items.length; i < total; i++) { for (let i = rowCount; i < totalRows; i++) {
unloadedSize += estimateFn(i); unloadedSize += estimateFn(i);
} }
@@ -191,8 +210,31 @@ const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex); onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms debounce }, 200); // 200ms debounce
// Calculate top/bottom padding for spacer elements
const topPad = $derived(
virtualizer.items.length > 0 ? virtualizer.items[0].start - gap : 0,
);
const botPad = $derived(
virtualizer.items.length > 0
? Math.max(
0,
estimatedTotalSize
- (virtualizer.items[virtualizer.items.length - 1].end + gap),
)
: estimatedTotalSize,
);
$effect(() => { $effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]); // Expand row indices to item indices
const visibleItemIndices: number[] = [];
for (const row of virtualizer.items) {
const startItemIndex = row.index * columns;
const endItemIndex = Math.min(startItemIndex + columns, items.length);
for (let i = startItemIndex; i < endItemIndex; i++) {
visibleItemIndices.push(i);
}
}
const visibleItems = visibleItemIndices.map(index => items[index]);
throttledVisibleChange(visibleItems); throttledVisibleChange(visibleItems);
}); });
@@ -200,41 +242,87 @@ $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)
// Only trigger if container has sufficient height to avoid false positives // Only trigger if container has sufficient height to avoid false positives
if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) { if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1]; const lastVisibleRow = virtualizer.items[virtualizer.items.length - 1];
// Convert row index to last item index in that row
const lastVisibleItemIndex = Math.min(
(lastVisibleRow.index + 1) * columns - 1,
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 - lastVisibleItemIndex;
if (itemsRemaining <= 5) { if (itemsRemaining <= 5) {
throttledNearBottom(lastVisibleItem.index); throttledNearBottom(lastVisibleItemIndex);
} }
} }
}); });
</script> </script>
{#if useWindowScroll} {#snippet content()}
<div class={cn('relative w-full', className)} bind:this={viewportRef}> <div
<div style:height="{estimatedTotalSize}px" class="relative w-full"> style:grid-template-columns="repeat({columns}, minmax(0, 1fr))"
{#each virtualizer.items as item (item.key)} class="grid w-full"
>
<!-- Top spacer for padding-based virtualization -->
<div
style:grid-column="1 / -1"
style:height="{topPad}px"
class="shrink-0"
>
</div>
{#each virtualizer.items as row (row.key)}
{#if row.index < rowCount}
{@const startItemIndex = row.index * columns}
{@const endItemIndex = Math.min(startItemIndex + columns, items.length)}
{#each Array.from({ length: endItemIndex - startItemIndex }) as _, colIndex (startItemIndex + colIndex)}
{@const itemIndex = startItemIndex + colIndex}
{#if colIndex === 0}
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
data-index={item.index} data-index={row.index}
class="absolute top-0 left-0 w-full will-change-transform" class="min-h-0"
style:transform="translateY({item.start}px)"
data-lenis-prevent
> >
{#if item.index < items.length} {#if itemIndex < items.length}
{@render children({ {@render children({
// TODO: Fix indentation rule for this case item: items[itemIndex],
item: items[item.index], index: itemIndex,
index: item.index, isFullyVisible: row.isFullyVisible,
isFullyVisible: item.isFullyVisible, isPartiallyVisible: row.isPartiallyVisible,
isPartiallyVisible: item.isPartiallyVisible, proximity: row.proximity,
proximity: item.proximity,
})} })}
{/if} {/if}
</div> </div>
{/each} {:else}
<div class="min-h-0">
{#if itemIndex < items.length}
{@render children({
item: items[itemIndex],
index: itemIndex,
isFullyVisible: row.isFullyVisible,
isPartiallyVisible: row.isPartiallyVisible,
proximity: row.proximity,
})}
{/if}
</div> </div>
{/if}
{/each}
{/if}
{/each}
<!-- Bottom spacer for padding-based virtualization -->
<div
style:grid-column="1 / -1"
style:height="{botPad}px"
class="shrink-0"
>
</div>
</div>
{/snippet}
{#if useWindowScroll}
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
{@render content()}
</div> </div>
{:else} {:else}
<div <div
@@ -246,26 +334,6 @@ $effect(() => {
className, className,
)} )}
> >
<div style:height="{estimatedTotalSize}px" class="relative w-full"> {@render content()}
{#each virtualizer.items as item (item.key)}
<div
use:virtualizer.measureElement
data-index={item.index}
class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)"
>
{#if item.index < items.length}
{@render children({
// TODO: Fix indentation rule for this case
item: items[item.index],
index: item.index,
isFullyVisible: item.isFullyVisible,
isPartiallyVisible: item.isPartiallyVisible,
proximity: item.proximity,
})}
{/if}
</div>
{/each}
</div>
</div> </div>
{/if} {/if}