feature/fetch-fonts #14

Merged
ilia merged 76 commits from feature/fetch-fonts into main 2026-01-14 11:01:44 +00:00
110 changed files with 7584 additions and 1397 deletions

View File

@@ -10,16 +10,37 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ github.workspace }}/.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Build Svelte App
run: yarn build

View File

@@ -15,16 +15,37 @@ jobs:
pipeline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ github.workspace }}/.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Validation
run: |
@@ -35,8 +56,3 @@ jobs:
run: yarn build
env:
NODE_ENV: production
- name: Deploy Step
run: |
echo "Deploying dist/ to ${{ github.event.inputs.environment || 'production' }}..."
# EXAMPLE: rsync -avz dist/ user@your-vps:/var/www/html/

View File

@@ -28,8 +28,14 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
@@ -39,10 +45,14 @@ jobs:
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
path: ${{ github.workspace }}/.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Lint
run: yarn lint

View File

@@ -11,59 +11,40 @@ jobs:
name: Svelte Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Checkout repository
uses: actions/checkout@v4
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ github.workspace }}/.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Type Check
run: yarn svelte-check --threshold warning
run: yarn check:shadcn-excluded
- name: Lint
run: yarn oxlint .
# e2e-tests:
# name: E2E Tests (Playwright)
# runs-on: ubuntu-latest
#
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
#
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20'
# cache: 'yarn'
#
# - name: Install dependencies
# run: yarn install --frozen-lockfile
#
# - name: Install Playwright browsers
# run: yarn playwright install --with-deps
#
# - name: Run Playwright tests
# run: yarn test:e2e
#
# - name: Upload Playwright report
# if: always()
# uses: actions/upload-artifact@v4
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 7
#
# - name: Upload Playwright screenshots (on failure)
# if: failure()
# uses: actions/upload-artifact@v4
# with:
# name: playwright-screenshots
# path: test-results/
# retention-days: 7
#
# Note: E2E tests are disabled until Playwright setup is complete.
# Uncomment this job section when Playwright tests are ready to run.
run: yarn lint

View File

@@ -1,9 +0,0 @@
import {
expect,
test,
} from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});

View File

@@ -20,6 +20,8 @@
"test:unit:ui": "vitest --ui",
"test:unit:coverage": "vitest run --coverage",
"test:component": "vitest run --config vitest.config.component.ts",
"test:component:browser": "vitest run --config vitest.config.browser.ts",
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
"test": "npm run test:e2e && npm run test:unit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
@@ -61,5 +63,9 @@
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-virtual": "^3.13.17"
}
}

View File

@@ -1,6 +1,10 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: { command: 'yarn build && yarn preview', port: 4173 },
webServer: {
command: 'yarn build && yarn preview',
port: 4173,
reuseExistingServer: true,
},
testDir: 'e2e',
});

View File

@@ -6,13 +6,17 @@
* layout shell. This is the root component mounted by the application.
*
* Structure:
* - QueryProvider provides TanStack Query client for data fetching
* - Layout provides sidebar, header/footer, and page container
* - Page renders the current route content
*/
import Page from '$routes/Page.svelte';
import { QueryProvider } from './providers';
import Layout from './ui/Layout.svelte';
</script>
<Layout>
<QueryProvider>
<Layout>
<Page />
</Layout>
</Layout>
</QueryProvider>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
/**
* Query Provider Component
*
* All components that use useQueryClient() or createQuery() must be
* descendants of this provider.
*/
import { queryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query';
/** Slot content for child components */
let { children } = $props();
</script>
<QueryClientProvider client={queryClient}>
{@render children?.()}
</QueryClientProvider>

View File

@@ -0,0 +1 @@
export { default as QueryProvider } from './QueryProvider.svelte';

View File

@@ -0,0 +1,161 @@
/**
* Fontshare API client
*
* Handles API requests to Fontshare API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters.
* However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront.
* For future optimization, consider implementing incremental pagination for large datasets.
*
* @see https://fontshare.com
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontshareApiModel,
FontshareFont,
} from '../../model/types/fontshare';
/**
* Fontshare API parameters
*/
export interface FontshareParams extends QueryParams {
/**
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
*/
categories?: string[];
/**
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
*/
tags?: string[];
/**
* Page number for pagination (1-indexed)
*/
page?: number;
/**
* Number of items per page
*/
limit?: number;
/**
* Search query to filter fonts
*/
q?: string;
}
/**
* Fontshare API response wrapper
* Re-exported from model/types/fontshare for backward compatibility
*/
export type FontshareResponse = FontshareApiModel;
/**
* Fetch fonts from Fontshare API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Fontshare API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all Sans category fonts
* const response = await fetchFontshareFonts({
* categories: ['Sans'],
* limit: 50
* });
*
* // Fetch fonts with specific tags
* const response = await fetchFontshareFonts({
* tags: ['Branding', 'Logos']
* });
*
* // Search fonts
* const response = await fetchFontshareFonts({
* search: 'Satoshi'
* });
* ```
*/
export async function fetchFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const queryString = buildQueryString(params);
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
try {
const response = await api.get<FontshareResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
}
}
/**
* Fetch font by slug
* Convenience function for fetching a single font
*
* @param slug - Font slug (e.g., "satoshi", "general-sans")
* @returns Promise resolving to Fontshare font item
*
* @example
* ```ts
* const satoshi = await fetchFontshareFontBySlug('satoshi');
* ```
*/
export async function fetchFontshareFontBySlug(
slug: string,
): Promise<FontshareFont | undefined> {
const response = await fetchFontshareFonts();
return response.fonts.find(font => font.slug === slug);
}
/**
* Fetch all fonts from Fontshare
* Convenience function for fetching all available fonts
* Uses pagination to get all items
*
* @returns Promise resolving to all Fontshare fonts
*
* @example
* ```ts
* const allFonts = await fetchAllFontshareFonts();
* console.log(`Found ${allFonts.fonts.length} fonts`);
* ```
*/
export async function fetchAllFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const allFonts: FontshareFont[] = [];
let page = 1;
const limit = 100; // Max items per page
while (true) {
const response = await fetchFontshareFonts({
...params,
page,
limit,
});
allFonts.push(...response.fonts);
// Check if we've fetched all items
if (response.fonts.length < limit) {
break;
}
page++;
}
// Return first response with all items combined
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
return {
...firstResponse,
fonts: allFonts,
};
}

View File

@@ -0,0 +1,127 @@
/**
* Google Fonts API client
*
* Handles API requests to Google Fonts API for fetching font metadata.
* Provides error handling, retry logic, and type-safe responses.
*
* Pagination: The Google Fonts API does NOT support pagination parameters.
* All fonts matching the query are returned in a single response.
* Use category, subset, or sort filters to reduce the result set if needed.
*
* @see https://developers.google.com/fonts/docs/developer_api
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontItem,
GoogleFontsApiModel,
} from '../../model/types/google';
/**
* Google Fonts API parameters
*/
export interface GoogleFontsParams extends QueryParams {
/**
* Google Fonts API key (required for Google Fonts API v1)
*/
key?: string;
/**
* Font family name (to fetch specific font)
*/
family?: string;
/**
* Font category filter (e.g., "sans-serif", "serif", "display")
*/
category?: string;
/**
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
*/
subset?: string;
/**
* Sort order for results
*/
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
/**
* Cap the number of fonts returned
*/
capability?: 'VF' | 'WOFF2';
}
/**
* Google Fonts API response wrapper
* Re-exported from model/types/google for backward compatibility
*/
export type GoogleFontsResponse = GoogleFontsApiModel;
/**
* Simplified font item from Google Fonts API
* Re-exported from model/types/google for backward compatibility
*/
export type GoogleFontItem = FontItem;
/**
* Google Fonts API base URL
* Note: Google Fonts API v1 requires an API key. For development/testing without a key,
* fonts may not load properly.
*/
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
/**
* Fetch fonts from Google Fonts API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Google Fonts API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts sorted by popularity
* const response = await fetchGoogleFonts({
* category: 'sans-serif',
* sort: 'popularity'
* });
*
* // Fetch specific font family
* const robotoResponse = await fetchGoogleFonts({
* family: 'Roboto'
* });
* ```
*/
export async function fetchGoogleFonts(
params: GoogleFontsParams = {},
): Promise<GoogleFontsResponse> {
const queryString = buildQueryString(params);
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
try {
const response = await api.get<GoogleFontsResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
}
}
/**
* Fetch font by family name
* Convenience function for fetching a single font
*
* @param family - Font family name (e.g., "Roboto")
* @returns Promise resolving to Google Font item
*
* @example
* ```ts
* const roboto = await fetchGoogleFontFamily('Roboto');
* ```
*/
export async function fetchGoogleFontFamily(
family: string,
): Promise<GoogleFontItem | undefined> {
const response = await fetchGoogleFonts({ family });
return response.items.find(item => item.family === family);
}

View File

@@ -0,0 +1,25 @@
/**
* Font API clients exports
*
* Exports API clients and normalization utilities
*/
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './google/googleFonts';
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './fontshare/fontshare';

View File

@@ -1,22 +1,75 @@
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './api/fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './api/fontshare/fontshare';
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './api/google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './api/google/googleFonts';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './lib/normalize/normalize';
export type {
// Domain types
FontCategory,
FontCollectionFilters,
FontCollectionSort,
// Store types
FontCollectionState,
FontFeatures,
FontFiles,
FontItem,
FontMetadata,
FontProvider,
FontSubset,
} from './model/font';
export type {
// Fontshare API types
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStore,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
} from './model/fontshare_fonts';
export type {
FontFiles,
FontItem,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
} from './model/google_fonts';
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './model';
export {
createFontshareStore,
fetchFontshareFontsQuery,
fontshareStore,
} from './model';
// Stores
export {
createGoogleFontsStore,
GoogleFontsStore,
} from './model/services/fetchGoogleFonts.svelte';
// UI elements
export { FontList } from './ui';

View File

@@ -0,0 +1,6 @@
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize/normalize';

View File

@@ -0,0 +1,584 @@
import {
describe,
expect,
it,
} from 'vitest';
import type {
FontItem,
FontshareFont,
GoogleFontItem,
} from '../../model/types';
import {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize';
describe('Font Normalization', () => {
describe('normalizeGoogleFont', () => {
const mockGoogleFont: GoogleFontItem = {
family: 'Roboto',
category: 'sans-serif',
variants: ['regular', '700', 'italic', '700italic'],
subsets: ['latin', 'latin-ext'],
files: {
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
'700':
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
'700italic':
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
},
version: 'v30',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
};
it('normalizes Google Font to unified model', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.id).toBe('Roboto');
expect(result.name).toBe('Roboto');
expect(result.provider).toBe('google');
expect(result.category).toBe('sans-serif');
});
it('maps font variants correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
});
it('maps subsets correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.subsets).toContain('latin');
expect(result.subsets).toContain('latin-ext');
expect(result.subsets).toHaveLength(2);
});
it('maps style URLs correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.styles.regular).toBeDefined();
expect(result.styles.bold).toBeDefined();
expect(result.styles.italic).toBeDefined();
expect(result.styles.boldItalic).toBeDefined();
});
it('includes metadata', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.metadata.cachedAt).toBeDefined();
expect(result.metadata.version).toBe('v30');
expect(result.metadata.lastModified).toBe('2022-01-01');
});
it('marks Google Fonts as non-variable', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.features.isVariable).toBe(false);
expect(result.features.tags).toEqual([]);
});
it('handles sans-serif category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('sans-serif');
});
it('handles serif category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('serif');
});
it('handles display category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'display' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('display');
});
it('handles handwriting category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles cursive category (maps to handwriting)', () => {
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles monospace category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('monospace');
});
it('filters invalid subsets', () => {
const font = {
...mockGoogleFont,
subsets: ['latin', 'latin-ext', 'invalid-subset'],
};
const result = normalizeGoogleFont(font);
expect(result.subsets).not.toContain('invalid-subset');
expect(result.subsets).toHaveLength(2);
});
it('maps variant weights correctly', () => {
const font: GoogleFontItem = {
...mockGoogleFont,
variants: ['regular', '100', '400', '700', '900'] as any,
};
const result = normalizeGoogleFont(font);
expect(result.variants).toContain('regular');
expect(result.variants).toContain('100');
expect(result.variants).toContain('400');
expect(result.variants).toContain('700');
expect(result.variants).toContain('900');
});
});
describe('normalizeFontshareFont', () => {
const mockFontshareFont: FontshareFont = {
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
name: 'Satoshi',
native_name: null,
slug: 'satoshi',
category: 'Sans',
script: 'latin',
publisher: {
bio: 'Indian Type Foundry',
email: null,
id: 'test-id',
links: [],
name: 'Indian Type Foundry',
},
designers: [
{
bio: 'Designer bio',
links: [],
name: 'Designer Name',
},
],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: true,
show_latin_metrics: false,
license_type: 'itf_ffl',
languages: 'Afar, Afrikaans',
inserted_at: '2021-03-12T20:49:05Z',
story: '<p>Font story</p>',
version: '1.0',
views: 10000,
views_recent: 500,
is_hot: true,
is_new: false,
is_shortlisted: false,
is_top: true,
axes: [],
font_tags: [
{ name: 'Branding' },
{ name: 'Logos' },
],
features: [
{
name: 'Alternate t',
on_by_default: false,
tag: 'ss01',
},
],
styles: [
{
id: 'style-id-1',
default: true,
file: '//cdn.fontshare.com/wf/satoshi.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
{
id: 'style-id-2',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Bold',
name: 'Bold',
native_name: null,
number: 700,
weight: 700,
},
},
{
id: 'style-id-3',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
is_italic: true,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
{
id: 'style-id-4',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
is_italic: true,
is_variable: false,
properties: {},
weight: {
label: 'Bold',
name: 'Bold',
native_name: null,
number: 700,
weight: 700,
},
},
],
};
it('normalizes Fontshare font to unified model', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.id).toBe('satoshi');
expect(result.name).toBe('Satoshi');
expect(result.provider).toBe('fontshare');
expect(result.category).toBe('sans-serif');
});
it('uses slug as unique identifier', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.id).toBe('satoshi');
});
it('extracts variant names from styles', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.variants).toContain('Regular');
expect(result.variants).toContain('Bold');
expect(result.variants).toContain('Regularitalic');
expect(result.variants).toContain('Bolditalic');
});
it('maps Fontshare Sans to sans-serif category', () => {
const font = { ...mockFontshareFont, category: 'Sans' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('sans-serif');
});
it('maps Fontshare Serif to serif category', () => {
const font = { ...mockFontshareFont, category: 'Serif' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('serif');
});
it('maps Fontshare Display to display category', () => {
const font = { ...mockFontshareFont, category: 'Display' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('display');
});
it('maps Fontshare Script to handwriting category', () => {
const font = { ...mockFontshareFont, category: 'Script' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('handwriting');
});
it('maps Fontshare Mono to monospace category', () => {
const font = { ...mockFontshareFont, category: 'Mono' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('monospace');
});
it('maps style URLs correctly', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
expect(result.styles.boldItalic).toBe(
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
);
});
it('handles variable fonts', () => {
const variableFont: FontshareFont = {
...mockFontshareFont,
axes: [
{
name: 'wght',
property: 'wght',
range_default: 400,
range_left: 300,
range_right: 900,
},
],
styles: [
{
id: 'var-style',
default: true,
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
is_italic: false,
is_variable: true,
properties: {},
weight: {
label: 'Variable',
name: 'Variable',
native_name: null,
number: 0,
weight: 0,
},
},
],
};
const result = normalizeFontshareFont(variableFont);
expect(result.features.isVariable).toBe(true);
expect(result.features.axes).toHaveLength(1);
expect(result.features.axes?.[0].name).toBe('wght');
});
it('extracts font tags', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.features.tags).toContain('Branding');
expect(result.features.tags).toContain('Logos');
expect(result.features.tags).toHaveLength(2);
});
it('includes popularity from views', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.metadata.popularity).toBe(10000);
});
it('includes metadata', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.metadata.cachedAt).toBeDefined();
expect(result.metadata.version).toBe('1.0');
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
});
it('handles missing subsets gracefully', () => {
const font = {
...mockFontshareFont,
script: 'invalid-script',
};
const result = normalizeFontshareFont(font);
expect(result.subsets).toEqual([]);
});
it('handles empty tags', () => {
const font = {
...mockFontshareFont,
font_tags: [],
};
const result = normalizeFontshareFont(font);
expect(result.features.tags).toBeUndefined();
});
it('handles empty axes', () => {
const font = {
...mockFontshareFont,
axes: [],
};
const result = normalizeFontshareFont(font);
expect(result.features.isVariable).toBe(false);
expect(result.features.axes).toBeUndefined();
});
});
describe('normalizeGoogleFonts', () => {
it('normalizes array of Google Fonts', () => {
const fonts: GoogleFontItem[] = [
{
family: 'Roboto',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
version: 'v1',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
},
{
family: 'Open Sans',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
version: 'v1',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
},
];
const result = normalizeGoogleFonts(fonts);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Roboto');
expect(result[1].name).toBe('Open Sans');
});
it('returns empty array for empty input', () => {
const result = normalizeGoogleFonts([]);
expect(result).toEqual([]);
});
});
describe('normalizeFontshareFonts', () => {
it('normalizes array of Fontshare fonts', () => {
const fonts: FontshareFont[] = [
{
...mockMinimalFontshareFont('font1', 'Font 1'),
},
{
...mockMinimalFontshareFont('font2', 'Font 2'),
},
];
const result = normalizeFontshareFonts(fonts);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Font 1');
expect(result[1].name).toBe('Font 2');
});
it('returns empty array for empty input', () => {
const result = normalizeFontshareFonts([]);
expect(result).toEqual([]);
});
});
describe('edge cases', () => {
it('handles Google Font with missing optional fields', () => {
const font: Partial<GoogleFontItem> = {
family: 'Test Font',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
};
const result = normalizeGoogleFont(font as GoogleFontItem);
expect(result.id).toBe('Test Font');
expect(result.metadata.version).toBeUndefined();
expect(result.metadata.lastModified).toBeUndefined();
});
it('handles Fontshare font with minimal data', () => {
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
expect(result.id).toBe('slug');
expect(result.name).toBe('Name');
expect(result.provider).toBe('fontshare');
});
it('handles unknown Fontshare category', () => {
const font = {
...mockMinimalFontshareFont('slug', 'Name'),
category: 'Unknown Category',
};
const result = normalizeFontshareFont(font);
expect(result.category).toBe('sans-serif'); // fallback
});
});
});
/**
* Helper function to create minimal Fontshare font mock
*/
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
return {
id: 'test-id',
name,
native_name: null,
slug,
category: 'Sans',
script: 'latin',
publisher: {
bio: '',
email: null,
id: '',
links: [],
name: '',
},
designers: [],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: false,
show_latin_metrics: false,
license_type: '',
languages: '',
inserted_at: '',
story: '',
version: '1.0',
views: 0,
views_recent: 0,
is_hot: false,
is_new: false,
is_shortlisted: null,
is_top: false,
axes: [],
font_tags: [],
features: [],
styles: [
{
id: 'style-id',
default: true,
file: '//cdn.fontshare.com/wf/test.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
],
};
}

View File

