133 lines
4.0 KiB
Svelte
133 lines
4.0 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
|
||
|
|
-->
|
||
|
|
<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>
|