feat: storybook cases and mocks

This commit is contained in:
Ilia Mashkov
2026-02-19 13:58:12 +03:00
parent 9d1f59d819
commit da79dd2e35
22 changed files with 3047 additions and 45 deletions

View File

@@ -0,0 +1,29 @@
<!--
Component: Decorator
Global Storybook decorator that wraps all stories with necessary providers.
This provides:
- ResponsiveManager context for breakpoint tracking
- TooltipProvider for shadcn Tooltip components
-->
<script lang="ts">
import { createResponsiveManager } from '$shared/lib';
import type { ResponsiveManager } from '$shared/lib';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { setContext } from 'svelte';
interface Props {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
// Create and provide responsive context
const responsiveManager = createResponsiveManager();
$effect(() => responsiveManager.init());
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
{@render children()}
</TooltipProvider>

View File

@@ -7,9 +7,9 @@ interface Props {
let { children, width = 'max-w-3xl' }: Props = $props(); let { children, width = 'max-w-3xl' }: Props = $props();
</script> </script>
<div class="flex min-h-screen w-full items-center justify-center bg-slate-50 p-8"> <div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
<div class="w-full bg-white shadow-xl ring-1 ring-slate-200 rounded-xl p-12 {width}"> <div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
<div class="relative flex justify-center items-center"> <div class="relative flex justify-center items-center text-foreground">
{@render children()} {@render children()}
</div> </div>
</div> </div>

View File

@@ -21,7 +21,8 @@ const config: StorybookConfig = {
{ {
name: '@storybook/addon-svelte-csf', name: '@storybook/addon-svelte-csf',
options: { options: {
legacyTemplate: true, // Enables the legacy template syntax // Use modern template syntax for better performance
legacyTemplate: false,
}, },
}, },
'@chromatic-com/storybook', '@chromatic-com/storybook',

View File

@@ -0,0 +1,13 @@
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
/>
<style>
body {
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
}
</style>

View File

@@ -1,4 +1,5 @@
import type { Preview } from '@storybook/svelte-vite'; import type { Preview } from '@storybook/svelte-vite';
import Decorator from './Decorator.svelte';
import StoryStage from './StoryStage.svelte'; import StoryStage from './StoryStage.svelte';
import '../src/app/styles/app.css'; import '../src/app/styles/app.css';
@@ -23,25 +24,41 @@ const preview: Preview = {
story: { story: {
// This sets the default height for the iframe in Autodocs // This sets the default height for the iframe in Autodocs
iframeHeight: '400px', iframeHeight: '400px',
// Ensure the story isn't forced into a tiny inline box
// inline: true,
}, },
}, },
head: `
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
/>
<style>
body {
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
}
</style>
`,
}, },
decorators: [ decorators: [
(storyFn, { parameters }) => { // Wrap with providers (TooltipProvider, ResponsiveManager)
const { Component, props } = storyFn(); story => ({
return { Component: Decorator,
Component: StoryStage, props: {
// We pass the actual story component into the Stage via a snippet/slot children: story(),
// Svelte 5 Storybook handles this mapping internally when you return this structure },
props: { }),
children: Component, // Wrap with StoryStage for presentation styling
width: parameters.stageWidth || 'max-w-3xl', story => ({
...props, Component: StoryStage,
}, props: {
}; children: story(),
}, },
}),
], ],
}; };

View File

