Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0692711726 | ||
|
|
86898bf83c | ||
|
|
1950cd4095 | ||
|
|
7a9f7e238c | ||
|
|
1f19e964ca | ||
|
|
eb10d58128 | ||
|
|
c78ab826a2 | ||
|
|
931a2df1ee | ||
|
|
bea3f7ae7f | ||
|
|
d1f035a6ad |
No files matched your search
@@ -7,13 +7,13 @@
|
||||
* @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 {
|
||||
FontshareApiModel,
|
||||
FontshareFont,
|
||||
} from '$entities/Font/model/types/fontshare';
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/utils';
|
||||
import type { QueryParams } from '$shared/utils';
|
||||
} from '../../model/types/fontshare';
|
||||
|
||||
/**
|
||||
* Fontshare API parameters
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
* @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 {
|
||||
FontItem,
|
||||
GoogleFontsApiModel,
|
||||
} from '$entities/Font/model/types/google';
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/utils';
|
||||
import type { QueryParams } from '$shared/utils';
|
||||
} from '../../model/types/google';
|
||||
|
||||
/**
|
||||
* Google Fonts API parameters
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type {
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
} from '$entities/Font/model/types';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type {
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
} from '../../model/types';
|
||||
import {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
|
||||
@@ -9,10 +9,10 @@ import type {
|
||||
FontCategory,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
} from '$entities/Font/model/types';
|
||||
import type { FontshareFont } from '$entities/Font/model/types/fontshare';
|
||||
import type { GoogleFontItem } from '$entities/Font/model/types/google';
|
||||
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
* Map Google Fonts category to unified FontCategory
|
||||
@@ -271,4 +271,4 @@ export function normalizeFontshareFonts(
|
||||
}
|
||||
|
||||
// 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,
|
||||
UnifiedFont,
|
||||
} 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 {
|
||||
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[] = [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type FilterModel,
|
||||
createFilterStore,
|
||||
} from '$shared/store/createFilterStore';
|
||||
} from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||
import { FONT_CATEGORIES } from '../const/const';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type FilterModel,
|
||||
createFilterStore,
|
||||
} from '$shared/store/createFilterStore';
|
||||
} from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||
import { FONT_PROVIDERS } from '../const/const';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type FilterModel,
|
||||
createFilterStore,
|
||||
} from '$shared/store/createFilterStore';
|
||||
} from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||
import { FONT_SUBSETS } from '../const/const';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
createControlStore,
|
||||
} from '$shared/store/createControlStore';
|
||||
} from '$shared/lib/store/createControlStore/createControlStore';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
createControlStore,
|
||||
} from '$shared/store/createControlStore';
|
||||
} from '$shared/lib/store/createControlStore/createControlStore';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
FONT_WEIGHT_STEP,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
createControlStore,
|
||||
} from '$shared/store/createControlStore';
|
||||
} from '$shared/lib/store/createControlStore/createControlStore';
|
||||
import {
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
LINE_HEIGHT_STEP,
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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';
|
||||
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
QueryParamValue,
|
||||
} 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">
|
||||
import type { Property } from '$shared/lib/store';
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||
import * as Collapsible from '$shared/shadcn/ui/collapsible';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import type { Property } from '$shared/store/createFilterStore';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { onMount } from 'svelte';
|
||||
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 {
|
||||
fireEvent,
|
||||
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>
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
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