@@ -0,0 +1,274 @@
/**
* Normalize fonts from Google Fonts and Fontshare to unified model
*
* Transforms provider-specific font data into a common interface
* for consistent handling across the application.
*/
import type {
FontCategory,
FontStyleUrls,
FontSubset,
FontshareFont,
GoogleFontItem,
UnifiedFont,
} from '../../model/types';
/**
* Map Google Fonts category to unified FontCategory
*/
function mapGoogleCategory(category: string): FontCategory {
const normalized = category.toLowerCase();
if (normalized.includes('sans-serif')) {
return 'sans-serif';
}
if (normalized.includes('serif')) {
return 'serif';
}
if (normalized.includes('display')) {
return 'display';
}
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
return 'handwriting';
}
if (normalized.includes('monospace')) {
return 'monospace';
}
// Default fallback
return 'sans-serif';
}
/**
* Map Fontshare category to unified FontCategory
*/
function mapFontshareCategory(category: string): FontCategory {
const normalized = category.toLowerCase();
if (normalized === 'sans' || normalized === 'sans-serif') {
return 'sans-serif';
}
if (normalized === 'serif') {
return 'serif';
}
if (normalized === 'display') {
return 'display';
}
if (normalized === 'script') {
return 'handwriting';
}
if (normalized === 'mono' || normalized === 'monospace') {
return 'monospace';
}
// Default fallback
return 'sans-serif';
}
/**
* Map Google subset to unified FontSubset
*/
function mapGoogleSubset(subset: string): FontSubset | null {
const validSubsets: FontSubset[] = [
'latin',
'latin-ext',
'cyrillic',
'greek',
'arabic',
'devanagari',
];
return validSubsets.includes(subset as FontSubset)
? (subset as FontSubset)
: null;
}
/**
* Map Fontshare script to unified FontSubset
*/
function mapFontshareScript(script: string): FontSubset | null {
const normalized = script.toLowerCase();
const mapping: Record<string, FontSubset | null> = {
latin: 'latin',
'latin-ext': 'latin-ext',
cyrillic: 'cyrillic',
greek: 'greek',
arabic: 'arabic',
devanagari: 'devanagari',
};
return mapping[normalized] ?? null;
}
/**
* Normalize Google Font to unified model
*
* @param apiFont - Font item from Google Fonts API
* @returns Unified font model
*
* @example
* ```ts
* const roboto = normalizeGoogleFont({
* family: 'Roboto',
* category: 'sans-serif',
* variants: ['regular', '700'],
* subsets: ['latin', 'latin-ext'],
* files: { regular: '...', '700': '...' }
* });
*
* console.log(roboto.id); // 'Roboto'
* console.log(roboto.provider); // 'google'
* ```
*/
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
const category = mapGoogleCategory(apiFont.category);
const subsets = apiFont.subsets
.map(mapGoogleSubset)
.filter((subset): subset is FontSubset => subset !== null);
// Map variant files to style URLs
const styles: FontStyleUrls = {};
for (const [variant, url] of Object.entries(apiFont.files)) {
const urlString = url as string; // Type assertion for Record<string, string>
if (variant === 'regular' || variant === '400') {
styles.regular = urlString;
} else if (variant === 'italic' || variant === '400italic') {
styles.italic = urlString;
} else if (variant === 'bold' || variant === '700') {
styles.bold = urlString;
} else if (variant === 'bolditalic' || variant === '700italic') {
styles.boldItalic = urlString;
}
}
return {
id: apiFont.family,
name: apiFont.family,
provider: 'google',
category,
subsets,
variants: apiFont.variants,
styles,
metadata: {
cachedAt: Date.now(),
version: apiFont.version,
lastModified: apiFont.lastModified,
},
features: {
isVariable: false, // Google Fonts doesn't expose variable font info
tags: [],
},
};
}
/**
* Normalize Fontshare font to unified model
*
* @param apiFont - Font item from Fontshare API
* @returns Unified font model
*
* @example
* ```ts
* const satoshi = normalizeFontshareFont({
* id: 'uuid',
* name: 'Satoshi',
* slug: 'satoshi',
* category: 'Sans',
* script: 'latin',
* styles: [ ... ]
* });
*
* console.log(satoshi.id); // 'satoshi'
* console.log(satoshi.provider); // 'fontshare'
* ```
*/
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
const category = mapFontshareCategory(apiFont.category);
const subset = mapFontshareScript(apiFont.script);
const subsets = subset ? [subset] : [];
// Extract variant names from styles
const variants = apiFont.styles.map(style => {
const weightLabel = style.weight.label;
const isItalic = style.is_italic;
return isItalic ? `${weightLabel}italic` : weightLabel;
});
// Map styles to URLs
const styles: FontStyleUrls = {};
for (const style of apiFont.styles) {
if (style.is_variable) {
// Variable font - store as primary variant
styles.regular = style.file;
break;
}
const weight = style.weight.number;
const isItalic = style.is_italic;
if (weight === 400 && !isItalic) {
styles.regular = style.file;
} else if (weight === 400 && isItalic) {
styles.italic = style.file;
} else if (weight >= 700 && !isItalic) {
styles.bold = style.file;
} else if (weight >= 700 && isItalic) {
styles.boldItalic = style.file;
}
}
// Extract variable font axes
const axes = apiFont.axes.map(axis => ({
name: axis.name,
property: axis.property,
default: axis.range_default,
min: axis.range_left,
max: axis.range_right,
}));
// Extract tags
const tags = apiFont.font_tags.map(tag => tag.name);
return {
id: apiFont.slug,
name: apiFont.name,
provider: 'fontshare',
category,
subsets,
variants,
styles,
metadata: {
cachedAt: Date.now(),
version: apiFont.version,
lastModified: apiFont.inserted_at,
popularity: apiFont.views,
},
features: {
isVariable: apiFont.axes.length > 0,
axes: axes.length > 0 ? axes : undefined,
tags: tags.length > 0 ? tags : undefined,
},
};
}
/**
* Normalize multiple Google Fonts to unified model
*
* @param apiFonts - Array of Google Font items
* @returns Array of unified fonts
*/
export function normalizeGoogleFonts(
apiFonts: GoogleFontItem[],
): UnifiedFont[] {
return apiFonts.map(normalizeGoogleFont);
}
/**
* Normalize multiple Fontshare fonts to unified model
*
* @param apiFonts - Array of Fontshare font items
* @returns Array of unified fonts
*/
export function normalizeFontshareFonts(
apiFonts: FontshareFont[],
): UnifiedFont[] {
return apiFonts.map(normalizeFontshareFont);
}
// Re-export UnifiedFont for backward compatibility
export type { UnifiedFont } from '../../model/types/normalize';

View File

@@ -1,14 +0,0 @@
/**
* Font category
*/
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/**
* Font provider
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Font subset
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';

View File

@@ -0,0 +1,43 @@
export type {
// Domain types
FontCategory,
FontCollectionFilters,
FontCollectionSort,
// Store types
FontCollectionState,
FontFeatures,
FontFiles,
FontItem,
FontMetadata,
FontProvider,
// Fontshare API types
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './types';
export { fetchFontshareFontsQuery } from './services';
export {
createFontshareStore,
type FontshareStore,
fontshareStore,
} from './store';

View File

@@ -0,0 +1,31 @@
import {
type FontshareParams,
fetchFontshareFonts,
} from '../../api';
import { normalizeFontshareFonts } from '../../lib';
import type { UnifiedFont } from '../types';
/**
* Query function for fetching fonts from Fontshare.
*
* @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.).
* @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare.
*/
export async function fetchFontshareFontsQuery(params: FontshareParams): Promise<UnifiedFont[]> {
try {
const response = await fetchFontshareFonts(params);
return normalizeFontshareFonts(response.fonts);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Fontshare. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Fontshare catalog.');
}
}
throw new Error('Failed to load fonts from Fontshare.');
}
}

View File

