2026-01-06 21:38:53 +03:00
|
|
|
<!--
|
|
|
|
|
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
|
|
|
|
|
-->
|
|
|
|
|
<script lang="ts" generics="T">
|
2026-01-07 16:54:12 +03:00
|
|
|
import { createVirtualizer } from '$shared/lib';
|
2026-01-06 21:38:53 +03:00
|
|
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
2026-01-16 17:46:06 +03:00
|
|
|
import type { Snippet } from 'svelte';
|
2026-01-06 21:38:53 +03:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
/**
|
|
|
|
|
* Array of items to render in the virtual list.
|
|
|
|
|
*
|
|
|
|
|
* @template T - The type of items in the list
|
|
|
|
|
*/
|
|
|
|
|
items: T[];
|
2026-01-30 19:21:47 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-01-06 21:38:53 +03:00
|
|
|
/**
|
|
|
|
|
* Height for each item, either as a fixed number
|
|
|
|
|
* or a function that returns height per index.
|
|
|
|
|
* @default 80
|
|
|
|
|
*/
|
|
|
|
|
itemHeight?: number | ((index: number) => number);
|
2026-01-13 20:02:50 +03:00
|
|
|
/**
|
|
|
|
|
* Optional overscan value for the virtual list.
|
|
|
|
|
* @default 5
|
|
|
|
|
*/
|
|
|
|
|
overscan?: number;
|
2026-01-06 21:38:53 +03:00
|
|
|
/**
|
|
|
|
|
* Optional CSS class string for styling the container
|
|
|
|
|
* (follows shadcn convention for className prop)
|
|
|
|
|
*/
|
|
|
|
|
class?: string;
|
2026-01-18 12:50:17 +03:00
|
|
|
/**
|
|
|
|
|
* An optional callback that will be called for each new set of loaded items
|
|
|
|
|
* @param items - Loaded items
|
|
|
|
|
*/
|
|
|
|
|
onVisibleItemsChange?: (items: T[]) => void;
|
2026-01-30 19:21:47 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-01-06 21:38:53 +03:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2026-01-31 11:48:14 +03:00
|
|
|
children: Snippet<
|
|
|
|
|
[{ item: T; index: number; isVisible: boolean; proximity: number }]
|
|
|
|
|
>;
|
2026-01-06 21:38:53 +03:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 19:21:47 +03:00
|
|
|
let {
|
|
|
|
|
items,
|
|
|
|
|
total = items.length,
|
|
|
|
|
itemHeight = 80,
|
|
|
|
|
overscan = 5,
|
|
|
|
|
class: className,
|
|
|
|
|
onVisibleItemsChange,
|
|
|
|
|
onNearBottom,
|
|
|
|
|
children,
|
|
|
|
|
}: Props = $props();
|
2026-01-06 21:38:53 +03:00
|
|
|
|
2026-01-15 13:33:59 +03:00
|
|
|
const virtualizer = createVirtualizer(() => ({
|
2026-01-31 11:48:14 +03:00
|
|
|
count: items.length,
|
2026-01-16 17:46:06 +03:00
|
|
|
data: items,
|
2026-01-06 21:38:53 +03:00
|
|
|
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
2026-01-13 20:02:50 +03:00
|
|
|
overscan,
|
2026-01-06 21:38:53 +03:00
|
|
|
}));
|
2026-01-18 12:50:17 +03:00
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
|
|
|
|
onVisibleItemsChange?.(visibleItems);
|
2026-01-30 19:21:47 +03:00
|
|
|
|
2026-01-31 11:48:14 +03:00
|
|
|
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
2026-01-30 19:21:47 +03:00
|
|
|
if (virtualizer.items.length > 0 && onNearBottom) {
|
|
|
|
|
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
|
2026-01-31 11:48:14 +03:00
|
|
|
// Compare against loaded items length, not total
|
|
|
|
|
const itemsRemaining = items.length - lastVisibleItem.index;
|
2026-01-30 19:21:47 +03:00
|
|
|
|
|
|
|
|
if (itemsRemaining <= 5) {
|
|
|
|
|
onNearBottom(lastVisibleItem.index);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-18 12:50:17 +03:00
|
|
|
});
|
2026-01-06 21:38:53 +03:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div
|
2026-01-15 13:33:59 +03:00
|
|
|
use:virtualizer.container
|
2026-01-06 21:38:53 +03:00
|
|
|
class={cn(
|
2026-01-18 12:50:17 +03:00
|
|
|
'relative overflow-auto rounded-md bg-background',
|
2026-01-16 17:46:06 +03:00
|
|
|
'h-150 w-full',
|
2026-01-22 15:40:37 +03:00
|
|
|
'scroll-smooth',
|
2026-01-06 21:38:53 +03:00
|
|
|
className,
|
|
|
|
|
)}
|
2026-01-22 15:40:37 +03:00
|
|
|
onfocusin={(e => {
|
|
|
|
|
// Prevent the browser from jumping the scroll when an inner element gets focus
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
})}
|
2026-01-06 21:38:53 +03:00
|
|
|
>
|
2026-01-16 17:46:06 +03:00
|
|
|
<div
|
|
|
|
|
style:height="{virtualizer.totalSize}px"
|
|
|
|
|
class="w-full pointer-events-none"
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-15 13:33:59 +03:00
|
|
|
{#each virtualizer.items as item (item.key)}
|
|
|
|
|
<div
|
|
|
|
|
use:virtualizer.measureElement
|
|
|
|
|
data-index={item.index}
|
2026-01-16 17:46:06 +03:00
|
|
|
class="absolute top-0 left-0 w-full"
|
|
|
|
|
style:transform="translateY({item.start}px)"
|
2026-01-15 13:33:59 +03:00
|
|
|
>
|
2026-01-30 19:21:47 +03:00
|
|
|
{#if item.index < items.length}
|
|
|
|
|
{@render children({
|
2026-01-22 15:40:37 +03:00
|
|
|
item: items[item.index],
|
|
|
|
|
index: item.index,
|
|
|
|
|
isVisible: item.isVisible,
|
|
|
|
|
proximity: item.proximity,
|
|
|
|
|
})}
|
2026-01-30 19:21:47 +03:00
|
|
|
{/if}
|
2026-01-15 13:33:59 +03:00
|
|
|
</div>
|
|
|
|
|
{/each}
|
2026-01-06 21:38:53 +03:00
|
|
|
</div>
|