diff --git a/src/shared/ui/VirtualList/VirtualList.svelte b/src/shared/ui/VirtualList/VirtualList.svelte index 82c5a85..93d2e9d 100644 --- a/src/shared/ui/VirtualList/VirtualList.svelte +++ b/src/shared/ui/VirtualList/VirtualList.svelte @@ -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(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); } } }); +{#snippet content()} +
+ +
+
+ + {#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} +
+ {#if itemIndex < items.length} + {@render children({ + item: items[itemIndex], + index: itemIndex, + isFullyVisible: row.isFullyVisible, + isPartiallyVisible: row.isPartiallyVisible, + proximity: row.proximity, +})} + {/if} +
+ {:else} +
+ {#if itemIndex < items.length} + {@render children({ + item: items[itemIndex], + index: itemIndex, + isFullyVisible: row.isFullyVisible, + isPartiallyVisible: row.isPartiallyVisible, + proximity: row.proximity, +})} + {/if} +
+ {/if} + {/each} + {/if} + {/each} + + +
+
+
+{/snippet} + {#if useWindowScroll}
-
- {#each virtualizer.items as item (item.key)} -
- {#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} -
- {/each} -
+ {@render content()}
{:else}
{ className, )} > -
- {#each virtualizer.items as item (item.key)} -
- {#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} -
- {/each} -
+ {@render content()}
{/if}