@@ -0,0 +1,274 @@
/**
* Service for fetching Google Fonts with Svelte 5 runes + TanStack Query
*/
import {
type CreateQueryResult,
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
import {
type GoogleFontsParams,
fetchGoogleFonts,
} from '../../api';
import { normalizeGoogleFonts } from '../../lib';
import type {
FontCategory,
FontSubset,
} from '../types';
import type { UnifiedFont } from '../types/normalize';
/**
* Query key factory
*/
function getGoogleFontsQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
/**
* Query function
*/
export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise<UnifiedFont[]> {
try {
const response = await fetchGoogleFonts({
category: params.category,
subset: params.subset,
sort: params.sort,
});
return normalizeGoogleFonts(response.items);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Google Fonts. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Google Fonts catalog.');
}
}
throw new Error('Failed to load fonts from Google Fonts.');
}
}
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore {
params = $state<GoogleFontsParams>({});
private query: CreateQueryResult<UnifiedFont[], Error>;
private queryClient = useQueryClient();
constructor(initialParams: GoogleFontsParams = {}) {
this.params = initialParams;
// Create the query - automatically reactive
this.query = createQuery(() => ({
queryKey: getGoogleFontsQueryKey(this.params),
queryFn: () => fetchGoogleFontsQuery(this.params),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
}
// Proxy TanStack Query's reactive state
get fonts() {
return this.query.data ?? [];
}
get isLoading() {
return this.query.isLoading;
}
get isFetching() {
return this.query.isFetching;
}
get isRefetching() {
return this.query.isRefetching;
}
get error() {
return this.query.error;
}
get isError() {
return this.query.isError;
}
get isSuccess() {
return this.query.isSuccess;
}
get status() {
return this.query.status;
}
// Derived helpers
get hasData() {
return this.fonts.length > 0;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
get fontCount() {
return this.fonts.length;
}
// Filtered fonts by category (if you need additional client-side filtering)
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
/**
* Update parameters - TanStack Query will automatically refetch
*/
setParams(newParams: Partial<GoogleFontsParams>) {
this.params = { ...this.params, ...newParams };
}
setCategory(category: FontCategory | undefined) {
this.setParams({ category });
}
setSubset(subset: FontSubset | undefined) {
this.setParams({ subset });
}
setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) {
this.setParams({ sort });
}
setSearch(search: string) {
this.setParams({ search });
}
clearSearch() {
this.setParams({ search: undefined });
}
clearFilters() {
this.params = {};
}
/**
* Manually refetch
*/
async refetch() {
await this.query.refetch();
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.queryClient.invalidateQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Invalidate all Google Fonts queries
*/
invalidateAll() {
this.queryClient.invalidateQueries({
queryKey: ['googleFonts'],
});
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
async prefetch(params: GoogleFontsParams) {
await this.queryClient.prefetchQuery({
queryKey: getGoogleFontsQueryKey(params),
queryFn: () => fetchGoogleFontsQuery(params),
staleTime: 5 * 60 * 1000,
});
}
/**
* Prefetch next category (useful for tab switching)
*/
async prefetchCategory(category: FontCategory) {
await this.prefetch({ ...this.params, category });
}
/**
* Cancel ongoing queries
*/
cancel() {
this.queryClient.cancelQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.queryClient.removeQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.queryClient.getQueryData<UnifiedFont[]>(
getGoogleFontsQueryKey(this.params),
);
}
/**
* Check if data exists in cache
*/
hasCache(params?: GoogleFontsParams) {
const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params);
return this.queryClient.getQueryData(key) !== undefined;
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.queryClient.setQueryData(
getGoogleFontsQueryKey(this.params),
updater,
);
}
/**
* Get query state for debugging
*/
getQueryState() {
return this.queryClient.getQueryState(
getGoogleFontsQueryKey(this.params),
);
}
}
/**
* Factory function to create Google Fonts store
*/
export function createGoogleFontsStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}

View File

@@ -0,0 +1,2 @@
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';

View File

@@ -0,0 +1,156 @@
import { queryClient } from '$shared/api/queryClient';
import {
type QueryKey,
QueryObserver,
type QueryObserverOptions,
type QueryObserverResult,
} from '@tanstack/query-core';
import type { UnifiedFont } from '../types';
/** */
export abstract class BaseFontStore<TParams extends Record<string, any>> {
// params = $state<TParams>({} as TParams);
cleanup: () => void;
#bindings = $state<(() => Partial<TParams>)[]>([]);
#internalParams = $state<TParams>({} as TParams);
params = $derived.by(() => {
let merged = { ...this.#internalParams };
// Loop through every "Cable" plugged into the store
for (const getter of this.#bindings) {
merged = { ...merged, ...getter() };
}
return merged as TParams;
});
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
protected observer: QueryObserver<UnifiedFont[], Error>;
protected qc = queryClient;
constructor(initialParams: TParams) {
this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack -> Svelte State
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte State -> TanStack Options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
* Mandatory: Child must define how to fetch data and what the key is.
*/
protected abstract getQueryKey(params: TParams): QueryKey;
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
private getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
staleTime: 5 * 60 * 1000,
};
}
// --- Common Getters ---
get fonts() {
return this.result.data ?? [];
}
get isLoading() {
return this.result.isLoading;
}
get isFetching() {
return this.result.isFetching;
}
get isError() {
return this.result.isError;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
// --- Common Actions ---
addBinding(getter: () => Partial<TParams>) {
this.#bindings.push(getter);
return () => {
this.#bindings = this.#bindings.filter(b => b !== getter);
};
}
setParams(newParams: Partial<TParams>) {
this.#internalParams = { ...this.params, ...newParams };
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
}
destroy() {
this.cleanup();
}
/**
* Manually refetch
*/
async refetch() {
await this.observer.refetch();
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
async prefetch(params: TParams) {
await this.qc.prefetchQuery(this.getOptions(params));
}
/**
* Cancel ongoing queries
*/
cancel() {
this.qc.cancelQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.qc.removeQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.qc.getQueryData<UnifiedFont[]>(
this.getQueryKey(this.params),
);
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.qc.setQueryData(
this.getQueryKey(this.params),
updater,
);
}
}

View File

@@ -0,0 +1,32 @@
import type { FontshareParams } from '../../api';
import { fetchFontshareFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Fontshare store wrapping TanStack Query with runes
*/
export class FontshareStore extends BaseFontStore<FontshareParams> {
constructor(initialParams: FontshareParams = {}) {
super(initialParams);
}
protected getQueryKey(params: FontshareParams) {
return ['fontshare', params] as const;
}
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
return fetchFontshareFontsQuery(params);
}
// Provider-specific methods (shortcuts)
setSearch(search: string) {
this.setParams({ q: search } as any);
}
}
export function createFontshareStore(params: FontshareParams = {}) {
return new FontshareStore(params);
}
export const fontshareStore = new FontshareStore();

View File

@@ -0,0 +1,27 @@
import type { GoogleFontsParams } from '../../api';
import { fetchGoogleFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore extends BaseFontStore<GoogleFontsParams> {
constructor(initialParams: GoogleFontsParams = {}) {
super(initialParams);
}
protected getQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
protected async fetchFn(params: GoogleFontsParams): Promise<UnifiedFont[]> {
return fetchGoogleFontsQuery(params);
}
}
export function createFontshareStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}
export const googleFontsStore = new GoogleFontsStore();

View File

@@ -0,0 +1,19 @@
/**
* ============================================================================
* UNIFIED FONT STORE EXPORTS
* ============================================================================
*
* Single export point for the unified font store infrastructure.
*/
// export {
// createUnifiedFontStore,
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from './unifiedFontStore.svelte';
export {
createFontshareStore,
type FontshareStore,
fontshareStore,
} from './fontshareStore.svelte';

View File

@@ -0,0 +1,43 @@
/**
* ============================================================================
* UNIFIED FONT STORE TYPES
* ============================================================================
*
* Type definitions for the unified font store infrastructure.
* Provides types for filters, sorting, and fetch parameters.
*/
import type {
FontshareParams,
GoogleFontsParams,
} from '$entities/Font/api';
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font/model/types/common';
/**
* Sort configuration
*/
export interface FontSort {
field: 'name' | 'popularity' | 'category' | 'date';
direction: 'asc' | 'desc';
}
/**
* Fetch params for unified API
*/
export interface FetchFontsParams {
providers?: FontProvider[];
categories?: FontCategory[];
subsets?: FontSubset[];
search?: string;
sort?: FontSort;
forceRefetch?: boolean;
}
/**
* Provider-specific params union
*/
export type ProviderParams = GoogleFontsParams | FontshareParams;

View File

@@ -0,0 +1,29 @@
import {
type Filter,
type FilterModel,
createFilter,
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
import type { FontProvider } from '../types';
import type { CheckboxFilter } from '../types/common';
import type { BaseFontStore } from './baseFontStore.svelte';
import { createFontshareStore } from './fontshareStore.svelte';
import type { ProviderParams } from './types';
export class UnitedFontStore {
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>;
filters: SvelteMap<CheckboxFilter, Filter>;
queryValue = $state('');
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) {
this.sources = {
fontshare: createFontshareStore(initialConfig?.fontshare),
};
this.filters = new SvelteMap();
}
get fonts() {
return Object.values(this.sources).map(store => store.fonts).flat();
}
}

View File

@@ -0,0 +1,34 @@
/**
* ============================================================================
* DOMAIN TYPES
* ============================================================================
*/
import type { FontCategory as FontshareFontCategory } from './fontshare';
import type { FontCategory as GoogleFontCategory } from './google';
/**
* Font category
*/
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
/**
* Font provider
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Font subset
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Filter state
*/
export interface FontFilters {
providers: FontProvider[];
categories: FontCategory[];
subsets: FontSubset[];
}
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
export type FilterType = CheckboxFilter | 'searchQuery';

View File

@@ -1,12 +1,39 @@
import type { CollectionApiModel } from '../../../shared/types/collection';
/**
* ============================================================================
* FONTHARE API TYPES
* ============================================================================
*/
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
/**
* Model of Fontshare API response
* @see https://fontshare.com
*
* Fontshare API uses 'fonts' key instead of 'items' for the array
*/
export type FontshareApiModel = CollectionApiModel<FontshareFont>;
export interface FontshareApiModel {
/**
* Number of items returned in current page/response
*/
count: number;
/**
* Total number of items available across all pages
*/
count_total: number;
/**
* Indicates if there are more items available beyond this page
*/
has_more: boolean;
/**
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
*/
fonts: FontshareFont[];
}
/**
* Individual font metadata from Fontshare API

View File

@@ -1,3 +1,11 @@
/**
* ============================================================================
* GOOGLE FONTS API TYPES
* ============================================================================
*/
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/**
* Model of google fonts api response
*/
@@ -9,6 +17,9 @@ export interface GoogleFontsApiModel {
items: FontItem[];
}
/**
* Individual font from Google Fonts API
*/
export interface FontItem {
/**
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
@@ -20,7 +31,7 @@ export interface FontItem {
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
* Useful for grouping and filtering fonts by style
*/
category: string;
category: FontCategory;
/**
* Available font variants for this font family
@@ -69,6 +80,12 @@ export interface FontItem {
menu: string;
}
/**
* Type alias for backward compatibility
* Google Fonts API font item
*/
export type GoogleFontItem = FontItem;
/**
* Standard font weights that can appear in Google Fonts API
*/

View File

@@ -0,0 +1,58 @@
/**
* ============================================================================
* SINGLE EXPORT POINT
* ============================================================================
*
* This is the single export point for all Font types.
* All imports should use: `import { X } from '$entities/Font/model/types'`
*/
// Domain types
export type {
FontCategory,
FontProvider,
FontSubset,
} from './common';
// Google Fonts API types
export type {
FontFiles,
FontItem,
FontVariant,
FontWeight,
FontWeightItalic,
GoogleFontItem,
GoogleFontsApiModel,
} from './google';
// Fontshare API types
export type {
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
} from './fontshare';
export { FONTSHARE_API_URL } from './fontshare';
// Normalization types
export type {
FontFeatures,
FontMetadata,
FontStyleUrls,
UnifiedFont,
UnifiedFontVariant,
} from './normalize';
// Store types
export type {
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
} from './store';

View File

@@ -0,0 +1,89 @@
/**
* ============================================================================
* NORMALIZATION TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
FontSubset,
} from './common';
/**
* Font variant types (standardized)
*/
export type UnifiedFontVariant = string;
/**
* Font style URLs
*/
export interface FontStyleUrls {
/** Regular weight URL */
regular?: string;
/** Italic URL */
italic?: string;
/** Bold weight URL */
bold?: string;
/** Bold italic URL */
boldItalic?: string;
}
/**
* Font metadata
*/
export interface FontMetadata {
/** Timestamp when font was cached */
cachedAt: number;
/** Font version from provider */
version?: string;
/** Last modified date from provider */
lastModified?: string;
/** Popularity rank (if available from provider) */
popularity?: number;
}
/**
* Font features (variable fonts, axes, tags)
*/
export interface FontFeatures {
/** Whether this is a variable font */
isVariable?: boolean;
/** Variable font axes (for Fontshare) */
axes?: Array<{
name: string;
property: string;
default: number;
min: number;
max: number;
}>;
/** Usage tags (for Fontshare) */
tags?: string[];
}
/**
* Unified font model
*
* Combines Google Fonts and Fontshare data into a common interface
* for consistent font handling across the application.
*/
export interface UnifiedFont {
/** Unique identifier (Google: family name, Fontshare: slug) */
id: string;
/** Font display name */
name: string;
/** Font provider (google | fontshare) */
provider: FontProvider;
/** Font category classification */
category: FontCategory;
/** Supported character subsets */
subsets: FontSubset[];
/** Available font variants (weights, styles) */
variants: UnifiedFontVariant[];
/** URL mapping for font file downloads */
styles: FontStyleUrls;
/** Additional metadata */
metadata: FontMetadata;
/** Advanced font features */
features: FontFeatures;
}

View File

@@ -0,0 +1,48 @@
/**
* ============================================================================
* STORE TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
FontSubset,
} from './common';
import type { UnifiedFont } from './normalize';
/**
* Font collection state
*/
export interface FontCollectionState {
/** All cached fonts */
fonts: Record<string, UnifiedFont>;
/** Active filters */
filters: FontCollectionFilters;
/** Sort configuration */
sort: FontCollectionSort;
}
/**
* Font collection filters
*/
export interface FontCollectionFilters {
/** Search query */
searchQuery: string;
/** Filter by providers */
providers?: FontProvider[];
/** Filter by categories */
categories?: FontCategory[];
/** Filter by subsets */
subsets?: FontSubset[];
}
/**
* Font collection sort configuration
*/
export interface FontCollectionSort {
/** Sort field */
field: 'name' | 'popularity' | 'category';
/** Sort direction */
direction: 'asc' | 'desc';
}

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
import {
Content as ItemContent,
Root as ItemRoot,
Title as ItemTitle,
} from '$shared/shadcn/ui/item';
import { VirtualList } from '$shared/ui';
/**
* FontList
*
* Displays a virtualized list of fonts with loading, empty, and error states.
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
*/
interface FontListProps {
/** Font items to display (defaults to filtered fonts from store) */
fonts?: UnifiedFont[];
/** Show loading state */
loading?: boolean;
/** Show empty state when no results */
showEmpty?: boolean;
/** Custom error message to display */
errorMessage?: string;
}
let {
fonts,
loading,
showEmpty = true,
errorMessage,
}: FontListProps = $props();
// const fontshareStore = getFontshareContext();
</script>
{#each fontshareStore.fonts as font (font.id)}
<ItemRoot>
<ItemContent>
<ItemTitle>{font.name}</ItemTitle>
<span class="text-xs text-muted-foreground">
{font.category}{font.provider}
</span>
</ItemContent>
</ItemRoot>
{/each}

View File

@@ -0,0 +1,3 @@
import FontList from './FontList/FontList.svelte';
export { FontList };

View File

@@ -1,5 +0,0 @@
export { categoryFilterStore } from './model/stores/categoryFilterStore';
export { providersFilterStore } from './model/stores/providersFilterStore';
export { subsetsFilterStore } from './model/stores/subsetsFilterStore';
export { clearAllFilters } from './model/services/clearAllFilters/clearAllFilters';

View File

@@ -1,9 +0,0 @@
import { categoryFilterStore } from '../../stores/categoryFilterStore';
import { providersFilterStore } from '../../stores/providersFilterStore';
import { subsetsFilterStore } from '../../stores/subsetsFilterStore';
export function clearAllFilters() {
categoryFilterStore.deselectAllProperties();
providersFilterStore.deselectAllProperties();
subsetsFilterStore.deselectAllProperties();
}

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/store/createFilterStore';
import { FONT_CATEGORIES } from '../const/const';
/**
* Initial state for CategoryFilter
*/
export const initialState: FilterModel = {
searchQuery: '',
properties: FONT_CATEGORIES,
};
/**
* CategoryFilter store
*/
export const categoryFilterStore = createFilterStore(initialState);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/store/createFilterStore';
import { FONT_PROVIDERS } from '../const/const';
/**
* Initial state for ProvidersFilter
*/
export const initialState: FilterModel = {
searchQuery: '',
properties: FONT_PROVIDERS,
};
/**
* ProvidersFilter store
*/
export const providersFilterStore = createFilterStore(initialState);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/store/createFilterStore';
import { FONT_SUBSETS } from '../const/const';
/**
* Initial state for SubsetsFilter
*/
const initialState: FilterModel = {
searchQuery: '',
properties: FONT_SUBSETS,
};
/**
* SubsetsFilter store
*/
export const subsetsFilterStore = createFilterStore(initialState);

View File

@@ -0,0 +1,19 @@
export {
createFilterManager,
type FilterManager,
mapManagerToParams,
} from './lib';
export {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from './model/const/const';
export { filterManager } from './model/state/manager.svelte';
export {
FilterControls,
Filters,
FontSearch,
} from './ui';

View File

@@ -0,0 +1,63 @@
import { createFilter } from '$shared/lib';
import { createDebouncedState } from '$shared/lib/helpers';
import type { FilterConfig } from '../../model';
/**
* Create a filter manager instance.
*/
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
// Create filter instances upfront
const groups = $state(
config.groups.map(config => ({
id: config.id,
label: config.label,
instance: createFilter({ properties: config.properties }),
})),
);
// Derived: any selection across all groups
const hasAnySelection = $derived(
groups.some(group => group.instance.selectedProperties.length > 0),
);
return {
// Getter for queryValue (immediate value for UI)
get queryValue() {
return search.immediate;
},
// Setter for queryValue
set queryValue(value) {
search.immediate = value;
},
// Getter for queryValue (debounced value for logic)
get debouncedQueryValue() {
return search.debounced;
},
// Direct array reference (reactive)
get groups() {
return groups;
},
// Derived values
get hasAnySelection() {
return hasAnySelection;
},
// Global action
deselectAllGlobal: () => {
groups.forEach(group => group.instance.deselectAll());
},
// Helper to get group by id
getGroup: (id: string) => {
return groups.find(g => g.id === id);
},
};
}
export type FilterManager = ReturnType<typeof createFilterManager>;

View File

@@ -0,0 +1,6 @@
export {
createFilterManager,
type FilterManager,
} from './filterManager/filterManager.svelte';
export { mapManagerToParams } from './mapper/mapManagerToParams';

View File

@@ -0,0 +1,12 @@
import type { FontshareParams } from '$entities/Font';
import type { FilterManager } from '../filterManager/filterManager.svelte';
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
return {
q: manager.debouncedQueryValue,
// Map groups to specific API keys
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
?? [],
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [],
};
}

View File

@@ -1,70 +1,90 @@
import type { Property } from '$shared/store/createFilterStore';
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font';
import type { Property } from '$shared/lib';
export const FONT_CATEGORIES: Property[] = [
export const FONT_CATEGORIES: Property<FontCategory>[] = [
{
id: 'serif',
name: 'Serif',
value: 'serif',
},
{
id: 'sans-serif',
name: 'Sans-serif',
value: 'sans-serif',
},
{
id: 'display',
name: 'Display',
value: 'display',
},
{
id: 'handwriting',
name: 'Handwriting',
value: 'handwriting',
},
{
id: 'monospace',
name: 'Monospace',
value: 'monospace',
},
{
id: 'script',
name: 'Script',
value: 'script',
},
{
id: 'slab',
name: 'Slab',
value: 'slab',
},
] as const;
export const FONT_PROVIDERS: Property[] = [
export const FONT_PROVIDERS: Property<FontProvider>[] = [
{
id: 'google',
name: 'Google Fonts',
value: 'google',
},
{
id: 'fontshare',
name: 'Fontshare',
value: 'fontshare',
},
] as const;
export const FONT_SUBSETS: Property[] = [
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',
},
] as const;

View File

@@ -0,0 +1,6 @@
export type {
FilterConfig,
FilterGroupConfig,
} from './types/filter';
export { filterManager } from './state/manager.svelte';

View File

@@ -0,0 +1,29 @@
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from '../const/const';
const initialConfig = {
queryValue: '',
groups: [
{
id: 'providers',
label: 'Font provider',
properties: FONT_PROVIDERS,
},
{
id: 'subsets',
label: 'Font subset',
properties: FONT_SUBSETS,
},
{
id: 'categories',
label: 'Font category',
properties: FONT_CATEGORIES,
},
],
};
export const filterManager = createFilterManager(initialConfig);

View File

@@ -0,0 +1,12 @@
import type { Property } from '$shared/lib';
export interface FilterGroupConfig<TValue extends string> {
id: string;
label: string;
properties: Property<TValue>[];
}
export interface FilterConfig<TValue extends string> {
queryValue?: string;
groups: FilterGroupConfig<TValue>[];
}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
/**
* Filters Component
*
* Orchestrates all filter properties for the sidebar. Connects filter stores
* to CheckboxFilter components, organizing them by filter type:
*
* - Font provider: Google Fonts vs Fontshare
* - Font subset: Character subsets available (Latin, Latin Extended, etc.)
* - Font category: Serif, Sans-serif, Display, etc.
*
* This component handles reactive sync between filterManager selections
* and the unifiedFontStore using an $effect block to ensure filters are
* automatically synchronized whenever selections change.
*/
import { CheckboxFilter } from '$shared/ui';
import { filterManager } from '../../model';
</script>
{#each filterManager.groups as group (group.id)}
<CheckboxFilter
displayedLabel={group.label}
filter={group.instance}
/>
{/each}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Button } from '$shared/shadcn/ui/button';
import { filterManager } from '../../model';
/**
* Controls Component
*
* Action button group for filter operations. Provides two buttons:
*
* - Reset: Clears all active filters (outline variant for secondary action)
*/
</script>
<div class="flex flex-row gap-2">
<Button
variant="outline"
class="flex-1 cursor-pointer"
onclick={filterManager.deselectAllGlobal}
>
Reset
</Button>
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import {
FontList,
fontshareStore,
} from '$entities/Font';
import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
/**
* FontSearch
*
* Font search component with search input and font list display.
* Uses unifiedFontStore for all font operations and search state.
*/
onMount(() => {
/**
* The Pairing:
* We "plug" this manager into the global store.
* addBinding returns a function that removes this binding when the component unmounts.
*/
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
return unbind;
});
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
</script>
<SearchBar
id="font-search"
class="w-full"
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<FontList />
</SearchBar>

View File

@@ -0,0 +1,9 @@
import Filters from './Filters/Filters.svelte';
import FilterControls from './FiltersControl/FilterControls.svelte';
import FontSearch from './FontSearch/FontSearch.svelte';
export {
FilterControls,
Filters,
FontSearch,
};

View File

@@ -0,0 +1,18 @@
import SetupFontMenu from './ui/SetupFontMenu.svelte';
export {
controlManager,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from './model';
export { SetupFontMenu };

View File

@@ -0,0 +1,22 @@
import {
type ControlModel,
createTypographyControl,
} from '$shared/lib';
export function createTypographyControlManager(configs: ControlModel[]) {
const controls = $state(
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
id,
increaseLabel,
decreaseLabel,
controlLabel,
instance: createTypographyControl(config),
})),
);
return {
get controls() {
return controls;
},
};
}

View File

@@ -0,0 +1 @@
export { createTypographyControlManager } from './controlManager/controlManager.svelte';

View File

@@ -1,13 +1,22 @@
/**
* Font size constants
*/
export const DEFAULT_FONT_SIZE = 16;
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 100;
export const FONT_SIZE_STEP = 1;
/**
* Font weight constants
*/
export const DEFAULT_FONT_WEIGHT = 400;
export const MIN_FONT_WEIGHT = 100;
export const MAX_FONT_WEIGHT = 900;
export const FONT_WEIGHT_STEP = 100;
/**
* Line height constants
*/
export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2;

View File

@@ -0,0 +1,16 @@
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from './const/const';
export { controlManager } from './state/manager.svelte';

View File

@@ -0,0 +1,54 @@
import type { ControlModel } from '$shared/lib';
import { createTypographyControlManager } from '../../lib';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from '../const/const';
const controlData: ControlModel[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Font Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Font Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Line Height',
},
];
export const controlManager = createTypographyControlManager(controlData);

View File

@@ -1,17 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/store/createControlStore';
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
};
export const fontSizeStore = createControlStore(initialValue);

View File

@@ -1,19 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/store/createControlStore';
import {
DEFAULT_FONT_WEIGHT,
FONT_WEIGHT_STEP,
MAX_FONT_WEIGHT,
MIN_FONT_WEIGHT,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
};
export const fontWeightStore = createControlStore(initialValue);

View File

@@ -1,19 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/store/createControlStore';
import {
DEFAULT_LINE_HEIGHT,
LINE_HEIGHT_STEP,
MAX_LINE_HEIGHT,
MIN_LINE_HEIGHT,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
};
export const lineHeightStore = createControlStore(initialValue);

View File

@@ -1,55 +1,19 @@
<script lang="ts">
import * as Item from '$shared/shadcn/ui/item';
/**
* Component containing controls for setting up font properties.
*/
import { Separator } from '$shared/shadcn/ui/separator/index';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import ComboControl from '$shared/ui/ComboControl/ComboControl.svelte';
import { fontSizeStore } from '../model/stores/fontSizeStore';
import { fontWeightStore } from '../model/stores/fontWeightStore';
import { lineHeightStore } from '../model/stores/lineHeightStore';
const fontSize = $derived($fontSizeStore);
const fontWeight = $derived($fontWeightStore);
const lineHeight = $derived($lineHeightStore);
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
import { ComboControl } from '$shared/ui';
import { controlManager } from '../model';
</script>
<div class="w-full p-2 flex flex-row items-center">
<Sidebar.Trigger />
<div class="p-2 flex flex-row items-center gap-2">
<SidebarTrigger />
<Separator orientation="vertical" class="h-full" />
<ComboControl
value={fontSize.value}
minValue={fontSize.min}
maxValue={fontSize.max}
onChange={fontSizeStore.setValue}
onIncrease={fontSizeStore.increase}
onDecrease={fontSizeStore.decrease}
increaseDisabled={fontSizeStore.isAtMax()}
decreaseDisabled={fontSizeStore.isAtMin()}
increaseLabel="Increase Font Size"
decreaseLabel="Decrease Font Size"
/>
<ComboControl
value={fontWeight.value}
minValue={fontWeight.min}
maxValue={fontWeight.max}
onChange={fontWeightStore.setValue}
onIncrease={fontWeightStore.increase}
onDecrease={fontWeightStore.decrease}
increaseDisabled={fontWeightStore.isAtMax()}
decreaseDisabled={fontWeightStore.isAtMin()}
increaseLabel="Increase Font Weight"
decreaseLabel="Decrease Font Weight"
/>
<ComboControl
value={lineHeight.value}
minValue={lineHeight.min}
maxValue={lineHeight.max}
step={lineHeight.step}
onChange={lineHeightStore.setValue}
onIncrease={lineHeightStore.increase}
onDecrease={lineHeightStore.decrease}
increaseDisabled={lineHeightStore.isAtMax()}
decreaseDisabled={lineHeightStore.isAtMin()}
increaseLabel="Increase Line Height"
decreaseLabel="Decrease Line Height"
/>
<div class="flex flex-row gap-2">
{#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} />
{/each}
</div>
</div>

View File

@@ -1,16 +1,27 @@
<script>
<script lang="ts">
/**
* Page Component
*
* Main page route component. This is the default route that users see when
* accessing the application. Currently displays a welcome message.
* Main page route component. Displays the font list and allows testing
* the unified font store functionality. Fetches fonts on mount and displays
* them using the FontList component.
*
* Note: This is a placeholder component. Replace with actual application content
* as the font comparison and filtering features are implemented.
* Receives unifiedFontStore from context created in Layout.svelte.
*/
// import {
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from '$entities/Font/model/store/unifiedFontStore.svelte';
import FontList from '$entities/Font/ui/FontList/FontList.svelte';
// import { applyFilters } from '$features/FontManagement';
import {
getContext,
onMount,
} from 'svelte';
// Receive store from context (created in Layout.svelte)
// const unifiedFontStore: UnifiedFontStore = getContext(UNIFIED_FONT_STORE_KEY);
</script>
<h1>Welcome to Svelte + Vite</h1>
<p>
Visit <a href="https://svelte.dev/docs">svelte.dev/docs</a> to read the documentation
</p>
<!-- Font List -->
<FontList showEmpty={true} />

View File

@@ -0,0 +1,26 @@
import { QueryClient } from '@tanstack/query-core';
/**
* Query client instance
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
/**
* Default staleTime: 5 minutes
*/
staleTime: 5 * 60 * 1000,
/**
* Default gcTime: 10 minutes
*/
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: true,
retry: 3,
/**
* Exponential backoff
*/
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});

View File

@@ -0,0 +1,445 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
type CacheItemInternalState,
type CacheOptions,
createCollectionCache,
} from './collectionCache';
describe('createCollectionCache', () => {
let cache: ReturnType<typeof createCollectionCache<number>>;
beforeEach(() => {
cache = createCollectionCache<number>();
});
describe('initialization', () => {
it('initializes with empty cache', () => {
const data = get(cache.data);
expect(data).toEqual({});
});
it('initializes with default options', () => {
const stats = cache.getStats();
expect(stats.total).toBe(0);
expect(stats.cached).toBe(0);
expect(stats.fetching).toBe(0);
expect(stats.errors).toBe(0);
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
});
it('accepts custom cache options', () => {
const options: CacheOptions = {
defaultTTL: 10 * 60 * 1000, // 10 minutes
maxSize: 500,
};
const customCache = createCollectionCache<number>(options);
expect(customCache).toBeDefined();
});
});
describe('set and get', () => {
it('sets a value in cache', () => {
cache.set('key1', 100);
const value = cache.get('key1');
expect(value).toBe(100);
});
it('sets multiple values in cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.set('key3', 300);
expect(cache.get('key1')).toBe(100);
expect(cache.get('key2')).toBe(200);
expect(cache.get('key3')).toBe(300);
});
it('updates existing value', () => {
cache.set('key1', 100);
cache.set('key1', 150);
expect(cache.get('key1')).toBe(150);
});
it('returns undefined for non-existent key', () => {
const value = cache.get('non-existent');
expect(value).toBeUndefined();
});
it('marks item as ready after set', () => {
cache.set('key1', 100);
const internalState = cache.getInternalState('key1');
expect(internalState?.ready).toBe(true);
expect(internalState?.fetching).toBe(false);
});
});
describe('has and hasFresh', () => {
it('returns false for non-existent key', () => {
expect(cache.has('non-existent')).toBe(false);
expect(cache.hasFresh('non-existent')).toBe(false);
});
it('returns true after setting value', () => {
cache.set('key1', 100);
expect(cache.has('key1')).toBe(true);
expect(cache.hasFresh('key1')).toBe(true);
});
it('returns false for fetching items', () => {
cache.markFetching('key1');
expect(cache.has('key1')).toBe(false);
expect(cache.hasFresh('key1')).toBe(false);
});
it('returns false for failed items', () => {
cache.markFailed('key1', 'Network error');
expect(cache.has('key1')).toBe(false);
expect(cache.hasFresh('key1')).toBe(false);
});
});
describe('remove', () => {
it('removes a value from cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.remove('key1');
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBe(200);
});
it('removes internal state', () => {
cache.set('key1', 100);
cache.remove('key1');
const state = cache.getInternalState('key1');
expect(state).toBeUndefined();
});
it('does nothing for non-existent key', () => {
expect(() => cache.remove('non-existent')).not.toThrow();
});
});
describe('clear', () => {
it('clears all values from cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.set('key3', 300);
cache.clear();
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBeUndefined();
expect(cache.get('key3')).toBeUndefined();
});
it('clears internal state', () => {
cache.set('key1', 100);
cache.clear();
const state = cache.getInternalState('key1');
expect(state).toBeUndefined();
});
it('resets cache statistics', () => {
cache.set('key1', 100); // This increments hits
const statsBefore = cache.getStats();
cache.clear();
const statsAfter = cache.getStats();
expect(statsAfter.hits).toBe(0);
expect(statsAfter.misses).toBe(0);
});
});
describe('markFetching', () => {
it('marks item as fetching', () => {
cache.markFetching('key1');
expect(cache.isFetching('key1')).toBe(true);
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(true);
expect(state?.ready).toBe(false);
expect(state?.startTime).toBeDefined();
});
it('updates existing state when called again', () => {
cache.markFetching('key1');
const startTime1 = cache.getInternalState('key1')?.startTime;
// Wait a bit to ensure different timestamp
vi.useFakeTimers();
vi.advanceTimersByTime(100);
cache.markFetching('key1');
const startTime2 = cache.getInternalState('key1')?.startTime;
expect(startTime2).toBeGreaterThan(startTime1!);
vi.useRealTimers();
});
it('sets endTime to undefined', () => {
cache.markFetching('key1');
const state = cache.getInternalState('key1');
expect(state?.endTime).toBeUndefined();
});
});
describe('markFailed', () => {
it('marks item as failed with error message', () => {
cache.markFailed('key1', 'Network error');
expect(cache.isFetching('key1')).toBe(false);
const error = cache.getError('key1');
expect(error).toBe('Network error');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(false);
expect(state?.ready).toBe(false);
expect(state?.error).toBe('Network error');
});
it('preserves start time from fetching state', () => {
cache.markFetching('key1');
const startTime = cache.getInternalState('key1')?.startTime;
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.startTime).toBe(startTime);
});
it('sets end time', () => {
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.endTime).toBeDefined();
});
it('increments error counter', () => {
const statsBefore = cache.getStats();
cache.markFailed('key1', 'Error1');
const statsAfter1 = cache.getStats();
expect(statsAfter1.errors).toBe(statsBefore.errors + 1);
cache.markFailed('key2', 'Error2');
const statsAfter2 = cache.getStats();
expect(statsAfter2.errors).toBe(statsAfter1.errors + 1);
});
});
describe('markMiss', () => {
it('increments miss counter', () => {
const statsBefore = cache.getStats();
cache.markMiss();
const statsAfter = cache.getStats();
expect(statsAfter.misses).toBe(statsBefore.misses + 1);
});
it('increments miss counter multiple times', () => {
const statsBefore = cache.getStats();
cache.markMiss();
cache.markMiss();
cache.markMiss();
const statsAfter = cache.getStats();
expect(statsAfter.misses).toBe(statsBefore.misses + 3);
});
});
describe('statistics', () => {
it('tracks total number of items', () => {
expect(cache.getStats().total).toBe(0);
cache.set('key1', 100);
expect(cache.getStats().total).toBe(1);
cache.set('key2', 200);
expect(cache.getStats().total).toBe(2);
cache.remove('key1');
expect(cache.getStats().total).toBe(1);
});
it('tracks number of cached (ready) items', () => {
expect(cache.getStats().cached).toBe(0);
cache.set('key1', 100);
expect(cache.getStats().cached).toBe(1);
cache.set('key2', 200);
expect(cache.getStats().cached).toBe(2);
cache.markFetching('key3');
expect(cache.getStats().cached).toBe(2);
});
it('tracks number of fetching items', () => {
expect(cache.getStats().fetching).toBe(0);
cache.markFetching('key1');
expect(cache.getStats().fetching).toBe(1);
cache.markFetching('key2');
expect(cache.getStats().fetching).toBe(2);
cache.set('key1', 100);
expect(cache.getStats().fetching).toBe(1);
});
it('tracks cache hits', () => {
const statsBefore = cache.getStats();
cache.set('key1', 100);
const statsAfter1 = cache.getStats();
expect(statsAfter1.hits).toBe(statsBefore.hits + 1);
cache.set('key2', 200);
const statsAfter2 = cache.getStats();
expect(statsAfter2.hits).toBe(statsAfter1.hits + 1);
});
it('provides derived stats store', () => {
cache.set('key1', 100);
cache.markFetching('key2');
const stats = get(cache.stats);
expect(stats.total).toBe(1);
expect(stats.cached).toBe(1);
expect(stats.fetching).toBe(1);
});
});
describe('store reactivity', () => {
it('updates data store reactively', () => {
let dataUpdates = 0;
const unsubscribe = cache.data.subscribe(() => {
dataUpdates++;
});
cache.set('key1', 100);
cache.set('key2', 200);
expect(dataUpdates).toBeGreaterThan(0);
unsubscribe();
});
it('updates internal state store reactively', () => {
let internalUpdates = 0;
const unsubscribe = cache.internal.subscribe(() => {
internalUpdates++;
});
cache.markFetching('key1');
cache.set('key1', 100);
cache.markFailed('key2', 'Error');
expect(internalUpdates).toBeGreaterThan(0);
unsubscribe();
});
it('updates stats store reactively', () => {
let statsUpdates = 0;
const unsubscribe = cache.stats.subscribe(() => {
statsUpdates++;
});
cache.set('key1', 100);
cache.markMiss();
expect(statsUpdates).toBeGreaterThan(0);
unsubscribe();
});
});
describe('edge cases', () => {
it('handles complex types', () => {
interface ComplexType {
id: string;
value: number;
tags: string[];
}
const complexCache = createCollectionCache<ComplexType>();
const item: ComplexType = {
id: '1',
value: 42,
tags: ['a', 'b', 'c'],
};
complexCache.set('item1', item);
const retrieved = complexCache.get('item1');
expect(retrieved).toEqual(item);
expect(retrieved?.tags).toEqual(['a', 'b', 'c']);
});
it('handles special characters in keys', () => {
cache.set('key with spaces', 1);
cache.set('key/with/slashes', 2);
cache.set('key-with-dashes', 3);
expect(cache.get('key with spaces')).toBe(1);
expect(cache.get('key/with/slashes')).toBe(2);
expect(cache.get('key-with-dashes')).toBe(3);
});
it('handles rapid set and remove operations', () => {
for (let i = 0; i < 100; i++) {
cache.set(`key${i}`, i);
}
for (let i = 0; i < 100; i += 2) {
cache.remove(`key${i}`);
}
expect(cache.getStats().total).toBe(50);
expect(cache.get('key0')).toBeUndefined();
expect(cache.get('key1')).toBe(1);
});
});
describe('error handling', () => {
it('handles concurrent markFetching for same key', () => {
cache.markFetching('key1');
cache.markFetching('key1');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(true);
expect(state?.startTime).toBeDefined();
});
it('handles marking failed without prior fetching', () => {
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(false);
expect(state?.ready).toBe(false);
expect(state?.error).toBe('Error');
});
it('handles operations on removed keys', () => {
cache.set('key1', 100);
cache.remove('key1');
expect(() => cache.set('key1', 200)).not.toThrow();
expect(() => cache.remove('key1')).not.toThrow();
expect(() => cache.getError('key1')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,334 @@
/**
* Collection cache manager
*
* Provides key-based caching, deduplication, and request tracking
* for any collection type. Integrates with Svelte stores for reactive updates.
*
* Key features:
* - Key-based caching (any ID, query hash)
* - Request deduplication (prevents concurrent requests for same key)
* - Request state tracking (fetching, ready, error)
* - TTL/staleness management
* - Performance timing tracking
*/
import type {
Readable,
Writable,
} from 'svelte/store';
import {
derived,
get,
writable,
} from 'svelte/store';
/**
* Internal state for a cached item
* Tracks request lifecycle (fetching → ready/error)
*/
export interface CacheItemInternalState {
/** Whether a fetch is currently in progress */
fetching: boolean;
/** Whether data is ready and cached */
ready: boolean;
/** Error message if fetch failed */
error?: string;
/** Request start timestamp (performance tracking) */
startTime?: number;
/** Request end timestamp (performance tracking) */
endTime?: number;
}
/**
* Cache configuration options
*/
export interface CacheOptions {
/** Default time-to-live for cached items (in milliseconds) */
defaultTTL?: number;
/** Maximum number of items to cache (LRU eviction) */
maxSize?: number;
}
/**
* Statistics about cache performance
*/
export interface CacheStats {
/** Total number of items in cache */
total: number;
/** Number of items marked as ready */
cached: number;
/** Number of items currently fetching */
fetching: number;
/** Number of items with errors */
errors: number;
/** Total cache hits (data returned from cache) */
hits: number;
/** Total cache misses (data fetched from API) */
misses: number;
}
/**
* Cache manager interface
* Type-safe interface for collection caching operations
*/
export interface CollectionCacheManager<T> {
/** Get an item from cache by key */
get: (key: string) => T | undefined;
/** Check if item exists in cache and is ready */
has: (key: string) => boolean;
/** Check if item exists and is not stale */
hasFresh: (key: string) => boolean;
/** Set an item in cache (manual cache write) */
set: (key: string, value: T, ttl?: number) => void;
/** Remove item from cache */
remove: (key: string) => void;
/** Clear all items from cache */
clear: () => void;
/** Check if key is currently being fetched */
isFetching: (key: string) => boolean;
/** Get error for a key */
getError: (key: string) => string | undefined;
/** Get internal state for a key (for debugging) */
getInternalState: (key: string) => CacheItemInternalState | undefined;
/** Get cache statistics */
getStats: () => CacheStats;
/** Mark item as fetching (used when starting API request) */
markFetching: (key: string) => void;
/** Mark item as failed (used when API request fails) */
markFailed: (key: string, error: string) => void;
/** Increment cache miss counter */
markMiss: () => void;
/** Store containing cached data */
data: Writable<Record<string, T>>;
/** Store containing internal state (fetching, ready, error) */
internal: Writable<Record<string, CacheItemInternalState>>;
/** Derived store containing cache statistics */
stats: Readable<CacheStats>;
}
/**
* Creates a collection cache manager
*
* @typeParam T - Type of data being cached (e.g., UnifiedFont, Product, User)
* @param options - Cache configuration options
* @returns Cache manager instance
*
* @example
* ```ts
* const fontCache = createCollectionCache<UnifiedFont>({
* defaultTTL: 5 * 60 * 1000, // 5 minutes
* maxSize: 1000
* });
*
* // Set font in cache
* fontCache.set('Roboto', robotoFont);
*
* // Get font from cache
* const font = fontCache.get('Roboto');
* if (fontCache.hasFresh('Roboto')) {
* // Use cached font
* }
* ```
*/
export function createCollectionCache<T>(options: CacheOptions = {}): CollectionCacheManager<T> {
const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options;
// Stores for reactive data
const data: Writable<Record<string, T>> = writable({});
const internal: Writable<Record<string, CacheItemInternalState>> = writable({});
// Cache statistics store
const statsState = writable<CacheStats>({
total: 0,
cached: 0,
fetching: 0,
errors: 0,
hits: 0,
misses: 0,
});
// Derived stats store for reactive updates
const stats = derived([data, internal, statsState], ([$data, $internal, $statsState]) => ({
...$statsState,
total: Object.keys($data).length,
cached: Object.values($internal).filter(s => s.ready).length,
fetching: Object.values($internal).filter(s => s.fetching).length,
errors: Object.values($internal).filter(s => s.error).length,
}));
return {
/**
* Get cached data by key
* Returns undefined if not found
*/
get: (key: string) => {
const currentData = get(data);
return currentData[key];
},
/**
* Check if key exists in cache and is ready
*/
has: (key: string) => {
const currentInternal = get(internal);
const state = currentInternal[key];
return state?.ready === true;
},
/**
* Check if key exists and is not stale (still within TTL)
*/
hasFresh: (key: string) => {
const currentInternal = get(internal);
const currentData = get(data);
const state = currentInternal[key];
if (!state?.ready) {
return false;
}
// Check if item exists in data store
if (!currentData[key]) {
return false;
}
// TODO: Implement TTL check with cachedAt timestamps
// For now, just check ready state
return true;
},
/**
* Set data in cache
* Marks entry as ready and stops fetching state
*/
set: (key: string, value: T, ttl?: number) => {
data.update(d => ({
...d,
[key]: value,
}));
internal.update(i => {
const existingState = i[key];
return {
...i,
[key]: {
fetching: false,
ready: true,
error: undefined,
startTime: existingState?.startTime,
endTime: Date.now(),
},
};
});
// Update statistics (cache hit)
statsState.update(s => ({ ...s, hits: s.hits + 1 }));
},
/**
* Remove item from cache
*/
remove: (key: string) => {
data.update(d => {
const { [key]: _, ...rest } = d;
return rest;
});
internal.update(i => {
const { [key]: _, ...rest } = i;
return rest;
});
},
/**
* Clear all items from cache
*/
clear: () => {
data.set({});
internal.set({});
statsState.update(s => ({ ...s, hits: 0, misses: 0 }));
},
/**
* Check if key is currently being fetched
*/
isFetching: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key]?.fetching === true;
},
/**
* Get error for a key
*/
getError: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key]?.error;
},
/**
* Get internal state for debugging
*/
getInternalState: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key];
},
/**
* Get current cache statistics
*/
getStats: () => {
return get(stats);
},
/**
* Mark item as fetching (used when starting API request)
*/
markFetching: (key: string) => {
internal.update(internal => ({
...internal,
[key]: {
fetching: true,
ready: false,
error: undefined,
startTime: Date.now(),
endTime: undefined,
},
}));
},
/**
* Mark item as failed (used when API request fails)
*/
markFailed: (key: string, error: string) => {
internal.update(internal => {
const existingState = internal[key];
return {
...internal,
[key]: {
fetching: false,
ready: false,
error,
startTime: existingState?.startTime,
endTime: Date.now(),
},
};
});
// Update statistics
const currentStats = get(stats);
statsState.update(s => ({ ...s, errors: currentStats.errors + 1 }));
},
/**
* Increment cache miss counter
*/
markMiss: () => {
statsState.update(s => ({ ...s, misses: s.misses + 1 }));
},
// Expose stores for reactive binding
data,
internal,
stats,
};
}

