+
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 78cd878..f0a4d6d 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -21,7 +21,8 @@ const config: StorybookConfig = {
{
name: '@storybook/addon-svelte-csf',
options: {
- legacyTemplate: true, // Enables the legacy template syntax
+ // Use modern template syntax for better performance
+ legacyTemplate: false,
},
},
'@chromatic-com/storybook',
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
new file mode 100644
index 0000000..6f1d1e0
--- /dev/null
+++ b/.storybook/preview-head.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 2734a23..281bb4e 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -1,4 +1,5 @@
import type { Preview } from '@storybook/svelte-vite';
+import Decorator from './Decorator.svelte';
import StoryStage from './StoryStage.svelte';
import '../src/app/styles/app.css';
@@ -23,25 +24,41 @@ const preview: Preview = {
story: {
// This sets the default height for the iframe in Autodocs
iframeHeight: '400px',
- // Ensure the story isn't forced into a tiny inline box
- // inline: true,
},
},
+
+ head: `
+
+
+
+
+
+
+ `,
},
+
decorators: [
- (storyFn, { parameters }) => {
- const { Component, props } = storyFn();
- return {
- Component: StoryStage,
- // We pass the actual story component into the Stage via a snippet/slot
- // Svelte 5 Storybook handles this mapping internally when you return this structure
- props: {
- children: Component,
- width: parameters.stageWidth || 'max-w-3xl',
- ...props,
- },
- };
- },
+ // Wrap with providers (TooltipProvider, ResponsiveManager)
+ story => ({
+ Component: Decorator,
+ props: {
+ children: story(),
+ },
+ }),
+ // Wrap with StoryStage for presentation styling
+ story => ({
+ Component: StoryStage,
+ props: {
+ children: story(),
+ },
+ }),
],
};
diff --git a/src/app/styles/app.css b/src/app/styles/app.css
index 9883313..f312953 100644
--- a/src/app/styles/app.css
+++ b/src/app/styles/app.css
@@ -167,7 +167,8 @@
--color-gradient-from: var(--gradient-from);
--color-gradient-via: var(--gradient-via);
--color-gradient-to: var(--gradient-to);
- --font-mono: var(--font-mono);
+ --font-mono: 'Major Mono Display', monospace;
+ --font-sans: 'Karla', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
}
@layer base {
diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts
index f0f07a5..4c3340d 100644
--- a/src/entities/Font/index.ts
+++ b/src/entities/Font/index.ts
@@ -78,6 +78,56 @@ export {
unifiedFontStore,
} from './model';
+// Mock data helpers for Storybook and testing
+export {
+ createCategoriesFilter,
+ createErrorState,
+ createGenericFilter,
+ createLoadingState,
+ createMockComparisonStore,
+ // Filter mocks
+ createMockFilter,
+ createMockFontApiResponse,
+ createMockFontStoreState,
+ // Store mocks
+ createMockQueryState,
+ createMockReactiveState,
+ createMockStore,
+ createProvidersFilter,
+ createSubsetsFilter,
+ createSuccessState,
+ FONTHARE_FONTS,
+ generateMixedCategoryFonts,
+ generateMockFonts,
+ generatePaginatedFonts,
+ generateSequentialFilter,
+ GENERIC_FILTERS,
+ getAllMockFonts,
+ getFontsByCategory,
+ getFontsByProvider,
+ GOOGLE_FONTS,
+ MOCK_FILTERS,
+ MOCK_FILTERS_ALL_SELECTED,
+ MOCK_FILTERS_EMPTY,
+ MOCK_FILTERS_SELECTED,
+ MOCK_FONT_STORE_STATES,
+ MOCK_STORES,
+ type MockFilterOptions,
+ type MockFilters,
+ mockFontshareFont,
+ type MockFontshareFontOptions,
+ type MockFontStoreState,
+ // Font mocks
+ mockGoogleFont,
+ // Types
+ type MockGoogleFontOptions,
+ type MockQueryObserverResult,
+ type MockQueryState,
+ mockUnifiedFont,
+ type MockUnifiedFontOptions,
+ UNIFIED_FONTS,
+} from './lib/mocks';
+
// UI elements
export {
FontApplicator,
diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts
index 8d7bad8..532d5b8 100644
--- a/src/entities/Font/lib/index.ts
+++ b/src/entities/Font/lib/index.ts
@@ -6,3 +6,53 @@ export {
} from './normalize/normalize';
export { getFontUrl } from './getFontUrl/getFontUrl';
+
+// Mock data helpers for Storybook and testing
+export {
+ createCategoriesFilter,
+ createErrorState,
+ createGenericFilter,
+ createLoadingState,
+ createMockComparisonStore,
+ // Filter mocks
+ createMockFilter,
+ createMockFontApiResponse,
+ createMockFontStoreState,
+ // Store mocks
+ createMockQueryState,
+ createMockReactiveState,
+ createMockStore,
+ createProvidersFilter,
+ createSubsetsFilter,
+ createSuccessState,
+ FONTHARE_FONTS,
+ generateMixedCategoryFonts,
+ generateMockFonts,
+ generatePaginatedFonts,
+ generateSequentialFilter,
+ GENERIC_FILTERS,
+ getAllMockFonts,
+ getFontsByCategory,
+ getFontsByProvider,
+ GOOGLE_FONTS,
+ MOCK_FILTERS,
+ MOCK_FILTERS_ALL_SELECTED,
+ MOCK_FILTERS_EMPTY,
+ MOCK_FILTERS_SELECTED,
+ MOCK_FONT_STORE_STATES,
+ MOCK_STORES,
+ type MockFilterOptions,
+ type MockFilters,
+ mockFontshareFont,
+ type MockFontshareFontOptions,
+ type MockFontStoreState,
+ // Font mocks
+ mockGoogleFont,
+ // Types
+ type MockGoogleFontOptions,
+ type MockQueryObserverResult,
+ type MockQueryState,
+ mockUnifiedFont,
+ type MockUnifiedFontOptions,
+ UNIFIED_FONTS,
+} from './mocks';
diff --git a/src/entities/Font/lib/mocks/filters.mock.ts b/src/entities/Font/lib/mocks/filters.mock.ts
new file mode 100644
index 0000000..35f1e31
--- /dev/null
+++ b/src/entities/Font/lib/mocks/filters.mock.ts
@@ -0,0 +1,348 @@
+/**
+ * ============================================================================
+ * MOCK FONT FILTER DATA
+ * ============================================================================
+ *
+ * Factory functions and preset mock data for font-related filters.
+ * Used in Storybook stories for font filtering components.
+ *
+ * ## Usage
+ *
+ * ```ts
+ * import {
+ * createMockFilter,
+ * MOCK_FILTERS,
+ * } from '$entities/Font/lib/mocks';
+ *
+ * // Create a custom filter
+ * const customFilter = createMockFilter({
+ * properties: [
+ * { id: 'option1', name: 'Option 1', value: 'option1' },
+ * { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
+ * ],
+ * });
+ *
+ * // Use preset filters
+ * const categoriesFilter = MOCK_FILTERS.categories;
+ * const subsetsFilter = MOCK_FILTERS.subsets;
+ * ```
+ */
+
+import type {
+ FontCategory,
+ FontProvider,
+ FontSubset,
+} from '$entities/Font/model/types';
+import type { Property } from '$shared/lib';
+import { createFilter } from '$shared/lib';
+
+// ============================================================================
+// TYPE DEFINITIONS
+// ============================================================================
+
+/**
+ * Options for creating a mock filter
+ */
+export interface MockFilterOptions {
+ /** Filter properties */
+ properties: Property
[];
+}
+
+/**
+ * Preset mock filters for font filtering
+ */
+export interface MockFilters {
+ /** Provider filter (Google, Fontshare) */
+ providers: ReturnType>;
+ /** Category filter (sans-serif, serif, display, etc.) */
+ categories: ReturnType>;
+ /** Subset filter (latin, latin-ext, cyrillic, etc.) */
+ subsets: ReturnType>;
+}
+
+// ============================================================================
+// FONT CATEGORIES
+// ============================================================================
+
+/**
+ * Google Fonts categories
+ */
+export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
+ { id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
+ { id: 'serif', name: 'Serif', value: 'serif' },
+ { id: 'display', name: 'Display', value: 'display' },
+ { id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
+ { id: 'monospace', name: 'Monospace', value: 'monospace' },
+];
+
+/**
+ * Fontshare categories (mapped to common naming)
+ */
+export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
+ { id: 'sans', name: 'Sans', value: 'sans' },
+ { id: 'serif', name: 'Serif', value: 'serif' },
+ { id: 'slab', name: 'Slab', value: 'slab' },
+ { id: 'display', name: 'Display', value: 'display' },
+ { id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
+ { id: 'script', name: 'Script', value: 'script' },
+];
+
+/**
+ * Unified categories (combines both providers)
+ */
+export const UNIFIED_CATEGORIES: Property[] = [
+ { id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
+ { id: 'serif', name: 'Serif', value: 'serif' },
+ { id: 'display', name: 'Display', value: 'display' },
+ { id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
+ { id: 'monospace', name: 'Monospace', value: 'monospace' },
+];
+
+// ============================================================================
+// FONT SUBSETS
+// ============================================================================
+
+/**
+ * Common font subsets
+ */
+export const FONT_SUBSETS: Property[] = [
+ { id: 'latin', name: 'Latin', value: 'latin' },
+ { id: 'latin-ext', name: 'Latin Extended', value: 'latin-ext' },
+ { id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
+ { id: 'greek', name: 'Greek', value: 'greek' },
+ { id: 'arabic', name: 'Arabic', value: 'arabic' },
+ { id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
+];
+
+// ============================================================================
+// FONT PROVIDERS
+// ============================================================================
+
+/**
+ * Font providers
+ */
+export const FONT_PROVIDERS: Property[] = [
+ { id: 'google', name: 'Google Fonts', value: 'google' },
+ { id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
+];
+
+// ============================================================================
+// FILTER FACTORIES
+// ============================================================================
+
+/**
+ * Create a mock filter from properties
+ */
+export function createMockFilter(
+ options: MockFilterOptions & { properties: Property[] },
+) {
+ return createFilter(options);
+}
+
+/**
+ * Create a mock filter for categories
+ */
+export function createCategoriesFilter(options?: { selected?: FontCategory[] }) {
+ const properties = UNIFIED_CATEGORIES.map(cat => ({
+ ...cat,
+ selected: options?.selected?.includes(cat.value) ?? false,
+ }));
+ return createFilter({ properties });
+}
+
+/**
+ * Create a mock filter for subsets
+ */
+export function createSubsetsFilter(options?: { selected?: FontSubset[] }) {
+ const properties = FONT_SUBSETS.map(subset => ({
+ ...subset,
+ selected: options?.selected?.includes(subset.value) ?? false,
+ }));
+ return createFilter({ properties });
+}
+
+/**
+ * Create a mock filter for providers
+ */
+export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
+ const properties = FONT_PROVIDERS.map(provider => ({
+ ...provider,
+ selected: options?.selected?.includes(provider.value) ?? false,
+ }));
+ return createFilter({ properties });
+}
+
+// ============================================================================
+// PRESET FILTERS
+// ============================================================================
+
+/**
+ * Preset mock filters - use these directly in stories
+ */
+export const MOCK_FILTERS: MockFilters = {
+ providers: createFilter({
+ properties: FONT_PROVIDERS,
+ }),
+ categories: createFilter({
+ properties: UNIFIED_CATEGORIES,
+ }),
+ subsets: createFilter({
+ properties: FONT_SUBSETS,
+ }),
+};
+
+/**
+ * Preset filters with some items selected
+ */
+export const MOCK_FILTERS_SELECTED: MockFilters = {
+ providers: createFilter({
+ properties: [
+ { ...FONT_PROVIDERS[0], selected: true },
+ { ...FONT_PROVIDERS[1] },
+ ],
+ }),
+ categories: createFilter({
+ properties: [
+ { ...UNIFIED_CATEGORIES[0], selected: true },
+ { ...UNIFIED_CATEGORIES[1], selected: true },
+ { ...UNIFIED_CATEGORIES[2] },
+ { ...UNIFIED_CATEGORIES[3] },
+ { ...UNIFIED_CATEGORIES[4] },
+ ],
+ }),
+ subsets: createFilter({
+ properties: [
+ { ...FONT_SUBSETS[0], selected: true },
+ { ...FONT_SUBSETS[1] },
+ { ...FONT_SUBSETS[2] },
+ { ...FONT_SUBSETS[3] },
+ { ...FONT_SUBSETS[4] },
+ ],
+ }),
+};
+
+/**
+ * Empty filters (all properties, none selected)
+ */
+export const MOCK_FILTERS_EMPTY: MockFilters = {
+ providers: createFilter({
+ properties: FONT_PROVIDERS.map(p => ({ ...p, selected: false })),
+ }),
+ categories: createFilter({
+ properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: false })),
+ }),
+ subsets: createFilter({
+ properties: FONT_SUBSETS.map(s => ({ ...s, selected: false })),
+ }),
+};
+
+/**
+ * All selected filters
+ */
+export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
+ providers: createFilter({
+ properties: FONT_PROVIDERS.map(p => ({ ...p, selected: true })),
+ }),
+ categories: createFilter({
+ properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: true })),
+ }),
+ subsets: createFilter({
+ properties: FONT_SUBSETS.map(s => ({ ...s, selected: true })),
+ }),
+};
+
+// ============================================================================
+// GENERIC FILTER MOCKS
+// ============================================================================
+
+/**
+ * Create a mock filter with generic string properties
+ * Useful for testing generic filter components
+ */
+export function createGenericFilter(
+ items: Array<{ id: string; name: string; selected?: boolean }>,
+ options?: { selected?: string[] },
+) {
+ const properties = items.map(item => ({
+ id: item.id,
+ name: item.name,
+ value: item.id,
+ selected: options?.selected?.includes(item.id) ?? item.selected ?? false,
+ }));
+ return createFilter({ properties });
+}
+
+/**
+ * Preset generic filters for testing
+ */
+export const GENERIC_FILTERS = {
+ /** Small filter with 3 items */
+ small: createFilter({
+ properties: [
+ { id: 'option-1', name: 'Option 1', value: 'option-1' },
+ { id: 'option-2', name: 'Option 2', value: 'option-2' },
+ { id: 'option-3', name: 'Option 3', value: 'option-3' },
+ ],
+ }),
+ /** Medium filter with 6 items */
+ medium: createFilter({
+ properties: [
+ { id: 'alpha', name: 'Alpha', value: 'alpha' },
+ { id: 'beta', name: 'Beta', value: 'beta' },
+ { id: 'gamma', name: 'Gamma', value: 'gamma' },
+ { id: 'delta', name: 'Delta', value: 'delta' },
+ { id: 'epsilon', name: 'Epsilon', value: 'epsilon' },
+ { id: 'zeta', name: 'Zeta', value: 'zeta' },
+ ],
+ }),
+ /** Large filter with 12 items */
+ large: createFilter({
+ properties: [
+ { id: 'jan', name: 'January', value: 'jan' },
+ { id: 'feb', name: 'February', value: 'feb' },
+ { id: 'mar', name: 'March', value: 'mar' },
+ { id: 'apr', name: 'April', value: 'apr' },
+ { id: 'may', name: 'May', value: 'may' },
+ { id: 'jun', name: 'June', value: 'jun' },
+ { id: 'jul', name: 'July', value: 'jul' },
+ { id: 'aug', name: 'August', value: 'aug' },
+ { id: 'sep', name: 'September', value: 'sep' },
+ { id: 'oct', name: 'October', value: 'oct' },
+ { id: 'nov', name: 'November', value: 'nov' },
+ { id: 'dec', name: 'December', value: 'dec' },
+ ],
+ }),
+ /** Filter with some pre-selected items */
+ partial: createFilter({
+ properties: [
+ { id: 'red', name: 'Red', value: 'red', selected: true },
+ { id: 'blue', name: 'Blue', value: 'blue', selected: false },
+ { id: 'green', name: 'Green', value: 'green', selected: true },
+ { id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
+ ],
+ }),
+ /** Filter with all items selected */
+ allSelected: createFilter({
+ properties: [
+ { id: 'cat', name: 'Cat', value: 'cat', selected: true },
+ { id: 'dog', name: 'Dog', value: 'dog', selected: true },
+ { id: 'bird', name: 'Bird', value: 'bird', selected: true },
+ ],
+ }),
+ /** Empty filter (no items) */
+ empty: createFilter({
+ properties: [],
+ }),
+};
+
+/**
+ * Generate a filter with sequential items
+ */
+export function generateSequentialFilter(count: number, prefix = 'Item ') {
+ const properties = Array.from({ length: count }, (_, i) => ({
+ id: `item-${i + 1}`,
+ name: `${prefix}${i + 1}`,
+ value: `item-${i + 1}`,
+ }));
+ return createFilter({ properties });
+}
diff --git a/src/entities/Font/lib/mocks/fonts.mock.ts b/src/entities/Font/lib/mocks/fonts.mock.ts
new file mode 100644
index 0000000..2ee8868
--- /dev/null
+++ b/src/entities/Font/lib/mocks/fonts.mock.ts
@@ -0,0 +1,630 @@
+/**
+ * ============================================================================
+ * MOCK FONT DATA
+ * ============================================================================
+ *
+ * Factory functions and preset mock data for fonts.
+ * Used in Storybook stories, tests, and development.
+ *
+ * ## Usage
+ *
+ * ```ts
+ * import {
+ * mockGoogleFont,
+ * mockFontshareFont,
+ * mockUnifiedFont,
+ * GOOGLE_FONTS,
+ * FONTHARE_FONTS,
+ * UNIFIED_FONTS,
+ * } from '$entities/Font/lib/mocks';
+ *
+ * // Create a mock Google Font
+ * const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
+ *
+ * // Create a mock Fontshare font
+ * const satoshi = mockFontshareFont({ name: 'Satoshi', slug: 'satoshi' });
+ *
+ * // Create a mock UnifiedFont
+ * const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
+ *
+ * // Use preset fonts
+ * import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
+ * ```
+ */
+
+import type {
+ FontCategory,
+ FontProvider,
+ FontSubset,
+ FontVariant,
+} from '$entities/Font/model/types';
+import type {
+ FontItem,
+ FontshareFont,
+ GoogleFontItem,
+} from '$entities/Font/model/types';
+import type {
+ FontFeatures,
+ FontMetadata,
+ FontStyleUrls,
+ UnifiedFont,
+} from '$entities/Font/model/types';
+
+// ============================================================================
+// GOOGLE FONTS MOCKS
+// ============================================================================
+
+/**
+ * Options for creating a mock Google Font
+ */
+export interface MockGoogleFontOptions {
+ /** Font family name (default: 'Mock Font') */
+ family?: string;
+ /** Font category (default: 'sans-serif') */
+ category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
+ /** Font variants (default: ['regular', '700', 'italic', '700italic']) */
+ variants?: FontVariant[];
+ /** Font subsets (default: ['latin']) */
+ subsets?: string[];
+ /** Font version (default: 'v30') */
+ version?: string;
+ /** Last modified date (default: current ISO date) */
+ lastModified?: string;
+ /** Custom file URLs (if not provided, mock URLs are generated) */
+ files?: Partial>;
+ /** Popularity rank (1 = most popular) */
+ popularity?: number;
+}
+
+/**
+ * Default mock Google Font
+ */
+export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
+ const {
+ family = 'Mock Font',
+ category = 'sans-serif',
+ variants = ['regular', '700', 'italic', '700italic'],
+ subsets = ['latin'],
+ version = 'v30',
+ lastModified = new Date().toISOString().split('T')[0],
+ files,
+ popularity = 1,
+ } = options;
+
+ const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
+
+ return {
+ family,
+ category,
+ variants: variants as FontVariant[],
+ subsets,
+ version,
+ lastModified,
+ files: files ?? {
+ regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
+ '700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
+ italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
+ '700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
+ },
+ menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
+ };
+}
+
+/**
+ * Preset Google Font mocks
+ */
+export const GOOGLE_FONTS: Record = {
+ roboto: mockGoogleFont({
+ family: 'Roboto',
+ category: 'sans-serif',
+ variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
+ subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
+ popularity: 1,
+ }),
+ openSans: mockGoogleFont({
+ family: 'Open Sans',
+ category: 'sans-serif',
+ variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
+ subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
+ popularity: 2,
+ }),
+ lato: mockGoogleFont({
+ family: 'Lato',
+ category: 'sans-serif',
+ variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
+ subsets: ['latin', 'latin-ext'],
+ popularity: 3,
+ }),
+ playfairDisplay: mockGoogleFont({
+ family: 'Playfair Display',
+ category: 'serif',
+ variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
+ subsets: ['latin', 'latin-ext', 'cyrillic'],
+ popularity: 10,
+ }),
+ montserrat: mockGoogleFont({
+ family: 'Montserrat',
+ category: 'sans-serif',
+ variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
+ subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
+ popularity: 4,
+ }),
+ sourceSansPro: mockGoogleFont({
+ family: 'Source Sans Pro',
+ category: 'sans-serif',
+ variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
+ subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
+ popularity: 5,
+ }),
+ merriweather: mockGoogleFont({
+ family: 'Merriweather',
+ category: 'serif',
+ variants: ['300', '400', '700', '900', 'italic', '700italic'],
+ subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
+ popularity: 15,
+ }),
+ robotoSlab: mockGoogleFont({
+ family: 'Roboto Slab',
+ category: 'serif',
+ variants: ['100', '300', '400', '500', '700', '900'],
+ subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
+ popularity: 8,
+ }),
+ oswald: mockGoogleFont({
+ family: 'Oswald',
+ category: 'sans-serif',
+ variants: ['200', '300', '400', '500', '600', '700'],
+ subsets: ['latin', 'latin-ext', 'vietnamese'],
+ popularity: 6,
+ }),
+ raleway: mockGoogleFont({
+ family: 'Raleway',
+ category: 'sans-serif',
+ variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
+ subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
+ popularity: 7,
+ }),
+};
+
+// ============================================================================
+// FONTHARE MOCKS
+// ============================================================================
+
+/**
+ * Options for creating a mock Fontshare font
+ */
+export interface MockFontshareFontOptions {
+ /** Font name (default: 'Mock Font') */
+ name?: string;
+ /** URL-friendly slug (default: derived from name) */
+ slug?: string;
+ /** Font category (default: 'sans') */
+ category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
+ /** Script (default: 'latin') */
+ script?: string;
+ /** Whether this is a variable font (default: false) */
+ isVariable?: boolean;
+ /** Font version (default: '1.0') */
+ version?: string;
+ /** Popularity/views count (default: 1000) */
+ views?: number;
+ /** Usage tags */
+ tags?: string[];
+ /** Font weights available */
+ weights?: number[];
+ /** Publisher name */
+ publisher?: string;
+ /** Designer name */
+ designer?: string;
+}
+
+/**
+ * Create a mock Fontshare style
+ */
+function mockFontshareStyle(
+ weight: number,
+ isItalic: boolean,
+ isVariable: boolean,
+ slug: string,
+): FontshareFont['styles'][number] {
+ const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
+ const suffix = isItalic ? 'italic' : '';
+ const variablePrefix = isVariable ? 'variable-' : '';
+
+ return {
+ id: `style-${weight}${isItalic ? '-italic' : ''}`,
+ default: weight === 400 && !isItalic,
+ file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
+ is_italic: isItalic,
+ is_variable: isVariable,
+ properties: {},
+ weight: {
+ label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
+ name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
+ native_name: null,
+ number: isVariable ? 0 : weight,
+ weight: isVariable ? 0 : weight,
+ },
+ };
+}
+
+/**
+ * Default mock Fontshare font
+ */
+export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
+ const {
+ name = 'Mock Font',
+ slug = name.toLowerCase().replace(/\s+/g, '-'),
+ category = 'sans',
+ script = 'latin',
+ isVariable = false,
+ version = '1.0',
+ views = 1000,
+ tags = [],
+ weights = [400, 700],
+ publisher = 'Mock Foundry',
+ designer = 'Mock Designer',
+ } = options;
+
+ // Generate styles based on weights and variable setting
+ const styles: FontshareFont['styles'] = isVariable
+ ? [
+ mockFontshareStyle(0, false, true, slug),
+ mockFontshareStyle(0, true, true, slug),
+ ]
+ : weights.flatMap(weight => [
+ mockFontshareStyle(weight, false, false, slug),
+ mockFontshareStyle(weight, true, false, slug),
+ ]);
+
+ return {
+ id: `mock-${slug}`,
+ name,
+ native_name: null,
+ slug,
+ category,
+ script,
+ publisher: {
+ bio: `Mock publisher bio for ${publisher}`,
+ email: null,
+ id: `pub-${slug}`,
+ links: [],
+ name: publisher,
+ },
+ designers: [
+ {
+ bio: `Mock designer bio for ${designer}`,
+ links: [],
+ name: designer,
+ },
+ ],
+ related_families: null,
+ display_publisher_as_designer: false,
+ trials_enabled: true,
+ show_latin_metrics: false,
+ license_type: 'ofl',
+ languages: 'English, Spanish, French, German',
+ inserted_at: '2021-03-12T20:49:05Z',
+ story: `A mock font story for ${name}.
`,
+ version,
+ views,
+ views_recent: Math.floor(views * 0.1),
+ is_hot: views > 5000,
+ is_new: views < 500,
+ is_shortlisted: null,
+ is_top: views > 10000,
+ axes: isVariable
+ ? [
+ {
+ name: 'Weight',
+ property: 'wght',
+ range_default: 400,
+ range_left: 300,
+ range_right: 700,
+ },
+ ]
+ : [],
+ font_tags: tags.map(name => ({ name })),
+ features: [],
+ styles,
+ };
+}
+
+/**
+ * Preset Fontshare font mocks
+ */
+export const FONTHARE_FONTS: Record = {
+ satoshi: mockFontshareFont({
+ name: 'Satoshi',
+ slug: 'satoshi',
+ category: 'sans',
+ isVariable: true,
+ views: 15000,
+ tags: ['Branding', 'Logos', 'Editorial'],
+ publisher: 'Indian Type Foundry',
+ designer: 'Denis Shelabovets',
+ }),
+ generalSans: mockFontshareFont({
+ name: 'General Sans',
+ slug: 'general-sans',
+ category: 'sans',
+ isVariable: true,
+ views: 12000,
+ tags: ['UI', 'Branding', 'Display'],
+ publisher: 'Indestructible Type',
+ designer: 'Eugene Tantsur',
+ }),
+ clashDisplay: mockFontshareFont({
+ name: 'Clash Display',
+ slug: 'clash-display',
+ category: 'display',
+ isVariable: false,
+ views: 8000,
+ tags: ['Headlines', 'Posters', 'Branding'],
+ weights: [400, 500, 600, 700],
+ publisher: 'Letterogika',
+ designer: 'Matěj Trnka',
+ }),
+ fonta: mockFontshareFont({
+ name: 'Fonta',
+ slug: 'fonta',
+ category: 'serif',
+ isVariable: false,
+ views: 5000,
+ tags: ['Editorial', 'Books', 'Magazines'],
+ weights: [300, 400, 500, 600, 700],
+ publisher: 'Fonta',
+ designer: 'Alexei Vanyashin',
+ }),
+ aileron: mockFontshareFont({
+ name: 'Aileron',
+ slug: 'aileron',
+ category: 'sans',
+ isVariable: false,
+ views: 3000,
+ tags: ['Display', 'Headlines'],
+ weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
+ publisher: 'Sorkin Type',
+ designer: 'Sorkin Type',
+ }),
+ beVietnamPro: mockFontshareFont({
+ name: 'Be Vietnam Pro',
+ slug: 'be-vietnam-pro',
+ category: 'sans',
+ isVariable: true,
+ views: 20000,
+ tags: ['UI', 'App', 'Web'],
+ publisher: 'ildefox',
+ designer: 'Manh Nguyen',
+ }),
+};
+
+// ============================================================================
+// UNIFIED FONT MOCKS
+// ============================================================================
+
+/**
+ * Options for creating a mock UnifiedFont
+ */
+export interface MockUnifiedFontOptions {
+ /** Unique identifier (default: derived from name) */
+ id?: string;
+ /** Font display name (default: 'Mock Font') */
+ name?: string;
+ /** Font provider (default: 'google') */
+ provider?: FontProvider;
+ /** Font category (default: 'sans-serif') */
+ category?: FontCategory;
+ /** Font subsets (default: ['latin']) */
+ subsets?: FontSubset[];
+ /** Font variants (default: ['regular', '700', 'italic', '700italic']) */
+ variants?: FontVariant[];
+ /** Style URLs (if not provided, mock URLs are generated) */
+ styles?: FontStyleUrls;
+ /** Metadata overrides */
+ metadata?: Partial;
+ /** Features overrides */
+ features?: Partial;
+}
+
+/**
+ * Default mock UnifiedFont
+ */
+export function mockUnifiedFont(options: MockUnifiedFontOptions = {}): UnifiedFont {
+ const {
+ id,
+ name = 'Mock Font',
+ provider = 'google',
+ category = 'sans-serif',
+ subsets = ['latin'],
+ variants = ['regular', '700', 'italic', '700italic'],
+ styles,
+ metadata,
+ features,
+ } = options;
+
+ const fontId = id ?? name.toLowerCase().replace(/\s+/g, '');
+ const baseUrl = provider === 'google'
+ ? `https://fonts.gstatic.com/s/${fontId}/v30`
+ : `//cdn.fontshare.com/wf/${fontId}`;
+
+ return {
+ id: fontId,
+ name,
+ provider,
+ category,
+ subsets,
+ variants: variants as FontVariant[],
+ styles: styles ?? {
+ regular: `${baseUrl}/regular.woff2`,
+ bold: `${baseUrl}/bold.woff2`,
+ italic: `${baseUrl}/italic.woff2`,
+ boldItalic: `${baseUrl}/bolditalic.woff2`,
+ },
+ metadata: {
+ cachedAt: Date.now(),
+ version: '1.0',
+ lastModified: new Date().toISOString().split('T')[0],
+ popularity: 1,
+ ...metadata,
+ },
+ features: {
+ isVariable: false,
+ ...features,
+ },
+ };
+}
+
+/**
+ * Preset UnifiedFont mocks
+ */
+export const UNIFIED_FONTS: Record = {
+ roboto: mockUnifiedFont({
+ id: 'roboto',
+ name: 'Roboto',
+ provider: 'google',
+ category: 'sans-serif',
+ subsets: ['latin', 'latin-ext'],
+ variants: ['100', '300', '400', '500', '700', '900'],
+ metadata: { popularity: 1 },
+ }),
+ openSans: mockUnifiedFont({
+ id: 'open-sans',
+ name: 'Open Sans',
+ provider: 'google',
+ category: 'sans-serif',
+ subsets: ['latin', 'latin-ext'],
+ variants: ['300', '400', '500', '600', '700', '800'],
+ metadata: { popularity: 2 },
+ }),
+ lato: mockUnifiedFont({
+ id: 'lato',
+ name: 'Lato',
+ provider: 'google',
+ category: 'sans-serif',
+ subsets: ['latin', 'latin-ext'],
+ variants: ['100', '300', '400', '700', '900'],
+ metadata: { popularity: 3 },
+ }),
+ playfairDisplay: mockUnifiedFont({
+ id: 'playfair-display',
+ name: 'Playfair Display',
+ provider: 'google',
+ category: 'serif',
+ subsets: ['latin'],
+ variants: ['400', '700', '900'],
+ metadata: { popularity: 10 },
+ }),
+ montserrat: mockUnifiedFont({
+ id: 'montserrat',
+ name: 'Montserrat',
+ provider: 'google',
+ category: 'sans-serif',
+ subsets: ['latin', 'latin-ext'],
+ variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
+ metadata: { popularity: 4 },
+ }),
+ satoshi: mockUnifiedFont({
+ id: 'satoshi',
+ name: 'Satoshi',
+ provider: 'fontshare',
+ category: 'sans-serif',
+ subsets: ['latin'],
+ variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
+ features: { isVariable: true, axes: [{ name: 'wght', property: 'wght', default: 400, min: 300, max: 700 }] },
+ metadata: { popularity: 15000 },
+ }),
+ generalSans: mockUnifiedFont({
+ id: 'general-sans',
+ name: 'General Sans',
+ provider: 'fontshare',
+ category: 'sans-serif',
+ subsets: ['latin'],
+ variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
+ features: { isVariable: true },
+ metadata: { popularity: 12000 },
+ }),
+ clashDisplay: mockUnifiedFont({
+ id: 'clash-display',
+ name: 'Clash Display',
+ provider: 'fontshare',
+ category: 'display',
+ subsets: ['latin'],
+ variants: ['regular', '500', '600', 'bold'] as FontVariant[],
+ features: { tags: ['Headlines', 'Posters', 'Branding'] },
+ metadata: { popularity: 8000 },
+ }),
+ oswald: mockUnifiedFont({
+ id: 'oswald',
+ name: 'Oswald',
+ provider: 'google',
+ category: 'sans-serif',
+ subsets: ['latin'],
+ variants: ['200', '300', '400', '500', '600', '700'],
+ metadata: { popularity: 6 },
+ }),
+ raleway: mockUnifiedFont({
+ id: 'raleway',
+ name: 'Raleway',
+ provider: 'google',
+ category: 'sans-serif',
+ subsets: ['latin'],
+ variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
+ metadata: { popularity: 7 },
+ }),
+};
+
+/**
+ * Get an array of all preset UnifiedFonts
+ */
+export function getAllMockFonts(): UnifiedFont[] {
+ return Object.values(UNIFIED_FONTS);
+}
+
+/**
+ * Get fonts by provider
+ */
+export function getFontsByProvider(provider: FontProvider): UnifiedFont[] {
+ return getAllMockFonts().filter(font => font.provider === provider);
+}
+
+/**
+ * Get fonts by category
+ */
+export function getFontsByCategory(category: FontCategory): UnifiedFont[] {
+ return getAllMockFonts().filter(font => font.category === category);
+}
+
+/**
+ * Generate an array of mock fonts with sequential naming
+ */
+export function generateMockFonts(count: number, options?: Omit): UnifiedFont[] {
+ return Array.from({ length: count }, (_, i) =>
+ mockUnifiedFont({
+ ...options,
+ id: `mock-font-${i + 1}`,
+ name: `Mock Font ${i + 1}`,
+ }));
+}
+
+/**
+ * Generate an array of mock fonts with different categories
+ */
+export function generateMixedCategoryFonts(countPerCategory: number = 2): UnifiedFont[] {
+ const categories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
+ const fonts: UnifiedFont[] = [];
+
+ categories.forEach(category => {
+ for (let i = 0; i < countPerCategory; i++) {
+ fonts.push(
+ mockUnifiedFont({
+ id: `${category}-${i + 1}`,
+ name: `${category.replace('-', ' ')} ${i + 1}`,
+ category,
+ }),
+ );
+ }
+ });
+
+ return fonts;
+}
diff --git a/src/entities/Font/lib/mocks/index.ts b/src/entities/Font/lib/mocks/index.ts
new file mode 100644
index 0000000..e2042e3
--- /dev/null
+++ b/src/entities/Font/lib/mocks/index.ts
@@ -0,0 +1,84 @@
+/**
+ * ============================================================================
+ * MOCK DATA HELPERS - MAIN EXPORT
+ * ============================================================================
+ *
+ * Comprehensive mock data for Storybook stories, tests, and development.
+ *
+ * ## Quick Start
+ *
+ * ```ts
+ * import {
+ * mockUnifiedFont,
+ * UNIFIED_FONTS,
+ * MOCK_FILTERS,
+ * createMockFontStoreState,
+ * } from '$entities/Font/lib/mocks';
+ *
+ * // Use in stories
+ * const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
+ * const presets = UNIFIED_FONTS;
+ * const filter = MOCK_FILTERS.categories;
+ * ```
+ *
+ * @module
+ */
+
+// Font mocks
+export {
+ FONTHARE_FONTS,
+ generateMixedCategoryFonts,
+ generateMockFonts,
+ getAllMockFonts,
+ getFontsByCategory,
+ getFontsByProvider,
+ GOOGLE_FONTS,
+ mockFontshareFont,
+ type MockFontshareFontOptions,
+ mockGoogleFont,
+ type MockGoogleFontOptions,
+ mockUnifiedFont,
+ type MockUnifiedFontOptions,
+ UNIFIED_FONTS,
+} from './fonts.mock';
+
+// Filter mocks
+export {
+ createCategoriesFilter,
+ createGenericFilter,
+ createMockFilter,
+ createProvidersFilter,
+ createSubsetsFilter,
+ FONT_PROVIDERS,
+ FONT_SUBSETS,
+ FONTHARE_CATEGORIES,
+ generateSequentialFilter,
+ GENERIC_FILTERS,
+ GOOGLE_CATEGORIES,
+ MOCK_FILTERS,
+ MOCK_FILTERS_ALL_SELECTED,
+ MOCK_FILTERS_EMPTY,
+ MOCK_FILTERS_SELECTED,
+ type MockFilterOptions,
+ type MockFilters,
+ UNIFIED_CATEGORIES,
+} from './filters.mock';
+
+// Store mocks
+export {
+ createErrorState,
+ createLoadingState,
+ createMockComparisonStore,
+ createMockFontApiResponse,
+ createMockFontStoreState,
+ createMockQueryState,
+ createMockReactiveState,
+ createMockStore,
+ createSuccessState,
+ generatePaginatedFonts,
+ MOCK_FONT_STORE_STATES,
+ MOCK_STORES,
+ type MockFontStoreState,
+ type MockQueryObserverResult,
+ type MockQueryState,
+} from './stores.mock';
diff --git a/src/entities/Font/lib/mocks/stores.mock.ts b/src/entities/Font/lib/mocks/stores.mock.ts
new file mode 100644
index 0000000..f6610c7
--- /dev/null
+++ b/src/entities/Font/lib/mocks/stores.mock.ts
@@ -0,0 +1,590 @@
+/**
+ * ============================================================================
+ * MOCK FONT STORE HELPERS
+ * ============================================================================
+ *
+ * Factory functions and preset mock data for TanStack Query stores and state management.
+ * Used in Storybook stories for components that use reactive stores.
+ *
+ * ## Usage
+ *
+ * ```ts
+ * import {
+ * createMockQueryState,
+ * MOCK_STORES,
+ * } from '$entities/Font/lib/mocks';
+ *
+ * // Create a mock query state
+ * const loadingState = createMockQueryState({ status: 'pending' });
+ * const errorState = createMockQueryState({ status: 'error', error: 'Failed to load' });
+ * const successState = createMockQueryState({ status: 'success', data: mockFonts });
+ *
+ * // Use preset stores
+ * const mockFontStore = MOCK_STORES.unifiedFontStore();
+ * ```
+ */
+
+import type { UnifiedFont } from '$entities/Font/model/types';
+import type {
+ QueryKey,
+ QueryObserverResult,
+ QueryStatus,
+} from '@tanstack/svelte-query';
+import {
+ UNIFIED_FONTS,
+ generateMockFonts,
+} from './fonts.mock';
+
+// ============================================================================
+// TANSTACK QUERY MOCK TYPES
+// ============================================================================
+
+/**
+ * Mock TanStack Query state
+ */
+export interface MockQueryState {
+ status: QueryStatus;
+ data?: TData;
+ error?: TError;
+ isLoading?: boolean;
+ isFetching?: boolean;
+ isSuccess?: boolean;
+ isError?: boolean;
+ isPending?: boolean;
+ dataUpdatedAt?: number;
+ errorUpdatedAt?: number;
+ failureCount?: number;
+ failureReason?: TError;
+ errorUpdateCount?: number;
+ isRefetching?: boolean;
+ isRefetchError?: boolean;
+ isPaused?: boolean;
+}
+
+/**
+ * Mock TanStack Query observer result
+ */
+export interface MockQueryObserverResult {
+ status?: QueryStatus;
+ data?: TData;
+ error?: TError;
+ isLoading?: boolean;
+ isFetching?: boolean;
+ isSuccess?: boolean;
+ isError?: boolean;
+ isPending?: boolean;
+ dataUpdatedAt?: number;
+ errorUpdatedAt?: number;
+ failureCount?: number;
+ failureReason?: TError;
+ errorUpdateCount?: number;
+ isRefetching?: boolean;
+ isRefetchError?: boolean;
+ isPaused?: boolean;
+}
+
+// ============================================================================
+// TANSTACK QUERY MOCK FACTORIES
+// ============================================================================
+
+/**
+ * Create a mock query state for TanStack Query
+ */
+export function createMockQueryState(
+ options: MockQueryState,
+): MockQueryObserverResult {
+ const {
+ status,
+ data,
+ error,
+ } = options;
+
+ return {
+ status: status ?? 'success',
+ data,
+ error,
+ isLoading: status === 'pending' ? true : false,
+ isFetching: status === 'pending' ? true : false,
+ isSuccess: status === 'success',
+ isError: status === 'error',
+ isPending: status === 'pending',
+ dataUpdatedAt: status === 'success' ? Date.now() : undefined,
+ errorUpdatedAt: status === 'error' ? Date.now() : undefined,
+ failureCount: status === 'error' ? 1 : 0,
+ failureReason: status === 'error' ? error : undefined,
+ errorUpdateCount: status === 'error' ? 1 : 0,
+ isRefetching: false,
+ isRefetchError: false,
+ isPaused: false,
+ };
+}
+
+/**
+ * Create a loading query state
+ */
+export function createLoadingState(): MockQueryObserverResult {
+ return createMockQueryState({ status: 'pending', data: undefined, error: undefined });
+}
+
+/**
+ * Create an error query state
+ */
+export function createErrorState(
+ error: TError,
+): MockQueryObserverResult {
+ return createMockQueryState({ status: 'error', data: undefined, error });
+}
+
+/**
+ * Create a success query state
+ */
+export function createSuccessState(data: TData): MockQueryObserverResult {
+ return createMockQueryState({ status: 'success', data, error: undefined });
+}
+
+// ============================================================================
+// FONT STORE MOCKS
+// ============================================================================
+
+/**
+ * Mock UnifiedFontStore state
+ */
+export interface MockFontStoreState {
+ /** All cached fonts */
+ fonts: Record;
+ /** Current page */
+ page: number;
+ /** Total pages available */
+ totalPages: number;
+ /** Items per page */
+ limit: number;
+ /** Total font count */
+ total: number;
+ /** Loading state */
+ isLoading: boolean;
+ /** Error state */
+ error: Error | null;
+ /** Search query */
+ searchQuery: string;
+ /** Selected provider */
+ provider: 'google' | 'fontshare' | 'all';
+ /** Selected category */
+ category: string | null;
+ /** Selected subset */
+ subset: string | null;
+}
+
+/**
+ * Create a mock font store state
+ */
+export function createMockFontStoreState(
+ options: Partial = {},
+): MockFontStoreState {
+ const {
+ page = 1,
+ limit = 24,
+ isLoading = false,
+ error = null,
+ searchQuery = '',
+ provider = 'all',
+ category = null,
+ subset = null,
+ } = options;
+
+ // Generate mock fonts if not provided
+ const mockFonts = options.fonts ?? Object.fromEntries(
+ Object.values(UNIFIED_FONTS).map(font => [font.id, font]),
+ );
+
+ const fontArray = Object.values(mockFonts);
+ const total = options.total ?? fontArray.length;
+ const totalPages = options.totalPages ?? Math.ceil(total / limit);
+
+ return {
+ fonts: mockFonts,
+ page,
+ totalPages,
+ limit,
+ total,
+ isLoading,
+ error,
+ searchQuery,
+ provider,
+ category,
+ subset,
+ };
+}
+
+/**
+ * Preset font store states
+ */
+export const MOCK_FONT_STORE_STATES = {
+ /** Initial loading state */
+ loading: createMockFontStoreState({
+ isLoading: true,
+ fonts: {},
+ total: 0,
+ page: 1,
+ }),
+
+ /** Empty state (no fonts found) */
+ empty: createMockFontStoreState({
+ fonts: {},
+ total: 0,
+ page: 1,
+ isLoading: false,
+ }),
+
+ /** First page with fonts */
+ firstPage: createMockFontStoreState({
+ fonts: Object.fromEntries(
+ Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
+ ),
+ total: 50,
+ page: 1,
+ limit: 10,
+ totalPages: 5,
+ isLoading: false,
+ }),
+
+ /** Second page with fonts */
+ secondPage: createMockFontStoreState({
+ fonts: Object.fromEntries(
+ Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
+ ),
+ total: 50,
+ page: 2,
+ limit: 10,
+ totalPages: 5,
+ isLoading: false,
+ }),
+
+ /** Last page with fonts */
+ lastPage: createMockFontStoreState({
+ fonts: Object.fromEntries(
+ Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
+ ),
+ total: 25,
+ page: 3,
+ limit: 10,
+ totalPages: 3,
+ isLoading: false,
+ }),
+
+ /** Error state */
+ error: createMockFontStoreState({
+ fonts: {},
+ error: new Error('Failed to load fonts'),
+ total: 0,
+ page: 1,
+ isLoading: false,
+ }),
+
+ /** With search query */
+ withSearch: createMockFontStoreState({
+ fonts: Object.fromEntries(
+ Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
+ ),
+ total: 3,
+ page: 1,
+ isLoading: false,
+ searchQuery: 'Roboto',
+ }),
+
+ /** Filtered by category */
+ filteredByCategory: createMockFontStoreState({
+ fonts: Object.fromEntries(
+ Object.values(UNIFIED_FONTS)
+ .filter(f => f.category === 'serif')
+ .slice(0, 5)
+ .map(font => [font.id, font]),
+ ),
+ total: 5,
+ page: 1,
+ isLoading: false,
+ category: 'serif',
+ }),
+
+ /** Filtered by provider */
+ filteredByProvider: createMockFontStoreState({
+ fonts: Object.fromEntries(
+ Object.values(UNIFIED_FONTS)
+ .filter(f => f.provider === 'google')
+ .slice(0, 5)
+ .map(font => [font.id, font]),
+ ),
+ total: 5,
+ page: 1,
+ isLoading: false,
+ provider: 'google',
+ }),
+
+ /** Large dataset */
+ largeDataset: createMockFontStoreState({
+ fonts: Object.fromEntries(
+ generateMockFonts(50).map(font => [font.id, font]),
+ ),
+ total: 500,
+ page: 1,
+ limit: 50,
+ totalPages: 10,
+ isLoading: false,
+ }),
+};
+
+// ============================================================================
+// MOCK STORE OBJECT
+// ============================================================================
+
+/**
+ * Create a mock store object that mimics TanStack Query behavior
+ * Useful for components that subscribe to store properties
+ */
+export function createMockStore(config: {
+ data?: T;
+ isLoading?: boolean;
+ isError?: boolean;
+ error?: Error;
+ isFetching?: boolean;
+}) {
+ const {
+ data,
+ isLoading = false,
+ isError = false,
+ error,
+ isFetching = false,
+ } = config;
+
+ return {
+ get data() {
+ return data;
+ },
+ get isLoading() {
+ return isLoading;
+ },
+ get isError() {
+ return isError;
+ },
+ get error() {
+ return error;
+ },
+ get isFetching() {
+ return isFetching;
+ },
+ get isSuccess() {
+ return !isLoading && !isError && data !== undefined;
+ },
+ get status() {
+ if (isLoading) return 'pending';
+ if (isError) return 'error';
+ return 'success';
+ },
+ };
+}
+
+/**
+ * Preset mock stores
+ */
+export const MOCK_STORES = {
+ /** Font store in loading state */
+ loadingFontStore: createMockStore({
+ isLoading: true,
+ data: undefined,
+ }),
+
+ /** Font store with fonts loaded */
+ successFontStore: createMockStore({
+ data: Object.values(UNIFIED_FONTS),
+ isLoading: false,
+ isError: false,
+ }),
+
+ /** Font store with error */
+ errorFontStore: createMockStore({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Failed to load fonts'),
+ }),
+
+ /** Font store with empty results */
+ emptyFontStore: createMockStore({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
+
+ /**
+ * Create a mock UnifiedFontStore-like object
+ * Note: This is a simplified mock for Storybook use
+ */
+ unifiedFontStore: (state: Partial = {}) => {
+ const mockState = createMockFontStoreState(state);
+ return {
+ // State properties
+ get fonts() {
+ return mockState.fonts;
+ },
+ get page() {
+ return mockState.page;
+ },
+ get totalPages() {
+ return mockState.totalPages;
+ },
+ get limit() {
+ return mockState.limit;
+ },
+ get total() {
+ return mockState.total;
+ },
+ get isLoading() {
+ return mockState.isLoading;
+ },
+ get error() {
+ return mockState.error;
+ },
+ get searchQuery() {
+ return mockState.searchQuery;
+ },
+ get provider() {
+ return mockState.provider;
+ },
+ get category() {
+ return mockState.category;
+ },
+ get subset() {
+ return mockState.subset;
+ },
+ // Methods (no-op for Storybook)
+ nextPage: () => {},
+ prevPage: () => {},
+ goToPage: (_page: number) => {},
+ setLimit: (_limit: number) => {},
+ setProvider: (_provider: typeof mockState.provider) => {},
+ setCategory: (_category: string | null) => {},
+ setSubset: (_subset: string | null) => {},
+ setSearch: (_query: string) => {},
+ resetFilters: () => {},
+ };
+ },
+};
+
+// ============================================================================
+// REACTIVE STATE MOCKS
+// ============================================================================
+
+/**
+ * Create a reactive state object using Svelte 5 runes pattern
+ * Useful for stories that need reactive state
+ *
+ * Note: This uses plain JavaScript objects since Svelte runes
+ * only work in .svelte files. For Storybook, this provides
+ * a similar API for testing.
+ */
+export function createMockReactiveState(initialValue: T) {
+ let value = initialValue;
+
+ return {
+ get value() {
+ return value;
+ },
+ set value(newValue: T) {
+ value = newValue;
+ },
+ update(fn: (current: T) => T) {
+ value = fn(value);
+ },
+ };
+}
+
+/**
+ * Mock comparison store for ComparisonSlider component
+ */
+export function createMockComparisonStore(config: {
+ fontA?: UnifiedFont;
+ fontB?: UnifiedFont;
+ text?: string;
+} = {}) {
+ const { fontA, fontB, text = 'The quick brown fox jumps over the lazy dog.' } = config;
+
+ return {
+ get fontA() {
+ return fontA ?? UNIFIED_FONTS.roboto;
+ },
+ get fontB() {
+ return fontB ?? UNIFIED_FONTS.openSans;
+ },
+ get text() {
+ return text;
+ },
+ // Methods (no-op for Storybook)
+ setFontA: (_font: UnifiedFont | undefined) => {},
+ setFontB: (_font: UnifiedFont | undefined) => {},
+ setText: (_text: string) => {},
+ swapFonts: () => {},
+ };
+}
+
+// ============================================================================
+// MOCK DATA GENERATORS
+// ============================================================================
+
+/**
+ * Generate paginated font data
+ */
+export function generatePaginatedFonts(
+ totalCount: number,
+ page: number,
+ limit: number,
+): {
+ fonts: UnifiedFont[];
+ page: number;
+ totalPages: number;
+ total: number;
+ hasNextPage: boolean;
+ hasPrevPage: boolean;
+} {
+ const totalPages = Math.ceil(totalCount / limit);
+ const startIndex = (page - 1) * limit;
+ const endIndex = Math.min(startIndex + limit, totalCount);
+
+ return {
+ fonts: generateMockFonts(endIndex - startIndex).map((font, i) => ({
+ ...font,
+ id: `font-${startIndex + i + 1}`,
+ name: `Font ${startIndex + i + 1}`,
+ })),
+ page,
+ totalPages,
+ total: totalCount,
+ hasNextPage: page < totalPages,
+ hasPrevPage: page > 1,
+ };
+}
+
+/**
+ * Create mock API response for fonts
+ */
+export function createMockFontApiResponse(config: {
+ fonts?: UnifiedFont[];
+ total?: number;
+ page?: number;
+ limit?: number;
+} = {}) {
+ const fonts = config.fonts ?? Object.values(UNIFIED_FONTS);
+ const total = config.total ?? fonts.length;
+ const page = config.page ?? 1;
+ const limit = config.limit ?? fonts.length;
+
+ return {
+ data: fonts,
+ meta: {
+ total,
+ page,
+ limit,
+ totalPages: Math.ceil(total / limit),
+ hasNextPage: page < Math.ceil(total / limit),
+ hasPrevPage: page > 1,
+ },
+ };
+}
diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts
index f2a116e..e1740f7 100644
--- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts
+++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts
@@ -12,9 +12,12 @@ import { AppliedFontsManager } from './appliedFontsStore.svelte';
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let mockFontFaceSet: any;
+ let mockFetch: any;
+ let failUrls: Set;
beforeEach(() => {
vi.useFakeTimers();
+ failUrls = new Set();
mockFontFaceSet = {
add: vi.fn(),
@@ -22,11 +25,13 @@ describe('AppliedFontsManager', () => {
};
// 1. Properly mock FontFace as a constructor function
- const MockFontFace = vi.fn(function(this: any, name: string, url: string) {
+ // The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
+ const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
this.name = name;
- this.url = url;
+ this.bufferOrUrl = bufferOrUrl;
this.load = vi.fn().mockImplementation(() => {
- if (url.includes('fail')) return Promise.reject(new Error('Load failed'));
+ // For error tests, we track which URLs should fail via failUrls
+ // The fetch mock will have already rejected for those URLs
return Promise.resolve(this);
});
});
@@ -44,18 +49,37 @@ describe('AppliedFontsManager', () => {
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
});
+ // 3. Mock fetch to return fake ArrayBuffer data
+ mockFetch = vi.fn((url: string) => {
+ if (failUrls.has(url)) {
+ return Promise.reject(new Error('Network error'));
+ }
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
+ clone: () => ({
+ ok: true,
+ status: 200,
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
+ }),
+ } as Response);
+ });
+ vi.stubGlobal('fetch', mockFetch);
+
manager = new AppliedFontsManager();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
+ vi.unstubAllGlobals();
});
it('should batch multiple font requests into a single process', async () => {
const configs = [
- { id: 'lato-400', name: 'Lato', url: 'lato.ttf', weight: 400 },
- { id: 'lato-700', name: 'Lato', url: 'lato-bold.ttf', weight: 700 },
+ { id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
+ { id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
];
manager.touch(configs);
@@ -71,7 +95,10 @@ describe('AppliedFontsManager', () => {
// Suppress expected console error for clean test logs
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const config = { id: 'broken', name: 'Broken', url: 'fail.ttf', weight: 400 };
+ const failUrl = 'https://example.com/fail.ttf';
+ failUrls.add(failUrl);
+
+ const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
@@ -81,7 +108,7 @@ describe('AppliedFontsManager', () => {
});
it('should purge fonts after TTL expires', async () => {
- const config = { id: 'ephemeral', name: 'Temp', url: 'temp.ttf', weight: 400 };
+ const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
@@ -96,7 +123,7 @@ describe('AppliedFontsManager', () => {
});
it('should NOT purge fonts that are still being "touched"', async () => {
- const config = { id: 'active', name: 'Active', url: 'active.ttf', weight: 400 };
+ const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
diff --git a/src/shared/lib/helpers/createEntityStore/createEntityStore.test.ts b/src/shared/lib/helpers/createEntityStore/createEntityStore.test.ts
new file mode 100644
index 0000000..0e5d11f
--- /dev/null
+++ b/src/shared/lib/helpers/createEntityStore/createEntityStore.test.ts
@@ -0,0 +1,420 @@
+import {
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from 'vitest';
+import {
+ type Entity,
+ EntityStore,
+ createEntityStore,
+} from './createEntityStore.svelte';
+
+interface TestEntity {
+ id: string;
+ name: string;
+ value: number;
+}
+
+describe('createEntityStore', () => {
+ describe('Construction and Initialization', () => {
+ it('should create an empty store when no initial entities are provided', () => {
+ const store = createEntityStore();
+
+ expect(store.all).toEqual([]);
+ });
+
+ it('should create a store with initial entities', () => {
+ const initialEntities: TestEntity[] = [
+ { id: '1', name: 'First', value: 1 },
+ { id: '2', name: 'Second', value: 2 },
+ ];
+ const store = createEntityStore(initialEntities);
+
+ expect(store.all).toHaveLength(2);
+ expect(store.all).toEqual(initialEntities);
+ });
+
+ it('should create EntityStore instance', () => {
+ const store = createEntityStore();
+
+ expect(store).toBeInstanceOf(EntityStore);
+ });
+ });
+
+ describe('Selectors', () => {
+ let store: EntityStore;
+ let entities: TestEntity[];
+
+ beforeEach(() => {
+ entities = [
+ { id: '1', name: 'First', value: 10 },
+ { id: '2', name: 'Second', value: 20 },
+ { id: '3', name: 'Third', value: 30 },
+ ];
+ store = createEntityStore(entities);
+ });
+
+ it('should return all entities as an array', () => {
+ const all = store.all;
+
+ expect(all).toEqual(entities);
+ expect(all).toHaveLength(3);
+ });
+
+ it('should get a single entity by ID', () => {
+ const entity = store.getById('2');
+
+ expect(entity).toEqual({ id: '2', name: 'Second', value: 20 });
+ });
+
+ it('should return undefined for non-existent ID', () => {
+ const entity = store.getById('999');
+
+ expect(entity).toBeUndefined();
+ });
+
+ it('should get multiple entities by IDs', () => {
+ const entities = store.getByIds(['1', '3']);
+
+ expect(entities).toEqual([
+ { id: '1', name: 'First', value: 10 },
+ { id: '3', name: 'Third', value: 30 },
+ ]);
+ });
+
+ it('should filter out undefined results when getting by IDs', () => {
+ const entities = store.getByIds(['1', '999', '3']);
+
+ expect(entities).toEqual([
+ { id: '1', name: 'First', value: 10 },
+ { id: '3', name: 'Third', value: 30 },
+ ]);
+ expect(entities).toHaveLength(2);
+ });
+
+ it('should return empty array when no IDs match', () => {
+ const entities = store.getByIds(['999', '888']);
+
+ expect(entities).toEqual([]);
+ });
+
+ it('should check if entity exists by ID', () => {
+ expect(store.has('1')).toBe(true);
+ expect(store.has('999')).toBe(false);
+ });
+ });
+
+ describe('CRUD Operations - Create', () => {
+ it('should add a single entity', () => {
+ const store = createEntityStore();
+
+ store.addOne({ id: '1', name: 'First', value: 1 });
+
+ expect(store.all).toHaveLength(1);
+ expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
+ });
+
+ it('should add multiple entities at once', () => {
+ const store = createEntityStore();
+
+ store.addMany([
+ { id: '1', name: 'First', value: 1 },
+ { id: '2', name: 'Second', value: 2 },
+ { id: '3', name: 'Third', value: 3 },
+ ]);
+
+ expect(store.all).toHaveLength(3);
+ });
+
+ it('should replace entity when adding with existing ID', () => {
+ const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]);
+
+ store.addOne({ id: '1', name: 'Updated', value: 2 });
+
+ expect(store.all).toHaveLength(1);
+ expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
+ });
+ });
+
+ describe('CRUD Operations - Update', () => {
+ it('should update an existing entity', () => {
+ const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]);
+
+ store.updateOne('1', { name: 'Updated' });
+
+ expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 1 });
+ });
+
+ it('should update multiple properties at once', () => {
+ const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]);
+
+ store.updateOne('1', { name: 'Updated', value: 2 });
+
+ expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
+ });
+
+ it('should do nothing when updating non-existent entity', () => {
+ const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]);
+
+ store.updateOne('999', { name: 'Updated' });
+
+ expect(store.all).toHaveLength(1);
+ expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
+ });
+
+ it('should preserve entity when no changes are provided', () => {
+ const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]);
+
+ store.updateOne('1', {});
+
+ expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
+ });
+ });
+
+ describe('CRUD Operations - Delete', () => {
+ it('should remove a single entity', () => {
+ const store = createEntityStore([
+ { id: '1', name: 'First', value: 1 },
+ { id: '2', name: 'Second', value: 2 },
+ ]);
+
+ store.removeOne('1');
+
+ expect(store.all).toHaveLength(1);
+ expect(store.getById('1')).toBeUndefined();
+ expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
+ });
+
+ it('should remove multiple entities', () => {
+ const store = createEntityStore([
+ { id: '1', name: 'First', value: 1 },
+ { id: '2', name: 'Second', value: 2 },
+ { id: '3', name: 'Third', value: 3 },
+ ]);
+
+ store.removeMany(['1', '3']);
+
+ expect(store.all).toHaveLength(1);
+ expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
+ });
+
+ it('should do nothing when removing non-existent entity', () => {
+ const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]);
+
+ store.removeOne('999');
+
+ expect(store.all).toHaveLength(1);
+ });
+
+ it('should handle empty array when removing many', () => {
+ const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]);
+
+ store.removeMany([]);
+
+ expect(store.all).toHaveLength(1);
+ });
+ });
+
+ describe('Bulk Operations', () => {
+ it('should set all entities, replacing existing', () => {
+ const store = createEntityStore([
+ { id: '1', name: 'First', value: 1 },
+ { id: '2', name: 'Second', value: 2 },
+ ]);
+
+ store.setAll([{ id: '3', name: 'Third', value: 3 }]);
+
+ expect(store.all).toHaveLength(1);
+ expect(store.getById('1')).toBeUndefined();
+ expect(store.getById('3')).toEqual({ id: '3', name: 'Third', value: 3 });
+ });
+
+ it('should clear all entities', () => {
+ const store = createEntityStore([
+ { id: '1', name: 'First', value: 1 },
+ { id: '2', name: 'Second', value: 2 },
+ ]);
+
+ store.clear();
+
+ expect(store.all).toEqual([]);
+ expect(store.all).toHaveLength(0);
+ });
+ });
+
+ describe('Reactivity with SvelteMap', () => {
+ it('should return reactive arrays', () => {
+ const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]);
+
+ // The all getter should return a fresh array (or reactive state)
+ const first = store.all;
+ const second = store.all;
+
+ // Both should have the same content
+ expect(first).toEqual(second);
+ });
+
+ it('should reflect changes in subsequent calls', () => {
+ const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]);
+
+ expect(store.all).toHaveLength(1);
+
+ store.addOne({ id: '2', name: 'Second', value: 2 });
+
+ expect(store.all).toHaveLength(2);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty initial array', () => {
+ const store = createEntityStore([]);
+
+ expect(store.all).toEqual([]);
+ });
+
+ it('should handle single entity', () => {
+ const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]);
+
+ expect(store.all).toHaveLength(1);
+ expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
+ });
+
+ it('should handle entities with complex objects', () => {
+ interface ComplexEntity extends Entity {
+ id: string;
+ data: {
+ nested: {
+ value: string;
+ };
+ };
+ tags: string[];
+ }
+
+ const entity: ComplexEntity = {
+ id: '1',
+ data: { nested: { value: 'test' } },
+ tags: ['a', 'b', 'c'],
+ };
+
+ const store = createEntityStore([entity]);
+
+ expect(store.getById('1')).toEqual(entity);
+ });
+
+ it('should handle numeric string IDs', () => {
+ const store = createEntityStore([
+ { id: '123', name: 'First', value: 1 },
+ { id: '456', name: 'Second', value: 2 },
+ ]);
+
+ expect(store.getById('123')).toEqual({ id: '123', name: 'First', value: 1 });
+ expect(store.getById('456')).toEqual({ id: '456', name: 'Second', value: 2 });
+ });
+
+ it('should handle UUID-like IDs', () => {
+ const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
+ const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
+
+ const store = createEntityStore([
+ { id: uuid1, name: 'First', value: 1 },
+ { id: uuid2, name: 'Second', value: 2 },
+ ]);
+
+ expect(store.getById(uuid1)).toEqual({ id: uuid1, name: 'First', value: 1 });
+ });
+ });
+
+ describe('Type Safety', () => {
+ it('should enforce Entity type with id property', () => {
+ // This test verifies type checking at compile time
+ const validEntity: TestEntity = { id: '1', name: 'Test', value: 1 };
+
+ const store = createEntityStore([validEntity]);
+
+ expect(store.getById('1')).toEqual(validEntity);
+ });
+
+ it('should work with different entity types', () => {
+ interface User extends Entity {
+ id: string;
+ name: string;
+ email: string;
+ }
+
+ interface Product extends Entity {
+ id: string;
+ title: string;
+ price: number;
+ }
+
+ const userStore = createEntityStore([
+ { id: 'u1', name: 'John', email: 'john@example.com' },
+ ]);
+
+ const productStore = createEntityStore([
+ { id: 'p1', title: 'Widget', price: 9.99 },
+ ]);
+
+ expect(userStore.getById('u1')?.email).toBe('john@example.com');
+ expect(productStore.getById('p1')?.price).toBe(9.99);
+ });
+ });
+
+ describe('Large Datasets', () => {
+ it('should handle large number of entities efficiently', () => {
+ const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
+ id: `id-${i}`,
+ name: `Entity ${i}`,
+ value: i,
+ }));
+
+ const store = createEntityStore(entities);
+
+ expect(store.all).toHaveLength(1000);
+ expect(store.getById('id-500')).toEqual({
+ id: 'id-500',
+ name: 'Entity 500',
+ value: 500,
+ });
+ });
+
+ it('should efficiently check existence in large dataset', () => {
+ const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
+ id: `id-${i}`,
+ name: `Entity ${i}`,
+ value: i,
+ }));
+
+ const store = createEntityStore(entities);
+
+ expect(store.has('id-999')).toBe(true);
+ expect(store.has('id-1000')).toBe(false);
+ });
+ });
+
+ describe('Method Chaining', () => {
+ it('should support chaining add operations', () => {
+ const store = createEntityStore();
+
+ store.addOne({ id: '1', name: 'First', value: 1 });
+ store.addOne({ id: '2', name: 'Second', value: 2 });
+ store.addOne({ id: '3', name: 'Third', value: 3 });
+
+ expect(store.all).toHaveLength(3);
+ });
+
+ it('should support chaining update operations', () => {
+ const store = createEntityStore([
+ { id: '1', name: 'First', value: 1 },
+ { id: '2', name: 'Second', value: 2 },
+ ]);
+
+ store.updateOne('1', { value: 10 });
+ store.updateOne('2', { value: 20 });
+
+ expect(store.getById('1')?.value).toBe(10);
+ expect(store.getById('2')?.value).toBe(20);
+ });
+ });
+});
diff --git a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts
new file mode 100644
index 0000000..9cbdac3
--- /dev/null
+++ b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts
@@ -0,0 +1,377 @@
+/** @vitest-environment jsdom */
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import { createPersistentStore } from './createPersistentStore.svelte';
+
+describe('createPersistentStore', () => {
+ let mockLocalStorage: Storage;
+ const testKey = 'test-store-key';
+
+ beforeEach(() => {
+ // Mock localStorage
+ const storeMap = new Map();
+
+ mockLocalStorage = {
+ get length() {
+ return storeMap.size;
+ },
+ clear() {
+ storeMap.clear();
+ },
+ getItem(key: string) {
+ return storeMap.get(key) ?? null;
+ },
+ setItem(key: string, value: string) {
+ storeMap.set(key, value);
+ },
+ removeItem(key: string) {
+ storeMap.delete(key);
+ },
+ key(index: number) {
+ return Array.from(storeMap.keys())[index] ?? null;
+ },
+ };
+
+ vi.stubGlobal('localStorage', mockLocalStorage);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe('Initialization', () => {
+ it('should create store with default value when localStorage is empty', () => {
+ const store = createPersistentStore(testKey, 'default');
+
+ expect(store.value).toBe('default');
+ });
+
+ it('should create store with value from localStorage', () => {
+ mockLocalStorage.setItem(testKey, JSON.stringify('stored value'));
+
+ const store = createPersistentStore(testKey, 'default');
+
+ expect(store.value).toBe('stored value');
+ });
+
+ it('should parse JSON from localStorage', () => {
+ const storedValue = { name: 'Test', count: 42 };
+ mockLocalStorage.setItem(testKey, JSON.stringify(storedValue));
+
+ const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
+
+ expect(store.value).toEqual(storedValue);
+ });
+
+ it('should use default value when localStorage has invalid JSON', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ mockLocalStorage.setItem(testKey, 'invalid json{');
+
+ const store = createPersistentStore(testKey, 'default');
+
+ expect(store.value).toBe('default');
+ expect(consoleSpy).toHaveBeenCalled();
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('Reading Values', () => {
+ it('should return current value via getter', () => {
+ const store = createPersistentStore(testKey, 'default');
+
+ expect(store.value).toBe('default');
+ });
+
+ it('should return updated value after setter', () => {
+ const store = createPersistentStore(testKey, 'default');
+
+ store.value = 'updated';
+
+ expect(store.value).toBe('updated');
+ });
+
+ it('should preserve type information', () => {
+ interface TestObject {
+ name: string;
+ count: number;
+ }
+ const defaultValue: TestObject = { name: 'Test', count: 0 };
+ const store = createPersistentStore(testKey, defaultValue);
+
+ expect(store.value.name).toBe('Test');
+ expect(store.value.count).toBe(0);
+ });
+ });
+
+ describe('Writing Values', () => {
+ it('should update value when set via setter', () => {
+ const store = createPersistentStore(testKey, 'default');
+
+ store.value = 'new value';
+
+ expect(store.value).toBe('new value');
+ });
+
+ it('should serialize objects to JSON', () => {
+ const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
+
+ store.value = { name: 'Updated', count: 42 };
+
+ // The value is updated in the store
+ expect(store.value).toEqual({ name: 'Updated', count: 42 });
+ });
+
+ it('should handle arrays', () => {
+ const store = createPersistentStore(testKey, []);
+
+ store.value = [1, 2, 3];
+
+ expect(store.value).toEqual([1, 2, 3]);
+ });
+
+ it('should handle booleans', () => {
+ const store = createPersistentStore(testKey, false);
+
+ store.value = true;
+
+ expect(store.value).toBe(true);
+ });
+
+ it('should handle null values', () => {
+ const store = createPersistentStore(testKey, null);
+
+ store.value = 'not null';
+
+ expect(store.value).toBe('not null');
+ });
+ });
+
+ describe('Clear Function', () => {
+ it('should reset value to default when clear is called', () => {
+ const store = createPersistentStore(testKey, 'default');
+
+ store.value = 'modified';
+ store.clear();
+
+ expect(store.value).toBe('default');
+ });
+
+ it('should work with object defaults', () => {
+ const defaultValue = { name: 'Default', count: 0 };
+ const store = createPersistentStore(testKey, defaultValue);
+
+ store.value = { name: 'Modified', count: 42 };
+ store.clear();
+
+ expect(store.value).toEqual(defaultValue);
+ });
+
+ it('should work with array defaults', () => {
+ const defaultValue = [1, 2, 3];
+ const store = createPersistentStore(testKey, defaultValue);
+
+ store.value = [4, 5, 6];
+ store.clear();
+
+ expect(store.value).toEqual(defaultValue);
+ });
+ });
+
+ describe('Type Support', () => {
+ it('should work with string type', () => {
+ const store = createPersistentStore(testKey, 'default');
+
+ store.value = 'test string';
+
+ expect(store.value).toBe('test string');
+ });
+
+ it('should work with number type', () => {
+ const store = createPersistentStore(testKey, 0);
+
+ store.value = 42;
+
+ expect(store.value).toBe(42);
+ });
+
+ it('should work with boolean type', () => {
+ const store = createPersistentStore(testKey, false);
+
+ store.value = true;
+
+ expect(store.value).toBe(true);
+ });
+
+ it('should work with object type', () => {
+ interface TestObject {
+ name: string;
+ value: number;
+ }
+ const defaultValue: TestObject = { name: 'Test', value: 0 };
+ const store = createPersistentStore(testKey, defaultValue);
+
+ store.value = { name: 'Updated', value: 42 };
+
+ expect(store.value.name).toBe('Updated');
+ expect(store.value.value).toBe(42);
+ });
+
+ it('should work with array type', () => {
+ const store = createPersistentStore(testKey, []);
+
+ store.value = ['a', 'b', 'c'];
+
+ expect(store.value).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should work with null type', () => {
+ const store = createPersistentStore(testKey, null);
+
+ expect(store.value).toBeNull();
+
+ store.value = 'not null';
+
+ expect(store.value).toBe('not null');
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty string', () => {
+ const store = createPersistentStore(testKey, 'default');
+
+ store.value = '';
+
+ expect(store.value).toBe('');
+ });
+
+ it('should handle zero number', () => {
+ const store = createPersistentStore(testKey, 100);
+
+ store.value = 0;
+
+ expect(store.value).toBe(0);
+ });
+
+ it('should handle false boolean', () => {
+ const store = createPersistentStore(testKey, true);
+
+ store.value = false;
+
+ expect(store.value).toBe(false);
+ });
+
+ it('should handle empty array', () => {
+ const store = createPersistentStore(testKey, [1, 2, 3]);
+
+ store.value = [];
+
+ expect(store.value).toEqual([]);
+ });
+
+ it('should handle empty object', () => {
+ const store = createPersistentStore>(testKey, { a: 1 });
+
+ store.value = {};
+
+ expect(store.value).toEqual({});
+ });
+
+ it('should handle special characters in string', () => {
+ const store = createPersistentStore(testKey, '');
+
+ const specialString = 'Hello "world"\nNew line\tTab';
+ store.value = specialString;
+
+ expect(store.value).toBe(specialString);
+ });
+
+ it('should handle unicode characters', () => {
+ const store = createPersistentStore(testKey, '');
+
+ store.value = 'Hello 世界 🌍';
+
+ expect(store.value).toBe('Hello 世界 🌍');
+ });
+ });
+
+ describe('Multiple Instances', () => {
+ it('should handle multiple stores with different keys', () => {
+ const store1 = createPersistentStore('key1', 'value1');
+ const store2 = createPersistentStore('key2', 'value2');
+
+ store1.value = 'updated1';
+ store2.value = 'updated2';
+
+ expect(store1.value).toBe('updated1');
+ expect(store2.value).toBe('updated2');
+ });
+
+ it('should keep stores independent', () => {
+ const store1 = createPersistentStore('key1', 'default1');
+ const store2 = createPersistentStore('key2', 'default2');
+
+ store1.clear();
+
+ expect(store1.value).toBe('default1');
+ expect(store2.value).toBe('default2');
+ });
+ });
+
+ describe('Complex Scenarios', () => {
+ it('should handle nested objects', () => {
+ interface NestedObject {
+ user: {
+ name: string;
+ settings: {
+ theme: string;
+ notifications: boolean;
+ };
+ };
+ }
+ const defaultValue: NestedObject = {
+ user: {
+ name: 'Test',
+ settings: { theme: 'light', notifications: true },
+ },
+ };
+ const store = createPersistentStore(testKey, defaultValue);
+
+ store.value = {
+ user: {
+ name: 'Updated',
+ settings: { theme: 'dark', notifications: false },
+ },
+ };
+
+ expect(store.value).toEqual({
+ user: {
+ name: 'Updated',
+ settings: { theme: 'dark', notifications: false },
+ },
+ });
+ });
+
+ it('should handle arrays of objects', () => {
+ interface Item {
+ id: number;
+ name: string;
+ }
+ const store = createPersistentStore- (testKey, []);
+
+ store.value = [
+ { id: 1, name: 'First' },
+ { id: 2, name: 'Second' },
+ { id: 3, name: 'Third' },
+ ];
+
+ expect(store.value).toHaveLength(3);
+ expect(store.value[0].name).toBe('First');
+ });
+ });
+});
diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts
new file mode 100644
index 0000000..c2fdaa5
--- /dev/null
+++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts
@@ -0,0 +1,550 @@
+/** @vitest-environment jsdom */
+import {
+ afterEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import { createVirtualizer } from './createVirtualizer.svelte';
+
+/**
+ * NOTE: Svelte 5 Runes Testing Limitations
+ *
+ * The createVirtualizer helper uses Svelte 5 runes ($state, $derived, $derived.by)
+ * which require a full Svelte runtime environment to work correctly. In unit tests
+ * with jsdom, these runes are stubbed and don't provide actual reactivity.
+ *
+ * These tests focus on:
+ * 1. API surface verification (methods, getters exist)
+ * 2. Initial state calculation
+ * 3. DOM integration (event listeners are attached)
+ * 4. Edge case handling
+ *
+ * For full reactivity testing, use browser-based tests with @vitest/browser-playwright
+ */
+
+// Mock ResizeObserver globally since it's not available in jsdom
+class MockResizeObserver {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+}
+
+globalThis.ResizeObserver = MockResizeObserver as any;
+
+// Mock requestAnimationFrame
+globalThis.requestAnimationFrame =
+ ((cb: FrameRequestCallback) =>
+ setTimeout(() => cb(performance.now()), 16) as unknown) as typeof requestAnimationFrame;
+globalThis.cancelAnimationFrame = vi.fn();
+
+/**
+ * Helper to create test data array
+ */
+function createTestData(count: number): string[] {
+ return Array.from({ length: count }, (_, i) => `Item ${i}`);
+}
+
+/**
+ * Helper to create a mock scrollable container element
+ */
+function createMockContainer(height = 500, scrollTop = 0): any {
+ const container = document.createElement('div');
+ Object.defineProperty(container, 'offsetHeight', {
+ value: height,
+ configurable: true,
+ writable: true,
+ });
+ Object.defineProperty(container, 'scrollTop', {
+ value: scrollTop,
+ writable: true,
+ configurable: true,
+ });
+ // Add scrollTo method for testing
+ container.scrollTo = vi.fn();
+ return container;
+}
+
+describe('createVirtualizer - Basic API and State', () => {
+ describe('Basic Initialization and API Surface', () => {
+ it('should initialize and return expected API surface', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 0,
+ data: [],
+ estimateSize: () => 50,
+ }));
+
+ // Verify API surface exists
+ expect(virtualizer).toHaveProperty('items');
+ expect(virtualizer).toHaveProperty('totalSize');
+ expect(virtualizer).toHaveProperty('scrollOffset');
+ expect(virtualizer).toHaveProperty('containerHeight');
+ expect(virtualizer).toHaveProperty('container');
+ expect(virtualizer).toHaveProperty('measureElement');
+ expect(virtualizer).toHaveProperty('scrollToIndex');
+ expect(virtualizer).toHaveProperty('scrollToOffset');
+
+ // Verify initial values
+ expect(virtualizer.items).toEqual([]);
+ expect(virtualizer.totalSize).toBe(0);
+ expect(virtualizer.scrollOffset).toBe(0);
+ expect(virtualizer.containerHeight).toBe(0);
+ });
+
+ it('should calculate correct totalSize for uniform item sizes', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 50,
+ }));
+
+ // 10 items * 50px each = 500px total
+ expect(virtualizer.totalSize).toBe(500);
+ });
+
+ it('should calculate correct totalSize for varying item sizes', () => {
+ const sizes = [50, 100, 150, 75, 125]; // Sum = 500
+ const virtualizer = createVirtualizer(() => ({
+ count: 5,
+ data: createTestData(5),
+ estimateSize: (i: number) => sizes[i],
+ }));
+
+ expect(virtualizer.totalSize).toBe(500);
+ });
+
+ it('should handle empty list (count = 0)', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 0,
+ data: [],
+ estimateSize: () => 50,
+ }));
+
+ expect(virtualizer.totalSize).toBe(0);
+ expect(virtualizer.items).toEqual([]);
+ });
+
+ it('should handle very large lists', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100000,
+ data: createTestData(100000),
+ estimateSize: () => 50,
+ }));
+
+ expect(virtualizer.totalSize).toBe(5000000); // 100000 * 50
+ });
+
+ it('should handle zero estimated size', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 0,
+ }));
+
+ expect(virtualizer.totalSize).toBe(0);
+ });
+ });
+
+ describe('Container Action', () => {
+ let cleanupHandlers: (() => void)[] = [];
+
+ afterEach(() => {
+ cleanupHandlers.forEach(cleanup => cleanup());
+ cleanupHandlers = [];
+ });
+
+ it('should attach container action and set up listeners', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const addEventListenerSpy = vi.spyOn(container, 'addEventListener');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ // Verify scroll listener was attached
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
+ 'scroll',
+ expect.any(Function),
+ { passive: true },
+ );
+ });
+
+ it('should update containerHeight when container is attached', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ expect(virtualizer.containerHeight).toBe(500);
+ });
+
+ it('should clean up listeners on destroy', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener');
+
+ const cleanup = virtualizer.container(container);
+ cleanup?.destroy?.();
+
+ expect(removeEventListenerSpy).toHaveBeenCalled();
+ });
+
+ it('should support window scrolling mode', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ useWindowScroll: true,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const windowAddSpy = vi.spyOn(window, 'addEventListener');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ // Should attach to window scroll
+ expect(windowAddSpy).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object));
+ expect(windowAddSpy).toHaveBeenCalledWith('resize', expect.any(Function));
+
+ windowAddSpy.mockRestore();
+ });
+ });
+
+ describe('scrollToIndex Method', () => {
+ let cleanupHandlers: (() => void)[] = [];
+
+ afterEach(() => {
+ cleanupHandlers.forEach(cleanup => cleanup());
+ cleanupHandlers = [];
+ });
+
+ it('should have scrollToIndex method that does not throw without container', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ // Should not throw when container is not attached
+ expect(() => virtualizer.scrollToIndex(50)).not.toThrow();
+ });
+
+ it('should scroll to specific index with container attached', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const scrollToSpy = vi.spyOn(container, 'scrollTo');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ virtualizer.scrollToIndex(10);
+
+ expect(scrollToSpy).toHaveBeenCalledWith({
+ top: expect.any(Number),
+ behavior: 'smooth',
+ });
+ });
+
+ it('should handle center alignment', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const scrollToSpy = vi.spyOn(container, 'scrollTo');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ virtualizer.scrollToIndex(10, 'center');
+
+ expect(scrollToSpy).toHaveBeenCalled();
+ });
+
+ it('should handle end alignment', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const scrollToSpy = vi.spyOn(container, 'scrollTo');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ virtualizer.scrollToIndex(10, 'end');
+
+ expect(scrollToSpy).toHaveBeenCalled();
+ });
+
+ it('should not scroll for out of bounds indices', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const scrollToSpy = vi.spyOn(container, 'scrollTo');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ // Negative index
+ virtualizer.scrollToIndex(-1);
+
+ // Index >= count
+ virtualizer.scrollToIndex(100);
+
+ // Should not have been called
+ expect(scrollToSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('scrollToOffset Method', () => {
+ let cleanupHandlers: (() => void)[] = [];
+
+ afterEach(() => {
+ cleanupHandlers.forEach(cleanup => cleanup());
+ cleanupHandlers = [];
+ });
+
+ it('should scroll to specific pixel offset', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const scrollToSpy = vi.spyOn(container, 'scrollTo');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ virtualizer.scrollToOffset(1000);
+
+ expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' });
+ });
+
+ it('should support smooth behavior', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const scrollToSpy = vi.spyOn(container, 'scrollTo');
+
+ const cleanup = virtualizer.container(container);
+ cleanupHandlers.push(() => cleanup?.destroy?.());
+
+ virtualizer.scrollToOffset(1000, 'smooth');
+
+ expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
+ });
+ });
+
+ describe('measureElement Action', () => {
+ it('should attach measureElement action to DOM element', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 50,
+ }));
+
+ const element = document.createElement('div');
+ element.dataset.index = '0';
+
+ // Should not throw when attaching measureElement
+ expect(() => {
+ const cleanup = virtualizer.measureElement(element);
+ cleanup?.destroy?.();
+ }).not.toThrow();
+ });
+
+ it('should clean up observer on destroy', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 50,
+ }));
+
+ const element = document.createElement('div');
+ element.dataset.index = '0';
+
+ const cleanup = virtualizer.measureElement(element);
+
+ // Should not throw when destroying
+ expect(() => cleanup?.destroy?.()).not.toThrow();
+ });
+
+ it('should handle multiple elements being measured', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 50,
+ }));
+
+ const elements = Array.from({ length: 5 }, (_, i) => {
+ const el = document.createElement('div');
+ el.dataset.index = String(i);
+ return el;
+ });
+
+ const cleanups = elements.map(el => virtualizer.measureElement(el));
+
+ // Should not throw when measuring multiple elements
+ expect(() => {
+ cleanups.forEach(cleanup => cleanup?.destroy?.());
+ }).not.toThrow();
+ });
+ });
+
+ describe('Options Handling', () => {
+ it('should use default overscan of 5', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ // Options with default overscan should work
+ expect(virtualizer).toHaveProperty('items');
+ });
+
+ it('should use custom overscan value', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ overscan: 10,
+ }));
+
+ expect(virtualizer).toHaveProperty('items');
+ });
+
+ it('should use index as default key when getItemKey is not provided', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 50,
+ }));
+
+ expect(virtualizer).toHaveProperty('items');
+ });
+
+ it('should use custom getItemKey when provided', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 50,
+ getItemKey: (i: number) => `custom-key-${i}`,
+ }));
+
+ expect(virtualizer).toHaveProperty('items');
+ });
+
+ it('should use custom scrollMargin when provided', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ scrollMargin: 100,
+ }));
+
+ expect(virtualizer).toHaveProperty('items');
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle single item list', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 1,
+ data: ['Item 0'],
+ estimateSize: () => 100,
+ }));
+
+ expect(virtualizer.totalSize).toBe(100);
+ });
+
+ it('should handle items larger than viewport', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 5,
+ data: createTestData(5),
+ estimateSize: () => 200, // Each item is 200px
+ }));
+
+ // Total size should still be calculated correctly
+ expect(virtualizer.totalSize).toBe(1000); // 5 * 200
+ });
+
+ it('should handle overscan larger than viewport', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => 50,
+ overscan: 100, // Very large overscan
+ }));
+
+ expect(virtualizer).toHaveProperty('items');
+ });
+
+ it('should handle negative estimated size (graceful degradation)', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 10,
+ data: createTestData(10),
+ estimateSize: () => -10,
+ }));
+
+ // Should calculate total size (may be negative, but shouldn't crash)
+ expect(virtualizer.totalSize).toBeLessThanOrEqual(0);
+ });
+ });
+
+ describe('Virtual Item Structure', () => {
+ it('should return items with correct structure when container is attached', () => {
+ const virtualizer = createVirtualizer(() => ({
+ count: 100,
+ data: createTestData(100),
+ estimateSize: () => 50,
+ }));
+
+ const container = createMockContainer(500, 0);
+ const cleanup = virtualizer.container(container);
+
+ // Items may be empty in test environment due to reactivity limitations
+ // but we verify the structure exists
+ expect(Array.isArray(virtualizer.items)).toBe(true);
+
+ cleanup?.destroy?.();
+ });
+ });
+});
diff --git a/src/shared/lib/storybook/MockIcon.svelte b/src/shared/lib/storybook/MockIcon.svelte
new file mode 100644
index 0000000..82c91ee
--- /dev/null
+++ b/src/shared/lib/storybook/MockIcon.svelte
@@ -0,0 +1,41 @@
+
+
+
+{#if Icon}
+ {@const __iconClass__ = cn('size-4', className)}
+
+
+{/if}
diff --git a/src/shared/lib/storybook/Providers.svelte b/src/shared/lib/storybook/Providers.svelte
new file mode 100644
index 0000000..3f26917
--- /dev/null
+++ b/src/shared/lib/storybook/Providers.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+
+ {@render children()}
+
+
diff --git a/src/shared/lib/storybook/index.ts b/src/shared/lib/storybook/index.ts
new file mode 100644
index 0000000..dc0137f
--- /dev/null
+++ b/src/shared/lib/storybook/index.ts
@@ -0,0 +1,24 @@
+/**
+ * ============================================================================
+ * STORYBOOK HELPERS
+ * ============================================================================
+ *
+ * Helper components and utilities for Storybook stories.
+ *
+ * ## Usage
+ *
+ * ```svelte
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @module
+ */
+
+export { default as MockIcon } from './MockIcon.svelte';
+export { default as Providers } from './Providers.svelte';
diff --git a/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
new file mode 100644
index 0000000..39d6044
--- /dev/null
+++ b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
@@ -0,0 +1,368 @@
+/** @vitest-environment jsdom */
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import { smoothScroll } from './smoothScroll';
+
+describe('smoothScroll', () => {
+ let mockAnchor: HTMLAnchorElement;
+ let mockTarget: HTMLElement;
+ let mockScrollIntoView: ReturnType;
+ let mockPushState: ReturnType;
+
+ beforeEach(() => {
+ // Mock scrollIntoView
+ mockScrollIntoView = vi.fn();
+ HTMLElement.prototype.scrollIntoView = mockScrollIntoView as (arg?: boolean | ScrollIntoViewOptions) => void;
+
+ // Mock history.pushState
+ mockPushState = vi.fn();
+ vi.stubGlobal('history', {
+ pushState: mockPushState,
+ });
+
+ // Create mock elements
+ mockAnchor = document.createElement('a');
+ mockAnchor.setAttribute('href', '#section-1');
+
+ mockTarget = document.createElement('div');
+ mockTarget.id = 'section-1';
+ document.body.appendChild(mockTarget);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ document.body.innerHTML = '';
+ });
+
+ describe('Basic Functionality', () => {
+ it('should be a function that returns an object with destroy method', () => {
+ const action = smoothScroll(mockAnchor);
+
+ expect(typeof action).toBe('object');
+ expect(typeof action.destroy).toBe('function');
+ });
+
+ it('should add click event listener to the anchor element', () => {
+ const addEventListenerSpy = vi.spyOn(mockAnchor, 'addEventListener');
+ smoothScroll(mockAnchor);
+
+ expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
+ addEventListenerSpy.mockRestore();
+ });
+
+ it('should remove click event listener when destroy is called', () => {
+ const action = smoothScroll(mockAnchor);
+ const removeEventListenerSpy = vi.spyOn(mockAnchor, 'removeEventListener');
+
+ action.destroy();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
+ removeEventListenerSpy.mockRestore();
+ });
+ });
+
+ describe('Click Handling', () => {
+ it('should prevent default behavior on click', () => {
+ const mockEvent = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ });
+ const preventDefaultSpy = vi.spyOn(mockEvent, 'preventDefault');
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ preventDefaultSpy.mockRestore();
+ });
+
+ it('should scroll to target element when clicked', () => {
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).toHaveBeenCalledWith({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ });
+
+ it('should update URL hash without jumping when clicked', () => {
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1');
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should do nothing when href attribute is missing', () => {
+ mockAnchor.removeAttribute('href');
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).not.toHaveBeenCalled();
+ expect(mockPushState).not.toHaveBeenCalled();
+ });
+
+ it('should do nothing when href is just "#"', () => {
+ mockAnchor.setAttribute('href', '#');
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).not.toHaveBeenCalled();
+ expect(mockPushState).not.toHaveBeenCalled();
+ });
+
+ it('should do nothing when target element does not exist', () => {
+ mockAnchor.setAttribute('href', '#non-existent');
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).not.toHaveBeenCalled();
+ expect(mockPushState).not.toHaveBeenCalled();
+ });
+
+ it('should handle empty href attribute', () => {
+ mockAnchor.setAttribute('href', '');
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Multiple Anchors', () => {
+ it('should work correctly with multiple anchor elements', () => {
+ const anchor1 = document.createElement('a');
+ anchor1.setAttribute('href', '#section-1');
+ const target1 = document.createElement('div');
+ target1.id = 'section-1';
+ document.body.appendChild(target1);
+
+ const anchor2 = document.createElement('a');
+ anchor2.setAttribute('href', '#section-2');
+ const target2 = document.createElement('div');
+ target2.id = 'section-2';
+ document.body.appendChild(target2);
+
+ const action1 = smoothScroll(anchor1);
+ const action2 = smoothScroll(anchor2);
+
+ const event1 = new MouseEvent('click', { bubbles: true, cancelable: true });
+ anchor1.dispatchEvent(event1);
+
+ expect(mockScrollIntoView).toHaveBeenCalledTimes(1);
+
+ const event2 = new MouseEvent('click', { bubbles: true, cancelable: true });
+ anchor2.dispatchEvent(event2);
+
+ expect(mockScrollIntoView).toHaveBeenCalledTimes(2);
+ expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-2');
+
+ // Cleanup
+ action1.destroy();
+ action2.destroy();
+ });
+ });
+
+ describe('Cleanup', () => {
+ it('should not trigger clicks after destroy is called', () => {
+ const action = smoothScroll(mockAnchor);
+
+ action.destroy();
+
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).not.toHaveBeenCalled();
+ expect(mockPushState).not.toHaveBeenCalled();
+ });
+
+ it('should allow multiple destroy calls without errors', () => {
+ const action = smoothScroll(mockAnchor);
+
+ expect(() => {
+ action.destroy();
+ action.destroy();
+ action.destroy();
+ }).not.toThrow();
+ });
+ });
+
+ describe('Scroll Options', () => {
+ it('should always use smooth behavior', () => {
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ behavior: 'smooth',
+ }),
+ );
+ });
+
+ it('should always use block: start', () => {
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ block: 'start',
+ }),
+ );
+ });
+ });
+
+ describe('Different Hash Formats', () => {
+ it('should handle simple hash like "#section"', () => {
+ const target = document.createElement('div');
+ target.id = 'section';
+ document.body.appendChild(target);
+
+ mockAnchor.setAttribute('href', '#section');
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).toHaveBeenCalled();
+ expect(mockPushState).toHaveBeenCalledWith(null, '', '#section');
+ });
+
+ it('should handle hash with multiple words like "#my-section"', () => {
+ const target = document.createElement('div');
+ target.id = 'my-section';
+ document.body.appendChild(target);
+
+ mockAnchor.setAttribute('href', '#my-section');
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).toHaveBeenCalled();
+ expect(mockPushState).toHaveBeenCalledWith(null, '', '#my-section');
+ });
+
+ it('should handle hash with numbers like "#section-1-2"', () => {
+ const target = document.createElement('div');
+ target.id = 'section-1-2';
+ document.body.appendChild(target);
+
+ mockAnchor.setAttribute('href', '#section-1-2');
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ smoothScroll(mockAnchor);
+ mockAnchor.dispatchEvent(mockEvent);
+
+ expect(mockScrollIntoView).toHaveBeenCalled();
+ expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1-2');
+ });
+ });
+
+ describe('Special Cases', () => {
+ it('should gracefully handle missing history.pushState', () => {
+ // Create a fresh test environment
+ const testAnchor = document.createElement('a');
+ testAnchor.href = '#test';
+ const testTarget = document.createElement('div');
+ testTarget.id = 'test';
+ document.body.appendChild(testTarget);
+
+ // Don't stub history - the action should still work without it
+ const action = smoothScroll(testAnchor);
+ const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
+
+ // Should not throw even if history.pushState might not exist
+ expect(() => testAnchor.dispatchEvent(mockEvent)).not.toThrow();
+
+ action.destroy();
+ testTarget.remove();
+ });
+ });
+
+ describe('Return Value', () => {
+ it('should return an action object compatible with Svelte use directive', () => {
+ const action = smoothScroll(mockAnchor);
+
+ expect(action).toHaveProperty('destroy');
+ expect(typeof action.destroy).toBe('function');
+ });
+
+ it('should allow chaining destroy calls', () => {
+ const action = smoothScroll(mockAnchor);
+
+ const result = action.destroy();
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('Real-World Scenarios', () => {
+ it('should handle table of contents navigation', () => {
+ const sections = ['intro', 'features', 'pricing', 'contact'];
+ sections.forEach(id => {
+ const section = document.createElement('section');
+ section.id = id;
+ document.body.appendChild(section);
+
+ const link = document.createElement('a');
+ link.href = `#${id}`;
+ document.body.appendChild(link);
+
+ const action = smoothScroll(link);
+
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ link.dispatchEvent(event);
+
+ expect(mockScrollIntoView).toHaveBeenCalled();
+
+ action.destroy();
+ });
+
+ expect(mockScrollIntoView).toHaveBeenCalledTimes(sections.length);
+ });
+
+ it('should work with back-to-top button', () => {
+ const topAnchor = document.createElement('a');
+ topAnchor.href = '#top';
+ document.body.appendChild(topAnchor);
+
+ const topElement = document.createElement('div');
+ topElement.id = 'top';
+ document.body.prepend(topElement);
+
+ const action = smoothScroll(topAnchor);
+
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ topAnchor.dispatchEvent(event);
+
+ expect(mockScrollIntoView).toHaveBeenCalled();
+
+ action.destroy();
+ });
+ });
+});
diff --git a/src/shared/lib/utils/splitArray/splitArray.test.ts b/src/shared/lib/utils/splitArray/splitArray.test.ts
new file mode 100644
index 0000000..f03bbf9
--- /dev/null
+++ b/src/shared/lib/utils/splitArray/splitArray.test.ts
@@ -0,0 +1,405 @@
+import {
+ describe,
+ expect,
+ it,
+} from 'vitest';
+import { splitArray } from './splitArray';
+
+describe('splitArray', () => {
+ describe('Basic Functionality', () => {
+ it('should split an array into two arrays based on callback', () => {
+ const input = [1, 2, 3, 4, 5];
+ const [pass, fail] = splitArray(input, n => n > 2);
+
+ expect(pass).toEqual([3, 4, 5]);
+ expect(fail).toEqual([1, 2]);
+ });
+
+ it('should return two arrays', () => {
+ const result = splitArray([1, 2, 3], () => true);
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+ expect(Array.isArray(result[0])).toBe(true);
+ expect(Array.isArray(result[1])).toBe(true);
+ });
+
+ it('should preserve original array', () => {
+ const input = [1, 2, 3, 4, 5];
+ const original = [...input];
+
+ splitArray(input, n => n % 2 === 0);
+
+ expect(input).toEqual(original);
+ });
+ });
+
+ describe('Empty Array', () => {
+ it('should return two empty arrays for empty input', () => {
+ const [pass, fail] = splitArray([], () => true);
+
+ expect(pass).toEqual([]);
+ expect(fail).toEqual([]);
+ });
+
+ it('should handle empty array with falsy callback', () => {
+ const [pass, fail] = splitArray([], () => false);
+
+ expect(pass).toEqual([]);
+ expect(fail).toEqual([]);
+ });
+ });
+
+ describe('All Pass', () => {
+ it('should put all elements in pass array when callback returns true for all', () => {
+ const input = [1, 2, 3, 4, 5];
+ const [pass, fail] = splitArray(input, () => true);
+
+ expect(pass).toEqual([1, 2, 3, 4, 5]);
+ expect(fail).toEqual([]);
+ });
+
+ it('should put all elements in pass array using always-true condition', () => {
+ const input = ['a', 'b', 'c'];
+ const [pass, fail] = splitArray(input, s => s.length > 0);
+
+ expect(pass).toEqual(['a', 'b', 'c']);
+ expect(fail).toEqual([]);
+ });
+ });
+
+ describe('All Fail', () => {
+ it('should put all elements in fail array when callback returns false for all', () => {
+ const input = [1, 2, 3, 4, 5];
+ const [pass, fail] = splitArray(input, () => false);
+
+ expect(pass).toEqual([]);
+ expect(fail).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it('should put all elements in fail array using always-false condition', () => {
+ const input = ['a', 'b', 'c'];
+ const [pass, fail] = splitArray(input, s => s.length > 10);
+
+ expect(pass).toEqual([]);
+ expect(fail).toEqual(['a', 'b', 'c']);
+ });
+ });
+
+ describe('Mixed Results', () => {
+ it('should split even and odd numbers', () => {
+ const input = [1, 2, 3, 4, 5, 6];
+ const [even, odd] = splitArray(input, n => n % 2 === 0);
+
+ expect(even).toEqual([2, 4, 6]);
+ expect(odd).toEqual([1, 3, 5]);
+ });
+
+ it('should split positive and negative numbers', () => {
+ const input = [-3, -2, -1, 0, 1, 2, 3];
+ const [positive, negative] = splitArray(input, n => n >= 0);
+
+ expect(positive).toEqual([0, 1, 2, 3]);
+ expect(negative).toEqual([-3, -2, -1]);
+ });
+
+ it('should split strings by length', () => {
+ const input = ['a', 'ab', 'abc', 'abcd'];
+ const [long, short] = splitArray(input, s => s.length >= 3);
+
+ expect(long).toEqual(['abc', 'abcd']);
+ expect(short).toEqual(['a', 'ab']);
+ });
+
+ it('should split objects by property', () => {
+ interface Item {
+ id: number;
+ active: boolean;
+ }
+ const input: Item[] = [
+ { id: 1, active: true },
+ { id: 2, active: false },
+ { id: 3, active: true },
+ { id: 4, active: false },
+ ];
+ const [active, inactive] = splitArray(input, item => item.active);
+
+ expect(active).toEqual([
+ { id: 1, active: true },
+ { id: 3, active: true },
+ ]);
+ expect(inactive).toEqual([
+ { id: 2, active: false },
+ { id: 4, active: false },
+ ]);
+ });
+ });
+
+ describe('Type Safety', () => {
+ it('should work with number arrays', () => {
+ const [pass, fail] = splitArray([1, 2, 3], n => n > 1);
+
+ expect(pass).toEqual([2, 3]);
+ expect(fail).toEqual([1]);
+
+ // Type check - should be numbers
+ const sum = pass[0] + pass[1];
+ expect(sum).toBe(5);
+ });
+
+ it('should work with string arrays', () => {
+ const [pass, fail] = splitArray(['a', 'bb', 'ccc'], s => s.length > 1);
+
+ expect(pass).toEqual(['bb', 'ccc']);
+ expect(fail).toEqual(['a']);
+
+ // Type check - should be strings
+ const concatenated = pass.join('');
+ expect(concatenated).toBe('bbccc');
+ });
+
+ it('should work with boolean arrays', () => {
+ const [pass, fail] = splitArray([true, false, true], b => b);
+
+ expect(pass).toEqual([true, true]);
+ expect(fail).toEqual([false]);
+ });
+
+ it('should work with generic objects', () => {
+ interface Person {
+ name: string;
+ age: number;
+ }
+ const people: Person[] = [
+ { name: 'Alice', age: 25 },
+ { name: 'Bob', age: 30 },
+ { name: 'Charlie', age: 20 },
+ ];
+ const [adults, minors] = splitArray(people, p => p.age >= 21);
+
+ expect(adults).toEqual([
+ { name: 'Alice', age: 25 },
+ { name: 'Bob', age: 30 },
+ ]);
+ expect(minors).toEqual([{ name: 'Charlie', age: 20 }]);
+ });
+
+ it('should work with null and undefined', () => {
+ const input = [null, undefined, 1, 0, ''];
+ const [truthy, falsy] = splitArray(input, item => !!item);
+
+ expect(truthy).toEqual([1]);
+ expect(falsy).toEqual([null, undefined, 0, '']);
+ });
+ });
+
+ describe('Callback Functions', () => {
+ it('should support arrow function syntax', () => {
+ const [pass, fail] = splitArray([1, 2, 3, 4], x => x % 2 === 0);
+
+ expect(pass).toEqual([2, 4]);
+ expect(fail).toEqual([1, 3]);
+ });
+
+ it('should support regular function syntax', () => {
+ const [pass, fail] = splitArray([1, 2, 3, 4], function(x) {
+ return x % 2 === 0;
+ });
+
+ expect(pass).toEqual([2, 4]);
+ expect(fail).toEqual([1, 3]);
+ });
+
+ it('should support inline conditions', () => {
+ const input = [1, 2, 3, 4, 5];
+ const [greaterThan3, others] = splitArray(input, x => x > 3);
+
+ expect(greaterThan3).toEqual([4, 5]);
+ expect(others).toEqual([1, 2, 3]);
+ });
+ });
+
+ describe('Order Preservation', () => {
+ it('should maintain order within each resulting array', () => {
+ const input = [5, 1, 4, 2, 3];
+ const [greaterThan2, lessOrEqual] = splitArray(input, n => n > 2);
+
+ expect(greaterThan2).toEqual([5, 4, 3]);
+ expect(lessOrEqual).toEqual([1, 2]);
+ });
+
+ it('should preserve relative order for complex objects', () => {
+ interface Item {
+ id: number;
+ value: string;
+ }
+ const input: Item[] = [
+ { id: 1, value: 'a' },
+ { id: 2, value: 'b' },
+ { id: 3, value: 'c' },
+ { id: 4, value: 'd' },
+ ];
+ const [evenIds, oddIds] = splitArray(input, item => item.id % 2 === 0);
+
+ expect(evenIds).toEqual([
+ { id: 2, value: 'b' },
+ { id: 4, value: 'd' },
+ ]);
+ expect(oddIds).toEqual([
+ { id: 1, value: 'a' },
+ { id: 3, value: 'c' },
+ ]);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle single element array (truthy)', () => {
+ const [pass, fail] = splitArray([1], () => true);
+
+ expect(pass).toEqual([1]);
+ expect(fail).toEqual([]);
+ });
+
+ it('should handle single element array (falsy)', () => {
+ const [pass, fail] = splitArray([1], () => false);
+
+ expect(pass).toEqual([]);
+ expect(fail).toEqual([1]);
+ });
+
+ it('should handle two element array', () => {
+ const [pass, fail] = splitArray([1, 2], n => n === 1);
+
+ expect(pass).toEqual([1]);
+ expect(fail).toEqual([2]);
+ });
+
+ it('should handle array with duplicate values', () => {
+ const [pass, fail] = splitArray([1, 1, 2, 2, 1, 1], n => n === 1);
+
+ expect(pass).toEqual([1, 1, 1, 1]);
+ expect(fail).toEqual([2, 2]);
+ });
+
+ it('should handle zero values', () => {
+ const [truthy, falsy] = splitArray([0, 1, 0, 2], Boolean);
+
+ expect(truthy).toEqual([1, 2]);
+ expect(falsy).toEqual([0, 0]);
+ });
+
+ it('should handle NaN values', () => {
+ const input = [1, NaN, 2, NaN, 3];
+ const [numbers, nans] = splitArray(input, n => !Number.isNaN(n));
+
+ expect(numbers).toEqual([1, 2, 3]);
+ expect(nans).toEqual([NaN, NaN]);
+ });
+ });
+
+ describe('Large Arrays', () => {
+ it('should handle large arrays efficiently', () => {
+ const largeArray = Array.from({ length: 10000 }, (_, i) => i);
+ const [even, odd] = splitArray(largeArray, n => n % 2 === 0);
+
+ expect(even).toHaveLength(5000);
+ expect(odd).toHaveLength(5000);
+ expect(even[0]).toBe(0);
+ expect(even[9999]).toBeUndefined();
+ expect(even[4999]).toBe(9998);
+ });
+
+ it('should maintain correct results for all elements in large array', () => {
+ const input = Array.from({ length: 1000 }, (_, i) => i);
+ const [multiplesOf3, others] = splitArray(input, n => n % 3 === 0);
+
+ // Verify counts
+ expect(multiplesOf3).toHaveLength(334); // 0, 3, 6, ..., 999
+ expect(others).toHaveLength(666);
+
+ // Verify all multiples of 3 are in correct array
+ multiplesOf3.forEach(n => {
+ expect(n % 3).toBe(0);
+ });
+
+ // Verify no multiples of 3 are in others
+ others.forEach(n => {
+ expect(n % 3).not.toBe(0);
+ });
+ });
+ });
+
+ describe('Real-World Use Cases', () => {
+ it('should separate valid from invalid emails', () => {
+ const emails = [
+ 'valid@example.com',
+ 'invalid',
+ 'another@test.org',
+ 'not-an-email',
+ 'user@domain.co.uk',
+ ];
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ const [valid, invalid] = splitArray(emails, email => emailRegex.test(email));
+
+ expect(valid).toEqual([
+ 'valid@example.com',
+ 'another@test.org',
+ 'user@domain.co.uk',
+ ]);
+ expect(invalid).toEqual(['invalid', 'not-an-email']);
+ });
+
+ it('should separate completed from pending tasks', () => {
+ interface Task {
+ id: number;
+ title: string;
+ completed: boolean;
+ }
+ const tasks: Task[] = [
+ { id: 1, title: 'Task 1', completed: true },
+ { id: 2, title: 'Task 2', completed: false },
+ { id: 3, title: 'Task 3', completed: true },
+ { id: 4, title: 'Task 4', completed: false },
+ ];
+ const [completed, pending] = splitArray(tasks, task => task.completed);
+
+ expect(completed).toHaveLength(2);
+ expect(pending).toHaveLength(2);
+ expect(completed.every(t => t.completed)).toBe(true);
+ expect(pending.every(t => !t.completed)).toBe(true);
+ });
+
+ it('should separate adults from minors by age', () => {
+ interface Person {
+ name: string;
+ age: number;
+ }
+ const people: Person[] = [
+ { name: 'Alice', age: 17 },
+ { name: 'Bob', age: 25 },
+ { name: 'Charlie', age: 16 },
+ { name: 'Diana', age: 30 },
+ { name: 'Eve', age: 18 },
+ ];
+ const [adults, minors] = splitArray(people, person => person.age >= 18);
+
+ expect(adults).toEqual([
+ { name: 'Bob', age: 25 },
+ { name: 'Diana', age: 30 },
+ { name: 'Eve', age: 18 },
+ ]);
+ expect(minors).toEqual([
+ { name: 'Alice', age: 17 },
+ { name: 'Charlie', age: 16 },
+ ]);
+ });
+
+ it('should separate truthy from falsy values', () => {
+ const mixed = [0, 1, false, true, '', 'hello', null, undefined, [], [0]];
+ const [truthy, falsy] = splitArray(mixed, Boolean);
+
+ expect(truthy).toEqual([1, true, 'hello', [], [0]]);
+ expect(falsy).toEqual([0, false, '', null, undefined]);
+ });
+ });
+});
diff --git a/src/shared/lib/utils/throttle/throttle.test.ts b/src/shared/lib/utils/throttle/throttle.test.ts
new file mode 100644
index 0000000..b22d5ac
--- /dev/null
+++ b/src/shared/lib/utils/throttle/throttle.test.ts
@@ -0,0 +1,319 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
+import { throttle } from './throttle';
+
+describe('throttle', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ describe('Basic Functionality', () => {
+ it('should execute function immediately on first call', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 300);
+
+ throttled('arg1', 'arg2');
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
+ });
+
+ it('should throttle subsequent calls within wait period', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 300);
+
+ throttled('first');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ // Call again within wait period - should not execute
+ throttled('second');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ // Advance time past wait period
+ vi.advanceTimersByTime(300);
+
+ // Now trailing call executes
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ expect(mockFn).toHaveBeenLastCalledWith('second');
+ });
+
+ it('should allow execution after wait period expires', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled('first');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(100);
+ throttled('second');
+
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Trailing Edge Execution', () => {
+ it('should execute throttled call after wait period', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 300);
+
+ throttled('first');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ throttled('second');
+ throttled('third');
+ // Still 1 because these are throttled
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(300);
+
+ // Trailing call executes
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ expect(mockFn).toHaveBeenLastCalledWith('third');
+ });
+
+ it('should cancel previous trailing call on new invocation', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled('first');
+ vi.advanceTimersByTime(50);
+ throttled('second');
+ vi.advanceTimersByTime(30);
+ throttled('third');
+
+ // At this point only first call executed
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ // Advance to trigger trailing call
+ vi.advanceTimersByTime(70);
+
+ // First call + trailing (third)
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ expect(mockFn).toHaveBeenLastCalledWith('third');
+ });
+ });
+
+ describe('Arguments and Context', () => {
+ it('should pass the correct arguments from the last throttled call', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled('arg1', 'arg2');
+ vi.advanceTimersByTime(50);
+ throttled('arg3', 'arg4');
+ vi.advanceTimersByTime(100);
+
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ expect(mockFn).toHaveBeenLastCalledWith('arg3', 'arg4');
+ });
+
+ it('should handle no arguments', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled();
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle single argument', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled('single');
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ expect(mockFn).toHaveBeenCalledWith('single');
+ });
+
+ it('should handle multiple arguments', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled(1, 2, 3, 'four', { five: 5 });
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ expect(mockFn).toHaveBeenCalledWith(1, 2, 3, 'four', { five: 5 });
+ });
+ });
+
+ describe('Timing', () => {
+ it('should handle very short wait times (1ms)', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 1);
+
+ throttled('first');
+ vi.advanceTimersByTime(1);
+ throttled('second');
+
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle longer wait times (1000ms)', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 1000);
+
+ throttled('first');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(500);
+ throttled('second');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(500);
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Rapid Calls', () => {
+ it('should handle rapid successive calls correctly', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled('call1');
+ vi.advanceTimersByTime(10);
+ throttled('call2');
+ vi.advanceTimersByTime(10);
+ throttled('call3');
+ vi.advanceTimersByTime(10);
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ expect(mockFn).toHaveBeenCalledWith('call1');
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ expect(mockFn).toHaveBeenLastCalledWith('call3');
+ });
+
+ it('should execute function at most once per wait period plus trailing', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ // Make many rapid calls
+ for (let i = 0; i < 10; i++) {
+ vi.advanceTimersByTime(5);
+ throttled(`call${i}`);
+ }
+
+ // Should execute immediately
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(100);
+
+ // Plus trailing call
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle zero wait time', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 0);
+
+ throttled('first');
+
+ // With zero wait time, function may execute synchronously
+ // but the internal timing may still prevent immediate re-execution
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle being called at exactly wait boundary', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 100);
+
+ throttled('first');
+ vi.advanceTimersByTime(100);
+ throttled('second');
+
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Return Value', () => {
+ it('should not return anything (void)', () => {
+ const mockFn = vi.fn().mockReturnValue('result');
+ const throttled = throttle(mockFn, 100);
+
+ const result = throttled('arg');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('Real-World Scenarios', () => {
+ it('should throttle scroll-like events', () => {
+ const mockFn = vi.fn();
+ const throttledScroll = throttle(mockFn, 100);
+
+ throttledScroll();
+ vi.advanceTimersByTime(10);
+ throttledScroll();
+ vi.advanceTimersByTime(10);
+ throttledScroll();
+ vi.advanceTimersByTime(10);
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(100);
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('should throttle resize-like events', () => {
+ const mockFn = vi.fn();
+ const throttledResize = throttle(mockFn, 200);
+
+ throttledResize();
+ for (let i = 1; i <= 10; i++) {
+ vi.advanceTimersByTime(10);
+ throttledResize();
+ }
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(200);
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Comparison Characteristics', () => {
+ it('should execute immediately on first call', () => {
+ const mockFn = vi.fn();
+ const throttled = throttle(mockFn, 300);
+
+ throttled('first');
+
+ // Throttle executes immediately (unlike debounce)
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should allow execution during continuous calls at intervals', () => {
+ const mockFn = vi.fn();
+ const waitTime = 100;
+ const throttled = throttle(mockFn, waitTime);
+
+ throttled('call1');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(waitTime);
+ throttled('call2');
+ expect(mockFn).toHaveBeenCalledTimes(2);
+
+ vi.advanceTimersByTime(waitTime);
+ throttled('call3');
+ expect(mockFn).toHaveBeenCalledTimes(3);
+ });
+ });
+});
diff --git a/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte b/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte
index f1ac0c6..232fadc 100644
--- a/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte
+++ b/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte
@@ -10,7 +10,8 @@ const { Story } = defineMeta({
parameters: {
docs: {
description: {
- component: 'ComboControl with input field and slider',
+ component:
+ 'ComboControl with input field and slider. Simplified version without increase/decrease buttons.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
@@ -26,18 +27,85 @@ const { Story } = defineMeta({
control: 'text',
description: 'Label for the ComboControl',
},
+ control: {
+ control: 'object',
+ description: 'TypographyControl instance managing the value and bounds',
+ },
},
});
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/shared/ui/IconButton/IconButton.stories.svelte b/src/shared/ui/IconButton/IconButton.stories.svelte
new file mode 100644
index 0000000..b485dde
--- /dev/null
+++ b/src/shared/ui/IconButton/IconButton.stories.svelte
@@ -0,0 +1,101 @@
+
+
+
+
+{#snippet chevronRightIcon({ className }: { className: string })}
+
+{/snippet}
+
+{#snippet chevronLeftIcon({ className }: { className: string })}
+
+{/snippet}
+
+{#snippet plusIcon({ className }: { className: string })}
+
+{/snippet}
+
+{#snippet minusIcon({ className }: { className: string })}
+
+{/snippet}
+
+{#snippet settingsIcon({ className }: { className: string })}
+
+{/snippet}
+
+{#snippet xIcon({ className }: { className: string })}
+
+{/snippet}
+
+
+ console.log('Default clicked')}>
+ {#snippet icon({ className })}
+
+ {/snippet}
+
+
+
+
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+
+
+
diff --git a/src/shared/ui/IconButton/IconButton.svelte b/src/shared/ui/IconButton/IconButton.svelte
index 54e0d9d..bb2b2b6 100644
--- a/src/shared/ui/IconButton/IconButton.svelte
+++ b/src/shared/ui/IconButton/IconButton.svelte
@@ -41,7 +41,7 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
size="icon"
{...rest}
>
- {@render icon({
+ {@render icon?.({
className: cn(
'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-2 group-active:scale-90 group-disabled:stroke-transparent',
rotation === 'clockwise'
diff --git a/src/shared/ui/Section/Section.stories.svelte b/src/shared/ui/Section/Section.stories.svelte
new file mode 100644
index 0000000..9e58126
--- /dev/null
+++ b/src/shared/ui/Section/Section.stories.svelte
@@ -0,0 +1,475 @@
+
+
+
+
+{#snippet searchIcon({ className }: { className?: string })}
+
+{/snippet}
+
+{#snippet welcomeTitle({ className }: { className?: string })}
+ Welcome
+{/snippet}
+
+{#snippet welcomeContent({ className }: { className?: string })}
+
+
+ This is the default section layout with a title and content area. The section uses a 2-column grid layout
+ with the title on the left and content on the right.
+
+
+{/snippet}
+
+{#snippet stickyTitle({ className }: { className?: string })}
+ Sticky Title
+{/snippet}
+
+{#snippet stickyContent({ className }: { className?: string })}
+
+
+ This section has a sticky title that stays fixed while you scroll through the content. Try scrolling down to
+ see the effect.
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
+
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
+ consequat.
+
+
+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
+
+
+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollim anim id est
+ laborum.
+
+
+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.
+
+
+
+{/snippet}
+
+{#snippet searchFontsTitle({ className }: { className?: string })}
+ Search Fonts
+{/snippet}
+
+{#snippet searchFontsDescription({ className }: { className?: string })}
+ Find your perfect typeface
+{/snippet}
+
+{#snippet searchFontsContent({ className }: { className?: string })}
+
+
+ Use the search bar to find fonts by name, or use the filters to browse by category, subset, or provider.
+
+
+{/snippet}
+
+{#snippet longContentTitle({ className }: { className?: string })}
+ Long Content
+{/snippet}
+
+{#snippet longContent({ className }: { className?: string })}
+
+
+
+ This section demonstrates how the sticky title behaves with longer content. As you scroll through this
+ content, the title remains visible at the top of the viewport.
+
+
+ Content block 1
+
+
+ The sticky position is achieved using CSS position: sticky with a configurable top offset. This is
+ useful for long sections where you want to maintain context while scrolling.
+
+
+ Content block 2
+
+
+ The Intersection Observer API is used to detect when the section title scrolls out of view, triggering
+ the optional onTitleStatusChange callback.
+
+
+ Content block 3
+
+
+
+{/snippet}
+
+{#snippet minimalTitle({ className }: { className?: string })}
+ Minimal Section
+{/snippet}
+
+{#snippet minimalContent({ className }: { className?: string })}
+
+
+ A minimal section without index, icon, or description. Just the essentials.
+
+
+{/snippet}
+
+{#snippet customTitle({ className }: { className?: string })}
+ Custom Content
+{/snippet}
+
+{#snippet customDescription({ className }: { className?: string })}
+ With interactive elements
+{/snippet}
+
+{#snippet customContent({ className }: { className?: string })}
+
+
+
+
Card 1
+
Some content here
+
+
+
Card 2
+
More content here
+
+
+
+{/snippet}
+
+
+
+
+ {#snippet title({ className })}
+ Welcome
+ {/snippet}
+ {#snippet content({ className })}
+
+
+ This is the default section layout with a title and content area. The section uses a 2-column
+ grid layout with the title on the left and content on the right.
+
+
+ {/snippet}
+
+
+
+
+
+
+
+
+ {#snippet title({ className })}
+ Sticky Title
+ {/snippet}
+ {#snippet content({ className })}
+
+
+ This section has a sticky title that stays fixed while you scroll through the content. Try
+ scrolling down to see the effect.
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
+ incididunt ut labore et dolore magna aliqua.
+
+
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
+ commodo consequat.
+
+
+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
+ nulla pariatur.
+
+
+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt
+ mollim anim id est laborum.
+
+
+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
+ laudantium.
+
+
+
+ {/snippet}
+
+
+
+
+
+
+
+
+ {#snippet title({ className })}
+ Search Fonts
+ {/snippet}
+ {#snippet icon({ className })}
+
+ {/snippet}
+ {#snippet description({ className })}
+ Find your perfect typeface
+ {/snippet}
+ {#snippet content({ className })}
+
+
+ Use the search bar to find fonts by name, or use the filters to browse by category, subset, or
+ provider.
+
+
+ {/snippet}
+
+
+
+
+
+
+
+ {#snippet title({ className })}
+ Typography
+ {/snippet}
+ {#snippet icon({ className })}
+
+ {/snippet}
+ {#snippet description({ className })}
+ Adjust text appearance
+ {/snippet}
+ {#snippet content({ className })}
+
+
+ Control the size, weight, and line height of your text. These settings apply across the
+ comparison view.
+
+
+ {/snippet}
+
+
+
+ {#snippet title({ className })}
+ Font Search
+ {/snippet}
+ {#snippet icon({ className })}
+
+ {/snippet}
+ {#snippet description({ className })}
+ Browse available typefaces
+ {/snippet}
+ {#snippet content({ className })}
+
+
+ Search through our collection of fonts from Google Fonts and Fontshare. Use filters to narrow
+ down your selection.
+
+
+ {/snippet}
+
+
+
+ {#snippet title({ className })}
+ Sample List
+ {/snippet}
+ {#snippet icon({ className })}
+
+ {/snippet}
+ {#snippet description({ className })}
+ Preview font samples
+ {/snippet}
+ {#snippet content({ className })}
+
+
+ Browse through font samples with your custom text. The list is virtualized for optimal
+ performance.
+
+
+ {/snippet}
+
+
+
+
+
+
+
+ {#snippet title({ className })}
+ Long Content
+ {/snippet}
+ {#snippet content({ className })}
+
+
+
+ This section demonstrates how the sticky title behaves with longer content. As you scroll
+ through this content, the title remains visible at the top of the viewport.
+
+
+ Content block 1
+
+
+ The sticky position is achieved using CSS position: sticky with a configurable top offset.
+ This is useful for long sections where you want to maintain context while scrolling.
+
+
+ Content block 2
+
+
+ The Intersection Observer API is used to detect when the section title scrolls out of view,
+ triggering the optional onTitleStatusChange callback.
+
+
+ Content block 3
+
+
+
+ {/snippet}
+
+
+
+
+
+
+
+ {#snippet title({ className })}
+ Minimal Section
+ {/snippet}
+ {#snippet content({ className })}
+
+
+ A minimal section without index, icon, or description. Just the essentials.
+
+
+ {/snippet}
+
+
+
+
+
+
+
+ {#snippet title({ className })}
+ Custom Content
+ {/snippet}
+ {#snippet description({ className })}
+ With interactive elements
+ {/snippet}
+ {#snippet content({ className })}
+
+
+
+
Card 1
+
Some content here
+
+
+
Card 2
+
More content here
+
+
+
+ {/snippet}
+
+
+
diff --git a/src/shared/ui/Slider/Slider.stories.svelte b/src/shared/ui/Slider/Slider.stories.svelte
index 9198125..3495c3f 100644
--- a/src/shared/ui/Slider/Slider.stories.svelte
+++ b/src/shared/ui/Slider/Slider.stories.svelte
@@ -31,21 +31,26 @@ const { Story } = defineMeta({
control: 'number',
description: 'Step size for value increments',
},
+ label: {
+ control: 'text',
+ description: 'Optional label displayed inline on the track',
+ },
},
});
-
-
+
+
-
-
+
+
+
+
+
+
diff --git a/src/shared/ui/VirtualList/VirtualList.stories.svelte b/src/shared/ui/VirtualList/VirtualList.stories.svelte
index 1e3064e..cb835d6 100644
--- a/src/shared/ui/VirtualList/VirtualList.stories.svelte
+++ b/src/shared/ui/VirtualList/VirtualList.stories.svelte
@@ -38,27 +38,31 @@ const { Story } = defineMeta({
-
- {#snippet children({ item })}
- {item}
- {/snippet}
-
+
+
+ {#snippet children({ item })}
+ {item}
+ {/snippet}
+
+
-
-
- {#snippet children({ item })}
- {item}
- {/snippet}
-
+
+
+
+ {#snippet children({ item })}
+ {item}
+ {/snippet}
+
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte
new file mode 100644
index 0000000..e46e755
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte
@@ -0,0 +1,136 @@
+
+
+
+
+
+ {@const _ = (comparisonStore.fontA = mockArial, comparisonStore.fontB = mockGeorgia)}
+
+
+
+
+
+
+ {@const _ = (comparisonStore.fontA = undefined, comparisonStore.fontB = undefined)}
+
+
+
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte
deleted file mode 100644
index 03135fd..0000000
--- a/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
- {#each chars as char, i}
-
- {char}
-
- {/each}
-
-
-
diff --git a/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte b/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte
new file mode 100644
index 0000000..c950054
--- /dev/null
+++ b/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Filters panel is open and visible
+
+
+
+
+
+
+
+
+
Filters panel is closed - click the slider icon to open
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Font Browser
+
Search and filter through our collection of fonts
+
+
+
+
+
+
+
+
Font results will appear here...
+
+
+
+
+
+
+
+
+ Demo Note: Click the slider icon to toggle filters. Use the
+ filter categories to select options. Use the filter controls to reset or apply your selections.
+
+
+
+
+
+
+
+
+
+
Resize browser to see responsive layout
+
+
+
+
+
+
diff --git a/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte b/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte
new file mode 100644
index 0000000..14a354a
--- /dev/null
+++ b/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
Font Samples
+
Scroll to see more fonts and load additional pages
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Typography Controls
+
Scroll down to see the typography menu appear
+
+
+
+
+
+
+
+
+
+
+
Custom Sample Text
+
Edit the text in any card to change all samples
+
+
+
+
+
+
+
+
+
+
+
Paginated List
+
Fonts load automatically as you scroll
+
+
+
+
+
+
+
+
+
+
+
Responsive Sample List
+
Resize browser to see responsive behavior
+
+
+
+
+