340 lines
10 KiB
Svelte
340 lines
10 KiB
Svelte
<!--
|
|
Component: VirtualList
|
|
|
|
High-performance virtualized list for large datasets with:
|
|
- Virtual scrolling (only renders visible items + overscan)
|
|
- Keyboard navigation (ArrowUp/Down, Home, End)
|
|
- Fixed or dynamic item heights
|
|
- ARIA listbox/option pattern with single tab stop
|
|
- Native browser scroll
|
|
-->
|
|
<script lang="ts" generics="T">
|
|
import { createVirtualizer } from '$shared/lib';
|
|
import { throttle } from '$shared/lib/utils';
|
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
import type { Snippet } from 'svelte';
|
|
|
|
interface Props {
|
|
/**
|
|
* Array of items to render in the virtual list.
|
|
*
|
|
* @template T - The type of items in the list
|
|
*/
|
|
items: T[];
|
|
/**
|
|
* Total number of items (including not-yet-loaded items for pagination).
|
|
* If not provided, defaults to items.length.
|
|
*
|
|
* Use this when implementing pagination to ensure the scrollbar
|
|
* reflects the total count of items, not just the loaded ones.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Pagination scenario: 1920 total fonts, but only 50 loaded
|
|
* <VirtualList items={loadedFonts} total={1920}>
|
|
* ```
|
|
*/
|
|
total?: number;
|
|
/**
|
|
* Height for each item, either as a fixed number
|
|
* or a function that returns height per index.
|
|
* @default 80
|
|
*/
|
|
itemHeight?: number | ((index: number) => number);
|
|
/**
|
|
* Optional overscan value for the virtual list.
|
|
* @default 5
|
|
*/
|
|
overscan?: number;
|
|
/**
|
|
* Optional CSS class string for styling the container
|
|
* (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
|
|
*/
|
|
onVisibleItemsChange?: (items: T[]) => void;
|
|
/**
|
|
* An optional callback that will be called when user scrolls near the end of the list.
|
|
* Useful for triggering auto-pagination.
|
|
*
|
|
* The callback receives the index of the last visible item. You can use this
|
|
* to determine if you should load more data.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* onNearBottom={(lastVisibleIndex) => {
|
|
* const itemsRemaining = total - lastVisibleIndex;
|
|
* if (itemsRemaining < 5 && hasMore && !isFetching) {
|
|
* loadMore();
|
|
* }
|
|
* }}
|
|
* ```
|
|
*/
|
|
onNearBottom?: (lastVisibleIndex: number) => void;
|
|
/**
|
|
* Snippet for rendering individual list items.
|
|
*
|
|
* The snippet receives an object containing:
|
|
* - `item`: The item from the items array (type T)
|
|
* - `index`: The current item's index in the array
|
|
*
|
|
* This pattern provides type safety and flexibility for
|
|
* rendering different item types without prop drilling.
|
|
*
|
|
* @template T - The type of items in the list
|
|
*/
|
|
/**
|
|
* Snippet for rendering individual list items.
|
|
*
|
|
* The snippet receives an object containing:
|
|
* - `item`: The item from the items array (type T)
|
|
* - `index`: The current item's index in the array
|
|
*
|
|
* This pattern provides type safety and flexibility for
|
|
* rendering different item types without prop drilling.
|
|
*
|
|
* @template T - The type of items in the list
|
|
*/
|
|
children: Snippet<
|
|
[
|
|
{
|
|
item: T;
|
|
index: number;
|
|
isFullyVisible: boolean;
|
|
isPartiallyVisible: boolean;
|
|
proximity: number;
|
|
},
|
|
]
|
|
>;
|
|
/**
|
|
* Whether to use the window as the scroll container.
|
|
* @default false
|
|
*/
|
|
useWindowScroll?: boolean;
|
|
/**
|
|
* Flag to show loading state
|
|
*/
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
let {
|
|
items,
|
|
total = items.length,
|
|
itemHeight = 80,
|
|
overscan = 5,
|
|
class: className,
|
|
onVisibleItemsChange,
|
|
onNearBottom,
|
|
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: rowCount,
|
|
data: items,
|
|
estimateSize: typeof itemHeight === 'function'
|
|
? (index: number) => itemHeight(index) + gap
|
|
: () => itemHeight + gap,
|
|
overscan,
|
|
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 (totalRows === rowCount) {
|
|
// No unloaded items, use virtualizer's totalSize
|
|
return virtualizer.totalSize;
|
|
}
|
|
|
|
// Start with the virtualized (loaded) rows size
|
|
const loadedSize = virtualizer.totalSize;
|
|
|
|
// Add estimated size for unloaded rows
|
|
const unloadedRows = totalRows - rowCount;
|
|
if (unloadedRows <= 0) return loadedSize;
|
|
|
|
// Estimate the size of unloaded rows
|
|
const estimateFn = typeof itemHeight === 'function'
|
|
? (index: number) => itemHeight(index * columns) + gap
|
|
: () => itemHeight + gap;
|
|
|
|
// Use estimateSize for unloaded rows (index from rowCount to totalRows - 1)
|
|
let unloadedSize = 0;
|
|
for (let i = rowCount; i < totalRows; i++) {
|
|
unloadedSize += estimateFn(i);
|
|
}
|
|
|
|
return loadedSize + unloadedSize;
|
|
});
|
|
|
|
// Attach virtualizer.container action to the viewport when it becomes available
|
|
$effect(() => {
|
|
if (viewportRef) {
|
|
const { destroy } = virtualizer.container(viewportRef);
|
|
return destroy;
|
|
}
|
|
});
|
|
|
|
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
|
onVisibleItemsChange?.(visibleItems);
|
|
}, 150); // 150ms throttle
|
|
|
|
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(() => {
|
|
// 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);
|
|
});
|
|
|
|
$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 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 - lastVisibleItemIndex;
|
|
|
|
if (itemsRemaining <= 5) {
|
|
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}>
|
|
{@render content()}
|
|
</div>
|
|
{:else}
|
|
<div
|
|
bind:this={viewportRef}
|
|
class={cn(
|
|
'relative overflow-y-auto overflow-x-hidden',
|
|
'rounded-md bg-background',
|
|
'w-full min-h-[200px]',
|
|
className,
|
|
)}
|
|
>
|
|
{@render content()}
|
|
</div>
|
|
{/if}
|