View File

@@ -0,0 +1,14 @@
/**
* Shared fetch layer exports
*
* Exports collection caching utilities and reactive patterns for Svelte 5
*/
export { createCollectionCache } from './collectionCache';
export type {
CacheItemInternalState,
CacheOptions,
CacheStats,
CollectionCacheManager,
} from './collectionCache';
export { reactiveQueryArgs } from './reactiveQueryArgs';

View File

@@ -0,0 +1,37 @@
import type { Readable } from 'svelte/store';
import { writable } from 'svelte/store';
/**
* Creates a reactive store that maintains stable references for query arguments
*
* This function wraps a callback in a Svelte store that updates via `$effect.pre()`,
* ensuring that the callback is called before DOM updates while maintaining object
* reference stability.
*
* @typeParam T - Type of query arguments (e.g., CreateQueryOptions)
* @param cb - Callback function that computes query arguments
* @returns Readable store containing current query arguments
*
* @example
* ```ts
* const queryArgsStore = reactiveQueryArgs(() => ({
* queryKey: ['fonts', search],
* queryFn: fetchFonts,
* staleTime: 5000
* }));
*
* // Use in component with TanStack Query
* const query = createQuery(queryArgsStore);
* ```
*/
export const reactiveQueryArgs = <T>(cb: () => T): Readable<T> => {
const store = writable<T>();
// Use $effect.pre() to run before DOM updates
// This ensures stable references while staying reactive
$effect.pre(() => {
store.set(cb());
});
return store;
};

View File

@@ -0,0 +1,58 @@
import { debounce } from '$shared/lib/utils';
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
let immediate = $state(initialValue);
let debounced = $state(initialValue);
const updateDebounced = debounce((value: T) => {
debounced = value;
}, wait);
return {
get immediate() {
return immediate;
},
set immediate(value: T) {
immediate = value;
updateDebounced(value); // Manually trigger the debounce on write
},
get debounced() {
return debounced;
},
reset(value?: T) {
const resetValue = value ?? initialValue;
immediate = resetValue;
debounced = resetValue;
},
};
}
// export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
// let immediate = $state(initialValue);
// let debounced = $state(initialValue);
// const updateDebounced = debounce((value: T) => {
// debounced = value;
// }, wait);
// $effect(() => {
// updateDebounced(immediate);
// });
// return {
// get immediate() {
// return immediate;
// },
// set immediate(value: T) {
// immediate = value;
// },
// get debounced() {
// return debounced;
// },
// reset(value?: T) {
// const resetValue = value ?? initialValue;
// immediate = resetValue;
// debounced = resetValue;
// },
// };
// }

View File

@@ -0,0 +1,111 @@
export interface Property<TValue extends string> {
/**
* Property identifier
*/
id: string;
/**
* Property name
*/
name: string;
/**
* Property value
*/
value: TValue;
/**
* Property selected state
*/
selected?: boolean;
}
export interface FilterModel<TValue extends string> {
/**
* Properties
*/
properties: Property<TValue>[];
}
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
*/
export function createFilter<TValue extends string>(
initialState: FilterModel<TValue>,
) {
let properties = $state(
initialState.properties.map(p => ({
...p,
selected: p.selected ?? false,
})),
);
const selectedProperties = $derived(properties.filter(p => p.selected));
const selectedCount = $derived(selectedProperties.length);
return {
/**
* Get all properties.
*/
get properties() {
return properties;
},
/**
* Get selected properties.
*/
get selectedProperties() {
return selectedProperties;
},
/**
* Get selected count.
*/
get selectedCount() {
return selectedCount;
},
/**
* Toggle property selection.
*/
toggleProperty: (id: string) => {
properties = properties.map(p => ({
...p,
selected: p.id === id ? !p.selected : p.selected,
}));
},
/**
* Select property.
*/
selectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? true : p.selected,
}));
},
/**
* Deselect property.
*/
deselectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? false : p.selected,
}));
},
/**
* Select all properties.
*/
selectAll: () => {
properties = properties.map(p => ({
...p,
selected: true,
}));
},
/**
* Deselect all properties.
*/
deselectAll: () => {
properties = properties.map(p => ({
...p,
selected: false,
}));
},
};
}
export type Filter = ReturnType<typeof createFilter>;

View File

@@ -0,0 +1,268 @@
import {
type Filter,
type Property,
createFilter,
} from '$shared/lib';
import {
describe,
expect,
it,
} from 'vitest';
/**
* Test Suite for createFilter Helper Function
*
* This suite tests the Filter logic and state management.
* Component rendering tests are in CheckboxFilter.svelte.test.ts
*/
describe('createFilter - Filter Logic', () => {
// Helper function to create test properties
function createTestProperties(count: number, selectedIndices: number[] = []) {
return Array.from({ length: count }, (_, i) => ({
id: `prop-${i}`,
name: `Property ${i}`,
value: `Value ${i}`,
selected: selectedIndices.includes(i),
}));
}
describe('Filter State Management', () => {
it('creates filter with initial properties', () => {
const filter = createFilter({ properties: createTestProperties(3) });
expect(filter.properties).toHaveLength(3);
});
it('initializes selected properties correctly', () => {
const filter = createFilter({ properties: createTestProperties(3, [1]) });
expect(filter.selectedProperties).toHaveLength(1);
expect(filter.selectedProperties[0].id).toBe('prop-1');
});
it('computes selected count accurately', () => {
const filter = createFilter({ properties: createTestProperties(3, [0, 2]) });
expect(filter.selectedCount).toBe(2);
});
});
describe('Filter Methods', () => {
it('toggleProperty correctly changes selection state', () => {
const filter = createFilter({ properties: createTestProperties(3, [0]) });
const initialSelected = filter.selectedCount;
filter.toggleProperty('prop-1');
expect(filter.selectedCount).toBe(initialSelected + 1);
expect(filter.properties.find(p => p.id === 'prop-1')?.selected).toBe(true);
filter.toggleProperty('prop-1');
expect(filter.selectedCount).toBe(initialSelected);
expect(filter.properties.find(p => p.id === 'prop-1')?.selected).toBe(false);
});
it('selectProperty sets property to selected', () => {
const filter = createFilter({ properties: createTestProperties(3) });
expect(filter.properties.find(p => p.id === 'prop-0')?.selected).toBe(false);
filter.selectProperty('prop-0');
expect(filter.properties.find(p => p.id === 'prop-0')?.selected).toBe(true);
expect(filter.selectedCount).toBe(1);
});
it('deselectProperty sets property to unselected', () => {
const filter = createFilter({ properties: createTestProperties(3, [1]) });
expect(filter.properties.find(p => p.id === 'prop-1')?.selected).toBe(true);
filter.deselectProperty('prop-1');
expect(filter.properties.find(p => p.id === 'prop-1')?.selected).toBe(false);
expect(filter.selectedCount).toBe(0);
});
it('selectAll marks all properties as selected', () => {
const filter = createFilter({ properties: createTestProperties(3, [1]) });
expect(filter.selectedCount).toBe(1);
filter.selectAll();
expect(filter.selectedCount).toBe(3);
expect(filter.properties.every(p => p.selected)).toBe(true);
});
it('deselectAll marks all properties as unselected', () => {
const filter = createFilter({ properties: createTestProperties(3, [0, 1, 2]) });
expect(filter.selectedCount).toBe(3);
filter.deselectAll();
expect(filter.selectedCount).toBe(0);
expect(filter.properties.every(p => !p.selected)).toBe(true);
});
});
describe('Derived State Reactivity', () => {
it('selectedProperties updates when properties change', () => {
const filter = createFilter({ properties: createTestProperties(3, [0]) });
expect(filter.selectedProperties).toHaveLength(1);
filter.selectProperty('prop-1');
expect(filter.selectedProperties).toHaveLength(2);
});
it('selectedCount is accurate after multiple operations', () => {
const filter = createFilter({ properties: createTestProperties(3) });
expect(filter.selectedCount).toBe(0);
filter.selectProperty('prop-0');
expect(filter.selectedCount).toBe(1);
filter.selectProperty('prop-1');
expect(filter.selectedCount).toBe(2);
filter.selectProperty('prop-2');
expect(filter.selectedCount).toBe(3);
filter.deselectProperty('prop-1');
expect(filter.selectedCount).toBe(2);
});
it('handles empty properties array', () => {
const filter = createFilter({ properties: [] });
expect(filter.properties).toHaveLength(0);
expect(filter.selectedCount).toBe(0);
expect(filter.selectedProperties).toHaveLength(0);
});
it('handles all selected properties', () => {
const filter = createFilter({ properties: createTestProperties(3, [0, 1, 2]) });
expect(filter.selectedCount).toBe(3);
expect(filter.selectedProperties).toHaveLength(3);
});
it('handles all unselected properties', () => {
const filter = createFilter({ properties: createTestProperties(3) });
expect(filter.selectedCount).toBe(0);
expect(filter.selectedProperties).toHaveLength(0);
});
});
describe('Property ID Lookup', () => {
it('correctly identifies property by ID for operations', () => {
const filter = createFilter({ properties: createTestProperties(3) });
filter.toggleProperty('prop-0');
expect(filter.properties.find(p => p.id === 'prop-0')?.selected).toBe(true);
filter.deselectProperty('prop-1');
filter.selectProperty('prop-1');
expect(filter.properties.find(p => p.id === 'prop-1')?.selected).toBe(true);
});
it('handles non-existent property IDs gracefully', () => {
const filter = createFilter({ properties: createTestProperties(3, [0]) });
const initialCount = filter.selectedCount;
// These should not throw errors
filter.toggleProperty('non-existent');
filter.selectProperty('non-existent');
filter.deselectProperty('non-existent');
// State should remain unchanged
expect(filter.selectedCount).toBe(initialCount);
});
});
describe('Single Property Edge Cases', () => {
it('handles single property filter', () => {
const filter = createFilter({ properties: createTestProperties(1, [0]) });
expect(filter.selectedCount).toBe(1);
expect(filter.selectedProperties).toHaveLength(1);
filter.deselectProperty('prop-0');
expect(filter.selectedCount).toBe(0);
expect(filter.selectedProperties).toHaveLength(0);
filter.selectProperty('prop-0');
expect(filter.selectedCount).toBe(1);
expect(filter.selectedProperties).toHaveLength(1);
});
it('handles single unselected property', () => {
const filter = createFilter({ properties: createTestProperties(1) });
expect(filter.selectedCount).toBe(0);
filter.selectProperty('prop-0');
expect(filter.selectedCount).toBe(1);
filter.deselectAll();
expect(filter.selectedCount).toBe(0);
});
});
describe('Large Dataset Performance', () => {
it('handles large property lists efficiently', () => {
const largeProps = createTestProperties(
100,
Array.from({ length: 10 }, (_, i) => i * 10),
);
const filter = createFilter({ properties: largeProps });
expect(filter.properties).toHaveLength(100);
expect(filter.selectedCount).toBe(10);
expect(filter.selectedProperties).toHaveLength(10);
// Test bulk operations
filter.selectAll();
expect(filter.selectedCount).toBe(100);
filter.deselectAll();
expect(filter.selectedCount).toBe(0);
});
});
describe('Type Safety', () => {
it('maintains Property type structure', () => {
const filter = createFilter({ properties: createTestProperties(3) });
filter.properties.forEach(property => {
expect(property).toHaveProperty('id');
expect(typeof property.id).toBe('string');
expect(property).toHaveProperty('name');
expect(typeof property.name).toBe('string');
expect(property).toHaveProperty('selected');
expect(typeof property.selected).toBe('boolean');
});
});
it('exposes correct Filter interface', () => {
const filter = createFilter({ properties: createTestProperties(3) });
expect(filter).toHaveProperty('properties');
expect(filter).toHaveProperty('selectedProperties');
expect(filter).toHaveProperty('selectedCount');
expect(typeof filter.toggleProperty).toBe('function');
expect(typeof filter.selectProperty).toBe('function');
expect(typeof filter.deselectProperty).toBe('function');
expect(typeof filter.selectAll).toBe('function');
expect(typeof filter.deselectAll).toBe('function');
});
});
});

