feature/project-redesign #28

Merged
ilia merged 88 commits from feature/project-redesign into main 2026-03-02 19:46:39 +00:00
Showing only changes of commit fb6cd495d3 - Show all commits

View File

@@ -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>
{#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)}
{#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={item.index}
class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)"
data-lenis-prevent
data-index={row.index}
class="min-h-0"
>
{#if item.index < items.length}
{#if itemIndex < 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,
item: items[itemIndex],
index: itemIndex,
isFullyVisible: row.isFullyVisible,
isPartiallyVisible: row.isPartiallyVisible,
proximity: row.proximity,
})}
{/if}
</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>
{/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>
{: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}