Files
frontend-svelte/src/shared/ui/VirtualList/VirtualList.svelte
2026-02-27 13:00:03 +03:00

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}