feat(VirtualList): add different layout support
This commit is contained in:
@@ -51,6 +51,16 @@ interface Props {
|
||||
* (follows shadcn convention for className prop)
|
||||
*/
|
||||
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
|
||||
* @param items - Loaded items
|
||||
@@ -131,18 +141,26 @@ let {
|
||||
children,
|
||||
useWindowScroll = false,
|
||||
isLoading = false,
|
||||
columns = 1,
|
||||
gap = 0,
|
||||
}: Props = $props();
|
||||
|
||||
// Reference to the scroll container element for attaching the virtualizer
|
||||
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
|
||||
// But calculate a separate totalSize for scrollbar that accounts for unloaded items
|
||||
const virtualizer = createVirtualizer(() => ({
|
||||
// Only virtualize loaded items - this keeps positions stable when new items load
|
||||
count: items.length,
|
||||
count: rowCount,
|
||||
data: items,
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
estimateSize: typeof itemHeight === 'function'
|
||||
? (index: number) => itemHeight(index) + gap
|
||||
: () => itemHeight + gap,
|
||||
overscan,
|
||||
useWindowScroll,
|
||||
}));
|
||||
@@ -150,25 +168,26 @@ const virtualizer = createVirtualizer(() => ({
|
||||
// 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) {
|
||||
if (totalRows === rowCount) {
|
||||
// No unloaded items, use virtualizer's totalSize
|
||||
return virtualizer.totalSize;
|
||||
}
|
||||
|
||||
// Start with the virtualized (loaded) items size
|
||||
// Start with the virtualized (loaded) rows size
|
||||
const loadedSize = virtualizer.totalSize;
|
||||
|
||||
// Add estimated size for unloaded items
|
||||
const unloadedCount = total - items.length;
|
||||
if (unloadedCount <= 0) return loadedSize;
|
||||
// Add estimated size for unloaded rows
|
||||
const unloadedRows = totalRows - rowCount;
|
||||
if (unloadedRows <= 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;
|
||||
// Estimate the size of unloaded rows
|
||||
const estimateFn = typeof itemHeight === 'function'
|
||||
? (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;
|
||||
for (let i = items.length; i < total; i++) {
|
||||
for (let i = rowCount; i < totalRows; i++) {
|
||||
unloadedSize += estimateFn(i);
|
||||
}
|
||||
|
||||
@@ -191,8 +210,31 @@ const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
}, 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(() => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -200,41 +242,87 @@ $effect(() => {
|
||||
// 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
|
||||
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
|
||||
const itemsRemaining = items.length - lastVisibleItem.index;
|
||||
const itemsRemaining = items.length - lastVisibleItemIndex;
|
||||
|
||||
if (itemsRemaining <= 5) {
|
||||
throttledNearBottom(lastVisibleItem.index);
|
||||
throttledNearBottom(lastVisibleItemIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
<div
|
||||
style:grid-template-columns="repeat({columns}, minmax(0, 1fr))"
|
||||
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
|
||||
use:virtualizer.measureElement
|
||||
data-index={row.index}
|
||||
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>
|
||||
{: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>
|
||||
{/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}>
|
||||
<div style:height="{estimatedTotalSize}px" class="relative w-full">
|
||||
{#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)"
|
||||
data-lenis-prevent
|
||||
>
|
||||
{#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>
|
||||
{@render content()}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
@@ -246,26 +334,6 @@ $effect(() => {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div style:height="{estimatedTotalSize}px" class="relative w-full">
|
||||
{#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>
|
||||
{@render content()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user