View File

@@ -0,0 +1,97 @@
import {
clampNumber,
roundToStepPrecision,
} from '$shared/lib/utils';
export interface ControlDataModel {
/**
* Control value
*/
value: number;
/**
* Minimal possible value
*/
min: number;
/**
* Maximal possible value
*/
max: number;
/**
* Step size for increase/decrease
*/
step: number;
}
export interface ControlModel extends ControlDataModel {
/**
* Control identifier
*/
id: string;
/**
* Area label for increase button
*/
increaseLabel: string;
/**
* Area label for decrease button
*/
decreaseLabel: string;
/**
* Control area label
*/
controlLabel: string;
}
export function createTypographyControl<T extends ControlDataModel>(
initialState: T,
) {
let value = $state(initialState.value);
let max = $state(initialState.max);
let min = $state(initialState.min);
let step = $state(initialState.step);
const { isAtMax, isAtMin } = $derived({
isAtMax: value >= max,
isAtMin: value <= min,
});
return {
get value() {
return value;
},
set value(newValue) {
value = roundToStepPrecision(
clampNumber(newValue, min, max),
step,
);
},
get max() {
return max;
},
get min() {
return min;
},
get step() {
return step;
},
get isAtMax() {
return isAtMax;
},
get isAtMin() {
return isAtMin;
},
increase() {
value = roundToStepPrecision(
clampNumber(value + step, min, max),
step,
);
},
decrease() {
value = roundToStepPrecision(
clampNumber(value - step, min, max),
step,
);
},
};
}
export type TypographyControl = ReturnType<typeof createTypographyControl>;

View File

@@ -0,0 +1,406 @@
import {
type TypographyControl,
createTypographyControl,
} from '$shared/lib';
import {
describe,
expect,
it,
} from 'vitest';
/**
* Test Strategy for createTypographyControl Helper
*
* This test suite validates the TypographyControl state management logic.
* These are unit tests for the pure control logic, separate from component rendering.
*
* Test Coverage:
* 1. Control Initialization: Creating controls with various configurations
* 2. Value Setting: Direct assignment with clamping and precision
* 3. Increase Method: Incrementing value with bounds checking
* 4. Decrease Method: Decrementing value with bounds checking
* 5. Derived State: isAtMax and isAtMin reactive properties
* 6. Combined Operations: Multiple method calls and value changes
* 7. Edge Cases: Boundary conditions and special values
* 8. Type Safety: Interface compliance and immutability
* 9. Use Case Scenarios: Real-world typography control examples
*/
describe('createTypographyControl - Unit Tests', () => {
/**
* Helper function to create a TypographyControl for testing
*/
function createMockControl(initialValue: number, options?: {
min?: number;
max?: number;
step?: number;
}): TypographyControl {
return createTypographyControl({
value: initialValue,
min: options?.min ?? 0,
max: options?.max ?? 100,
step: options?.step ?? 1,
});
}
describe('Control Initialization', () => {
it('creates control with default values', () => {
const control = createTypographyControl({
value: 50,
min: 0,
max: 100,
step: 1,
});
expect(control.value).toBe(50);
expect(control.min).toBe(0);
expect(control.max).toBe(100);
expect(control.step).toBe(1);
});
it('creates control with custom min/max/step', () => {
const control = createTypographyControl({
value: 5,
min: -10,
max: 20,
step: 0.5,
});
expect(control.value).toBe(5);
expect(control.min).toBe(-10);
expect(control.max).toBe(20);
expect(control.step).toBe(0.5);
});
// NOTE: Derived state initialization tests removed because
// Svelte 5's $derived runes require a reactivity context which
// is not available in Node.js unit tests. These behaviors
// should be tested in E2E tests with Playwright.
});
describe('Value Setting', () => {
it('updates value when set to valid number', () => {
const control = createMockControl(50);
control.value = 75;
expect(control.value).toBe(75);
});
it('clamps value below min when set', () => {
const control = createMockControl(50, { min: 0, max: 100 });
control.value = -10;
expect(control.value).toBe(0);
});
it('clamps value above max when set', () => {
const control = createMockControl(50, { min: 0, max: 100 });
control.value = 150;
expect(control.value).toBe(100);
});
it('rounds to step precision when set', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 0.25 });
control.value = 5.13;
// roundToStepPrecision fixes floating point issues by rounding to step's decimal places
// 5.13 with step 0.25 (2 decimals) → 5.13
expect(control.value).toBeCloseTo(5.13);
});
it('handles step of 0.01 precision', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 0.01 });
control.value = 5.1234;
expect(control.value).toBeCloseTo(5.12);
});
it('handles step of 0.5 precision', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 0.5 });
control.value = 5.3;
// 5.3 with step 0.5 (1 decimal) → 5.3 (already correct precision)
expect(control.value).toBeCloseTo(5.3);
});
it('handles integer step', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
control.value = 5.7;
expect(control.value).toBe(6);
});
it('handles negative range', () => {
const control = createMockControl(-5, { min: -10, max: 10 });
control.value = -15;
expect(control.value).toBe(-10); // Clamped to min
control.value = 15;
expect(control.value).toBe(10); // Clamped to max
});
});
describe('Increase Method', () => {
it('increases value by step', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
control.increase();
expect(control.value).toBe(6);
});
it('respects max bound when increasing', () => {
const control = createMockControl(9.5, { min: 0, max: 10, step: 1 });
control.increase();
expect(control.value).toBe(10);
control.increase();
expect(control.value).toBe(10); // Still at max
});
it('respects step precision when increasing', () => {
const control = createMockControl(5.25, { min: 0, max: 10, step: 0.25 });
control.increase();
expect(control.value).toBe(5.5);
});
// NOTE: Derived state (isAtMax, isAtMin) tests removed because
// Svelte 5's $derived runes require a reactivity context which
// is not available in Node.js unit tests. These behaviors
// should be tested in E2E tests with Playwright.
});
describe('Decrease Method', () => {
it('decreases value by step', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
control.decrease();
expect(control.value).toBe(4);
});
it('respects min bound when decreasing', () => {
const control = createMockControl(0.5, { min: 0, max: 10, step: 1 });
control.decrease();
expect(control.value).toBe(0);
control.decrease();
expect(control.value).toBe(0); // Still at min
});
it('respects step precision when decreasing', () => {
const control = createMockControl(5.5, { min: 0, max: 10, step: 0.25 });
control.decrease();
expect(control.value).toBe(5.25);
});
// NOTE: Derived state (isAtMax, isAtMin) tests removed because
// Svelte 5's $derived runes require a reactivity context which
// is not available in Node.js unit tests. These behaviors
// should be tested in E2E tests with Playwright.
});
// NOTE: Derived State Reactivity tests removed because
// Svelte 5's $derived runes require a reactivity context which
// is not available in Node.js unit tests. These behaviors
// should be tested in E2E tests with Playwright.
describe('Combined Operations', () => {
it('handles multiple increase/decrease operations', () => {
const control = createMockControl(50, { min: 0, max: 100, step: 5 });
control.increase();
control.increase();
control.increase();
expect(control.value).toBe(65);
control.decrease();
control.decrease();
expect(control.value).toBe(55);
});
it('handles value setting followed by method calls', () => {
const control = createMockControl(50, { min: 0, max: 100, step: 1 });
control.value = 90;
expect(control.value).toBe(90);
control.increase();
expect(control.value).toBe(91);
control.increase();
expect(control.value).toBe(92);
control.decrease();
expect(control.value).toBe(91);
});
it('handles rapid value changes', () => {
const control = createMockControl(50, { min: 0, max: 100, step: 0.1 });
for (let i = 0; i < 100; i++) {
control.increase();
}
expect(control.value).toBe(60);
for (let i = 0; i < 50; i++) {
control.decrease();
}
expect(control.value).toBe(55);
});
});
describe('Edge Cases', () => {
it('handles step larger than range', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 20 });
control.increase();
expect(control.value).toBe(10); // Clamped to max
control.decrease();
expect(control.value).toBe(0); // Clamped to min
});
it('handles very small step values', () => {
const control = createMockControl(5, { min: 0, max: 10, step: 0.001 });
control.value = 5.0005;
expect(control.value).toBeCloseTo(5.001);
});
it('handles floating point precision issues', () => {
const control = createMockControl(0.1, { min: 0, max: 1, step: 0.1 });
control.value = 0.3;
expect(control.value).toBeCloseTo(0.3);
control.increase();
expect(control.value).toBeCloseTo(0.4);
});
it('handles zero as valid value', () => {
const control = createMockControl(0, { min: 0, max: 100 });
expect(control.value).toBe(0);
control.increase();
expect(control.value).toBe(1);
});
it('handles negative step values effectively', () => {
// Step is always positive in the interface, but we test the logic
const control = createMockControl(5, { min: 0, max: 10, step: 1 });
// Even with negative value initially, it should work
expect(control.min).toBe(0);
expect(control.max).toBe(10);
});
it('handles equal min and max', () => {
const control = createMockControl(5, { min: 5, max: 5, step: 1 });
expect(control.value).toBe(5);
control.increase();
expect(control.value).toBe(5);
control.decrease();
expect(control.value).toBe(5);
});
it('handles very large values', () => {
const control = createMockControl(1000, { min: 0, max: 10000, step: 100 });
control.value = 5500;
expect(control.value).toBe(5500); // 5500 is already on step of 100
control.increase();
expect(control.value).toBe(5600);
});
});
describe('Type Safety and Interface', () => {
it('exposes correct TypographyControl interface', () => {
const control = createMockControl(50);
expect(control).toHaveProperty('value');
expect(typeof control.value).toBe('number');
expect(control).toHaveProperty('min');
expect(typeof control.min).toBe('number');
expect(control).toHaveProperty('max');
expect(typeof control.max).toBe('number');
expect(control).toHaveProperty('step');
expect(typeof control.step).toBe('number');
expect(control).toHaveProperty('isAtMax');
expect(typeof control.isAtMax).toBe('boolean');
expect(control).toHaveProperty('isAtMin');
expect(typeof control.isAtMin).toBe('boolean');
expect(typeof control.increase).toBe('function');
expect(typeof control.decrease).toBe('function');
});
it('maintains immutability of min/max/step', () => {
const control = createMockControl(50, { min: 0, max: 100, step: 1 });
// These should be read-only
const originalMin = control.min;
const originalMax = control.max;
const originalStep = control.step;
// TypeScript should prevent assignment, but test runtime behavior
expect(control.min).toBe(originalMin);
expect(control.max).toBe(originalMax);
expect(control.step).toBe(originalStep);
});
});
describe('Use Case Scenarios', () => {
it('typical font size control (12px to 72px, step 1px)', () => {
const control = createMockControl(16, { min: 12, max: 72, step: 1 });
expect(control.value).toBe(16);
// Increase to 18
control.increase();
control.increase();
expect(control.value).toBe(18);
// Set to 24
control.value = 24;
expect(control.value).toBe(24);
// Try to go below min
control.value = 10;
expect(control.value).toBe(12); // Clamped to 12
// Try to go above max
control.value = 80;
expect(control.value).toBe(72); // Clamped to 72
});
it('typical letter spacing control (-0.1em to 0.5em, step 0.01em)', () => {
const control = createMockControl(0, { min: -0.1, max: 0.5, step: 0.01 });
expect(control.value).toBe(0);
// Increase to 0.02
control.increase();
control.increase();
expect(control.value).toBeCloseTo(0.02);
// Set to negative value
control.value = -0.05;
expect(control.value).toBeCloseTo(-0.05);
// Precision rounding
control.value = 0.1234;
expect(control.value).toBeCloseTo(0.12);
});
it('typical line height control (0.8 to 2.0, step 0.1)', () => {
const control = createMockControl(1.5, { min: 0.8, max: 2.0, step: 0.1 });
expect(control.value).toBe(1.5);
// Decrease to 1.3
control.decrease();
control.decrease();
expect(control.value).toBeCloseTo(1.3);
// Set to specific value
control.value = 1.65;
// 1.65 with step 0.1 → rounds to 1 decimal place → 1.6 (banker's rounding)
expect(control.value).toBeCloseTo(1.6);
});
});
});

View File

@@ -0,0 +1,116 @@
import {
createVirtualizer as coreCreateVirtualizer,
observeElementRect,
} from '@tanstack/svelte-virtual';
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core';
import { get } from 'svelte/store';
export interface VirtualItem {
index: number;
start: number;
size: number;
end: number;
key: string | number;
}
export interface VirtualizerOptions {
/** Total number of items in the data array */
count: number;
/** Function to estimate the size of an item at a given index */
estimateSize: (index: number) => number;
/** Number of extra items to render outside viewport (default: 5) */
overscan?: number;
/** Function to get the key of an item at a given index (defaults to index) */
getItemKey?: (index: number) => string | number;
/** Optional margin in pixels for scroll calculations */
scrollMargin?: number;
}
/**
* Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library.
*
* @example
* ```ts
* const virtualizer = createVirtualizer(() => ({
* count: items.length,
* estimateSize: () => 80,
* overscan: 5,
* }));
*
* // In template:
* // <div bind:this={virtualizer.scrollElement}>
* // {#each virtualizer.items as item}
* // <div style="transform: translateY({item.start}px)">
* // {items[item.index]}
* // </div>
* // {/each}
* // </div>
* ```
*/
export function createVirtualizer(
optionsGetter: () => VirtualizerOptions,
) {
let element = $state<HTMLElement | null>(null);
const internalStore = coreCreateVirtualizer({
get count() {
return optionsGetter().count;
},
get estimateSize() {
return optionsGetter().estimateSize;
},
get overscan() {
return optionsGetter().overscan ?? 5;
},
get scrollMargin() {
return optionsGetter().scrollMargin;
},
get getItemKey() {
return optionsGetter().getItemKey ?? (i => i);
},
getScrollElement: () => element,
observeElementRect: observeElementRect,
});
const state = $derived(get(internalStore));
const virtualItems = $derived(
state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({
index: item.index,
start: item.start,
size: item.size,
end: item.end,
key: typeof item.key === 'bigint' ? Number(item.key) : item.key,
})),
);
return {
get items() {
return virtualItems;
},
get totalSize() {
return state.getTotalSize();
},
get scrollOffset() {
return state.scrollOffset ?? 0;
},
get scrollElement() {
return element;
},
set scrollElement(el) {
element = el;
},
scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
state.scrollToIndex(idx, opt),
scrollToOffset: (off: number) => state.scrollToOffset(off),
measureElement: (el: HTMLElement) => state.measureElement(el),
};
}
export type Virtualizer = ReturnType<typeof createVirtualizer>;

View File

@@ -0,0 +1,22 @@
export {
createFilter,
type Filter,
type FilterModel,
type Property,
} from './createFilter/createFilter.svelte';
export {
type ControlDataModel,
type ControlModel,
createTypographyControl,
type TypographyControl,
} from './createTypographyControl/createTypographyControl.svelte';
export {
createVirtualizer,
type VirtualItem,
type Virtualizer,
type VirtualizerOptions,
} from './createVirtualizer/createVirtualizer.svelte';
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';

14
src/shared/lib/index.ts Normal file
View File

@@ -0,0 +1,14 @@
export {
type ControlDataModel,
type ControlModel,
createFilter,
createTypographyControl,
createVirtualizer,
type Filter,
type FilterModel,
type Property,
type TypographyControl,
type VirtualItem,
type Virtualizer,
type VirtualizerOptions,
} from './helpers';

View File

@@ -0,0 +1,194 @@
/**
* Tests for buildQueryString utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { buildQueryString } from './buildQueryString';
describe('buildQueryString', () => {
describe('basic parameter building', () => {
test('should build query string with string parameter', () => {
const result = buildQueryString({ category: 'serif' });
expect(result).toBe('?category=serif');
});
test('should build query string with number parameter', () => {
const result = buildQueryString({ limit: 50 });
expect(result).toBe('?limit=50');
});
test('should build query string with boolean parameter', () => {
const result = buildQueryString({ active: true });
expect(result).toBe('?active=true');
});
test('should build query string with multiple parameters', () => {
const result = buildQueryString({
category: 'serif',
limit: 50,
page: 1,
});
expect(result).toBe('?category=serif&limit=50&page=1');
});
});
describe('array handling', () => {
test('should handle array of strings', () => {
const result = buildQueryString({
subsets: ['latin', 'latin-ext', 'cyrillic'],
});
expect(result).toBe('?subsets=latin&subsets=latin-ext&subsets=cyrillic');
});
test('should handle array of numbers', () => {
const result = buildQueryString({ ids: [1, 2, 3] });
expect(result).toBe('?ids=1&ids=2&ids=3');
});
test('should handle mixed arrays and primitives', () => {
const result = buildQueryString({
category: 'serif',
subsets: ['latin', 'latin-ext'],
limit: 50,
});
expect(result).toBe('?category=serif&subsets=latin&subsets=latin-ext&limit=50');
});
test('should filter out null/undefined values in arrays', () => {
const result = buildQueryString({
// @ts-expect-error - Testing runtime behavior with invalid types
ids: [1, null, 3, undefined],
});
expect(result).toBe('?ids=1&ids=3');
});
});
describe('optional values', () => {
test('should exclude undefined values', () => {
const result = buildQueryString({
category: 'serif',
search: undefined,
});
expect(result).toBe('?category=serif');
});
test('should exclude null values', () => {
const result = buildQueryString({
category: 'serif',
search: null,
});
expect(result).toBe('?category=serif');
});
test('should handle all undefined/null values', () => {
const result = buildQueryString({
category: undefined,
search: null,
});
expect(result).toBe('');
});
});
describe('URL encoding', () => {
test('should encode spaces', () => {
const result = buildQueryString({ search: 'hello world' });
expect(result).toBe('?search=hello+world');
});
test('should encode special characters', () => {
const result = buildQueryString({ query: 'a&b=c+d' });
expect(result).toBe('?query=a%26b%3Dc%2Bd');
});
test('should encode Unicode characters', () => {
const result = buildQueryString({ text: 'café' });
expect(result).toBe('?text=caf%C3%A9');
});
test('should encode reserved URL characters', () => {
const result = buildQueryString({ url: 'https://example.com' });
expect(result).toBe('?url=https%3A%2F%2Fexample.com');
});
});
describe('edge cases', () => {
test('should return empty string for empty object', () => {
const result = buildQueryString({});
expect(result).toBe('');
});
test('should return empty string when all values are excluded', () => {
const result = buildQueryString({
a: undefined,
b: null,
});
expect(result).toBe('');
});
test('should handle empty arrays', () => {
const result = buildQueryString({ tags: [] });
expect(result).toBe('');
});
test('should handle zero values', () => {
const result = buildQueryString({ page: 0, count: 0 });
expect(result).toBe('?page=0&count=0');
});
test('should handle false boolean', () => {
const result = buildQueryString({ active: false });
expect(result).toBe('?active=false');
});
test('should handle empty string', () => {
const result = buildQueryString({ search: '' });
expect(result).toBe('?search=');
});
});
describe('parameter order', () => {
test('should maintain parameter order from input object', () => {
const result = buildQueryString({
a: '1',
b: '2',
c: '3',
});
expect(result).toBe('?a=1&b=2&c=3');
});
});
describe('real-world examples', () => {
test('should handle Google Fonts API parameters', () => {
const result = buildQueryString({
category: 'sans-serif',
sort: 'popularity',
subset: 'latin',
});
expect(result).toBe('?category=sans-serif&sort=popularity&subset=latin');
});
test('should handle Fontshare API parameters', () => {
const result = buildQueryString({
categories: ['Sans', 'Serif'],
page: 1,
limit: 50,
search: 'satoshi',
});
expect(result).toBe('?categories=Sans&categories=Serif&page=1&limit=50&search=satoshi');
});
test('should handle pagination parameters', () => {
const result = buildQueryString({
page: 2,
per_page: 20,
sort: 'name',
order: 'desc',
});
expect(result).toBe('?page=2&per_page=20&sort=name&order=desc');
});
});
});

View File

@@ -0,0 +1,79 @@
/**
* Build query string from URL parameters
*
* Generic, type-safe function to build properly encoded query strings
* from URL parameters. Supports primitives, arrays, and optional values.
*
* @param params - Object containing query parameters
* @returns Encoded query string (empty string if no parameters)
*
* @example
* ```ts
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
* // Returns: "category=serif&subsets=latin&subsets=latin-ext"
*
* buildQueryString({ limit: 50, page: 1 })
* // Returns: "limit=50&page=1"
*
* buildQueryString({})
* // Returns: ""
*
* buildQueryString({ search: 'hello world', active: true })
* // Returns: "search=hello%20world&active=true"
* ```
*/
/**
* Query parameter value type
* Supports primitives, arrays, and excludes null/undefined
*/
export type QueryParamValue = string | number | boolean | string[] | number[];
/**
* Query parameters object
*/
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
/**
* Build query string from URL parameters
*
* Handles:
* - Primitive values (string, number, boolean)
* - Arrays (multiple values with same key)
* - Optional values (excludes undefined/null)
* - Proper URL encoding
*
* Edge cases:
* - Empty object → empty string
* - No parameters → empty string
* - Nested objects → flattens to string representation
* - Special characters → proper encoding
*
* @param params - Object containing query parameters
* @returns Encoded query string (with "?" prefix if non-empty)
*/
export function buildQueryString(params: QueryParams): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
// Skip undefined/null values
if (value === undefined || value === null) {
continue;
}
// Handle arrays (multiple values with same key)
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null) {
searchParams.append(key, String(item));
}
}
} else {
// Handle primitives
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}

