Merge pull request 'feature/test-coverage' (#27) from feature/test-coverage into main
Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
29
.storybook/Decorator.svelte
Normal file
29
.storybook/Decorator.svelte
Normal 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>
|
||||
@@ -7,9 +7,9 @@ interface Props {
|
||||
let { children, width = 'max-w-3xl' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen w-full items-center justify-center bg-slate-50 p-8">
|
||||
<div class="w-full bg-white shadow-xl ring-1 ring-slate-200 rounded-xl p-12 {width}">
|
||||
<div class="relative flex justify-center items-center">
|
||||
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
||||
<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 text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
13
.storybook/preview-head.html
Normal file
13
.storybook/preview-head.html
Normal 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>
|
||||
@@ -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: `
|
||||
<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: [
|
||||
(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(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal file
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal 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 });
|
||||
}
|
||||
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal file
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal 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;
|
||||
}
|
||||
84
src/entities/Font/lib/mocks/index.ts
Normal file
84
src/entities/Font/lib/mocks/index.ts
Normal 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';
|
||||
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal file
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||
describe('AppliedFontsManager', () => {
|
||||
let manager: AppliedFontsManager;
|
||||
let mockFontFaceSet: any;
|
||||
let mockFetch: any;
|
||||
let failUrls: Set<string>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<TestEntity>();
|
||||
|
||||
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<TestEntity>();
|
||||
|
||||
expect(store).toBeInstanceOf(EntityStore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selectors', () => {
|
||||
let store: EntityStore<TestEntity>;
|
||||
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<TestEntity>();
|
||||
|
||||
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<TestEntity>();
|
||||
|
||||
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<TestEntity>([{ 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<TestEntity>([{ 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<TestEntity>([{ 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<TestEntity>([{ 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<TestEntity>([{ 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<TestEntity>([
|
||||
{ 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<TestEntity>([
|
||||
{ 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<TestEntity>([{ 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<TestEntity>([{ 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<TestEntity>([
|
||||
{ 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<TestEntity>([
|
||||
{ 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<TestEntity>([{ 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<TestEntity>([{ 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<TestEntity>([]);
|
||||
|
||||
expect(store.all).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single entity', () => {
|
||||
const store = createEntityStore<TestEntity>([{ 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<ComplexEntity>([entity]);
|
||||
|
||||
expect(store.getById('1')).toEqual(entity);
|
||||
});
|
||||
|
||||
it('should handle numeric string IDs', () => {
|
||||
const store = createEntityStore<TestEntity>([
|
||||
{ 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<TestEntity>([
|
||||
{ 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<TestEntity>([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<User>([
|
||||
{ id: 'u1', name: 'John', email: 'john@example.com' },
|
||||
]);
|
||||
|
||||
const productStore = createEntityStore<Product>([
|
||||
{ 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<TestEntity>();
|
||||
|
||||
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<TestEntity>([
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
|
||||
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<TestObject>(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<number[]>(testKey, []);
|
||||
|
||||
store.value = [1, 2, 3];
|
||||
|
||||
expect(store.value).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should handle booleans', () => {
|
||||
const store = createPersistentStore<boolean>(testKey, false);
|
||||
|
||||
store.value = true;
|
||||
|
||||
expect(store.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
const store = createPersistentStore<string | null>(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<number[]>(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<string>(testKey, 'default');
|
||||
|
||||
store.value = 'test string';
|
||||
|
||||
expect(store.value).toBe('test string');
|
||||
});
|
||||
|
||||
it('should work with number type', () => {
|
||||
const store = createPersistentStore<number>(testKey, 0);
|
||||
|
||||
store.value = 42;
|
||||
|
||||
expect(store.value).toBe(42);
|
||||
});
|
||||
|
||||
it('should work with boolean type', () => {
|
||||
const store = createPersistentStore<boolean>(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<TestObject>(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<string[]>(testKey, []);
|
||||
|
||||
store.value = ['a', 'b', 'c'];
|
||||
|
||||
expect(store.value).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should work with null type', () => {
|
||||
const store = createPersistentStore<string | null>(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<number>(testKey, 100);
|
||||
|
||||
store.value = 0;
|
||||
|
||||
expect(store.value).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle false boolean', () => {
|
||||
const store = createPersistentStore<boolean>(testKey, true);
|
||||
|
||||
store.value = false;
|
||||
|
||||
expect(store.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const store = createPersistentStore<number[]>(testKey, [1, 2, 3]);
|
||||
|
||||
store.value = [];
|
||||
|
||||
expect(store.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const store = createPersistentStore<Record<string, unknown>>(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<NestedObject>(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<Item[]>(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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
41
src/shared/lib/storybook/MockIcon.svelte
Normal file
41
src/shared/lib/storybook/MockIcon.svelte
Normal 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}
|
||||
64
src/shared/lib/storybook/Providers.svelte
Normal file
64
src/shared/lib/storybook/Providers.svelte
Normal 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>
|
||||
24
src/shared/lib/storybook/index.ts
Normal file
24
src/shared/lib/storybook/index.ts
Normal 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';
|
||||
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal file
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
let mockPushState: ReturnType<typeof vi.fn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
405
src/shared/lib/utils/splitArray/splitArray.test.ts
Normal file
405
src/shared/lib/utils/splitArray/splitArray.test.ts
Normal file
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
319
src/shared/lib/utils/throttle/throttle.test.ts
Normal file
319
src/shared/lib/utils/throttle/throttle.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<Story name="Horizontal" args={{ orientation: 'horizontal', control }}>
|
||||
<ComboControlV2 control={control} orientation="horizontal" />
|
||||
<Story
|
||||
name="Horizontal"
|
||||
args={{
|
||||
control: horizontalControl,
|
||||
orientation: 'horizontal',
|
||||
label: 'Size',
|
||||
}}
|
||||
>
|
||||
<ComboControlV2 control={horizontalControl} orientation="horizontal" label="Size" />
|
||||
</Story>
|
||||
|
||||
<Story name="Vertical" args={{ orientation: 'vertical', control, class: 'h-48' }}>
|
||||
<ComboControlV2 control={control} orientation="vertical" />
|
||||
<Story
|
||||
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>
|
||||
|
||||
101
src/shared/ui/IconButton/IconButton.stories.svelte
Normal file
101
src/shared/ui/IconButton/IconButton.stories.svelte
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
475
src/shared/ui/Section/Section.stories.svelte
Normal file
475
src/shared/ui/Section/Section.stories.svelte
Normal 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>
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let minValue = 0;
|
||||
let maxValue = 100;
|
||||
let stepValue = 1;
|
||||
let value = $state(50);
|
||||
</script>
|
||||
|
||||
<Story name="Horizontal" args={{ orientation: 'horizontal', min: minValue, max: maxValue, step: stepValue, value }}>
|
||||
<Slider bind:value min={minValue} max={maxValue} step={stepValue} />
|
||||
<Story name="Horizontal" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
|
||||
<Slider bind:value />
|
||||
</Story>
|
||||
|
||||
<Story name="Vertical" args={{ orientation: 'vertical', min: minValue, max: maxValue, step: stepValue, value }}>
|
||||
<Slider bind:value min={minValue} max={maxValue} step={stepValue} orientation="vertical" />
|
||||
<Story name="Vertical" args={{ orientation: 'vertical', min: 0, max: 100, step: 1, value }}>
|
||||
<Slider bind:value />
|
||||
</Story>
|
||||
|
||||
<Story name="With Label" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value, label: 'SIZE' }}>
|
||||
<Slider bind:value />
|
||||
</Story>
|
||||
|
||||
@@ -38,27 +38,31 @@ const { Story } = defineMeta({
|
||||
|
||||
<script lang="ts">
|
||||
const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`);
|
||||
const largeDataSet = Array.from(
|
||||
{ length: 10000 },
|
||||
const mediumDataSet = Array.from(
|
||||
{ length: 200 },
|
||||
(_, i) => `${i + 1}) I will not skateboard in the halls.`,
|
||||
);
|
||||
const emptyDataSet: string[] = [];
|
||||
</script>
|
||||
|
||||
<Story name="Small Dataset">
|
||||
<VirtualList items={smallDataSet} itemHeight={40}>
|
||||
{#snippet children({ item })}
|
||||
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
<div class="h-[400px]">
|
||||
<VirtualList items={smallDataSet} itemHeight={40}>
|
||||
{#snippet children({ item })}
|
||||
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="Large Dataset">
|
||||
<VirtualList items={largeDataSet} itemHeight={40}>
|
||||
{#snippet children({ item })}
|
||||
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
<Story name="Medium Dataset (200 items)">
|
||||
<div class="h-[400px]">
|
||||
<VirtualList items={mediumDataSet} itemHeight={40}>
|
||||
{#snippet children({ item })}
|
||||
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="Empty Dataset">
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<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="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>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
fontName: string;
|
||||
isAnimating: boolean;
|
||||
onAnimationComplete?: () => void;
|
||||
}
|
||||
|
||||
let { text, fontName, isAnimating, onAnimationComplete }: Props = $props();
|
||||
|
||||
// Split text into characters, preserving spaces
|
||||
const chars = $derived(text.split('').map(c => c === ' ' ? '\u00A0' : c));
|
||||
|
||||
let completedCount = 0;
|
||||
|
||||
function handleTransitionEnd() {
|
||||
completedCount++;
|
||||
if (completedCount === chars.length) {
|
||||
onAnimationComplete?.();
|
||||
completedCount = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative inline-flex flex-wrap leading-tight">
|
||||
{#each chars as char, i}
|
||||
<span
|
||||
class={cn(
|
||||
'inline-block transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
|
||||
isAnimating ? 'opacity-0 -translate-y-4 rotate-x-90' : 'opacity-100 translate-y-0 rotate-x-0',
|
||||
)}
|
||||
style:font-family={fontName}
|
||||
style:transition-delay="{i * 25}ms"
|
||||
ontransitionend={i === chars.length - 1 ? handleTransitionEnd : null}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Necessary for the "Flip" feel */
|
||||
div {
|
||||
perspective: 1000px;
|
||||
}
|
||||
span {
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
102
src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte
Normal file
102
src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user