/**
* ============================================================================
* VIRTUALIZER STORE - STORE PATTERN
* ============================================================================
*
* Svelte store-based virtualizer for virtualized lists.
*
* Benefits of store pattern over hook:
* - More Svelte-native (stores are idiomatic, hooks are React-inspired)
* - Better reactivity (stores auto-derive values using derived())
* - Consistent with project patterns (createFilterStore, createControlStore)
* - More extensible (can add store methods)
* - Type-safe with TypeScript generics
*
* Performance:
* - 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
*
* Usage:
* ```svelte
*
*
*
*
* {#each virtualItems as item (item.key)}
*
* {items[item.index].name}
*
* {/each}
*
*
* ```
*/
import { createVirtualizer } from '@tanstack/svelte-virtual';
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core';
import {
type Readable,
type Writable,
derived,
writable,
} from 'svelte/store';
/**
* Virtual item returned by the virtualizer
*/
export interface VirtualItem {
/** Item index in the original array */
index: number;
/** Start position (pixels) */
start: number;
/** Item size (pixels) */
size: number;
/** End position (pixels) */
end: number;
/** Stable key for rendering */
key: string | number;
}
/**
* Configuration options for createVirtualizerStore
*/
export interface VirtualizerOptions {
/** Fixed count of items (required) */
count: number;
/** Estimated size for each item (in pixels) */
estimateSize: (index: number) => number;
/** Number of items to render beyond viewport */
overscan?: number;
/** Function to get stable key for each item */
getItemKey?: (index: number) => string | number;
/** Scroll offset threshold for triggering update (in pixels) */
scrollMargin?: number;
}
/**
* Options for scrollToIndex
*/
export interface ScrollToIndexOptions {
/** Alignment behavior */
align?: 'start' | 'center' | 'end' | 'auto';
}
/**
* Virtualizer store model with reactive stores and methods
*/
export interface VirtualizerStore {
/** Subscribe to scroll element state */
subscribe: Writable<{ scrollElement: HTMLElement | null }>['subscribe'];
/** Set scroll element state */
set: Writable<{ scrollElement: HTMLElement | null }>['set'];
/** Update scroll element state */
update: Writable<{ scrollElement: HTMLElement | null }>['update'];
/** Array of virtual items to render (reactive store) */
virtualItems: Readable;
/** Total size of all items (in pixels) (reactive store) */
totalSize: Readable;
/** Current scroll offset (in pixels) (reactive store) */
scrollOffset: Readable;
/** Scroll to a specific item index */
scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;
/** Scroll to a specific offset */
scrollToOffset: (offset: number) => void;
/** Manually measure an item element */
measureElement: (element: HTMLElement) => void;
/** Scroll element reference (getter/setter for binding) */
scrollElement: HTMLElement | null;
}
/**
* Create a virtualizer store using Svelte stores
*
* This store wraps TanStack Virtual in a Svelte-idiomatic way.
* The scroll element can be bound to the store for reactive virtualization.
*
* @param options - Virtualization configuration
* @returns VirtualizerStore with reactive values and methods
*
* @example
* ```svelte
*
*
*
*
*
* ```
*/
export function createVirtualizerStore(
options: VirtualizerOptions,
): VirtualizerStore {
const {
count,
estimateSize,
overscan = 5,
getItemKey,
scrollMargin,
} = options;
// Internal state for scroll element
const { subscribe: scrollElementSubscribe, set: setScrollElement, update } = writable<
{ scrollElement: HTMLElement | null }
>({ scrollElement: null });
// Create virtualizer - returns a readable store
const virtualizerStore = createVirtualizer({
count,
getScrollElement: () => {
let scrollElement: HTMLElement | null = null;
scrollElementSubscribe(state => {
scrollElement = state.scrollElement;
});
return scrollElement;
},
estimateSize,
overscan,
scrollMargin,
getItemKey: getItemKey ?? ((index: number) => index),
});
// Current virtualizer instance (unwrapped from readable store)
let virtualizerInstance: any;
// Subscribe to the readable store
const _unsubscribe = virtualizerStore.subscribe(value => {
virtualizerInstance = value;
});
/**
* Get virtual items from current instance
*/
function getVirtualItems(): VirtualItem[] {
if (!virtualizerInstance) return [];
const items = virtualizerInstance.getVirtualItems();
return items.map((item: CoreVirtualItem): VirtualItem => ({
index: item.index,
start: item.start,
size: item.size,
end: item.end,
key: String(item.key),
}));
}
/**
* Get total size from current instance
*/
function getTotalSize(): number {
return virtualizerInstance ? virtualizerInstance.getTotalSize() : 0;
}
/**
* Get current scroll offset
*/
function getScrollOffset(): number {
return virtualizerInstance?.scrollOffset ?? 0;
}
/**
* Scroll to a specific item index
*
* @param index - Item index to scroll to
* @param options - Alignment options
*/
function scrollToIndex(index: number, options?: ScrollToIndexOptions): void {
virtualizerInstance?.scrollToIndex(index, options);
}
/**
* Scroll to a specific offset
*
* @param offset - Scroll offset in pixels
*/
function scrollToOffset(offset: number): void {
virtualizerInstance?.scrollToOffset(offset);
}
/**
* Manually measure an item element
*
* Useful when item sizes are dynamic and need precise measurement.
*
* @param element - The element to measure
*/
function measureElement(element: HTMLElement): void {
virtualizerInstance?.measureElement(element);
}
// Create derived stores for reactive values
const virtualItemsStore = derived(virtualizerStore, () => getVirtualItems());
const totalSizeStore = derived(virtualizerStore, () => getTotalSize());
const scrollOffsetStore = derived(virtualizerStore, () => getScrollOffset());
// Return store object with methods and derived stores
return {
subscribe: scrollElementSubscribe,
set: setScrollElement,
update,
virtualItems: virtualItemsStore,
totalSize: totalSizeStore,
scrollOffset: scrollOffsetStore,
scrollToIndex,
scrollToOffset,
measureElement,
get scrollElement() {
let scrollElement: HTMLElement | null = null;
scrollElementSubscribe(state => {
scrollElement = state.scrollElement;
});
return scrollElement;
},
set scrollElement(el) {
setScrollElement({ scrollElement: el });
},
};
}