refactor(VirtualList): refactor VirtualList with modern svelte 5 patterns

This commit is contained in:
Ilia Mashkov
2026-01-06 21:38:53 +03:00
parent 7a9f7e238c
commit 1950cd4095
4 changed files with 132 additions and 482 deletions

View File

@@ -1,177 +0,0 @@
<script lang="ts">
/**
* Generic virtualized list component optimized for smooth scrolling with
* large datasets. Uses TanStack Virtual to render only visible items.
*
* Key optimizations:
* - Renders only visible items (50-100 max regardless of total count)
* - Maintains 60FPS scrolling with 10,000+ items
* - Minimal memory usage
* - Smooth scrolling without jank
*
* Accessibility:
* - ARIA roles for virtual list
* - Keyboard navigation support
* - Focus management
* - Screen reader support
*/
import { createVirtualizerStore } from '$shared/store/createVirtualizerStore';
/**
* Props for VirtualList
*/
interface VirtualListProps<T> {
/** Items to display in the virtual list */
items: T[];
/** Fixed height for each item (in pixels) */
itemHeight?: number | ((index: number) => number);
/** Number of items to render beyond viewport */
overscan?: number;
/** Height of the list container (Tailwind class, e.g., "h-96", "h-[500px]") */
height?: string;
/** Scroll offset threshold for triggering update (in pixels) */
scrollMargin?: number;
/** CSS class name for the scroll container */
class?: string;
/** Function to get stable key for each item */
getItemKey?: (item: T, index: number) => string | number;
}
let {
items,
itemHeight: rawItemHeight = 80,
overscan = 5,
scrollMargin,
height = 'h-96',
class: className = '',
getItemKey: rawGetItemKey,
}: VirtualListProps<any> = $props();
// Reactive state for items
const currentItems = $derived(items);
// Create virtualizer store
const virtualizer = createVirtualizerStore({
get count() {
return currentItems.length;
},
estimateSize: typeof rawItemHeight === 'function'
? (index: number) => (rawItemHeight as (index: number) => number)(index)
: () => rawItemHeight,
get overscan() {
return overscan;
},
get scrollMargin() {
return scrollMargin;
},
getItemKey: rawGetItemKey
? (index: number) => {
const item = currentItems[index];
if (!item) return index;
return rawGetItemKey(item, index);
}
: undefined,
});
// Reactive virtual items and total size using store subscription
let virtualItems: Array<{
index: number;
start: number;
size: number;
key: string | number;
}> = $state([]);
let totalSize = $state(0);
// Subscribe to store updates
$effect(() => {
const unsubscribe1 = virtualizer.virtualItems.subscribe(
(items: Array<{ index: number; start: number; size: number; key: string | number }>) => {
virtualItems = items;
},
);
const unsubscribe2 = virtualizer.totalSize.subscribe((size: number) => {
totalSize = size;
});
return () => {
unsubscribe1();
unsubscribe2();
};
});
/**
* Handle keyboard navigation
*/
function handleKeydown(event: KeyboardEvent): void {
const items = document.querySelectorAll('[data-index]');
if (!items.length) return;
const currentIndex = Array.from(items).findIndex(el => el === document.activeElement);
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
(items[nextIndex] as HTMLElement).focus();
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex = Math.max(currentIndex - 1, 0);
(items[prevIndex] as HTMLElement).focus();
break;
}
case 'PageDown': {
event.preventDefault();
const nextIndex = Math.min(currentIndex + 10, items.length - 1);
(items[nextIndex] as HTMLElement).focus();
break;
}
case 'PageUp': {
event.preventDefault();
const prevIndex = Math.max(currentIndex - 10, 0);
(items[prevIndex] as HTMLElement).focus();
break;
}
case 'Home': {
event.preventDefault();
(items[0] as HTMLElement).focus();
break;
}
case 'End': {
event.preventDefault();
(items[items.length - 1] as HTMLElement).focus();
break;
}
}
}
</script>
<!-- Scroll container with ARIA role for accessibility -->
<div
bind:this={virtualizer.scrollElement}
class="overflow-auto {height} {className}"
role="listbox"
aria-label="Virtual list"
tabindex="0"
onkeydown={handleKeydown}
>
<!-- Virtual items container -->
<div style="height: {totalSize}px; position: relative;">
{#each virtualItems as item (item.key)}
<div
data-index={item.index}
style="position: absolute;
top: {item.start}px;
height: {item.size}px;
width: 100%;"
role="option"
aria-selected="false"
tabindex="0"
>
<slot item={currentItems[item.index]} index={item.index} />
</div>
{/each}
</div>
</div>

View File

@@ -0,0 +1,132 @@
<!--
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">
import { createVirtualizer } from '$shared/lib/utils';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
type Snippet,
tick,
} from 'svelte';
interface Props {
/**
* Array of items to render in the virtual list.
*
* @template T - The type of items in the list
*/
items: T[];
/**
* 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 CSS class string for styling the container
* (follows shadcn convention for className prop)
*/
class?: string;
/**
* 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 }]>;
}
let { items, itemHeight = 80, class: className, children }: Props = $props();
let activeIndex = $state(0);
const itemRefs = new Map<number, HTMLElement>();
const virtual = createVirtualizer(() => ({
count: items.length,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
}));
function registerItem(node: HTMLElement, index: number) {
itemRefs.set(index, node);
return {
destroy() {
itemRefs.delete(index);
},
};
}
async function focusItem(index: number) {
activeIndex = index;
virtual.scrollToIndex(index, { align: 'auto' });
await tick();
itemRefs.get(index)?.focus();
}
async function handleKeydown(event: KeyboardEvent) {
let nextIndex = activeIndex;
if (event.key === 'ArrowDown') nextIndex++;
else if (event.key === 'ArrowUp') nextIndex--;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = items.length - 1;
else return;
if (nextIndex >= 0 && nextIndex < items.length) {
event.preventDefault();
await focusItem(nextIndex);
}
}
</script>
<!--
Scroll container with single tab stop pattern:
- tabindex="0" on container, tabindex="-1" on items
- Arrow keys navigate within, Tab moves out
-->
<div
bind:this={virtual.scrollElement}
class={cn(
'relative overflow-auto border rounded-md bg-background',
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
'h-full w-full',
className,
)}
role="listbox"
tabindex="0"
onkeydown={handleKeydown}
onfocusin={(e => e.target === virtual.scrollElement && focusItem(activeIndex))}
>
<!-- Total scrollable height placeholder -->
<div
class="relative w-full"
style:height="{virtual.totalSize}px"
>
{#each virtual.items as row (row.key)}
<!-- Individual item positioned absolutely via GPU-accelerated transform -->
<div
use:registerItem={row.index}
data-index={row.index}
role="option"
aria-selected={activeIndex === row.index}
tabindex="-1"
onmousedown={() => (activeIndex = row.index)}
class="absolute top-0 left-0 w-full outline-none focus:bg-accent focus:text-accent-foreground"
style:height="{row.size}px"
style:transform="translateY({row.start}px)"
>
{@render children({ item: items[row.index], index: row.index })}
</div>
{/each}
</div>
</div>