refactor(createVirtualizer): refactor createVirtualizerStore with modern svelte 5 patterns
This commit is contained in:
114
src/shared/lib/utils/createVirtualizer/createVirtualizer.ts
Normal file
114
src/shared/lib/utils/createVirtualizer/createVirtualizer.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
createVirtualizer as coreCreateVirtualizer,
|
||||
observeElementRect,
|
||||
} from '@tanstack/svelte-virtual';
|
||||
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export interface VirtualItem {
|
||||
index: number;
|
||||
start: number;
|
||||
size: number;
|
||||
end: number;
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
export interface VirtualizerOptions {
|
||||
/** Total number of items in the data array */
|
||||
count: number;
|
||||
/** Function to estimate the size of an item at a given index */
|
||||
estimateSize: (index: number) => number;
|
||||
/** Number of extra items to render outside viewport (default: 5) */
|
||||
overscan?: number;
|
||||
/** Function to get the key of an item at a given index (defaults to index) */
|
||||
getItemKey?: (index: number) => string | number;
|
||||
/** Optional margin in pixels for scroll calculations */
|
||||
scrollMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const virtualizer = createVirtualizer(() => ({
|
||||
* count: items.length,
|
||||
* estimateSize: () => 80,
|
||||
* overscan: 5,
|
||||
* }));
|
||||
*
|
||||
* // In template:
|
||||
* // <div bind:this={virtualizer.scrollElement}>
|
||||
* // {#each virtualizer.items as item}
|
||||
* // <div style="transform: translateY({item.start}px)">
|
||||
* // {items[item.index]}
|
||||
* // </div>
|
||||
* // {/each}
|
||||
* // </div>
|
||||
* ```
|
||||
*/
|
||||
export function createVirtualizer(
|
||||
optionsGetter: () => VirtualizerOptions,
|
||||
) {
|
||||
let element = $state<HTMLElement | null>(null);
|
||||
|
||||
const internalStore = coreCreateVirtualizer({
|
||||
get count() {
|
||||
return optionsGetter().count;
|
||||
},
|
||||
get estimateSize() {
|
||||
return optionsGetter().estimateSize;
|
||||
},
|
||||
get overscan() {
|
||||
return optionsGetter().overscan ?? 5;
|
||||
},
|
||||
get scrollMargin() {
|
||||
return optionsGetter().scrollMargin;
|
||||
},
|
||||
get getItemKey() {
|
||||
return optionsGetter().getItemKey ?? (i => i);
|
||||
},
|
||||
getScrollElement: () => element,
|
||||
observeElementRect: observeElementRect,
|
||||
});
|
||||
|
||||
const state = $derived(get(internalStore));
|
||||
|
||||
const virtualItems = $derived(
|
||||
state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({
|
||||
index: item.index,
|
||||
start: item.start,
|
||||
size: item.size,
|
||||
end: item.end,
|
||||
key: typeof item.key === 'bigint' ? Number(item.key) : item.key,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return virtualItems;
|
||||
},
|
||||
|
||||
get totalSize() {
|
||||
return state.getTotalSize();
|
||||
},
|
||||
|
||||
get scrollOffset() {
|
||||
return state.scrollOffset ?? 0;
|
||||
},
|
||||
|
||||
get scrollElement() {
|
||||
return element;
|
||||
},
|
||||
set scrollElement(el) {
|
||||
element = el;
|
||||
},
|
||||
|
||||
scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
|
||||
state.scrollToIndex(idx, opt),
|
||||
|
||||
scrollToOffset: (off: number) => state.scrollToOffset(off),
|
||||
|
||||
measureElement: (el: HTMLElement) => state.measureElement(el),
|
||||
};
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* VIRTUALIZER STORE - UNIT TESTS
|
||||
* ============================================================================
|
||||
*
|
||||
* Tests for createVirtualizerStore
|
||||
*
|
||||
* Note: These tests focus on the store API and behavior without requiring
|
||||
* a full DOM environment. Integration tests with actual DOM are in
|
||||
* component tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createVirtualizerStore } from './createVirtualizerStore';
|
||||
|
||||
describe('createVirtualizerStore', () => {
|
||||
const count = 100;
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create virtualizer store with default options', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store).toBeDefined();
|
||||
expect(store.virtualItems).toBeDefined();
|
||||
expect(typeof store.virtualItems.subscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should use custom estimateSize function', () => {
|
||||
const estimateSize = vi.fn(() => 120);
|
||||
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize,
|
||||
});
|
||||
|
||||
expect(typeof estimateSize).toBe('function');
|
||||
expect(store).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use custom overscan value', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
expect(store).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use getItemKey for stable keys', () => {
|
||||
const getItemKey = vi.fn((index: number) => `item-${index}`);
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
getItemKey,
|
||||
});
|
||||
|
||||
expect(typeof getItemKey).toBe('function');
|
||||
});
|
||||
|
||||
it('should use scrollMargin', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
scrollMargin: 50,
|
||||
});
|
||||
|
||||
expect(store).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('virtual items structure', () => {
|
||||
it('should provide virtual items as a readable store', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.virtualItems).toBeDefined();
|
||||
expect(typeof store.virtualItems.subscribe).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('total size calculation', () => {
|
||||
it('should provide totalSize as a readable store', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.totalSize).toBeDefined();
|
||||
expect(typeof store.totalSize.subscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should calculate total size via subscription', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
let size = 0;
|
||||
const unsubscribe = store.totalSize.subscribe(value => {
|
||||
size = value;
|
||||
});
|
||||
|
||||
expect(size).toBeGreaterThanOrEqual(0);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll offset', () => {
|
||||
it('should provide scrollOffset as a readable store', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.scrollOffset).toBeDefined();
|
||||
expect(typeof store.scrollOffset.subscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should initialize scrollOffset to 0 via subscription', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
let offset = 0;
|
||||
const unsubscribe = store.scrollOffset.subscribe(value => {
|
||||
offset = value;
|
||||
});
|
||||
|
||||
expect(offset).toBe(0);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll element binding', () => {
|
||||
it('should allow binding scrollElement', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
const mockElement = {} as HTMLElement;
|
||||
store.scrollElement = mockElement;
|
||||
|
||||
expect(store.scrollElement).toBe(mockElement);
|
||||
});
|
||||
|
||||
it('should allow updating scrollElement', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
const mockElement1 = {} as HTMLElement;
|
||||
const mockElement2 = {} as HTMLElement;
|
||||
|
||||
store.scrollElement = mockElement1;
|
||||
expect(store.scrollElement).toBe(mockElement1);
|
||||
|
||||
store.scrollElement = mockElement2;
|
||||
expect(store.scrollElement).toBe(mockElement2);
|
||||
});
|
||||
|
||||
it('should initialize scrollElement as null', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.scrollElement).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll methods API', () => {
|
||||
it('should provide scrollToIndex method', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.scrollToIndex).toBeDefined();
|
||||
expect(typeof store.scrollToIndex).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide scrollToOffset method', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.scrollToOffset).toBeDefined();
|
||||
expect(typeof store.scrollToOffset).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide measureElement method', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.measureElement).toBeDefined();
|
||||
expect(typeof store.measureElement).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll methods functionality', () => {
|
||||
it('should handle scrollToIndex with options', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
store.scrollToIndex(10, { align: 'start' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle scrollToIndex without options', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
store.scrollToIndex(10);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle scrollToOffset', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
store.scrollToOffset(100);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle measureElement', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
const mockElement = {
|
||||
dataset: { index: '5' },
|
||||
getAttribute: () => null,
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
expect(() => {
|
||||
store.measureElement(mockElement);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty items (count: 0)', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count: 0,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.virtualItems).toBeDefined();
|
||||
expect(store.totalSize).toBeDefined();
|
||||
expect(store.scrollOffset).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle single item (count: 1)', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count: 1,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.virtualItems).toBeDefined();
|
||||
expect(store.totalSize).toBeDefined();
|
||||
expect(store.scrollOffset).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle large dataset', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count: 10000,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store.virtualItems).toBeDefined();
|
||||
expect(store.totalSize).toBeDefined();
|
||||
expect(store.scrollOffset).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom configuration', () => {
|
||||
it('should accept all optional parameters', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 100,
|
||||
overscan: 7,
|
||||
scrollMargin: 50,
|
||||
getItemKey: index => `key-${index}`,
|
||||
});
|
||||
|
||||
expect(store).toBeDefined();
|
||||
expect(store.virtualItems).toBeDefined();
|
||||
expect(store.totalSize).toBeDefined();
|
||||
expect(store.scrollOffset).toBeDefined();
|
||||
expect(store.scrollToIndex).toBeDefined();
|
||||
expect(store.scrollToOffset).toBeDefined();
|
||||
expect(store.measureElement).toBeDefined();
|
||||
expect(store.scrollElement).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work with minimal configuration', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count: 0,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
expect(store).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactive stores', () => {
|
||||
it('should return virtualItems as a readable store', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
// virtualItems should be a Readable store
|
||||
expect(typeof store.virtualItems.subscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should return totalSize as a readable store', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
// totalSize should be a Readable store
|
||||
expect(typeof store.totalSize.subscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should return scrollOffset as a readable store', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
// scrollOffset should be a Readable store
|
||||
expect(typeof store.scrollOffset.subscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide virtualItems via subscription', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
let items: any[] = [];
|
||||
const unsubscribe = store.virtualItems.subscribe(value => {
|
||||
items = value;
|
||||
});
|
||||
|
||||
expect(Array.isArray(items)).toBe(true);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should provide totalSize via subscription', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
let size = 0;
|
||||
const unsubscribe = store.totalSize.subscribe(value => {
|
||||
size = value;
|
||||
});
|
||||
|
||||
expect(typeof size).toBe('number');
|
||||
expect(size).toBeGreaterThanOrEqual(0);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should provide scrollOffset via subscription', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
let offset = 0;
|
||||
const unsubscribe = store.scrollOffset.subscribe(value => {
|
||||
offset = value;
|
||||
});
|
||||
|
||||
expect(typeof offset).toBe('number');
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic estimateSize', () => {
|
||||
it('should handle function-based estimateSize', () => {
|
||||
const estimateSize = (index: number): number => {
|
||||
return 80 + (index % 2) * 40; // Alternate between 80 and 120
|
||||
};
|
||||
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize,
|
||||
});
|
||||
|
||||
expect(store).toBeDefined();
|
||||
expect(store.totalSize).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle constant estimateSize', () => {
|
||||
const store = createVirtualizerStore({
|
||||
count,
|
||||
estimateSize: () => 100,
|
||||
});
|
||||
|
||||
expect(store).toBeDefined();
|
||||
expect(store.totalSize).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* 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
|
||||
* <script lang="ts">
|
||||
* import { createVirtualizerStore } from '$shared/store';
|
||||
*
|
||||
* const items = $state(fontData);
|
||||
*
|
||||
* const virtualizer = createVirtualizerStore({
|
||||
* count: items.length,
|
||||
* estimateSize: () => 80,
|
||||
* overscan: 5,
|
||||
* getItemKey: (index) => items[index].id,
|
||||
* });
|
||||
*
|
||||
* const virtualItems = $derived(virtualizer.virtualItems);
|
||||
* const totalSize = $derived(virtualizer.totalSize);
|
||||
* </script>
|
||||
*
|
||||
* <div bind:this={virtualizer.scrollElement} class="h-96 overflow-auto">
|
||||
* <div style="height: {totalSize}px; position: relative;">
|
||||
* {#each virtualItems as item (item.key)}
|
||||
* <div style="position: absolute; top: {item.start}px; height: {item.size}px;">
|
||||
* {items[item.index].name}
|
||||
* </div>
|
||||
* {/each}
|
||||
* </div>
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<VirtualItem[]>;
|
||||
/** Total size of all items (in pixels) (reactive store) */
|
||||
totalSize: Readable<number>;
|
||||
/** Current scroll offset (in pixels) (reactive store) */
|
||||
scrollOffset: Readable<number>;
|
||||
/** 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
|
||||
* <script lang="ts">
|
||||
* import { createVirtualizerStore } from '$shared/store';
|
||||
*
|
||||
* const items = $state(fontData);
|
||||
*
|
||||
* const virtualizer = createVirtualizerStore({
|
||||
* count: items.length,
|
||||
* estimateSize: () => 80,
|
||||
* overscan: 5,
|
||||
* });
|
||||
* </script>
|
||||
*
|
||||
* <div bind:this={virtualizer.scrollElement} class="h-96 overflow-auto">
|
||||
* <!-- virtual list content -->
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Shared store exports
|
||||
*
|
||||
* Exports all store creators and types for Svelte 5 reactive state management
|
||||
*/
|
||||
|
||||
export { createFilterStore } from './createFilterStore';
|
||||
export type {
|
||||
FilterModel,
|
||||
FilterStore,
|
||||
Property,
|
||||
} from './createFilterStore';
|
||||
|
||||
export { createControlStore } from './createControlStore';
|
||||
export type {
|
||||
ControlModel,
|
||||
ControlStoreModel,
|
||||
} from './createControlStore';
|
||||
|
||||
export { createVirtualizerStore } from './createVirtualizerStore';
|
||||
export type {
|
||||
ScrollToIndexOptions,
|
||||
VirtualItem,
|
||||
VirtualizerOptions,
|
||||
VirtualizerStore,
|
||||
} from './createVirtualizerStore';
|
||||
Reference in New Issue
Block a user