@@ -78,6 +78,56 @@ export {
unifiedFontStore, unifiedFontStore,
} from './model'; } 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 // UI elements
export { export {
FontApplicator, FontApplicator,

View File

@@ -6,3 +6,53 @@ export {
} from './normalize/normalize'; } from './normalize/normalize';
export { getFontUrl } from './getFontUrl/getFontUrl'; 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';

View File

@@ -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<string>[];
}
/**
* Preset mock filters for font filtering
*/
export interface MockFilters {
/** Provider filter (Google, Fontshare) */
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
/** Category filter (sans-serif, serif, display, etc.) */
categories: ReturnType<typeof createFilter<FontCategory>>;
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
subsets: ReturnType<typeof createFilter<FontSubset>>;
}
// ============================================================================
// 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<FontCategory>[] = [
{ 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<FontSubset>[] = [
{ 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<FontProvider>[] = [
{ id: 'google', name: 'Google Fonts', value: 'google' },
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
];
// ============================================================================
// FILTER FACTORIES
// ============================================================================
/**
* Create a mock filter from properties
*/
export function createMockFilter<TValue extends string>(
options: MockFilterOptions & { properties: Property<TValue>[] },
) {
return createFilter<TValue>(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<FontCategory>({ 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<FontSubset>({ 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<FontProvider>({ 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 });
}

View File

@@ -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<Record<FontVariant, string>>;
/** 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<string, GoogleFontItem> = {
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: `<p>A mock font story for ${name}.</p>`,
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<string, FontshareFont> = {
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<FontMetadata>;
/** Features overrides */
features?: Partial<FontFeatures>;
}
/**
* 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<string, UnifiedFont> = {
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<MockUnifiedFontOptions, 'id' | 'name'>): 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;
}

View File

@@ -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';

View File

@@ -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<TData = unknown, TError = Error> {
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<TData = unknown, TError = Error> {
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<TData = unknown, TError = Error>(
options: MockQueryState<TData, TError>,
): MockQueryObserverResult<TData, TError> {
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<TData = unknown>(): MockQueryObserverResult<TData> {
return createMockQueryState<TData>({ status: 'pending', data: undefined, error: undefined });
}
/**
* Create an error query state
*/
export function createErrorState<TError = Error>(
error: TError,
): MockQueryObserverResult<unknown, TError> {
return createMockQueryState<unknown, TError>({ status: 'error', data: undefined, error });
}
/**
* Create a success query state
*/
export function createSuccessState<TData>(data: TData): MockQueryObserverResult<TData> {
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
}
// ============================================================================
// FONT STORE MOCKS
// ============================================================================
/**
* Mock UnifiedFontStore state
*/
export interface MockFontStoreState {
/** All cached fonts */
fonts: Record<string, UnifiedFont>;
/** 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> = {},
): 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<T>(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<UnifiedFont[]>({
isLoading: true,
data: undefined,
}),
/** Font store with fonts loaded */
successFontStore: createMockStore<UnifiedFont[]>({
data: Object.values(UNIFIED_FONTS),
isLoading: false,
isError: false,
}),
/** Font store with error */
errorFontStore: createMockStore<UnifiedFont[]>({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to load fonts'),
}),
/** Font store with empty results */
emptyFontStore: createMockStore<UnifiedFont[]>({
data: [],
isLoading: false,
isError: false,
}),
/**
* Create a mock UnifiedFontStore-like object
* Note: This is a simplified mock for Storybook use
*/
unifiedFontStore: (state: Partial<MockFontStoreState> = {}) => {
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<T>(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,
},
};
}

View File

@@ -0,0 +1,41 @@
<!--
Component: MockIcon
Wrapper component for Lucide icons to properly handle className in Storybook.
Lucide Svelte icons from @lucide/svelte/icons/* don't properly handle
the className prop directly. This wrapper ensures the class is applied
correctly via the HTML element's class attribute.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type {
Component,
Snippet,
} from 'svelte';
interface Props {
/**
* The Lucide icon component
*/
icon: Component;
/**
* CSS classes to apply to the icon
*/
class?: string;
/**
* Additional icon-specific attributes
*/
attrs?: Record<string, unknown>;
}
let { icon: Icon, class: className, attrs = {} }: Props = $props();
</script>
{#if Icon}
{@const __iconClass__ = cn('size-4', className)}
<!-- Render icon component dynamically with class prop -->
<Icon
class={__iconClass__}
{...attrs}
/>
{/if}

View File

@@ -0,0 +1,64 @@
<!--
Component: Providers
Storybook wrapper that provides required contexts for components.
Provides:
- responsive: ResponsiveManager context for breakpoint tracking
- tooltip: Tooltip.Provider context for shadcn Tooltip components
- Additional Radix UI providers can be added here as needed
-->
<script lang="ts">
import { createResponsiveManager } from '$shared/lib';
import type { ResponsiveManager } from '$shared/lib';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
/**
* Initial viewport width for the responsive context (default: 1280)
*/
initialWidth?: number;
/**
* Initial viewport height for the responsive context (default: 720)
*/
initialHeight?: number;
/**
* Tooltip provider options
*/
tooltipDelayDuration?: number;
/**
* Tooltip skip delay duration
*/
tooltipSkipDelayDuration?: number;
}
let {
children,
initialWidth = 1280,
initialHeight = 720,
tooltipDelayDuration = 200,
tooltipSkipDelayDuration = 300,
}: Props = $props();
// Create a responsive manager with default breakpoints
const responsiveManager = createResponsiveManager();
// Initialize the responsive manager to set up resize listeners
$effect(() => {
return responsiveManager.init();
});
// Provide the responsive context
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
<div class="storybook-providers" style:width="100%" style:height="100%">
<TooltipProvider
delayDuration={tooltipDelayDuration}
skipDelayDuration={tooltipSkipDelayDuration}
>
{@render children()}
</TooltipProvider>
</div>

View File

@@ -0,0 +1,24 @@
/**
* ============================================================================
* STORYBOOK HELPERS
* ============================================================================
*
* Helper components and utilities for Storybook stories.
*
* ## Usage
*
* ```svelte
* <script lang="ts">
* import { Providers, MockIcon } from '$shared/lib/storybook';
* </script>
*
* <Providers>
* <YourComponent />
* </Providers>
* ```
*
* @module
*/
export { default as MockIcon } from './MockIcon.svelte';
export { default as Providers } from './Providers.svelte';

View File

@@ -10,7 +10,8 @@ const { Story } = defineMeta({
parameters: { parameters: {
docs: { docs: {
description: { 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 story: { inline: false }, // Render stories in iframe for state isolation
}, },
@@ -26,18 +27,85 @@ const { Story } = defineMeta({
control: 'text', control: 'text',
description: 'Label for the ComboControl', description: 'Label for the ComboControl',
}, },
control: {
control: 'object',
description: 'TypographyControl instance managing the value and bounds',
},
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
const control = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 }); const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
const verticalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
const floatControl = createTypographyControl({ min: 0, max: 1, step: 0.01, value: 0.5 });
const atMinControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 0 });
const atMaxControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 100 });
const largeRangeControl = createTypographyControl({ min: 0, max: 1000, step: 10, value: 500 });
</script> </script>
<Story name="Horizontal" args={{ orientation: 'horizontal', control }}> <Story
<ComboControlV2 control={control} orientation="horizontal" /> name="Horizontal"
args={{
control: horizontalControl,
orientation: 'horizontal',
label: 'Size',
}}
>
<ComboControlV2 control={horizontalControl} orientation="horizontal" label="Size" />
</Story> </Story>
<Story name="Vertical" args={{ orientation: 'vertical', control, class: 'h-48' }}> <Story
<ComboControlV2 control={control} orientation="vertical" /> name="Vertical"
args={{
control: verticalControl,
orientation: 'vertical',
label: 'Size',
}}
>
<ComboControlV2 control={verticalControl} orientation="vertical" class="h-48" label="Size" />
</Story>
<Story
name="With Float Values"
args={{
control: floatControl,
orientation: 'vertical',
label: 'Opacity',
}}
>
<ComboControlV2 control={floatControl} orientation="vertical" class="h-48" label="Opacity" />
</Story>
<Story
name="At Minimum"
args={{
control: atMinControl,
orientation: 'horizontal',
label: 'Size',
}}
>
<ComboControlV2 control={atMinControl} orientation="horizontal" label="Size" />
</Story>
<Story
name="At Maximum"
args={{
control: atMaxControl,
orientation: 'horizontal',
label: 'Size',
}}
>
<ComboControlV2 control={atMaxControl} orientation="horizontal" label="Size" />
</Story>
<Story
name="Large Range"
args={{
control: largeRangeControl,
orientation: 'horizontal',
label: 'Scale',
}}
>
<ComboControlV2 control={largeRangeControl} orientation="horizontal" label="Scale" />
</Story> </Story>

View File

@@ -0,0 +1,101 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import IconButton from './IconButton.svelte';
const { Story } = defineMeta({
title: 'Shared/IconButton',
component: IconButton,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Icon button with rotation animation on click. Features clockwise/counterclockwise rotation options and icon snippet support for flexible icon rendering.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
rotation: {
control: 'select',
options: ['clockwise', 'counterclockwise'],
description: 'Direction of rotation animation on click',
},
icon: {
control: 'object',
description: 'Icon snippet to render (required)',
},
disabled: {
control: 'boolean',
description: 'Disable the button',
},
onclick: {
action: 'clicked',
description: 'Click handler',
},
},
});
</script>
<script lang="ts">
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings';
import XIcon from '@lucide/svelte/icons/x';
</script>
{#snippet chevronRightIcon({ className }: { className: string })}
<ChevronRight class={className} />
{/snippet}
{#snippet chevronLeftIcon({ className }: { className: string })}
<ChevronLeft class={className} />
{/snippet}
{#snippet plusIcon({ className }: { className: string })}
<PlusIcon class={className} />
{/snippet}
{#snippet minusIcon({ className }: { className: string })}
<MinusIcon class={className} />
{/snippet}
{#snippet settingsIcon({ className }: { className: string })}
<SettingsIcon class={className} />
{/snippet}
{#snippet xIcon({ className }: { className: string })}
<XIcon class={className} />
{/snippet}
<Story
name="Default"
args={{
icon: chevronRightIcon,
}}
>
<IconButton onclick={() => console.log('Default clicked')}>
{#snippet icon({ className })}
<ChevronRight class={className} />
{/snippet}
</IconButton>
</Story>
<Story
name="Disabled"
args={{
icon: chevronRightIcon,
disabled: true,
}}
>
<div class="flex flex-col gap-4 items-center">
<IconButton disabled>
{#snippet icon({ className })}
<ChevronRight class={className} />
{/snippet}
</IconButton>
</div>
</Story>

View File

@@ -0,0 +1,475 @@
<script module>
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { defineMeta } from '@storybook/addon-svelte-csf';
import Section from './Section.svelte';
const { Story } = defineMeta({
title: 'Shared/Section',
component: Section,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Page layout component with optional sticky title feature. Provides a container for page widgets with title, icon, description snippets. The title can remain fixed while scrolling through content.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
id: {
control: 'text',
description: 'ID of the section',
},
index: {
control: 'number',
description: 'Index of the section (used for default description)',
},
stickyTitle: {
control: 'boolean',
description: 'When true, title stays fixed while scrolling through content',
},
stickyOffset: {
control: 'text',
description: 'Top offset for sticky title (e.g. "60px")',
},
class: {
control: 'text',
description: 'Additional CSS classes',
},
onTitleStatusChange: {
action: 'titleStatusChanged',
description: 'Callback when title visibility status changes',
},
},
});
</script>
<script lang="ts">
import ListIcon from '@lucide/svelte/icons/list';
import SearchIcon from '@lucide/svelte/icons/search';
import SettingsIcon from '@lucide/svelte/icons/settings';
</script>
{#snippet searchIcon({ className }: { className?: string })}
<SearchIcon class={className} />
{/snippet}
{#snippet welcomeTitle({ className }: { className?: string })}
<h2 class={className}>Welcome</h2>
{/snippet}
{#snippet welcomeContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
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.
</p>
</div>
{/snippet}
{#snippet stickyTitle({ className }: { className?: string })}
<h2 class={className}>Sticky Title</h2>
{/snippet}
{#snippet stickyContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted mb-4">
This section has a sticky title that stays fixed while you scroll through the content. Try scrolling down to
see the effect.
</p>
<div class="space-y-4">
<p class="text-text-muted">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</p>
<p class="text-text-muted">
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</p>
<p class="text-text-muted">
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
</p>
<p class="text-text-muted">
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollim anim id est
laborum.
</p>
<p class="text-text-muted">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.
</p>
</div>
</div>
{/snippet}
{#snippet searchFontsTitle({ className }: { className?: string })}
<h2 class={className}>Search Fonts</h2>
{/snippet}
{#snippet searchFontsDescription({ className }: { className?: string })}
<span class={className}>Find your perfect typeface</span>
{/snippet}
{#snippet searchFontsContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Use the search bar to find fonts by name, or use the filters to browse by category, subset, or provider.
</p>
</div>
{/snippet}
{#snippet longContentTitle({ className }: { className?: string })}
<h2 class={className}>Long Content</h2>
{/snippet}
{#snippet longContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<div class="space-y-6">
<p class="text-lg text-text-muted">
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.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 1</span>
</div>
<p class="text-text-muted">
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.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 2</span>
</div>
<p class="text-text-muted">
The Intersection Observer API is used to detect when the section title scrolls out of view, triggering
the optional onTitleStatusChange callback.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 3</span>
</div>
</div>
</div>
{/snippet}
{#snippet minimalTitle({ className }: { className?: string })}
<h2 class={className}>Minimal Section</h2>
{/snippet}
{#snippet minimalContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-text-muted">
A minimal section without index, icon, or description. Just the essentials.
</p>
</div>
{/snippet}
{#snippet customTitle({ className }: { className?: string })}
<h2 class={className}>Custom Content</h2>
{/snippet}
{#snippet customDescription({ className }: { className?: string })}
<span class={className}>With interactive elements</span>
{/snippet}
{#snippet customContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 1</h3>
<p class="text-sm text-text-muted">Some content here</p>
</div>
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 2</h3>
<p class="text-sm text-text-muted">More content here</p>
</div>
</div>
</div>
{/snippet}
<Story
name="Default"
args={{
title: welcomeTitle,
content: welcomeContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section index={1}>
{#snippet title({ className })}
<h2 class={className}>Welcome</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
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.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="With Sticky Title"
args={{
title: stickyTitle,
content: stickyContent,
}}
>
<div class="h-[200vh]">
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section
id="sticky-section"
index={1}
stickyTitle={true}
stickyOffset="20px"
>
{#snippet title({ className })}
<h2 class={className}>Sticky Title</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted mb-4">
This section has a sticky title that stays fixed while you scroll through the content. Try
scrolling down to see the effect.
</p>
<div class="space-y-4">
<p class="text-text-muted">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
</p>
<p class="text-text-muted">
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat.
</p>
<p class="text-text-muted">
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur.
</p>
<p class="text-text-muted">
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt
mollim anim id est laborum.
</p>
<p class="text-text-muted">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
laudantium.
</p>
</div>
</div>
{/snippet}
</Section>
</div>
</div>
</Story>
<Story
name="With Icon and Description"
args={{
icon: searchIcon,
title: searchFontsTitle,
description: searchFontsDescription,
content: searchFontsContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section index={1}>
{#snippet title({ className })}
<h2 class={className}>Search Fonts</h2>
{/snippet}
{#snippet icon({ className })}
<SearchIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Find your perfect typeface</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Use the search bar to find fonts by name, or use the filters to browse by category, subset, or
provider.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story name="Multiple Sections" tags={['!autodocs']}>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section
id="section-1"
index={1}
stickyTitle={true}
>
{#snippet title({ className })}
<h2 class={className}>Typography</h2>
{/snippet}
{#snippet icon({ className })}
<SettingsIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Adjust text appearance</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Control the size, weight, and line height of your text. These settings apply across the
comparison view.
</p>
</div>
{/snippet}
</Section>
<Section
id="section-2"
index={2}
stickyTitle={true}
>
{#snippet title({ className })}
<h2 class={className}>Font Search</h2>
{/snippet}
{#snippet icon({ className })}
<SearchIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Browse available typefaces</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Search through our collection of fonts from Google Fonts and Fontshare. Use filters to narrow
down your selection.
</p>
</div>
{/snippet}
</Section>
<Section
id="section-3"
index={3}
stickyTitle={true}
>
{#snippet title({ className })}
<h2 class={className}>Sample List</h2>
{/snippet}
{#snippet icon({ className })}
<ListIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Preview font samples</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Browse through font samples with your custom text. The list is virtualized for optimal
performance.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="With Long Content"
args={{
title: longContentTitle,
content: longContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section
index={1}
stickyTitle={true}
stickyOffset="0px"
>
{#snippet title({ className })}
<h2 class={className}>Long Content</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<div class="space-y-6">
<p class="text-lg text-text-muted">
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.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 1</span>
</div>
<p class="text-text-muted">
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.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 2</span>
</div>
<p class="text-text-muted">
The Intersection Observer API is used to detect when the section title scrolls out of view,
triggering the optional onTitleStatusChange callback.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 3</span>
</div>
</div>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="Minimal"
args={{
title: minimalTitle,
content: minimalContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section>
{#snippet title({ className })}
<h2 class={className}>Minimal Section</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-text-muted">
A minimal section without index, icon, or description. Just the essentials.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="Custom Content"
args={{
title: customTitle,
description: customDescription,
content: customContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section index={42}>
{#snippet title({ className })}
<h2 class={className}>Custom Content</h2>
{/snippet}
{#snippet description({ className })}
<span class={className}>With interactive elements</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 1</h3>
<p class="text-sm text-text-muted">Some content here</p>
</div>
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 2</h3>
<p class="text-sm text-text-muted">More content here</p>
</div>
</div>
</div>
{/snippet}
</Section>
</div>
</Story>

View File

@@ -31,21 +31,26 @@ const { Story } = defineMeta({
control: 'number', control: 'number',
description: 'Step size for value increments', description: 'Step size for value increments',
}, },
label: {
control: 'text',
description: 'Optional label displayed inline on the track',
},
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
let minValue = 0;
let maxValue = 100;
let stepValue = 1;
let value = $state(50); let value = $state(50);
</script> </script>
<Story name="Horizontal" args={{ orientation: 'horizontal', min: minValue, max: maxValue, step: stepValue, value }}> <Story name="Horizontal" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} /> <Slider bind:value />
</Story> </Story>
<Story name="Vertical" args={{ orientation: 'vertical', min: minValue, max: maxValue, step: stepValue, value }}> <Story name="Vertical" args={{ orientation: 'vertical', min: 0, max: 100, step: 1, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} orientation="vertical" /> <Slider bind:value />
</Story>
<Story name="With Label" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value, label: 'SIZE' }}>
<Slider bind:value />
</Story> </Story>

View File

@@ -38,27 +38,31 @@ const { Story } = defineMeta({
<script lang="ts"> <script lang="ts">
const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`); const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`);
const largeDataSet = Array.from( const mediumDataSet = Array.from(
{ length: 10000 }, { length: 200 },
(_, i) => `${i + 1}) I will not skateboard in the halls.`, (_, i) => `${i + 1}) I will not skateboard in the halls.`,
); );
const emptyDataSet: string[] = []; const emptyDataSet: string[] = [];
</script> </script>
<Story name="Small Dataset"> <Story name="Small Dataset">
<VirtualList items={smallDataSet} itemHeight={40}> <div class="h-[400px]">
{#snippet children({ item })} <VirtualList items={smallDataSet} itemHeight={40}>
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div> {#snippet children({ item })}
{/snippet} <div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
</VirtualList> {/snippet}
</VirtualList>
</div>
</Story> </Story>
<Story name="Large Dataset"> <Story name="Medium Dataset (200 items)">
<VirtualList items={largeDataSet} itemHeight={40}> <div class="h-[400px]">
{#snippet children({ item })} <VirtualList items={mediumDataSet} itemHeight={40}>
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div> {#snippet children({ item })}
{/snippet} <div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
</VirtualList> {/snippet}
</VirtualList>
</div>
</Story> </Story>
<Story name="Empty Dataset"> <Story name="Empty Dataset">

View File

@@ -0,0 +1,217 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ComparisonSlider from './ComparisonSlider.svelte';
const { Story } = defineMeta({
title: 'Widgets/ComparisonSlider',
component: ComparisonSlider,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'A multiline text comparison slider that morphs between two fonts. Features character-level morphing, responsive layout, and performance optimization using offscreen canvas. Switch between slider mode (interactive text) and settings mode (controls panel).',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
// This component uses internal stores, so no direct props to document
},
});
</script>
<script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
// Mock fonts for testing - using web-safe fonts that are always available
const mockArial: UnifiedFont = {
id: 'arial',
name: 'Arial',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 1,
},
features: {
isVariable: false,
},
};
const mockGeorgia: UnifiedFont = {
id: 'georgia',
name: 'Georgia',
provider: 'google',
category: 'serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 2,
},
features: {
isVariable: false,
},
};
const mockVerdana: UnifiedFont = {
id: 'verdana',
name: 'Verdana',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 3,
},
features: {
isVariable: false,
},
};
const mockCourier: UnifiedFont = {
id: 'courier-new',
name: 'Courier New',
provider: 'google',
category: 'monospace',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 10,
},
features: {
isVariable: false,
},
};
</script>
<Story name="Default">
{@const _ = (comparisonStore.fontA = mockArial, comparisonStore.fontB = mockGeorgia)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Serif vs Sans-Serif">
{@const _ = (comparisonStore.fontA = mockCourier, comparisonStore.fontB = mockVerdana)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Loading State">
{@const _ = (comparisonStore.fontA = undefined, comparisonStore.fontB = undefined)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="With Custom Text">
{@const _ = (
comparisonStore.fontA = mockArial,
comparisonStore.fontB = mockVerdana,
comparisonStore.text = 'Typography is the art and technique of arranging type.'
)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Short Text">
{@const _ = (comparisonStore.fontA = mockGeorgia, comparisonStore.fontB = mockCourier, comparisonStore.text = 'Hello')}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Multiline Text">
{@const _ = (
comparisonStore.fontA = mockArial,
comparisonStore.fontB = mockGeorgia,
comparisonStore.text =
'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump!'
)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Settings Mode">
{@const _ = (comparisonStore.fontA = mockVerdana, comparisonStore.fontB = mockArial)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<div class="mb-8 text-center">
<p class="text-text-muted">Click the settings icon to toggle settings mode</p>
</div>
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Responsive Container">
{@const _ = (comparisonStore.fontA = mockGeorgia, comparisonStore.fontB = mockVerdana)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full">
<div class="mb-8 text-center">
<p class="text-text-muted">Resize the browser to see responsive behavior</p>
</div>
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>

View File

@@ -0,0 +1,102 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FontSearch from './FontSearch.svelte';
const { Story } = defineMeta({
title: 'Widgets/FontSearch',
component: FontSearch,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Primary search interface with filter panel. Provides a search input and filtering for fonts by provider, category, and subset. Filters can be toggled open/closed with an animated transition.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
showFilters: {
control: 'boolean',
description: 'Controllable flag to show/hide filters (bindable)',
},
},
});
</script>
<script lang="ts">
let showFiltersDefault = $state(true);
let showFiltersClosed = $state(false);
let showFiltersOpen = $state(true);
</script>
<Story name="Default">
<div class="w-full max-w-2xl">
<FontSearch bind:showFilters={showFiltersDefault} />
</div>
</Story>
<Story name="Filters Open">
<div class="w-full max-w-2xl">
<FontSearch bind:showFilters={showFiltersOpen} />
<div class="mt-8 text-center">
<p class="text-text-muted text-sm">Filters panel is open and visible</p>
</div>
</div>
</Story>
<Story name="Filters Closed">
<div class="w-full max-w-2xl">
<FontSearch bind:showFilters={showFiltersClosed} />
<div class="mt-8 text-center">
<p class="text-text-muted text-sm">Filters panel is closed - click the slider icon to open</p>
</div>
</div>
</Story>
<Story name="Full Width">
<div class="w-full px-8">
<FontSearch />
</div>
</Story>
<Story name="In Context" tags={['!autodocs']}>
<div class="w-full max-w-3xl p-8 space-y-6">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2">Font Browser</h1>
<p class="text-text-muted">Search and filter through our collection of fonts</p>
</div>
<div class="bg-background-20 rounded-2xl p-6">
<FontSearch />
</div>
<div class="mt-8 p-6 bg-background-40 rounded-xl">
<p class="text-text-muted text-center">Font results will appear here...</p>
</div>
</div>
</Story>
<Story name="With Filters Demo">
<div class="w-full max-w-2xl">
<div class="mb-4 p-4 bg-background-40 rounded-lg">
<p class="text-sm text-text-muted">
<strong class="text-foreground">Demo Note:</strong> Click the slider icon to toggle filters. Use the
filter categories to select options. Use the filter controls to reset or apply your selections.
</p>
</div>
<FontSearch />
</div>
</Story>
<Story name="Responsive Behavior">
<div class="w-full">
<div class="mb-4 text-center">
<p class="text-text-muted text-sm">Resize browser to see responsive layout</p>
</div>
<div class="px-4 sm:px-8 md:px-12">
<FontSearch />
</div>
</div>
</Story>

View File

@@ -0,0 +1,89 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import SampleList from './SampleList.svelte';
const { Story } = defineMeta({
title: 'Widgets/SampleList',
component: SampleList,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Virtualized font list with pagination. Renders a list of fonts with auto-loading when scrolling near the bottom. Includes a typography menu for font setup that appears when scrolling past the middle of the viewport.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
// This component uses internal stores, so no direct props to document
},
});
</script>
<Story name="Default">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Font Samples</h1>
<p class="text-text-muted">Scroll to see more fonts and load additional pages</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Full Page">
<div class="min-h-screen bg-background">
<SampleList />
</div>
</Story>
<Story name="With Typography Controls">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Typography Controls</h1>
<p class="text-text-muted">Scroll down to see the typography menu appear</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Custom Text">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Custom Sample Text</h1>
<p class="text-text-muted">Edit the text in any card to change all samples</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Pagination Info">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Paginated List</h1>
<p class="text-text-muted">Fonts load automatically as you scroll</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Responsive Layout">
<div class="min-h-screen bg-background">
<div class="max-w-6xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Responsive Sample List</h1>
<p class="text-text-muted">Resize browser to see responsive behavior</p>
</div>
<SampleList />
</div>
</div>
</Story>