View File

@@ -0,0 +1,176 @@
/**
* Tests for clampNumber utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { clampNumber } from './clampNumber';
describe('clampNumber', () => {
describe('basic functionality', () => {
test('should return value when within range', () => {
expect(clampNumber(5, 0, 10)).toBe(5);
expect(clampNumber(0.5, 0, 1)).toBe(0.5);
expect(clampNumber(-3, -10, 10)).toBe(-3);
});
test('should clamp value to minimum', () => {
expect(clampNumber(-5, 0, 10)).toBe(0);
expect(clampNumber(-100, -50, 100)).toBe(-50);
expect(clampNumber(0, 1, 10)).toBe(1);
});
test('should clamp value to maximum', () => {
expect(clampNumber(15, 0, 10)).toBe(10);
expect(clampNumber(150, -50, 100)).toBe(100);
expect(clampNumber(100, 1, 50)).toBe(50);
});
test('should handle boundary values', () => {
expect(clampNumber(0, 0, 10)).toBe(0);
expect(clampNumber(10, 0, 10)).toBe(10);
expect(clampNumber(-5, -5, 5)).toBe(-5);
expect(clampNumber(5, -5, 5)).toBe(5);
});
});
describe('negative ranges', () => {
test('should handle fully negative ranges', () => {
expect(clampNumber(-5, -10, -1)).toBe(-5);
expect(clampNumber(-15, -10, -1)).toBe(-10);
expect(clampNumber(-0.5, -10, -1)).toBe(-1);
});
test('should handle ranges spanning zero', () => {
expect(clampNumber(0, -10, 10)).toBe(0);
expect(clampNumber(-5, -10, 10)).toBe(-5);
expect(clampNumber(5, -10, 10)).toBe(5);
});
});
describe('floating-point numbers', () => {
test('should clamp floating-point values correctly', () => {
expect(clampNumber(0.75, 0, 1)).toBe(0.75);
expect(clampNumber(1.5, 0, 1)).toBe(1);
expect(clampNumber(-0.25, 0, 1)).toBe(0);
});
test('should handle very small decimals', () => {
expect(clampNumber(0.001, 0, 0.01)).toBe(0.001);
expect(clampNumber(0.1, 0, 0.01)).toBe(0.01);
});
test('should handle large floating-point numbers', () => {
expect(clampNumber(123.456, 100, 200)).toBe(123.456);
expect(clampNumber(99.999, 100, 200)).toBe(100);
expect(clampNumber(200.001, 100, 200)).toBe(200);
});
});
describe('edge cases', () => {
test('should handle when min equals max', () => {
expect(clampNumber(5, 10, 10)).toBe(10);
expect(clampNumber(10, 10, 10)).toBe(10);
expect(clampNumber(15, 10, 10)).toBe(10);
expect(clampNumber(0, 0, 0)).toBe(0);
});
test('should handle zero values', () => {
expect(clampNumber(0, 0, 10)).toBe(0);
expect(clampNumber(0, -10, 10)).toBe(0);
expect(clampNumber(5, 0, 0)).toBe(0);
});
test('should handle reversed min/max (min > max)', () => {
// When min > max, Math.max/Math.min will still produce a result
// but it's logically incorrect - we test the actual behavior
// Math.min(Math.max(5, 10), 0) = Math.min(10, 0) = 0
expect(clampNumber(5, 10, 0)).toBe(0);
expect(clampNumber(15, 10, 0)).toBe(0);
expect(clampNumber(-5, 10, 0)).toBe(0);
});
});
describe('special number values', () => {
test('should handle Infinity', () => {
expect(clampNumber(Infinity, 0, 10)).toBe(10);
expect(clampNumber(-Infinity, 0, 10)).toBe(0);
expect(clampNumber(5, -Infinity, Infinity)).toBe(5);
});
test('should handle NaN', () => {
expect(clampNumber(NaN, 0, 10)).toBeNaN();
});
});
describe('real-world scenarios', () => {
test('should clamp font size values', () => {
// Typical font size range: 8px to 72px
expect(clampNumber(16, 8, 72)).toBe(16);
expect(clampNumber(4, 8, 72)).toBe(8);
expect(clampNumber(100, 8, 72)).toBe(72);
});
test('should clamp slider values', () => {
// Slider range: 0 to 100
expect(clampNumber(50, 0, 100)).toBe(50);
expect(clampNumber(-10, 0, 100)).toBe(0);
expect(clampNumber(150, 0, 100)).toBe(100);
});
test('should clamp opacity values', () => {
// Opacity range: 0 to 1
expect(clampNumber(0.5, 0, 1)).toBe(0.5);
expect(clampNumber(-0.2, 0, 1)).toBe(0);
expect(clampNumber(1.2, 0, 1)).toBe(1);
});
test('should clamp percentage values', () => {
// Percentage range: 0 to 100
expect(clampNumber(75, 0, 100)).toBe(75);
expect(clampNumber(-5, 0, 100)).toBe(0);
expect(clampNumber(105, 0, 100)).toBe(100);
});
test('should clamp coordinate values', () => {
// Canvas coordinates: 0 to 800 width, 0 to 600 height
expect(clampNumber(400, 0, 800)).toBe(400);
expect(clampNumber(-50, 0, 800)).toBe(0);
expect(clampNumber(900, 0, 800)).toBe(800);
});
test('should clamp font weight values', () => {
// Font weight range: 100 to 900 (in increments of 100)
expect(clampNumber(400, 100, 900)).toBe(400);
expect(clampNumber(50, 100, 900)).toBe(100);
expect(clampNumber(950, 100, 900)).toBe(900);
});
test('should clamp line height values', () => {
// Line height range: 0.5 to 3.0
expect(clampNumber(1.5, 0.5, 3.0)).toBe(1.5);
expect(clampNumber(0.3, 0.5, 3.0)).toBe(0.5);
expect(clampNumber(4.0, 0.5, 3.0)).toBe(3.0);
});
});
describe('numeric constraints', () => {
test('should handle very large numbers', () => {
expect(clampNumber(Number.MAX_VALUE, 0, 100)).toBe(100);
expect(clampNumber(Number.MIN_VALUE, -10, 10)).toBe(Number.MIN_VALUE);
});
test('should handle negative infinity boundaries', () => {
expect(clampNumber(5, -Infinity, 10)).toBe(5);
expect(clampNumber(-1000, -Infinity, 10)).toBe(-1000);
});
test('should handle positive infinity boundaries', () => {
expect(clampNumber(5, 0, Infinity)).toBe(5);
expect(clampNumber(1000, 0, Infinity)).toBe(1000);
});
});
});

View File

@@ -0,0 +1,10 @@
/**
* Clamp a number within a range.
* @param value The number to clamp.
* @param min minimum value
* @param max maximum value
* @returns The clamped number.
*/
export function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View File

