Compare commits
10 Commits
c0ccf4baff
...
0692711726
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0692711726 | ||
|
|
86898bf83c | ||
|
|
1950cd4095 | ||
|
|
7a9f7e238c | ||
|
|
1f19e964ca | ||
|
|
eb10d58128 | ||
|
|
c78ab826a2 | ||
|
|
931a2df1ee | ||
|
|
bea3f7ae7f | ||
|
|
d1f035a6ad |
@@ -7,13 +7,13 @@
|
|||||||
* @see https://fontshare.com
|
* @see https://fontshare.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '$shared/api/api';
|
||||||
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
import type {
|
import type {
|
||||||
FontshareApiModel,
|
FontshareApiModel,
|
||||||
FontshareFont,
|
FontshareFont,
|
||||||
} from '$entities/Font/model/types/fontshare';
|
} from '../../model/types/fontshare';
|
||||||
import { api } from '$shared/api/api';
|
|
||||||
import { buildQueryString } from '$shared/utils';
|
|
||||||
import type { QueryParams } from '$shared/utils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fontshare API parameters
|
* Fontshare API parameters
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
* @see https://developers.google.com/fonts/docs/developer_api
|
* @see https://developers.google.com/fonts/docs/developer_api
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '$shared/api/api';
|
||||||
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
import type {
|
import type {
|
||||||
FontItem,
|
FontItem,
|
||||||
GoogleFontsApiModel,
|
GoogleFontsApiModel,
|
||||||
} from '$entities/Font/model/types/google';
|
} from '../../model/types/google';
|
||||||
import { api } from '$shared/api/api';
|
|
||||||
import { buildQueryString } from '$shared/utils';
|
|
||||||
import type { QueryParams } from '$shared/utils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Fonts API parameters
|
* Google Fonts API parameters
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type {
|
|
||||||
FontshareFont,
|
|
||||||
GoogleFontItem,
|
|
||||||
} from '$entities/Font/model/types';
|
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
it,
|
it,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
import type {
|
||||||
|
FontshareFont,
|
||||||
|
GoogleFontItem,
|
||||||
|
} from '../../model/types';
|
||||||
import {
|
import {
|
||||||
normalizeFontshareFont,
|
normalizeFontshareFont,
|
||||||
normalizeFontshareFonts,
|
normalizeFontshareFonts,
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import type {
|
|||||||
FontCategory,
|
FontCategory,
|
||||||
FontStyleUrls,
|
FontStyleUrls,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
} from '$entities/Font/model/types';
|
FontshareFont,
|
||||||
import type { FontshareFont } from '$entities/Font/model/types/fontshare';
|
GoogleFontItem,
|
||||||
import type { GoogleFontItem } from '$entities/Font/model/types/google';
|
UnifiedFont,
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
|
} from '../../model/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Google Fonts category to unified FontCategory
|
* Map Google Fonts category to unified FontCategory
|
||||||
@@ -271,4 +271,4 @@ export function normalizeFontshareFonts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-export UnifiedFont for backward compatibility
|
// Re-export UnifiedFont for backward compatibility
|
||||||
export type { UnifiedFont } from '$entities/Font/model/types/normalize';
|
export type { UnifiedFont } from '../../model/types/normalize';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
FontSubset,
|
FontSubset,
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
} from '$entities/Font/model/types';
|
} from '$entities/Font/model/types';
|
||||||
import { createCollectionCache } from '$shared/fetch/collectionCache';
|
import { createCollectionCache } from '$shared/lib/fetch/collectionCache';
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
derived,
|
derived,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Property } from '$shared/store/createFilterStore';
|
import type { Property } from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||||
|
|
||||||
export const FONT_CATEGORIES: Property[] = [
|
export const FONT_CATEGORIES: Property[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
createFilterStore,
|
createFilterStore,
|
||||||
} from '$shared/store/createFilterStore';
|
} from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||||
import { FONT_CATEGORIES } from '../const/const';
|
import { FONT_CATEGORIES } from '../const/const';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
createFilterStore,
|
createFilterStore,
|
||||||
} from '$shared/store/createFilterStore';
|
} from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||||
import { FONT_PROVIDERS } from '../const/const';
|
import { FONT_PROVIDERS } from '../const/const';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
createFilterStore,
|
createFilterStore,
|
||||||
} from '$shared/store/createFilterStore';
|
} from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||||
import { FONT_SUBSETS } from '../const/const';
|
import { FONT_SUBSETS } from '../const/const';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createControlStore,
|
createControlStore,
|
||||||
} from '$shared/store/createControlStore';
|
} from '$shared/lib/store/createControlStore/createControlStore';
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createControlStore,
|
createControlStore,
|
||||||
} from '$shared/store/createControlStore';
|
} from '$shared/lib/store/createControlStore/createControlStore';
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createControlStore,
|
createControlStore,
|
||||||
} from '$shared/store/createControlStore';
|
} from '$shared/lib/store/createControlStore/createControlStore';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
LINE_HEIGHT_STEP,
|
LINE_HEIGHT_STEP,
|
||||||
|
|||||||
18
src/shared/lib/store/index.ts
Normal file
18
src/shared/lib/store/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Shared store exports
|
||||||
|
*
|
||||||
|
* Exports all store creators and types for Svelte 5 reactive state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { createFilterStore } from './createFilterStore/createFilterStore';
|
||||||
|
export type {
|
||||||
|
FilterModel,
|
||||||
|
FilterStore,
|
||||||
|
Property,
|
||||||
|
} from './createFilterStore/createFilterStore';
|
||||||
|
|
||||||
|
export { createControlStore } from './createControlStore/createControlStore';
|
||||||
|
export type {
|
||||||
|
ControlModel,
|
||||||
|
ControlStoreModel,
|
||||||
|
} from './createControlStore/createControlStore';
|
||||||
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export type {
|
|||||||
QueryParams,
|
QueryParams,
|
||||||
QueryParamValue,
|
QueryParamValue,
|
||||||
} from './buildQueryString';
|
} from './buildQueryString';
|
||||||
|
export { createVirtualizer } from './createVirtualizer/createVirtualizer';
|
||||||
@@ -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';
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Property } from '$shared/lib/store';
|
||||||
import { Badge } from '$shared/shadcn/ui/badge';
|
import { Badge } from '$shared/shadcn/ui/badge';
|
||||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||||
import * as Collapsible from '$shared/shadcn/ui/collapsible';
|
import * as Collapsible from '$shared/shadcn/ui/collapsible';
|
||||||
import { Label } from '$shared/shadcn/ui/label';
|
import { Label } from '$shared/shadcn/ui/label';
|
||||||
import type { Property } from '$shared/store/createFilterStore';
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Property } from '$shared/store/createFilterStore';
|
import type { Property } from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||||
import {
|
import {
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
132
src/shared/ui/VirtualList/VirtualList.svelte
Normal file
132
src/shared/ui/VirtualList/VirtualList.svelte
Normal 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>
|
||||||
@@ -4,4 +4,6 @@
|
|||||||
* Exports all shared UI components and their types
|
* Exports all shared UI components and their types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as VirtualList } from './VirtualList.svelte';
|
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||||
|
|
||||||
|
export { VirtualList };
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
# Virtualization - Store Pattern Implementation
|
|
||||||
|
|
||||||
This folder contains the virtualization layer for smooth 60FPS scrolling with large font collections.
|
|
||||||
|
|
||||||
**Updated:** Now uses Svelte 5 rune-based store pattern instead of React-inspired hooks.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### Store
|
|
||||||
|
|
||||||
- **createVirtualizerStore.ts**: Svelte 5 rune-based store for virtualized lists
|
|
||||||
|
|
||||||
### Component
|
|
||||||
|
|
||||||
- **VirtualList.svelte** (moved to `shared/ui/`): Generic virtualized list component
|
|
||||||
|
|
||||||
## Why Store Pattern?
|
|
||||||
|
|
||||||
The store pattern is more idiomatic for Svelte than React-inspired hooks:
|
|
||||||
|
|
||||||
1. **More Svelte-native**: Stores are core to Svelte, hooks are React-specific
|
|
||||||
2. **Better reactivity**: Stores auto-derive values using `$derived`, hooks need manual updates
|
|
||||||
3. **Consistent with project patterns**: Matches `createFilterStore` and `createControlStore`
|
|
||||||
4. **More extensible**: Easy to add store methods and computed values
|
|
||||||
5. **Type-safe**: Full TypeScript generics support
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Usage with Store
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import { createVirtualizerStore } from '$shared/store';
|
|
||||||
|
|
||||||
const fonts = $state<UnifiedFont[]>(fontData);
|
|
||||||
|
|
||||||
const virtualizer = createVirtualizerStore({
|
|
||||||
count: fonts.length,
|
|
||||||
estimateSize: () => 80,
|
|
||||||
overscan: 5,
|
|
||||||
getItemKey: index => fonts[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; width: 100%;"
|
|
||||||
role="option"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
<FontListItem font={fonts[item.index]} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using VirtualList Component
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
|
||||||
import { FontListItem } from '$entities/Font/ui';
|
|
||||||
import { VirtualList } from '$shared/ui';
|
|
||||||
|
|
||||||
const fonts = $state<UnifiedFont[]>(fontData);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<VirtualList
|
|
||||||
items={fonts}
|
|
||||||
itemHeight={80}
|
|
||||||
height="h-96"
|
|
||||||
let:item
|
|
||||||
let:index
|
|
||||||
>
|
|
||||||
<FontListItem {item} {index} />
|
|
||||||
</VirtualList>
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### createVirtualizerStore
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function createVirtualizerStore(options: VirtualizerOptions): VirtualizerStore;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
|
|
||||||
- `count`: Number of items (required)
|
|
||||||
- `estimateSize`: Function returning estimated height for each item (required)
|
|
||||||
- `overscan`: Number of items to render beyond viewport (default: 5)
|
|
||||||
- `getItemKey`: Function to get stable key for each item
|
|
||||||
- `scrollMargin`: Scroll offset threshold (in pixels)
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
|
|
||||||
- `virtualItems`: Array of visible virtual items (reactive getter)
|
|
||||||
- `totalSize`: Total height of all items (reactive getter)
|
|
||||||
- `scrollOffset`: Current scroll offset (reactive getter)
|
|
||||||
- `scrollToIndex`: Scroll to specific item index
|
|
||||||
- `scrollToOffset`: Scroll to specific pixel offset
|
|
||||||
- `measureElement`: Manually measure item element
|
|
||||||
- `scrollElement`: Reference to scroll element (bindable)
|
|
||||||
|
|
||||||
### VirtualList Component Props
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface VirtualListProps<T> {
|
|
||||||
items: T[]; // Items to virtualize
|
|
||||||
itemHeight?: number | ((index: number) => number; // Item height (default: 80)
|
|
||||||
overscan?: number; // Overscan items (default: 5)
|
|
||||||
height?: string; // Container height class (default: "h-96")
|
|
||||||
scrollMargin?: number; // Scroll margin
|
|
||||||
class?: string; // CSS class name
|
|
||||||
getItemKey?: (item: T, index: number) => string | number; // Key function
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Slots:**
|
|
||||||
|
|
||||||
- `let:item`: Current item
|
|
||||||
- `let:index`: Current item index
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
- 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
|
|
||||||
- ARIA roles for accessibility
|
|
||||||
- Keyboard navigation support
|
|
||||||
- Customizable overscan for smoother scrolling
|
|
||||||
- Stable keys for efficient re-rendering
|
|
||||||
- Responsive height using Tailwind CSS classes
|
|
||||||
- No pixel-based styling in `<style>` tags
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
The component uses Tailwind CSS for all styling:
|
|
||||||
|
|
||||||
- **Height**: Use Tailwind classes like `h-96`, `h-[500px]`, `h-[calc(100vh-200px)]`
|
|
||||||
- **Responsive**: Use responsive classes like `h-96 md:h-[700px]`
|
|
||||||
- **Custom**: Pass custom CSS classes via `class` prop
|
|
||||||
|
|
||||||
No pixel values in `<style>` tags - all styling is done through Tailwind utility classes or inline styles for dynamic positioning (which is required for virtualization).
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
The virtualization ensures:
|
|
||||||
|
|
||||||
- **Minimal DOM nodes**: Only visible items are rendered
|
|
||||||
- **Smooth scrolling**: Overscan reduces blank space during fast scrolling
|
|
||||||
- **Efficient updates**: TanStack Virtual optimizes item updates
|
|
||||||
- **Memory efficient**: Constant memory usage regardless of dataset size
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run unit tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn test:unit src/shared/store/createVirtualizerStore.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Run E2E tests (with component):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration from Hook Pattern
|
|
||||||
|
|
||||||
**Old (hook):**
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
const { virtualItems, totalSize } = useVirtualList({
|
|
||||||
items: fonts,
|
|
||||||
scrollElement,
|
|
||||||
estimateSize: () => 80,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**New (store):**
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
const virtualizer = createVirtualizerStore({
|
|
||||||
count: fonts.length,
|
|
||||||
estimateSize: () => 80,
|
|
||||||
});
|
|
||||||
const virtualItems = $derived(() => virtualizer.virtualItems);
|
|
||||||
const totalSize = $derived(() => virtualizer.totalSize);
|
|
||||||
```
|
|
||||||
|
|
||||||
Key differences:
|
|
||||||
|
|
||||||
1. Use `count` instead of `items` array
|
|
||||||
2. Store created once, reactive values accessed via getters
|
|
||||||
3. Bind `scrollElement` property instead of passing in options
|
|
||||||
4. Use `$derived` for reactive values in Svelte 5
|
|
||||||
|
|
||||||
## Breaking Changes from Phase 2
|
|
||||||
|
|
||||||
1. **Component renamed**: `FontVirtualList` → `VirtualList`
|
|
||||||
2. **Component moved**: `shared/virtual/` → `shared/ui/`
|
|
||||||
3. **Hook removed**: `useVirtualList` replaced with `createVirtualizerStore`
|
|
||||||
4. **Props changed**: `items` prop (was `{fonts}` shorthand)
|
|
||||||
5. **Styling**: Removed pixel values from `<style>` tags, use Tailwind classes
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Dynamic Item Height
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<VirtualList
|
|
||||||
{fonts}
|
|
||||||
itemHeight={(index => fonts[index].isFeatured ? 120 : 80)}
|
|
||||||
height="h-96"
|
|
||||||
let:item
|
|
||||||
>
|
|
||||||
<FontListItem font={item} />
|
|
||||||
</VirtualList>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Item Keys
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<VirtualList
|
|
||||||
{fonts}
|
|
||||||
itemHeight={80}
|
|
||||||
getItemKey={(font => font.id)}
|
|
||||||
let:item
|
|
||||||
>
|
|
||||||
<FontListItem {item} />
|
|
||||||
</VirtualList>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Responsive Height
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<VirtualList
|
|
||||||
{fonts}
|
|
||||||
itemHeight={80}
|
|
||||||
height="h-[500px] md:h-[700px] lg:h-[800px]"
|
|
||||||
let:item
|
|
||||||
>
|
|
||||||
<FontListItem {item} />
|
|
||||||
</VirtualList>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scroll Control
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script>
|
|
||||||
const virtualizer = createVirtualizerStore({
|
|
||||||
count: fonts.length,
|
|
||||||
estimateSize: () => 80,
|
|
||||||
});
|
|
||||||
|
|
||||||
function scrollToTop() {
|
|
||||||
virtualizer.scrollToIndex(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
virtualizer.scrollToIndex(fonts.length - 1);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button on:click={scrollToTop}>Top</button>
|
|
||||||
<button on:click={scrollToBottom}>Bottom</button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* SHARED VIRTUALIZATION LAYER - MIGRATION GUIDE
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* The virtualization API has been refactored to use Svelte 5 store pattern.
|
|
||||||
*
|
|
||||||
* Migration:
|
|
||||||
* - Component moved: src/shared/virtual/FontVirtualList.svelte → src/shared/ui/VirtualList.svelte
|
|
||||||
* - Hook removed: src/shared/virtual/useVirtualList.ts → src/shared/store/createVirtualizerStore.ts
|
|
||||||
* - Pattern changed: Hook pattern → Store pattern (more Svelte-idiomatic)
|
|
||||||
*
|
|
||||||
* New Imports:
|
|
||||||
* ```ts
|
|
||||||
* import { VirtualList } from '$shared/ui';
|
|
||||||
* import { createVirtualizerStore } from '$shared/store';
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Old Imports (deprecated):
|
|
||||||
* ```ts
|
|
||||||
* import { useVirtualList, FontVirtualList } from '$shared/virtual';
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* See src/shared/virtual/README.md for detailed usage examples and API documentation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// This file serves as migration guide - no exports needed
|
|
||||||
export {};
|
|
||||||
Reference in New Issue
Block a user