@@ -0,0 +1,77 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { debounce } from './debounce';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should delay execution by the specified wait time', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('arg1', 'arg2');
expect(mockFn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
});
it('should cancel previous invocation and restart timer on subsequent calls', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('first');
vi.advanceTimersByTime(100);
debounced('second');
vi.advanceTimersByTime(100);
debounced('third');
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('third');
});
it('should handle rapid calls correctly', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('1');
vi.advanceTimersByTime(50);
debounced('2');
vi.advanceTimersByTime(50);
debounced('3');
vi.advanceTimersByTime(50);
debounced('4');
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('4');
});
it('should not execute if timer is cleared before wait time', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('test');
vi.advanceTimersByTime(200);
expect(mockFn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,43 @@
/**
* ============================================================================
* DEBOUNCE UTILITY
* ============================================================================
*
* Creates a debounced function that delays execution until after wait milliseconds
* have elapsed since the last time it was invoked.
*
* @example
* ```typescript
* const debouncedSearch = debounce((query: string) => {
* console.log('Searching for:', query);
* }, 300);
*
* debouncedSearch('hello');
* debouncedSearch('hello world'); // Only this will execute after 300ms
* ```
*/
/**
* Creates a debounced version of a function
*
* @param fn - The function to debounce
* @param wait - The delay in milliseconds
* @returns A debounced function that will execute after the specified delay
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, wait);
};
}

View File

@@ -0,0 +1 @@
export { debounce } from './debounce';

View File

@@ -0,0 +1,188 @@
/**
* Tests for getDecimalPlaces utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { getDecimalPlaces } from './getDecimalPlaces';
describe('getDecimalPlaces', () => {
describe('basic functionality', () => {
test('should return 0 for integers', () => {
expect(getDecimalPlaces(0)).toBe(0);
expect(getDecimalPlaces(1)).toBe(0);
expect(getDecimalPlaces(42)).toBe(0);
expect(getDecimalPlaces(-7)).toBe(0);
expect(getDecimalPlaces(1000)).toBe(0);
});
test('should return correct decimal places for decimals', () => {
expect(getDecimalPlaces(0.1)).toBe(1);
expect(getDecimalPlaces(0.5)).toBe(1);
expect(getDecimalPlaces(0.01)).toBe(2);
expect(getDecimalPlaces(0.05)).toBe(2);
expect(getDecimalPlaces(0.001)).toBe(3);
expect(getDecimalPlaces(0.123)).toBe(3);
expect(getDecimalPlaces(0.123456)).toBe(6);
});
test('should handle negative decimal numbers', () => {
expect(getDecimalPlaces(-0.1)).toBe(1);
expect(getDecimalPlaces(-0.05)).toBe(2);
expect(getDecimalPlaces(-1.5)).toBe(1);
expect(getDecimalPlaces(-99.99)).toBe(2);
});
});
describe('whole numbers with decimal part', () => {
test('should handle numbers with integer and decimal parts', () => {
expect(getDecimalPlaces(1.5)).toBe(1);
expect(getDecimalPlaces(10.25)).toBe(2);
expect(getDecimalPlaces(100.125)).toBe(3);
expect(getDecimalPlaces(1234.5678)).toBe(4);
});
test('should handle trailing zeros correctly', () => {
// Note: JavaScript string representation drops trailing zeros
expect(getDecimalPlaces(1.5)).toBe(1);
expect(getDecimalPlaces(1.50)).toBe(1); // 1.50 becomes "1.5" in string
});
});
describe('edge cases', () => {
test('should handle zero', () => {
expect(getDecimalPlaces(0)).toBe(0);
expect(getDecimalPlaces(0.0)).toBe(0);
});
test('should handle very small decimals', () => {
expect(getDecimalPlaces(0.0001)).toBe(4);
expect(getDecimalPlaces(0.00001)).toBe(5);
expect(getDecimalPlaces(0.000001)).toBe(6);
});
test('should handle very large numbers', () => {
expect(getDecimalPlaces(123456789.123)).toBe(3);
expect(getDecimalPlaces(999999.9999)).toBe(4);
});
test('should handle negative whole numbers', () => {
expect(getDecimalPlaces(-1)).toBe(0);
expect(getDecimalPlaces(-100)).toBe(0);
expect(getDecimalPlaces(-9999)).toBe(0);
});
});
describe('special number values', () => {
test('should handle Infinity', () => {
expect(getDecimalPlaces(Infinity)).toBe(0);
expect(getDecimalPlaces(-Infinity)).toBe(0);
});
test('should handle NaN', () => {
expect(getDecimalPlaces(NaN)).toBe(0);
});
});
describe('scientific notation', () => {
test('should handle numbers in scientific notation', () => {
// Very small numbers may be represented in scientific notation
const tiny = 1e-10;
const result = getDecimalPlaces(tiny);
// The result depends on how JS represents this as a string
expect(typeof result).toBe('number');
});
test('should handle large scientific notation numbers', () => {
const large = 1.23e5; // 123000
expect(getDecimalPlaces(large)).toBe(0);
});
});
describe('real-world scenarios', () => {
test('should handle currency values (2 decimal places)', () => {
expect(getDecimalPlaces(0.01)).toBe(2); // 1 cent
expect(getDecimalPlaces(0.99)).toBe(2); // 99 cents
// Note: JavaScript string representation drops trailing zeros
// 10.50 becomes "10.5" in string, so returns 1 decimal place
expect(getDecimalPlaces(10.50)).toBe(1); // $10.50
expect(getDecimalPlaces(999.99)).toBe(2); // $999.99
});
test('should handle measurement values', () => {
expect(getDecimalPlaces(12.5)).toBe(1); // 12.5 mm
expect(getDecimalPlaces(12.34)).toBe(2); // 12.34 cm
expect(getDecimalPlaces(12.345)).toBe(3); // 12.345 m
});
test('should handle step values for sliders', () => {
expect(getDecimalPlaces(0.1)).toBe(1); // Fine adjustment
expect(getDecimalPlaces(0.25)).toBe(2); // Quarter steps
expect(getDecimalPlaces(0.5)).toBe(1); // Half steps
expect(getDecimalPlaces(1)).toBe(0); // Whole steps
});
test('should handle font size increments', () => {
expect(getDecimalPlaces(0.5)).toBe(1); // Half point increments
expect(getDecimalPlaces(1)).toBe(0); // Whole point increments
});
test('should handle opacity values', () => {
expect(getDecimalPlaces(0.1)).toBe(1); // 10% increments
expect(getDecimalPlaces(0.05)).toBe(2); // 5% increments
expect(getDecimalPlaces(0.01)).toBe(2); // 1% increments
});
test('should handle percentage values', () => {
expect(getDecimalPlaces(0.5)).toBe(1); // 0.5%
expect(getDecimalPlaces(12.5)).toBe(1); // 12.5%
expect(getDecimalPlaces(33.33)).toBe(2); // 33.33%
});
test('should handle coordinate precision', () => {
expect(getDecimalPlaces(12.3456789)).toBe(7); // High precision GPS
expect(getDecimalPlaces(100.5)).toBe(1); // Low precision coordinates
});
test('should handle time values', () => {
expect(getDecimalPlaces(0.1)).toBe(1); // 100ms
expect(getDecimalPlaces(0.01)).toBe(2); // 10ms
expect(getDecimalPlaces(0.001)).toBe(3); // 1ms
});
});
describe('common step values', () => {
test('should correctly identify precision of common step values', () => {
expect(getDecimalPlaces(0.05)).toBe(2); // Very fine steps
expect(getDecimalPlaces(0.1)).toBe(1); // Fine steps
expect(getDecimalPlaces(0.25)).toBe(2); // Quarter steps
expect(getDecimalPlaces(0.5)).toBe(1); // Half steps
expect(getDecimalPlaces(1)).toBe(0); // Whole steps
expect(getDecimalPlaces(2)).toBe(0); // Even steps
expect(getDecimalPlaces(5)).toBe(0); // Five steps
expect(getDecimalPlaces(10)).toBe(0); // Ten steps
expect(getDecimalPlaces(25)).toBe(0); // Twenty-five steps
expect(getDecimalPlaces(50)).toBe(0); // Fifty steps
expect(getDecimalPlaces(100)).toBe(0); // Hundred steps
});
});
describe('floating-point representation', () => {
test('should handle standard floating-point representation', () => {
expect(getDecimalPlaces(1.1)).toBe(1);
expect(getDecimalPlaces(1.2)).toBe(1);
expect(getDecimalPlaces(1.3)).toBe(1);
});
test('should handle numbers that might have floating-point issues', () => {
// 0.1 + 0.2 = 0.30000000000000004 in JS
const sum = 0.1 + 0.2;
const places = getDecimalPlaces(sum);
// The function analyzes the string representation
expect(typeof places).toBe('number');
});
});
});

View File

@@ -0,0 +1,17 @@
/**
* Get the number of decimal places in a number
*
* For example:
* - 1 -> 0
* - 0.1 -> 1
* - 0.01 -> 2
* - 0.05 -> 2
*
* @param step - The step number to analyze
* @returns The number of decimal places
*/
export function getDecimalPlaces(step: number): number {
const str = step.toString();
const decimalPart = str.split('.')[1];
return decimalPart ? decimalPart.length : 0;
}

View File

@@ -0,0 +1,13 @@
/**
* Shared utility functions
*/
export {
buildQueryString,
type QueryParams,
type QueryParamValue,
} from './buildQueryString/buildQueryString';
export { clampNumber } from './clampNumber/clampNumber';
export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';

View File

@@ -0,0 +1,270 @@
/**
* Tests for roundToStepPrecision utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { roundToStepPrecision } from './roundToStepPrecision';
describe('roundToStepPrecision', () => {
describe('basic functionality', () => {
test('should return value unchanged for step=1', () => {
// step=1 has 0 decimal places, so it rounds to integers
expect(roundToStepPrecision(5, 1)).toBe(5);
expect(roundToStepPrecision(5.5, 1)).toBe(6); // rounds to nearest integer
expect(roundToStepPrecision(5.999, 1)).toBe(6);
});
test('should round to 1 decimal place for step=0.1', () => {
expect(roundToStepPrecision(1.23, 0.1)).toBeCloseTo(1.2);
expect(roundToStepPrecision(1.25, 0.1)).toBeCloseTo(1.3);
expect(roundToStepPrecision(1.29, 0.1)).toBeCloseTo(1.3);
});
test('should round to 2 decimal places for step=0.01', () => {
expect(roundToStepPrecision(1.234, 0.01)).toBeCloseTo(1.23);
expect(roundToStepPrecision(1.235, 0.01)).toBeCloseTo(1.24);
expect(roundToStepPrecision(1.239, 0.01)).toBeCloseTo(1.24);
});
test('should round to 3 decimal places for step=0.001', () => {
expect(roundToStepPrecision(1.2345, 0.001)).toBeCloseTo(1.235);
expect(roundToStepPrecision(1.2344, 0.001)).toBeCloseTo(1.234);
});
});
describe('floating-point precision issues', () => {
test('should fix floating-point precision errors with step=0.05', () => {
// Known floating-point issue: 0.1 + 0.05 = 0.15000000000000002
const value = 0.1 + 0.05;
const result = roundToStepPrecision(value, 0.05);
expect(result).toBeCloseTo(0.15, 2);
});
test('should fix floating-point errors with repeated additions', () => {
// Simulate adding 0.05 multiple times
let value = 1;
for (let i = 0; i < 10; i++) {
value += 0.05;
}
// value should be 1.5 but might be 1.4999999999999998
const result = roundToStepPrecision(value, 0.05);
expect(result).toBeCloseTo(1.5, 2);
});
test('should fix floating-point errors with step=0.1', () => {
// Known floating-point issue: 0.1 + 0.2 = 0.30000000000000004
const value = 0.1 + 0.2;
const result = roundToStepPrecision(value, 0.1);
expect(result).toBeCloseTo(0.3, 1);
});
test('should fix floating-point errors with step=0.01', () => {
// Known floating-point issue: 0.01 + 0.02 = 0.029999999999999999
const value = 0.01 + 0.02;
const result = roundToStepPrecision(value, 0.01);
expect(result).toBeCloseTo(0.03, 2);
});
test('should fix floating-point errors with step=0.25', () => {
const value = 0.5 + 0.25;
const result = roundToStepPrecision(value, 0.25);
expect(result).toBeCloseTo(0.75, 2);
});
test('should handle classic 0.1 + 0.2 problem', () => {
// Classic JavaScript floating-point issue
const value = 0.1 + 0.2;
// Without rounding: 0.30000000000000004
const result = roundToStepPrecision(value, 0.1);
expect(result).toBe(0.3);
});
});
describe('edge cases', () => {
test('should return value unchanged when step <= 0', () => {
expect(roundToStepPrecision(5, 0)).toBe(5);
expect(roundToStepPrecision(5, -1)).toBe(5);
expect(roundToStepPrecision(5, -0.5)).toBe(5);
});
test('should handle zero value', () => {
expect(roundToStepPrecision(0, 0.1)).toBe(0);
expect(roundToStepPrecision(0, 0.01)).toBe(0);
});
test('should handle negative values', () => {
expect(roundToStepPrecision(-1.234, 0.01)).toBeCloseTo(-1.23);
expect(roundToStepPrecision(-0.15, 0.05)).toBeCloseTo(-0.15);
expect(roundToStepPrecision(-5.5, 0.5)).toBeCloseTo(-5.5);
});
test('should handle very small step values', () => {
expect(roundToStepPrecision(1.1234, 0.0001)).toBeCloseTo(1.1234);
expect(roundToStepPrecision(1.12345, 0.0001)).toBeCloseTo(1.1235);
});
test('should handle very large values', () => {
expect(roundToStepPrecision(12345.6789, 0.01)).toBeCloseTo(12345.68);
expect(roundToStepPrecision(99999.9999, 0.001)).toBeCloseTo(100000);
});
});
describe('special number values', () => {
test('should handle Infinity', () => {
expect(roundToStepPrecision(Infinity, 0.1)).toBe(Infinity);
expect(roundToStepPrecision(-Infinity, 0.1)).toBe(-Infinity);
});
test('should handle NaN', () => {
expect(roundToStepPrecision(NaN, 0.1)).toBeNaN();
});
test('should handle step=Infinity', () => {
// getDecimalPlaces(Infinity) returns 0, so this rounds to 0 decimal places (integer)
const result = roundToStepPrecision(1.234, Infinity);
expect(result).toBeCloseTo(1);
});
});
describe('real-world scenarios', () => {
test('should handle currency calculations with step=0.01', () => {
// Add items with tax that might have floating-point errors
const subtotal = 10.99 + 5.99 + 2.99;
const rounded = roundToStepPrecision(subtotal, 0.01);
expect(rounded).toBeCloseTo(19.97, 2);
});
test('should handle slider values with step=0.1', () => {
// Slider value after multiple increments
let sliderValue = 0;
for (let i = 0; i < 15; i++) {
sliderValue += 0.1;
}
const rounded = roundToStepPrecision(sliderValue, 0.1);
expect(rounded).toBeCloseTo(1.5, 1);
});
test('should handle font size adjustments with step=0.5', () => {
// Font size adjustments
let fontSize = 12;
fontSize += 0.5; // 12.5
fontSize += 0.5; // 13.0
const rounded = roundToStepPrecision(fontSize, 0.5);
expect(rounded).toBeCloseTo(13, 1);
});
test('should handle opacity values with step=0.05', () => {
// Opacity from 0 to 1 in 5% increments
let opacity = 0;
for (let i = 0; i < 10; i++) {
opacity += 0.05;
}
const rounded = roundToStepPrecision(opacity, 0.05);
expect(rounded).toBeCloseTo(0.5, 2);
});
test('should handle percentage calculations with step=0.01', () => {
// Calculate percentage with floating-point issues
const percentage = (1 / 3) * 100;
const rounded = roundToStepPrecision(percentage, 0.01);
expect(rounded).toBeCloseTo(33.33, 2);
});
test('should handle coordinate rounding with step=0.000001', () => {
// GPS coordinates with micro-degree precision
const lat = 40.7128 + 0.000001;
const rounded = roundToStepPrecision(lat, 0.000001);
expect(rounded).toBeCloseTo(40.712801, 6);
});
test('should handle time values with step=0.001', () => {
// Millisecond precision timing
const time = 123.456 + 0.001 + 0.001;
const rounded = roundToStepPrecision(time, 0.001);
expect(rounded).toBeCloseTo(123.458, 3);
});
});
describe('common step values', () => {
test('should correctly round for step=0.05', () => {
// step=0.05 has 2 decimal places, so it rounds to 2 decimal places
// Note: This rounds to the DECIMAL PRECISION, not to the step increment
expect(roundToStepPrecision(1.34, 0.05)).toBeCloseTo(1.34);
expect(roundToStepPrecision(1.36, 0.05)).toBeCloseTo(1.36);
expect(roundToStepPrecision(1.37, 0.05)).toBeCloseTo(1.37);
expect(roundToStepPrecision(1.38, 0.05)).toBeCloseTo(1.38);
});
test('should correctly round for step=0.25', () => {
// step=0.25 has 2 decimal places, so it rounds to 2 decimal places
// Note: This rounds to the DECIMAL PRECISION, not to the step increment
expect(roundToStepPrecision(1.24, 0.25)).toBeCloseTo(1.24);
expect(roundToStepPrecision(1.26, 0.25)).toBeCloseTo(1.26);
expect(roundToStepPrecision(1.37, 0.25)).toBeCloseTo(1.37);
expect(roundToStepPrecision(1.38, 0.25)).toBeCloseTo(1.38);
});
test('should correctly round for step=0.1', () => {
// step=0.1 has 1 decimal place, so it rounds to 1 decimal place
expect(roundToStepPrecision(1.04, 0.1)).toBeCloseTo(1.0);
expect(roundToStepPrecision(1.05, 0.1)).toBeCloseTo(1.1);
expect(roundToStepPrecision(1.14, 0.1)).toBeCloseTo(1.1);
expect(roundToStepPrecision(1.15, 0.1)).toBeCloseTo(1.1); // standard banker's rounding
});
test('should correctly round for step=0.01', () => {
expect(roundToStepPrecision(1.234, 0.01)).toBeCloseTo(1.23);
expect(roundToStepPrecision(1.235, 0.01)).toBeCloseTo(1.24);
expect(roundToStepPrecision(1.236, 0.01)).toBeCloseTo(1.24);
});
});
describe('integration with getDecimalPlaces', () => {
test('should use correct decimal places from step parameter', () => {
// step=0.1 has 1 decimal place
expect(roundToStepPrecision(1.234, 0.1)).toBeCloseTo(1.2);
// step=0.01 has 2 decimal places
expect(roundToStepPrecision(1.234, 0.01)).toBeCloseTo(1.23);
// step=0.001 has 3 decimal places
expect(roundToStepPrecision(1.2345, 0.001)).toBeCloseTo(1.235);
});
test('should handle steps with different precisions correctly', () => {
const value = 1.123456789;
expect(roundToStepPrecision(value, 0.1)).toBeCloseTo(1.1);
expect(roundToStepPrecision(value, 0.01)).toBeCloseTo(1.12);
expect(roundToStepPrecision(value, 0.001)).toBeCloseTo(1.123);
expect(roundToStepPrecision(value, 0.0001)).toBeCloseTo(1.1235);
});
});
describe('return type behavior', () => {
test('should return finite number for valid inputs', () => {
expect(Number.isFinite(roundToStepPrecision(1.23, 0.01))).toBe(true);
});
});
describe('precision edge cases', () => {
test('should round 0.9999 correctly with step=0.01', () => {
expect(roundToStepPrecision(0.9999, 0.01)).toBeCloseTo(1);
});
test('should round 0.99999 correctly with step=0.001', () => {
expect(roundToStepPrecision(0.99999, 0.001)).toBeCloseTo(1);
});
test('should handle rounding up to next integer', () => {
expect(roundToStepPrecision(0.999, 0.001)).toBeCloseTo(0.999);
});
test('should handle values just below step boundary', () => {
expect(roundToStepPrecision(1.4999, 0.01)).toBeCloseTo(1.5);
expect(roundToStepPrecision(1.499, 0.01)).toBeCloseTo(1.5);
});
});
});

View File

@@ -0,0 +1,24 @@
import { getDecimalPlaces } from '$shared/lib/utils';
/**
* Round a value to the precision of the given step
*
* This fixes floating-point precision errors that occur with decimal steps.
* For example, with step=0.05, adding it repeatedly can produce values like
* 1.3499999999999999 instead of 1.35.
*
* We use toFixed() to round to the appropriate decimal places instead of
* Math.round(value / step) * step, which doesn't always work correctly
* due to floating-point arithmetic errors.
*
* @param value - The value to round
* @param step - The step to round to (defaults to 1)
* @returns The rounded value
*/
export function roundToStepPrecision(value: number, step: number = 1): number {
if (step <= 0) {
return value;
}
const decimals = getDecimalPlaces(step);
return parseFloat(value.toFixed(decimals));
}

View File

@@ -1,89 +0,0 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
type ControlModel,
createControlStore,
} from './createControlStore';
describe('createControlStore', () => {
let store: ReturnType<typeof createControlStore<number>>;
beforeEach(() => {
const initialState: ControlModel<number> = {
value: 10,
min: 0,
max: 100,
step: 5,
};
store = createControlStore(initialState);
});
it('initializes with correct state', () => {
expect(get(store)).toEqual({
value: 10,
min: 0,
max: 100,
step: 5,
});
});
it('increases value by step', () => {
store.increase();
expect(get(store).value).toBe(15);
});
it('decreases value by step', () => {
store.decrease();
expect(get(store).value).toBe(5);
});
it('clamps value at maximum', () => {
store.setValue(200);
expect(get(store).value).toBe(100);
});
it('clamps value at minimum', () => {
store.setValue(-10);
expect(get(store).value).toBe(0);
});
it('rounds to step precision', () => {
store.setValue(12.34);
// With step=5, 12.34 is clamped and rounded to nearest integer (0 decimal places)
expect(get(store).value).toBe(12);
});
it('handles decimal steps correctly', () => {
const decimalStore = createControlStore({
value: 1.0,
min: 0,
max: 2,
step: 0.05,
});
decimalStore.increase();
expect(get(decimalStore).value).toBe(1.05);
});
it('isAtMax returns true when at maximum', () => {
store.setValue(100);
expect(store.isAtMax()).toBe(true);
});
it('isAtMax returns false when not at maximum', () => {
expect(store.isAtMax()).toBe(false);
});
it('isAtMin returns true when at minimum', () => {
store.setValue(0);
expect(store.isAtMin()).toBe(true);
});
it('isAtMin returns false when not at minimum', () => {
expect(store.isAtMin()).toBe(false);
});
});

View File

@@ -1,117 +0,0 @@
import {
type Writable,
get,
writable,
} from 'svelte/store';
/**
* Model for a control value with min/max bounds
*/
export type ControlModel<
TValue extends number = number,
> = {
value: TValue;
min: TValue;
max: TValue;
step?: TValue;
};
/**
* Store model with methods for control manipulation
*/
export type ControlStoreModel<
TValue extends number,
> =
& Writable<ControlModel<TValue>>
& {
increase: () => void;
decrease: () => void;
/** Set a specific value */
setValue: (newValue: TValue) => void;
isAtMax: () => boolean;
isAtMin: () => boolean;
};
/**
* Create a writable store for numeric control values with bounds
*
* @template TValue - The value type (extends number)
* @param initialState - Initial state containing value, min, and max
*/
/**
* Get the number of decimal places in a number
*
* For example:
* - 1 -> 0
* - 0.1 -> 1
* - 0.01 -> 2
* - 0.05 -> 2
*
* @param step - The step number to analyze
* @returns The number of decimal places
*/
function getDecimalPlaces(step: number): number {
const str = step.toString();
const decimalPart = str.split('.')[1];
return decimalPart ? decimalPart.length : 0;
}
/**
* Round a value to the precision of the given step
*
* This fixes floating-point precision errors that occur with decimal steps.
* For example, with step=0.05, adding it repeatedly can produce values like
* 1.3499999999999999 instead of 1.35.
*
* We use toFixed() to round to the appropriate decimal places instead of
* Math.round(value / step) * step, which doesn't always work correctly
* due to floating-point arithmetic errors.
*
* @param value - The value to round
* @param step - The step to round to (defaults to 1)
* @returns The rounded value
*/
function roundToStepPrecision(value: number, step: number = 1): number {
if (step <= 0) {
return value;
}
const decimals = getDecimalPlaces(step);
return parseFloat(value.toFixed(decimals));
}
export function createControlStore<
TValue extends number = number,
>(
initialState: ControlModel<TValue>,
): ControlStoreModel<TValue> {
const store = writable(initialState);
const { subscribe, set, update } = store;
const clamp = (value: number): TValue => {
return Math.max(initialState.min, Math.min(value, initialState.max)) as TValue;
};
return {
subscribe,
set,
update,
increase: () =>
update(m => {
const step = m.step ?? 1;
const newValue = clamp(m.value + step);
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
}),
decrease: () =>
update(m => {
const step = m.step ?? 1;
const newValue = clamp(m.value - step);
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
}),
setValue: (v: TValue) => {
const step = initialState.step ?? 1;
update(m => ({ ...m, value: roundToStepPrecision(clamp(v), step) as TValue }));
},
isAtMin: () => get(store).value === initialState.min,
isAtMax: () => get(store).value === initialState.max,
};
}

View File

@@ -1,136 +0,0 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
type FilterModel,
type Property,
createFilterStore,
} from './createFilterStore';
describe('createFilterStore', () => {
const mockProperties: Property[] = [
{ id: '1', name: 'Sans-serif', selected: false },
{ id: '2', name: 'Serif', selected: false },
{ id: '3', name: 'Display', selected: false },
];
let store: ReturnType<typeof createFilterStore>;
beforeEach(() => {
const initialState: FilterModel = {
searchQuery: '',
properties: mockProperties,
};
store = createFilterStore(initialState);
});
it('initializes with correct state', () => {
const state = get(store);
expect(state).toEqual({
searchQuery: '',
properties: mockProperties,
});
});
it('sets search query', () => {
store.setSearchQuery('serif');
const state = get(store);
expect(state.searchQuery).toBe('serif');
});
it('clears search query', () => {
store.setSearchQuery('test');
store.clearSearchQuery();
const state = get(store);
expect(state.searchQuery).toBeUndefined();
});
it('selects a property', () => {
store.selectProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(true);
});
it('deselects a property', () => {
store.selectProperty('1');
store.deselectProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(false);
});
it('toggles property from unselected to selected', () => {
store.toggleProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(true);
});
it('toggles property from selected to unselected', () => {
store.selectProperty('1');
store.toggleProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(false);
});
it('selects all properties', () => {
store.selectAllProperties();
const state = get(store);
expect(state.properties.every(p => p.selected)).toBe(true);
});
it('deselects all properties', () => {
store.selectAllProperties();
store.deselectAllProperties();
const state = get(store);
expect(state.properties.every(p => !p.selected)).toBe(true);
});
it('gets all properties', () => {
const allProps = store.getAllProperties();
const props = get(allProps);
expect(props).toEqual(mockProperties);
});
it('gets selected properties', () => {
store.selectProperty('1');
store.selectProperty('3');
const selectedProps = store.getSelectedProperties();
const props = get(selectedProps);
expect(props).toHaveLength(2);
expect(props?.[0].id).toBe('1');
expect(props?.[1].id).toBe('3');
});
it('filters properties by search query', () => {
store.setSearchQuery('serif');
const filteredProps = store.getFilteredProperties();
const props = get(filteredProps);
// 'serif' is a substring of 'Sans-serif' (case-sensitive match)
expect(props).toHaveLength(1);
expect(props?.[0].id).toBe('1');
});
it('filter is case-sensitive', () => {
store.setSearchQuery('San');
const filteredProps = store.getFilteredProperties();
const props = get(filteredProps);
// 'San' matches 'Sans-serif' exactly (case-sensitive)
expect(props).toHaveLength(1);
expect(props?.[0].id).toBe('1');
});
it('filter returns all properties when query is empty', () => {
store.setSearchQuery('');
const filteredProps = store.getFilteredProperties();
let props: Property[] | undefined = undefined;
filteredProps.subscribe(p => (props = p))();
expect(props).toHaveLength(3);
});
});

View File

@@ -1,226 +0,0 @@
import {
type Readable,
type Writable,
derived,
writable,
} from 'svelte/store';
export interface Property {
/**
* Property identifier
*/
id: string;
/**
* Property name
*/
name: string;
/**
* Property selected state
*/
selected?: boolean;
}
export interface FilterModel {
/**
* Search query
*/
searchQuery?: string;
/**
* Properties
*/
properties: Property[];
}
/**
* Model for reusable filter store with search support and property selection
*/
export interface FilterStore<T extends FilterModel> extends Writable<T> {
/**
* Get the store.
* @returns Readable store with filter data
*/
getStore: () => Readable<T>;
/**
* Get all properties.
* @returns Readable store with properties
*/
getAllProperties: () => Readable<Property[]>;
/**
* Get the selected properties.
* @returns Readable store with selected properties
*/
getSelectedProperties: () => Readable<Property[]>;
/**
* Get the filtered properties.
* @returns Readable store with filtered properties
*/
getFilteredProperties: () => Readable<Property[]>;
/**
* Update the search query filter.
*
* @param searchQuery - Search text (undefined to clear)
*/
setSearchQuery: (searchQuery: string | undefined) => void;
/**
* Clear the search query filter.
*/
clearSearchQuery: () => void;
/**
* Select a property.
*
* @param property - Property to select
*/
selectProperty: (propertyId: string) => void;
/**
* Deselect a property.
*
* @param property - Property to deselect
*/
deselectProperty: (propertyId: string) => void;
/**
* Toggle a property.
*
* @param propertyId - Property ID
*/
toggleProperty: (propertyId: string) => void;
/**
* Select all properties.
*/
selectAllProperties: () => void;
/**
* Deselect all properties.
*/
deselectAllProperties: () => void;
}
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
* @returns FilterStore<T>
*/
export function createFilterStore<T extends FilterModel>(
initialState?: T,
): FilterStore<T> {
const { subscribe, set, update } = writable<T>(initialState);
return {
/*
* Expose subscribe, set, and update from Writable.
* This makes FilterStore compatible with Writable interface.
*/
subscribe,
set,
update,
/**
* Get the current state of the filter store.
*/
getStore: () => {
return {
subscribe,
};
},
/**
* Get the filtered properties.
*/
getAllProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties;
});
},
/**
* Get the selected properties.
*/
getSelectedProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties.filter(property => property.selected);
});
},
/**
* Get the filtered properties.
*/
getFilteredProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties.filter(property =>
property.name.includes($store.searchQuery || '')
);
});
},
/**
* Update the search query filter.
*
* @param searchQuery - Search text (undefined to clear)
*/
setSearchQuery: (searchQuery: string | undefined) => {
update(state => ({
...state,
searchQuery: searchQuery || undefined,
}));
},
/**
* Clear the search query filter.
*/
clearSearchQuery: () => {
update(state => ({
...state,
searchQuery: undefined,
}));
},
/**
* Select a property.
*
* @param propertyId - Property ID
*/
selectProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: true } : c
),
}));
},
/**
* Deselect a property.
*
* @param propertyId - Property ID
*/
deselectProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: false } : c
),
}));
},
/**
* Toggle a property.
*
* @param propertyId - Property ID
*/
toggleProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: !c.selected } : c
),
}));
},
/**
* Select all properties
*/
selectAllProperties: () => {
update(state => ({
...state,
properties: state.properties.map(c => ({ ...c, selected: true })),
}));
},
/**
* Deselect all properties
*/
deselectAllProperties: () => {
update(state => ({
...state,
properties: state.properties.map(c => ({ ...c, selected: false })),
}));
},
};
}

View File

@@ -1,21 +0,0 @@
/**
* Generic collection API response model
* Use this for APIs that return collections of items
*
* @template T - The type of items in the collection array
* @template K - The key used to access the collection array in the response
*/
export type CollectionApiModel<T, K extends string = 'items'> = Record<K, T[]> & {
/**
* Number of items returned in the current page/response
*/
count: number;
/**
* Total number of items available across all pages
*/
count_total: number;
/**
* Indicates if there are more items available beyond this page
*/
has_more: boolean;
};

View File

@@ -1,10 +1,13 @@
<script lang="ts">
import type { Filter } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
import * as Collapsible from '$shared/shadcn/ui/collapsible';
import {
Root as CollapsibleRoot,
Trigger as CollapsibleTrigger,
} from '$shared/shadcn/ui/collapsible';
import { Label } from '$shared/shadcn/ui/label';
import type { Property } from '$shared/store/createFilterStore';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
@@ -26,13 +29,11 @@ import { slide } from 'svelte/transition';
interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */
displayedLabel: string;
/** Array of properties with their selection states */
properties: Property[];
/** Callback when a property checkbox is toggled */
onPropertyToggle: (id: string) => void;
/** Filter entity */
filter: Filter;
}
const { displayedLabel, properties, onPropertyToggle }: PropertyFilterProps = $props();
const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability
let isOpen = $state(true);
@@ -63,18 +64,18 @@ const slideConfig = $derived({
});
// Derived for reactive updates when properties change - avoids recomputing on every render
const selectedCount = $derived(properties.filter(c => c.selected).length);
const selectedCount = $derived(filter.selectedCount);
const hasSelection = $derived(selectedCount > 0);
</script>
<!-- Collapsible card wrapper with subtle hover state for affordance -->
<Collapsible.Root
<CollapsibleRoot
bind:open={isOpen}
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5"
>
<!-- Trigger row: title, expand indicator, and optional count badge -->
<div class="flex items-center justify-between px-4 py-2">
<Collapsible.Trigger
<CollapsibleTrigger
class={buttonVariants({
variant: 'ghost',
size: 'sm',
@@ -88,6 +89,7 @@ const hasSelection = $derived(selectedCount > 0);
{#if hasSelection}
<Badge
variant="secondary"
data-testid="badge"
class="mr-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums"
>
{selectedCount}
@@ -96,12 +98,13 @@ const hasSelection = $derived(selectedCount > 0);
<!-- Chevron rotates based on open state for visual feedback -->
<div
data-testid="chevron"
class="shrink-0 transition-transform duration-200 ease-out"
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
>
<ChevronDownIcon class="h-4 w-4" />
</div>
</Collapsible.Trigger>
</CollapsibleTrigger>
</div>
<!-- Expandable content with slide animation -->
@@ -114,7 +117,7 @@ const hasSelection = $derived(selectedCount > 0);
<div class="flex flex-col gap-0.5">
<!-- Each item: checkbox + label with interactive hover/focus states -->
<!-- Keyed by property.id for efficient DOM updates -->
{#each properties as property (property.id)}
{#each filter.properties as property (property.id)}
<Label
for={property.id}
class="
@@ -129,8 +132,7 @@ const hasSelection = $derived(selectedCount > 0);
-->
<Checkbox
id={property.id}
checked={property.selected}
onclick={() => onPropertyToggle(property.id)}
bind:checked={property.selected}
class="
shrink-0 cursor-pointer transition-all duration-150 ease-out
data-[state=checked]:scale-100
@@ -155,4 +157,4 @@ const hasSelection = $derived(selectedCount > 0);
</div>
</div>
{/if}
</Collapsible.Root>
</CollapsibleRoot>

View File

@@ -1,85 +1,573 @@
import type { Property } from '$shared/store/createFilterStore';
import {
type Property,
createFilter,
} from '$shared/lib';
import {
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/svelte';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import CheckboxFilter from './CheckboxFilter.svelte';
describe('CheckboxFilter', () => {
const mockProperties: Property[] = [
{ id: '1', name: 'Sans-serif', selected: false },
{ id: '2', name: 'Serif', selected: true },
{ id: '3', name: 'Display', selected: false },
];
/**
* Test Suite for CheckboxFilter Component
*
* This suite tests the actual Svelte component rendering, interactions, and behavior
* using a real browser environment (Playwright) via @vitest/browser-playwright.
*
* Tests for the createFilter helper function are in createFilter.test.ts
*
* IMPORTANT: These tests use the browser environment because Svelte 5's $state,
* $derived, and onMount lifecycle require a browser environment. The bits-ui
* Checkbox component renders as <button type="button"> with role="checkbox",
* not as <input type="checkbox">.
*/
const mockOnPropertyToggle = vi.fn();
describe('CheckboxFilter Component', () => {
/**
* Helper function to create a filter for testing
*/
function createTestFilter<T extends string>(properties: Property<T>[]) {
return createFilter({ properties });
}
beforeEach(() => {
mockOnPropertyToggle.mockClear();
});
/**
* Helper function to create mock properties
*/
function createMockProperties(count: number, selectedIndices: number[] = []) {
return Array.from({ length: count }, (_, i) => ({
id: `prop-${i}`,
name: `Property ${i}`,
value: `Value ${i}`,
selected: selectedIndices.includes(i),
}));
}
it('renders with correct label', () => {
describe('Rendering', () => {
it('displays the label', () => {
const filter = createTestFilter(createMockProperties(3));
render(CheckboxFilter, {
displayedLabel: 'Font Categories',
properties: mockProperties,
onPropertyToggle: mockOnPropertyToggle,
displayedLabel: 'Test Label',
filter,
});
expect(screen.getByText('Font Categories')).toBeInTheDocument();
expect(screen.getByText('Test Label')).toBeInTheDocument();
});
it('displays all properties as checkboxes', () => {
it('renders all properties as checkboxes with labels', () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Categories',
properties: mockProperties,
onPropertyToggle: mockOnPropertyToggle,
displayedLabel: 'Test',
filter,
});
expect(screen.getByLabelText('Sans-serif')).toBeInTheDocument();
expect(screen.getByLabelText('Serif')).toBeInTheDocument();
expect(screen.getByLabelText('Display')).toBeInTheDocument();
// Check that all property names are rendered
expect(screen.getByText('Property 0')).toBeInTheDocument();
expect(screen.getByText('Property 1')).toBeInTheDocument();
expect(screen.getByText('Property 2')).toBeInTheDocument();
});
it('shows selected count badge when items selected', () => {
it('shows selected count badge when items are selected', () => {
const properties = createMockProperties(3, [0, 2]); // Select 2 items
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Categories',
properties: mockProperties,
onPropertyToggle: mockOnPropertyToggle,
displayedLabel: 'Test',
filter,
});
expect(screen.getByText('2')).toBeInTheDocument();
});
it('hides badge when no items selected', () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
const { container } = render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
// Badge should not be in the document
const badges = container.querySelectorAll('[class*="badge"]');
expect(badges).toHaveLength(0);
});
it('renders with no properties', () => {
const filter = createTestFilter([]);
render(CheckboxFilter, {
displayedLabel: 'Empty Filter',
filter,
});
expect(screen.getByText('Empty Filter')).toBeInTheDocument();
});
});
describe('Checkbox Interactions', () => {
it('checkboxes reflect initial selected state', async () => {
const properties = createMockProperties(3, [0, 2]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
// Wait for component to render
// bits-ui Checkbox renders as <button type="button"> with role="checkbox"
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(3);
// Check that the correct checkboxes are checked using aria-checked attribute
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
expect(checkboxes[2]).toHaveAttribute('data-state', 'checked');
});
it('clicking checkbox toggles property.selected state', async () => {
const properties = createMockProperties(3, [0]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
const checkboxes = await screen.findAllByRole('checkbox');
// Initially, first checkbox is checked
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
expect(filter.selectedCount).toBe(1);
// Click to uncheck it
await fireEvent.click(checkboxes[0]);
// Now it should be unchecked
await waitFor(() => {
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
});
expect(filter.selectedCount).toBe(0);
// Click it again to re-check
await fireEvent.click(checkboxes[0]);
await waitFor(() => {
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
});
expect(filter.selectedCount).toBe(1);
});
it('label styling changes based on selection state', async () => {
const properties = createMockProperties(2, [0]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
const checkboxes = await screen.findAllByRole('checkbox');
// Find label elements - they are siblings of checkboxes
const labels = checkboxes.map(cb => cb.nextElementSibling);
// First label should have font-medium and text-foreground classes
expect(labels[0]).toHaveClass('font-medium', 'text-foreground');
// Second label should not have these classes
expect(labels[1]).not.toHaveClass('font-medium', 'text-foreground');
// Uncheck the first checkbox
await fireEvent.click(checkboxes[0]);
await waitFor(() => {
// Now first label should not have these classes
expect(labels[0]).not.toHaveClass('font-medium', 'text-foreground');
});
});
it('multiple checkboxes can be toggled independently', async () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
const checkboxes = await screen.findAllByRole('checkbox');
// Check all three checkboxes
await fireEvent.click(checkboxes[0]);
await fireEvent.click(checkboxes[1]);
await fireEvent.click(checkboxes[2]);
await waitFor(() => {
expect(filter.selectedCount).toBe(3);
});
// Uncheck middle one
await fireEvent.click(checkboxes[1]);
await waitFor(() => {
expect(filter.selectedCount).toBe(2);
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
expect(checkboxes[2]).toHaveAttribute('data-state', 'checked');
});
});
});
describe('Collapsible Behavior', () => {
it('is open by default', () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
// Check that properties are visible (content is expanded)
expect(screen.getByText('Property 0')).toBeInTheDocument();
expect(screen.getByText('Property 1')).toBeInTheDocument();
});
it('clicking trigger toggles open/close state', async () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
// Content is initially visible
expect(screen.getByText('Property 0')).toBeVisible();
// Click the trigger (button) - use role and text to find it
const trigger = screen.getByRole('button', { name: /Test/ });
await fireEvent.click(trigger);
// Content should now be hidden
await waitFor(() => {
expect(screen.queryByText('Property 0')).not.toBeInTheDocument();
});
// Click again to open
await fireEvent.click(trigger);
// Content should be visible again
await waitFor(() => {
expect(screen.getByText('Property 0')).toBeInTheDocument();
});
});
it('chevron icon rotates based on open state', async () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
const trigger = screen.getByRole('button', { name: /Test/ });
const chevronContainer = trigger.querySelector('.lucide-chevron-down')
?.parentElement as HTMLElement;
// Initially open, transform should be rotate(0deg) or no rotation
expect(chevronContainer?.style.transform).toContain('0deg');
// Click to close
await fireEvent.click(trigger);
await waitFor(() => {
// Now should be rotated -90deg
expect(chevronContainer?.style.transform).toContain('-90deg');
});
// Click to open again
await fireEvent.click(trigger);
await waitFor(() => {
// Back to 0deg
expect(chevronContainer?.style.transform).toContain('0deg');
});
});
});
describe('Count Display', () => {
it('badge shows correct count based on filter.selectedCount', async () => {
const properties = createMockProperties(5, [0, 2, 4]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
// Should show 3
expect(screen.getByText('3')).toBeInTheDocument();
// Click a checkbox to change selection
const checkboxes = await screen.findAllByRole('checkbox');
await fireEvent.click(checkboxes[1]);
// Should now show 4
await waitFor(() => {
expect(screen.getByText('4')).toBeInTheDocument();
});
});
it('badge visibility changes with hasSelection (selectedCount > 0)', async () => {
const properties = createMockProperties(2, [0]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
// Initially has 1 selection, badge should be visible
expect(screen.getByText('1')).toBeInTheDocument();
});
it('does not show badge when no items selected', () => {
const allUnselected = mockProperties.map(p => ({ ...p, selected: false }));
render(CheckboxFilter, {
displayedLabel: 'Categories',
properties: allUnselected,
onPropertyToggle: mockOnPropertyToggle,
});
// Uncheck the selected item
const checkboxes = await screen.findAllByRole('checkbox');
await fireEvent.click(checkboxes[0]);
// Now 0 selections, badge should be hidden
await waitFor(() => {
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
it('calls onPropertyToggle when checkbox clicked', async () => {
render(CheckboxFilter, {
displayedLabel: 'Categories',
properties: mockProperties,
onPropertyToggle: mockOnPropertyToggle,
// Check it again
await fireEvent.click(checkboxes[0]);
// Badge should be visible again
await waitFor(() => {
expect(screen.getByText('1')).toBeInTheDocument();
});
});
const checkbox = screen.getByLabelText('Sans-serif');
await checkbox.click();
it('badge shows count correctly when all items are selected', () => {
const properties = createMockProperties(5, [0, 1, 2, 3, 4]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
expect(mockOnPropertyToggle).toHaveBeenCalledWith('1');
expect(screen.getByText('5')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('provides proper ARIA labels on buttons', () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
displayedLabel: 'Test Label',
filter,
});
// The trigger button should be findable by its text
const trigger = screen.getByRole('button', { name: /Test Label/ });
expect(trigger).toBeInTheDocument();
});
it('labels are properly associated with checkboxes', async () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
const checkboxes = await screen.findAllByRole('checkbox');
checkboxes.forEach((checkbox, index) => {
// Each checkbox should have an id
expect(checkbox).toHaveAttribute('id', `prop-${index}`);
// Find the label element (Label component wraps checkbox)
const labelElement = checkbox.closest('label');
expect(labelElement).toHaveAttribute('for', `prop-${index}`);
});
});
it('checkboxes have proper role', async () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
const checkboxes = await screen.findAllByRole('checkbox');
checkboxes.forEach(checkbox => {
expect(checkbox).toHaveAttribute('role', 'checkbox');
expect(checkbox).toHaveAttribute('type', 'button');
});
});
it('labels are clickable and toggle associated checkboxes', async () => {
const properties = createMockProperties(2);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
const checkboxes = await screen.findAllByRole('checkbox');
// Find the label text element (span inside label)
const firstLabelText = screen.getByText('Property 0');
// Initially unchecked
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
// Click the label text
await fireEvent.click(firstLabelText);
// Checkbox should now be checked
await waitFor(() => {
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
});
// Click again
await fireEvent.click(firstLabelText);
// Should be unchecked again
await waitFor(() => {
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
});
});
});
describe('Edge Cases', () => {
it('handles long property names', () => {
const properties: Property<string>[] = [
{
id: '1',
name: 'This is a very long property name that might wrap to multiple lines',
value: '1',
selected: false,
},
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
expect(
screen.getByText(
'This is a very long property name that might wrap to multiple lines',
),
).toBeInTheDocument();
});
it('handles special characters in property names', () => {
const properties: Property<string>[] = [
{ id: '1', name: 'Café & Restaurant', value: '1', selected: true },
{ id: '2', name: '100% Organic', value: '2', selected: false },
{ id: '3', name: '(Special) <Characters>', value: '3', selected: false },
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
expect(screen.getByText('Café & Restaurant')).toBeInTheDocument();
expect(screen.getByText('100% Organic')).toBeInTheDocument();
expect(screen.getByText('(Special) <Characters>')).toBeInTheDocument();
});
it('handles single property filter', () => {
const properties: Property<string>[] = [
{ id: '1', name: 'Only One', value: '1', selected: true },
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Single',
filter,
});
expect(screen.getByText('Only One')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
});
it('handles very large number of properties', async () => {
const properties = createMockProperties(50, [0, 25, 49]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Large List',
filter,
});
const checkboxes = await screen.findAllByRole('checkbox');
expect(checkboxes).toHaveLength(50);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('updates badge when filter is manipulated externally', async () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
displayedLabel: 'Test',
filter,
});
// Initially no badge (0 selections)
expect(screen.queryByText('0')).not.toBeInTheDocument();
// Externally select properties
filter.selectProperty('prop-0');
filter.selectProperty('prop-1');
// Badge should now show 2
// Note: This might not update immediately in the DOM due to Svelte reactivity
// In a real browser environment, this would update
await waitFor(() => {
expect(screen.getByText('2')).toBeInTheDocument();
});
});
});
describe('Component Integration', () => {
it('works correctly with real filter data', async () => {
const realProperties: Property<string>[] = [
{ id: 'sans-serif', name: 'Sans-serif', value: 'sans-serif', selected: true },
{ id: 'serif', name: 'Serif', value: 'serif', selected: false },
{ id: 'display', name: 'Display', value: 'display', selected: false },
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting', selected: true },
{ id: 'monospace', name: 'Monospace', value: 'monospace', selected: false },
];
const filter = createTestFilter(realProperties);
render(CheckboxFilter, {
displayedLabel: 'Font Category',
filter,
});
// Check label
expect(screen.getByText('Font Category')).toBeInTheDocument();
// Check count badge
expect(screen.getByText('2')).toBeInTheDocument();
// Check property names
expect(screen.getByText('Sans-serif')).toBeInTheDocument();
expect(screen.getByText('Serif')).toBeInTheDocument();
expect(screen.getByText('Display')).toBeInTheDocument();
expect(screen.getByText('Handwriting')).toBeInTheDocument();
expect(screen.getByText('Monospace')).toBeInTheDocument();
// Check initial checkbox states
const checkboxes = await screen.findAllByRole('checkbox');
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
expect(checkboxes[2]).toHaveAttribute('data-state', 'unchecked');
expect(checkboxes[3]).toHaveAttribute('data-state', 'checked');
expect(checkboxes[4]).toHaveAttribute('data-state', 'unchecked');
// Interact with checkboxes
await fireEvent.click(checkboxes[1]);
await waitFor(() => {
expect(filter.selectedCount).toBe(3);
});
});
});
});

View File

@@ -1,64 +0,0 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
const { Story } = defineMeta({
title: 'Shared/UI/ComboControl',
component: ComboControl,
tags: ['autodocs'],
argTypes: {
value: { control: 'number' },
minValue: { control: 'number' },
maxValue: { control: 'number' },
step: { control: 'number' },
increaseDisabled: { control: 'boolean' },
decreaseDisabled: { control: 'boolean' },
onChange: { action: 'onChange' },
onIncrease: { action: 'onIncrease' },
onDecrease: { action: 'onDecrease' },
},
});
</script>
<script lang="ts">
import ComboControl from './ComboControl.svelte';
let integerStep = 1;
let decimalStep = 0.05;
let integerValue = 16;
let decimalValue = 1.5;
let integerMinValue = 8;
let decimalMinValue = 1;
let integerMaxValue = 100;
let decimalMaxValue = 2;
function onChange() {}
function onIncrease() {}
function onDecrease() {}
</script>
<Story name="Integer Step">
<ComboControl
value={integerValue}
step={integerStep}
onChange={onChange}
onIncrease={onIncrease}
onDecrease={onDecrease}
minValue={integerMinValue}
maxValue={integerMaxValue}
/>
</Story>
<Story name="Decimal Step">
<ComboControl
value={decimalValue}
step={decimalStep}
onChange={onChange}
onIncrease={onIncrease}
onDecrease={onDecrease}
minValue={decimalMinValue}
maxValue={decimalMaxValue}
/>
</Story>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button';
import * as ButtonGroup from '$shared/shadcn/ui/button-group';
import { Input } from '$shared/shadcn/ui/input';
@@ -9,22 +10,6 @@ import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements';
interface ComboControlProps {
/**
* Controlled value
*/
value: number;
/**
* Callback function to handle value change
*/
onChange: (value: number) => void;
/**
* Callback function to handle increase
*/
onIncrease: () => void;
/**
* Callback function to handle decrease
*/
onDecrease: () => void;
/**
* Text for increase button aria-label
*/
@@ -33,59 +18,36 @@ interface ComboControlProps {
* Text for decrease button aria-label
*/
decreaseLabel?: string;
/**
* Flag for disabling increase button
*/
increaseDisabled?: boolean;
/**
* Flag for disabling decrease button
*/
decreaseDisabled?: boolean;
/**
* Text for control button aria-label
*/
controlLabel?: string;
/**
* Minimum value for the input
* Control instance
*/
minValue?: number;
/**
* Maximum value for the input
*/
maxValue?: number;
/**
* Step value for the slider
*/
step?: number;
control: TypographyControl;
}
const {
value,
onChange,
onIncrease,
onDecrease,
increaseLabel,
control,
decreaseLabel,
increaseDisabled,
decreaseDisabled,
increaseLabel,
controlLabel,
minValue = 0,
maxValue = 100,
step = 1,
}: ComboControlProps = $props();
// Local state for the slider to prevent infinite loops
let sliderValue = $state(value);
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
let sliderValue = $state(Number(control.value));
// Sync sliderValue when external value changes
$effect(() => {
sliderValue = value;
sliderValue = Number(control.value);
});
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) {
onChange(parsedValue);
control.value = parsedValue;
}
};
@@ -93,8 +55,8 @@ const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
* Handle slider value change.
* The Slider component passes the value as a number directly.
*/
const handleSliderChange = (value: number) => {
onChange(value);
const handleSliderChange = (newValue: number) => {
control.value = newValue;
};
</script>
@@ -103,8 +65,8 @@ const handleSliderChange = (value: number) => {
variant="outline"
size="icon"
aria-label={decreaseLabel}
onclick={onDecrease}
disabled={decreaseDisabled}
onclick={control.decrease}
disabled={control.isAtMin}
>
<MinusIcon />
</Button>
@@ -117,16 +79,16 @@ const handleSliderChange = (value: number) => {
size="icon"
aria-label={controlLabel}
>
{value}
{control.value}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-4">
<div class="flex flex-col items-center gap-3">
<Slider
min={minValue}
max={maxValue}
step={step}
min={control.min}
max={control.max}
step={control.step}
value={sliderValue}
onValueChange={handleSliderChange}
type="single"
@@ -134,10 +96,10 @@ const handleSliderChange = (value: number) => {
class="h-48"
/>
<Input
value={String(value)}
min={minValue}
max={maxValue}
value={control.value}
onchange={handleInputChange}
min={control.min}
max={control.max}
class="w-16 text-center"
/>
</div>
@@ -147,8 +109,8 @@ const handleSliderChange = (value: number) => {
variant="outline"
size="icon"
aria-label={increaseLabel}
onclick={onIncrease}
disabled={increaseDisabled}
onclick={control.increase}
disabled={control.isAtMax}
>
<PlusIcon />
</Button>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label';
import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { useId } from 'bits-ui';
import {
type Snippet,
tick,
} from 'svelte';
interface Props {
id: string;
value: string;
class?: string;
placeholder?: string;
label?: string;
children: Snippet<[{ id: string }]> | undefined;
}
let {
id = 'search-bar',
value = $bindable(),
class: className,
placeholder,
label,
children,
}: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLInputElement>(null!);
const contentId = useId(id);
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef?.focus();
});
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault();
}
}
function handleInputClick() {
open = true;
tick().then(() => {
triggerRef?.focus();
});
}
</script>
<PopoverRoot>
<PopoverTrigger bind:ref={triggerRef}>
{#snippet child({ props })}
<div {...props} class="flex flex-row flex-1 w-full">
{#if label}
<Label for={id}>{label}</Label>
{/if}
<Input
id={id}
placeholder={placeholder}
bind:value={value}
onkeydown={handleKeyDown}
class="flex flex-row flex-1"
/>
</div>
{/snippet}
</PopoverTrigger>
<PopoverContent
onOpenAutoFocus={e => e.preventDefault()}
class="w-max"
>
{@render children?.({ id: contentId })}
</PopoverContent>
</PopoverRoot>

Some files were not shown because too many files have changed in this diff Show More