Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0d39d861f | |||
| b6494a8cb5 | |||
| cc218934f4 | |||
| 3a327e2d92 | |||
| 30621c33df | |||
| cb8f6ffc97 | |||
| 33d3429060 | |||
| e60309af78 | |||
| 1573950605 | |||
| 773ab55f5c | |||
| 67e02e4e75 | |||
| 5ca7a433ff | |||
| 3b6ea99d09 | |||
| f762a09c23 | |||
| 95ae72719e | |||
| f3c4e72b86 | |||
| f41c4aab9c | |||
| d1eb83fa90 | |||
| c01fc79a3e | |||
| 6bfa7ca777 | |||
| 0d4356b8f1 | |||
| c18574d4c3 | |||
| 1c9a7f9fe1 | |||
| fae6694479 | |||
| a105c94176 | |||
| 77c2b27f8b | |||
| 1ce0d6c66f | |||
| 6c20a68e19 | |||
| 3894912a22 | |||
| e8d3727c6a | |||
| 5fbf090b24 | |||
| a94e1f8b65 | |||
| f8ba2d7eb0 | |||
| 3594033bcb | |||
| 2ae24912f7 | |||
| 877719f106 | |||
| 4eafb96d35 | |||
| 652dfa5c90 | |||
| 54087b7b2a | |||
| cffebf05e3 | |||
| ada484e2e0 | |||
| dbcc1caeb0 | |||
| 2c579a3336 | |||
| fe0d4e7daa | |||
| 108df323f9 | |||
| 2803bcd22c | |||
| 47a8487ce9 | |||
| 1d5af5ea70 | |||
| 2221ecad4c | |||
| cd8599d5b5 | |||
| 6c91d570ec | |||
| 91b80a5ada |
@@ -47,7 +47,8 @@ jobs:
|
||||
run: yarn test:unit
|
||||
|
||||
- name: Run Component Tests
|
||||
run: yarn test:component
|
||||
timeout-minutes: 5
|
||||
run: yarn test:component --reporter=verbose --logHeapUsage
|
||||
|
||||
publish:
|
||||
needs: build # Only runs if tests/lint pass
|
||||
@@ -62,5 +63,9 @@ jobs:
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
run: |
|
||||
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
||||
docker build \
|
||||
-t git.allmy.work/${{ gitea.repository }}:latest \
|
||||
-t git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} \
|
||||
.
|
||||
docker push git.allmy.work/${{ gitea.repository }}:latest
|
||||
docker push git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
|
||||
|
||||
@@ -10,6 +10,9 @@ node_modules
|
||||
/build
|
||||
/dist
|
||||
|
||||
# IDE settings
|
||||
.vscode
|
||||
|
||||
# Git worktrees (isolated development branches)
|
||||
.worktrees
|
||||
|
||||
|
||||
+4
-5
@@ -13,7 +13,7 @@
|
||||
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
|
||||
"https://plugins.dprint.dev/json-0.19.3.wasm",
|
||||
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm"
|
||||
],
|
||||
"typescript": {
|
||||
"lineWidth": 120,
|
||||
@@ -57,9 +57,8 @@
|
||||
"quotes": "double",
|
||||
"scriptIndent": false,
|
||||
"styleIndent": false,
|
||||
|
||||
"vBindStyle": "short",
|
||||
"vOnStyle": "short",
|
||||
"formatComments": true
|
||||
"formatComments": true,
|
||||
"svelteAttrShorthand": true,
|
||||
"svelteDirectiveShorthand": true
|
||||
}
|
||||
}
|
||||
|
||||
+33
-33
@@ -27,45 +27,45 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@storybook/addon-a11y": "^10.1.11",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-svelte-csf": "^5.0.10",
|
||||
"@storybook/addon-vitest": "^10.1.11",
|
||||
"@storybook/svelte-vite": "^10.1.11",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@chromatic-com/storybook": "5.1.2",
|
||||
"@internationalized/date": "3.12.1",
|
||||
"@lucide/svelte": "^1.14.0",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@storybook/addon-a11y": "10.3.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-svelte-csf": "5.1.2",
|
||||
"@storybook/addon-vitest": "10.3.6",
|
||||
"@storybook/svelte-vite": "10.3.6",
|
||||
"@sveltejs/vite-plugin-svelte": "7.1.0",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@tsconfig/svelte": "^5.0.6",
|
||||
"@types/jsdom": "^27",
|
||||
"@vitest/browser-playwright": "^4.0.16",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"bits-ui": "^2.14.4",
|
||||
"@tsconfig/svelte": "5.0.8",
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"bits-ui": "2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dprint": "^0.50.2",
|
||||
"jsdom": "^27.4.0",
|
||||
"lefthook": "^2.0.13",
|
||||
"oxlint": "^1.35.0",
|
||||
"playwright": "^1.57.0",
|
||||
"storybook": "^10.1.11",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-language-server": "^0.17.23",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"dprint": "0.54.0",
|
||||
"jsdom": "29.1.1",
|
||||
"lefthook": "2.1.6",
|
||||
"oxlint": "1.62.0",
|
||||
"playwright": "1.59.1",
|
||||
"storybook": "10.3.6",
|
||||
"svelte": "5.55.5",
|
||||
"svelte-check": "4.4.8",
|
||||
"svelte-language-server": "0.18.0",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.0.10",
|
||||
"vitest": "4.1.5",
|
||||
"vitest-browser-svelte": "2.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"@tanstack/svelte-query": "^6.0.14"
|
||||
"@chenglou/pretext": "0.0.6",
|
||||
"@tanstack/svelte-query": "6.1.28"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,11 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--swiss-white);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
|
||||
Vendored
+2
@@ -36,6 +36,8 @@ declare module '*.jpg' {
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.css';
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
|
||||
@@ -3,21 +3,12 @@
|
||||
Application shell with providers and page wrapper
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Layout Component
|
||||
*
|
||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
||||
*
|
||||
* Layout structure:
|
||||
* - Header area (currently empty, reserved for future use)
|
||||
*
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import GD from '$shared/assets/GD.svg';
|
||||
import G from '$shared/assets/G.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Footer } from '$widgets/Footer';
|
||||
|
||||
import {
|
||||
type Snippet,
|
||||
onDestroy,
|
||||
@@ -40,7 +31,7 @@ onDestroy(() => themeManager.destroy());
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={GD} />
|
||||
<link rel="icon" href={G} type="image/svg+xml" />
|
||||
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link
|
||||
@@ -82,14 +73,15 @@ onDestroy(() => themeManager.destroy());
|
||||
<ResponsiveProvider>
|
||||
<div
|
||||
id="app-root"
|
||||
class={clsx(
|
||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
||||
class={cn(
|
||||
'min-h-dvh w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
|
||||
theme === 'dark' ? 'dark' : '',
|
||||
)}
|
||||
>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
<footer></footer>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
|
||||
@@ -20,6 +20,7 @@ let mockObserverInstances: MockIntersectionObserver[] = [];
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
root = null;
|
||||
rootMargin = '';
|
||||
scrollMargin = '';
|
||||
thresholds: number[] = [];
|
||||
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
|
||||
readonly observedElements = new Set<Element>();
|
||||
|
||||
-2
@@ -2,9 +2,7 @@
|
||||
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
||||
*/
|
||||
export async function yieldToMainThread(): Promise<void> {
|
||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
||||
await scheduler.yield();
|
||||
} else {
|
||||
await new Promise<void>(resolve => {
|
||||
|
||||
+3
-3
@@ -3,12 +3,12 @@ import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
seedFontCache,
|
||||
} from '../../api/proxy/proxyFonts';
|
||||
} from '../../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../lib/errors/errors';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
} from '../../../lib/errors/errors';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
|
||||
/**
|
||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||
+2
-2
@@ -7,11 +7,11 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import * as api from '../../api/proxy/proxyFonts';
|
||||
import * as api from '../../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../lib/errors/errors';
|
||||
} from '../../../lib/errors/errors';
|
||||
import { BatchFontStore } from './batchFontStore.svelte';
|
||||
|
||||
describe('BatchFontStore', () => {
|
||||
@@ -2,7 +2,7 @@
|
||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
// Batch font store
|
||||
export { BatchFontStore } from './batchFontStore.svelte';
|
||||
export { BatchFontStore } from './batchFontStore/batchFontStore.svelte';
|
||||
|
||||
// Single FontStore
|
||||
export {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
@@ -61,7 +61,7 @@ const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||
{:else}
|
||||
<div
|
||||
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||
class={clsx(className)}
|
||||
class={cn(className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
export {
|
||||
createFilterManager,
|
||||
type FilterManager,
|
||||
mapManagerToParams,
|
||||
} from './lib';
|
||||
|
||||
export { filtersStore } from './model/state/filters.svelte';
|
||||
export { filterManager } from './model/state/manager.svelte';
|
||||
export { mapAppliedFiltersToParams } from './lib';
|
||||
|
||||
export {
|
||||
type AppliedFilterStore,
|
||||
appliedFilterStore,
|
||||
/**
|
||||
* Filter Store
|
||||
*/
|
||||
availableFilterStore,
|
||||
/**
|
||||
* Filter Manager
|
||||
*/
|
||||
createAppliedFilterStore,
|
||||
/**
|
||||
* Sort Store
|
||||
*/
|
||||
SORT_MAP,
|
||||
SORT_OPTIONS,
|
||||
type SortApiValue,
|
||||
type SortOption,
|
||||
sortStore,
|
||||
} from './model/store/sortStore.svelte';
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
FilterControls,
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export {
|
||||
createFilterManager,
|
||||
type FilterManager,
|
||||
} from './filterManager/filterManager.svelte';
|
||||
|
||||
export { mapManagerToParams } from './mapper/mapManagerToParams';
|
||||
export { mapAppliedFiltersToParams } from './mapper/mapAppliedFiltersToParams';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Property } from '$shared/lib';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { createAppliedFilterStore } from '../../model/store/appliedFilterStore/appliedFilterStore.svelte';
|
||||
import { mapAppliedFiltersToParams } from './mapAppliedFiltersToParams';
|
||||
|
||||
/**
|
||||
* Build a Property with explicit selection state.
|
||||
*/
|
||||
function prop(value: string, selected = false): Property<string> {
|
||||
return { id: value, name: value, value, selected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a filter group with a known id and a list of (value, selected) entries.
|
||||
*/
|
||||
function group(id: string, props: Array<[string, boolean]>) {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
properties: props.map(([value, selected]) => prop(value, selected)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('mapAppliedFiltersToParams', () => {
|
||||
describe('search query', () => {
|
||||
it('omits q when query is empty', () => {
|
||||
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
|
||||
expect(mapAppliedFiltersToParams(manager).q).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the debounced query through as q', () => {
|
||||
// Constructor seeds both immediate and debounced synchronously.
|
||||
const manager = createAppliedFilterStore({ queryValue: 'roboto', groups: [] });
|
||||
expect(mapAppliedFiltersToParams(manager).q).toBe('roboto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group selections', () => {
|
||||
it('omits a group entirely when no group with that id exists', () => {
|
||||
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
|
||||
const params = mapAppliedFiltersToParams(manager);
|
||||
expect(params.providers).toBeUndefined();
|
||||
expect(params.categories).toBeUndefined();
|
||||
expect(params.subsets).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits a group when it exists but has no selections', () => {
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: [group('providers', [['google', false], ['fontshare', false]])],
|
||||
});
|
||||
expect(mapAppliedFiltersToParams(manager).providers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the selected values for a single group', () => {
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: [group('providers', [['google', true], ['fontshare', false]])],
|
||||
});
|
||||
expect(mapAppliedFiltersToParams(manager).providers).toEqual(['google']);
|
||||
});
|
||||
|
||||
it('returns multiple selected values in selection order', () => {
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: [
|
||||
group('categories', [
|
||||
['serif', true],
|
||||
['sans-serif', false],
|
||||
['display', true],
|
||||
['monospace', true],
|
||||
]),
|
||||
],
|
||||
});
|
||||
expect(mapAppliedFiltersToParams(manager).categories).toEqual(['serif', 'display', 'monospace']);
|
||||
});
|
||||
|
||||
it('maps each of the three recognized group ids independently', () => {
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: [
|
||||
group('providers', [['google', true]]),
|
||||
group('categories', [['serif', true], ['sans-serif', true]]),
|
||||
group('subsets', [['latin', true]]),
|
||||
],
|
||||
});
|
||||
const params = mapAppliedFiltersToParams(manager);
|
||||
expect(params.providers).toEqual(['google']);
|
||||
expect(params.categories).toEqual(['serif', 'sans-serif']);
|
||||
expect(params.subsets).toEqual(['latin']);
|
||||
});
|
||||
|
||||
it('ignores groups whose id does not match providers/categories/subsets', () => {
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: [group('weights', [['400', true], ['700', true]])],
|
||||
});
|
||||
const params = mapAppliedFiltersToParams(manager);
|
||||
expect(params.providers).toBeUndefined();
|
||||
expect(params.categories).toBeUndefined();
|
||||
expect(params.subsets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined output', () => {
|
||||
it('produces a complete param object when query and selections coexist', () => {
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: 'inter',
|
||||
groups: [
|
||||
group('providers', [['google', true]]),
|
||||
group('categories', [['sans-serif', true]]),
|
||||
group('subsets', [['latin', false]]),
|
||||
],
|
||||
});
|
||||
expect(mapAppliedFiltersToParams(manager)).toEqual({
|
||||
q: 'inter',
|
||||
providers: ['google'],
|
||||
categories: ['sans-serif'],
|
||||
subsets: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||
import type { AppliedFilterStore } from '../../model';
|
||||
|
||||
/**
|
||||
* Maps filter manager to proxy API parameters.
|
||||
*
|
||||
* Updated to support multiple filter values (arrays)
|
||||
*
|
||||
* @param manager - Filter manager instance with reactive state
|
||||
* @returns - Partial proxy API parameters ready for API call
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Example filter manager state:
|
||||
* // {
|
||||
* // queryValue: 'roboto',
|
||||
* // providers: ['google', 'fontshare'],
|
||||
* // categories: ['sans-serif', 'serif'],
|
||||
* // subsets: ['latin']
|
||||
* // }
|
||||
*
|
||||
* const params = mapAppliedFiltersToParams(manager);
|
||||
* // Returns: {
|
||||
* // providers: ['google', 'fontshare'],
|
||||
* // categories: ['sans-serif', 'serif'],
|
||||
* // subsets: ['latin'],
|
||||
* // q: 'roboto'
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export function mapAppliedFiltersToParams(manager: AppliedFilterStore): Partial<ProxyFontsParams> {
|
||||
/**
|
||||
* Return the list of selected values for a group, or undefined when
|
||||
* the group is missing or has no selection — matches the API's
|
||||
* "omit empty filters" contract.
|
||||
*/
|
||||
const selectedIn = (id: string): string[] | undefined => {
|
||||
const values = manager.getGroup(id)?.instance.selectedProperties.map(p => p.value);
|
||||
return values && values.length > 0 ? values : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
q: manager.debouncedQueryValue || undefined,
|
||||
providers: selectedIn('providers'),
|
||||
categories: selectedIn('categories'),
|
||||
subsets: selectedIn('subsets'),
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||
|
||||
/**
|
||||
* Maps filter manager to proxy API parameters.
|
||||
*
|
||||
* Updated to support multiple filter values (arrays)
|
||||
*
|
||||
* @param manager - Filter manager instance with reactive state
|
||||
* @returns - Partial proxy API parameters ready for API call
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Example filter manager state:
|
||||
* // {
|
||||
* // queryValue: 'roboto',
|
||||
* // providers: ['google', 'fontshare'],
|
||||
* // categories: ['sans-serif', 'serif'],
|
||||
* // subsets: ['latin']
|
||||
* // }
|
||||
*
|
||||
* const params = mapManagerToParams(manager);
|
||||
* // Returns: {
|
||||
* // providers: ['google', 'fontshare'],
|
||||
* // categories: ['sans-serif', 'serif'],
|
||||
* // subsets: ['latin'],
|
||||
* // q: 'roboto'
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
||||
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
|
||||
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
|
||||
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
|
||||
|
||||
return {
|
||||
// Search query (debounced)
|
||||
q: manager.debouncedQueryValue || undefined,
|
||||
|
||||
// NEW: Support arrays - send all selected values
|
||||
providers: providers && providers.length > 0
|
||||
? providers as string[]
|
||||
: undefined,
|
||||
|
||||
categories: categories && categories.length > 0
|
||||
? categories as string[]
|
||||
: undefined,
|
||||
|
||||
subsets: subsets && subsets.length > 0
|
||||
? subsets as string[]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -16,18 +16,32 @@ export {
|
||||
/**
|
||||
* Low-level property selection store
|
||||
*/
|
||||
filtersStore,
|
||||
} from './state/filters.svelte';
|
||||
availableFilterStore,
|
||||
} from './store/availableFilterStore/availableFilterStore.svelte';
|
||||
|
||||
/**
|
||||
* Main filter controller
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Reactive interface returned by `createAppliedFilterStore`
|
||||
*/
|
||||
type AppliedFilterStore,
|
||||
/**
|
||||
* High-level manager for syncing search and filters
|
||||
*/
|
||||
filterManager,
|
||||
} from './state/manager.svelte';
|
||||
appliedFilterStore,
|
||||
/**
|
||||
* Factory for constructing a filter manager instance
|
||||
*/
|
||||
createAppliedFilterStore,
|
||||
} from './store/appliedFilterStore/appliedFilterStore.svelte';
|
||||
|
||||
/**
|
||||
* Side-effect import: installs the global appliedFilterStore+sortStore → fontStore
|
||||
* bridge on first import of this feature barrel. No exports.
|
||||
*/
|
||||
import './store/bindings.svelte';
|
||||
|
||||
/**
|
||||
* Sorting logic
|
||||
@@ -53,4 +67,4 @@ export {
|
||||
* Reactive store for the current sort selection
|
||||
*/
|
||||
sortStore,
|
||||
} from './store/sortStore.svelte';
|
||||
} from './store/sortStore/sortStore.svelte';
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Filter manager singleton
|
||||
*
|
||||
* Creates filterManager with empty groups initially, then reactively
|
||||
* populates groups when filtersStore loads data from backend.
|
||||
*/
|
||||
|
||||
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
|
||||
import { filtersStore } from './filters.svelte';
|
||||
|
||||
export const filterManager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Reactively sync backend filter metadata into filterManager groups.
|
||||
* When filtersStore.filters resolves, setGroups replaces the empty groups.
|
||||
*/
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const dynamicFilters = filtersStore.filters;
|
||||
|
||||
if (dynamicFilters.length > 0) {
|
||||
filterManager.setGroups(
|
||||
dynamicFilters.map(filter => ({
|
||||
id: filter.id,
|
||||
label: filter.name,
|
||||
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
value: opt.value,
|
||||
selected: false,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+23
-8
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Filter manager for font filtering
|
||||
* Filter manager factory and singleton.
|
||||
*
|
||||
* Manages multiple filter groups (providers, categories, subsets)
|
||||
* with debounced search input. Provides reactive state for filter
|
||||
* selections and convenience methods for bulk operations.
|
||||
* Owns multiple filter groups (providers, categories, subsets) plus a
|
||||
* debounced search input. Provides reactive state for filter selections
|
||||
* and convenience methods for bulk operations.
|
||||
*
|
||||
* The factory (`createAppliedFilterStore`) is exported for tests; the app
|
||||
* consumes the `appliedFilterStore` singleton at the bottom of this file.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const manager = createFilterManager({
|
||||
* const manager = createAppliedFilterStore({
|
||||
* queryValue: '',
|
||||
* groups: [
|
||||
* { id: 'providers', label: 'Provider', properties: [...] },
|
||||
@@ -25,7 +28,7 @@ import { createDebouncedState } from '$shared/lib/helpers';
|
||||
import type {
|
||||
FilterConfig,
|
||||
FilterGroupConfig,
|
||||
} from '../../model';
|
||||
} from '../../types/filter';
|
||||
|
||||
/**
|
||||
* Creates a filter manager instance
|
||||
@@ -36,7 +39,7 @@ import type {
|
||||
* @param config - Configuration with query value and filter groups
|
||||
* @returns Filter manager instance with reactive state and methods
|
||||
*/
|
||||
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
||||
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
|
||||
const search = createDebouncedState(config.queryValue ?? '');
|
||||
|
||||
// Create filter instances upfront
|
||||
@@ -122,4 +125,16 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
|
||||
};
|
||||
}
|
||||
|
||||
export type FilterManager = ReturnType<typeof createFilterManager>;
|
||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||
|
||||
/**
|
||||
* App-wide filter manager singleton.
|
||||
*
|
||||
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
|
||||
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||
* metadata arrives.
|
||||
*/
|
||||
export const appliedFilterStore = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
});
|
||||
+54
-54
@@ -7,10 +7,10 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createFilterManager } from './filterManager.svelte';
|
||||
import { createAppliedFilterStore } from './appliedFilterStore.svelte';
|
||||
|
||||
/**
|
||||
* Test Suite for createFilterManager Helper Function
|
||||
* Test Suite for createAppliedFilterStore Helper Function
|
||||
*
|
||||
* This suite tests the filter manager logic including:
|
||||
* - Debounced query state (immediate vs delayed)
|
||||
@@ -54,9 +54,9 @@ function createTestGroups(count: number, propertiesPerGroup = 3) {
|
||||
}));
|
||||
}
|
||||
|
||||
describe('createFilterManager - Initialization', () => {
|
||||
describe('createAppliedFilterStore - Initialization', () => {
|
||||
it('creates manager with empty query value', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(2),
|
||||
});
|
||||
@@ -66,7 +66,7 @@ describe('createFilterManager - Initialization', () => {
|
||||
});
|
||||
|
||||
it('creates manager with initial query value', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: 'search term',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe('createFilterManager - Initialization', () => {
|
||||
});
|
||||
|
||||
it('creates manager with undefined query value (defaults to empty string)', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('createFilterManager - Initialization', () => {
|
||||
|
||||
it('creates filter groups for each config group', () => {
|
||||
const groups = createTestGroups(3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -99,7 +99,7 @@ describe('createFilterManager - Initialization', () => {
|
||||
|
||||
it('creates filter instances for each group', () => {
|
||||
const groups = createTestGroups(2, 5);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -118,7 +118,7 @@ describe('createFilterManager - Initialization', () => {
|
||||
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
|
||||
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe('createFilterManager - Initialization', () => {
|
||||
|
||||
it('handles single group', () => {
|
||||
const groups = createTestGroups(1);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe('createFilterManager - Initialization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Debounced Query', () => {
|
||||
describe('createAppliedFilterStore - Debounced Query', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -149,7 +149,7 @@ describe('createFilterManager - Debounced Query', () => {
|
||||
});
|
||||
|
||||
it('immediate query value updates instantly', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('createFilterManager - Debounced Query', () => {
|
||||
});
|
||||
|
||||
it('debounced query value updates after default delay (300ms)', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -178,7 +178,7 @@ describe('createFilterManager - Debounced Query', () => {
|
||||
});
|
||||
|
||||
it('rapid query changes reset the debounce timer', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -200,7 +200,7 @@ describe('createFilterManager - Debounced Query', () => {
|
||||
});
|
||||
|
||||
it('handles empty string in query', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: 'initial',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -213,7 +213,7 @@ describe('createFilterManager - Debounced Query', () => {
|
||||
});
|
||||
|
||||
it('preserves initial query value until changed', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: 'initial search',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -228,9 +228,9 @@ describe('createFilterManager - Debounced Query', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
describe('createAppliedFilterStore - hasAnySelection Derived State', () => {
|
||||
it('returns false when no filters are selected', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(3, 3),
|
||||
});
|
||||
@@ -240,7 +240,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
|
||||
it('returns true when one filter in one group is selected', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -255,7 +255,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
|
||||
it('returns true when multiple filters across groups are selected', () => {
|
||||
const groups = createTestGroups(3, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -272,7 +272,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
|
||||
it('returns false after deselecting all filters', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -286,7 +286,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
|
||||
it('reacts to selection changes in individual groups', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -318,7 +318,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
properties: createTestProperties(3, []),
|
||||
},
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -331,7 +331,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -340,10 +340,10 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - getGroup() Method', () => {
|
||||
describe('createAppliedFilterStore - getGroup() Method', () => {
|
||||
it('returns the correct group by ID', () => {
|
||||
const groups = createTestGroups(3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -357,7 +357,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
||||
|
||||
it('returns undefined for non-existent group ID', () => {
|
||||
const groups = createTestGroups(2);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -369,7 +369,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
||||
|
||||
it('returns group with accessible filter instance', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -385,7 +385,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
||||
|
||||
it('returns first group when requested', () => {
|
||||
const groups = createTestGroups(3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -398,7 +398,7 @@ describe('createFilterManager - getGroup() Method', () => {
|
||||
|
||||
it('returns last group when requested', () => {
|
||||
const groups = createTestGroups(5);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -410,10 +410,10 @@ describe('createFilterManager - getGroup() Method', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - deselectAllGlobal() Method', () => {
|
||||
describe('createAppliedFilterStore - deselectAllGlobal() Method', () => {
|
||||
it('deselects all filters across all groups', () => {
|
||||
const groups = createTestGroups(3, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -436,7 +436,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
||||
|
||||
it('handles deselecting when nothing is selected', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -453,7 +453,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -464,7 +464,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
||||
|
||||
it('can select filters after global deselect', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -482,7 +482,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
||||
|
||||
it('handles partially selected groups', () => {
|
||||
const groups = createTestGroups(3, 5);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -505,7 +505,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Complex Scenarios', () => {
|
||||
describe('createAppliedFilterStore - Complex Scenarios', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -516,7 +516,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
||||
|
||||
it('handles query changes and filter selections together', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -553,7 +553,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -582,7 +582,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
||||
|
||||
it('manages multiple independent filter groups correctly', () => {
|
||||
const groups = createTestGroups(4, 5);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -607,7 +607,7 @@ describe('createFilterManager - Complex Scenarios', () => {
|
||||
|
||||
it('handles toggle operations via getGroup', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -623,9 +623,9 @@ describe('createFilterManager - Complex Scenarios', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Interface Compliance', () => {
|
||||
describe('createAppliedFilterStore - Interface Compliance', () => {
|
||||
it('exposes queryValue getter', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: 'test',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -636,7 +636,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
|
||||
it('exposes queryValue setter', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: 'test',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -647,7 +647,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
|
||||
it('exposes debouncedQueryValue getter', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: 'test',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -658,7 +658,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
|
||||
it('exposes groups getter', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -669,7 +669,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
|
||||
it('exposes hasAnySelection getter', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -680,7 +680,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
|
||||
it('exposes getGroup method', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -689,7 +689,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
|
||||
it('exposes deselectAllGlobal method', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -698,7 +698,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
|
||||
it('does not expose debouncedQueryValue setter', () => {
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
@@ -708,7 +708,7 @@ describe('createFilterManager - Interface Compliance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Edge Cases', () => {
|
||||
describe('createAppliedFilterStore - Edge Cases', () => {
|
||||
it('handles single property groups', () => {
|
||||
const groups: Array<{
|
||||
id: string;
|
||||
@@ -722,7 +722,7 @@ describe('createFilterManager - Edge Cases', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -749,7 +749,7 @@ describe('createFilterManager - Edge Cases', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
@@ -773,7 +773,7 @@ describe('createFilterManager - Edge Cases', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
const manager = createAppliedFilterStore({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
+6
-6
@@ -6,12 +6,12 @@
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { filtersStore } from '$features/GetFonts';
|
||||
* import { availableFilterStore } from '$features/GetFonts';
|
||||
*
|
||||
* // Access filters (reactive)
|
||||
* $: filters = filtersStore.filters;
|
||||
* $: isLoading = filtersStore.isLoading;
|
||||
* $: error = filtersStore.error;
|
||||
* $: filters = availableFilterStore.filters;
|
||||
* $: isLoading = availableFilterStore.isLoading;
|
||||
* $: error = availableFilterStore.error;
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
* Fetches and caches filter metadata using fetchProxyFilters()
|
||||
* Provides reactive access to filter data
|
||||
*/
|
||||
class FiltersStore {
|
||||
export class AvailableFilterStore {
|
||||
/**
|
||||
* TanStack Query result state
|
||||
*/
|
||||
@@ -125,4 +125,4 @@ class FiltersStore {
|
||||
/**
|
||||
* Singleton instance
|
||||
*/
|
||||
export const filtersStore = new FiltersStore();
|
||||
export const availableFilterStore = new AvailableFilterStore();
|
||||
@@ -0,0 +1,116 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import * as filtersApi from '../../../api/filters/filters';
|
||||
import type { FilterMetadata } from '../../../api/filters/filters';
|
||||
import { AvailableFilterStore } from './availableFilterStore.svelte';
|
||||
|
||||
/**
|
||||
* Build a minimal FilterMetadata fixture for tests.
|
||||
*/
|
||||
function metadata(id: string, optionValues: string[] = []): FilterMetadata {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: '',
|
||||
type: 'enum',
|
||||
options: optionValues.map(value => ({
|
||||
id: value,
|
||||
name: value,
|
||||
value,
|
||||
count: 1,
|
||||
})),
|
||||
} as FilterMetadata;
|
||||
}
|
||||
|
||||
describe('AvailableFilterStore', () => {
|
||||
let store: AvailableFilterStore;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
// TanStack defaults retry=3 with exponential backoff, which would
|
||||
// make the error-path test wait >5s. Disable for deterministic timing.
|
||||
queryClient.setDefaultOptions({ queries: { retry: false } });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store?.destroy();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with an empty filter list', () => {
|
||||
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||
store = new AvailableFilterStore();
|
||||
expect(store.filters).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports null error before any failure', () => {
|
||||
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||
store = new AvailableFilterStore();
|
||||
expect(store.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful fetch', () => {
|
||||
it('populates filters with the fetched metadata', async () => {
|
||||
const data = [
|
||||
metadata('providers', ['google', 'fontshare']),
|
||||
metadata('categories', ['serif', 'sans-serif']),
|
||||
];
|
||||
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
|
||||
|
||||
store = new AvailableFilterStore();
|
||||
|
||||
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
|
||||
expect(store.isError).toBe(false);
|
||||
expect(store.error).toBeNull();
|
||||
});
|
||||
|
||||
it('calls fetchProxyFilters exactly once for the initial load', async () => {
|
||||
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
|
||||
store = new AvailableFilterStore();
|
||||
|
||||
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('flips isError and exposes the error message on fetch failure', async () => {
|
||||
vi.spyOn(filtersApi, 'fetchProxyFilters').mockRejectedValue(new Error('boom'));
|
||||
store = new AvailableFilterStore();
|
||||
|
||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||
expect(store.error).toBe('boom');
|
||||
expect(store.filters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
it('does not trigger a second fetch when another instance shares the query key', async () => {
|
||||
const data = [metadata('providers', ['google'])];
|
||||
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
|
||||
|
||||
store = new AvailableFilterStore();
|
||||
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second observer on the same query key should reuse the cached
|
||||
// result rather than triggering a new request.
|
||||
const second = new AvailableFilterStore();
|
||||
try {
|
||||
// Give the new observer a tick to potentially refetch (it shouldn't).
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
second.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Bridges feature-level UI state (appliedFilterStore + sortStore) to the
|
||||
* entity-level fontStore query params.
|
||||
*
|
||||
* Centralizing this here means consumers (Search, FontSearch,
|
||||
* FilterControls, etc.) bind to the manager/store directly without
|
||||
* each repeating the same mapping effect. The bridge is a singleton
|
||||
* concern — it tracks singleton state and writes to a singleton query
|
||||
* observer, so it lives at module scope, not in any individual widget.
|
||||
*/
|
||||
|
||||
import { fontStore } from '$entities/Font';
|
||||
import { untrack } from 'svelte';
|
||||
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
||||
import { sortStore } from './sortStore/sortStore.svelte';
|
||||
|
||||
$effect.root(() => {
|
||||
/**
|
||||
* Populate appliedFilterStore groups when backend filter metadata resolves.
|
||||
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
|
||||
* and the UI renders nothing for them.
|
||||
*/
|
||||
$effect(() => {
|
||||
const dynamicFilters = availableFilterStore.filters;
|
||||
|
||||
if (dynamicFilters.length > 0) {
|
||||
appliedFilterStore.setGroups(
|
||||
dynamicFilters.map(filter => ({
|
||||
id: filter.id,
|
||||
label: filter.name,
|
||||
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
value: opt.value,
|
||||
selected: false,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirror filter selections + debounced search query into fontStore params.
|
||||
* untrack the write so fontStore's internal $state reads don't feed back
|
||||
* into this effect's dependency graph.
|
||||
*/
|
||||
$effect(() => {
|
||||
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
||||
untrack(() => fontStore.setParams(params));
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirror sort selection into fontStore.
|
||||
*/
|
||||
$effect(() => {
|
||||
const apiSort = sortStore.apiValue;
|
||||
untrack(() => fontStore.setSort(apiSort));
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -17,7 +17,7 @@ export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'
|
||||
|
||||
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
||||
|
||||
function createSortStore(initial: SortOption = 'Popularity') {
|
||||
export function createSortStore(initial: SortOption = 'Popularity') {
|
||||
let current = $state<SortOption>(initial);
|
||||
|
||||
return {
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import {
|
||||
SORT_MAP,
|
||||
SORT_OPTIONS,
|
||||
type SortOption,
|
||||
createSortStore,
|
||||
sortStore,
|
||||
} from './sortStore.svelte';
|
||||
|
||||
describe('createSortStore', () => {
|
||||
describe('initialization', () => {
|
||||
it('defaults to Popularity when no initial value is provided', () => {
|
||||
const store = createSortStore();
|
||||
expect(store.value).toBe('Popularity');
|
||||
});
|
||||
|
||||
it('accepts an explicit initial value', () => {
|
||||
const store = createSortStore('Newest');
|
||||
expect(store.value).toBe('Newest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiValue mapping', () => {
|
||||
it.each<[SortOption, (typeof SORT_MAP)[SortOption]]>([
|
||||
['Name', 'name'],
|
||||
['Popularity', 'popularity'],
|
||||
['Newest', 'lastModified'],
|
||||
])('maps %s to %s', (display, api) => {
|
||||
const store = createSortStore(display);
|
||||
expect(store.apiValue).toBe(api);
|
||||
});
|
||||
});
|
||||
|
||||
describe('set()', () => {
|
||||
it('updates both value and apiValue together', () => {
|
||||
const store = createSortStore('Name');
|
||||
store.set('Newest');
|
||||
expect(store.value).toBe('Newest');
|
||||
expect(store.apiValue).toBe('lastModified');
|
||||
});
|
||||
|
||||
it('is idempotent — setting the current value keeps state consistent', () => {
|
||||
const store = createSortStore('Popularity');
|
||||
store.set('Popularity');
|
||||
expect(store.value).toBe('Popularity');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortStore singleton', () => {
|
||||
it('exposes the same shape as a factory instance', () => {
|
||||
expect(typeof sortStore.value).toBe('string');
|
||||
expect(typeof sortStore.apiValue).toBe('string');
|
||||
expect(typeof sortStore.set).toBe('function');
|
||||
});
|
||||
|
||||
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
|
||||
for (const option of SORT_OPTIONS) {
|
||||
sortStore.set(option);
|
||||
expect(sortStore.value).toBe(option);
|
||||
expect(sortStore.apiValue).toBe(SORT_MAP[option]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const { Story } = defineMeta({
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
|
||||
'Renders the full list of filter groups managed by appliedFilterStore. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the appliedFilterStore singleton.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { FilterGroup } from '$shared/ui';
|
||||
import { filterManager } from '../../model';
|
||||
import { appliedFilterStore } from '../../model';
|
||||
</script>
|
||||
|
||||
{#each filterManager.groups as group (group.id)}
|
||||
{#each appliedFilterStore.groups as group (group.id)}
|
||||
<FilterGroup
|
||||
displayedLabel={group.label}
|
||||
filter={group.instance}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
filterManager,
|
||||
filtersStore,
|
||||
appliedFilterStore,
|
||||
availableFilterStore,
|
||||
} from '$features/GetFonts';
|
||||
import {
|
||||
render,
|
||||
@@ -11,9 +11,9 @@ import Filters from './Filters.svelte';
|
||||
|
||||
describe('Filters', () => {
|
||||
beforeEach(() => {
|
||||
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
|
||||
filterManager.setGroups([]);
|
||||
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
|
||||
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
|
||||
appliedFilterStore.setGroups([]);
|
||||
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -28,7 +28,7 @@ describe('Filters', () => {
|
||||
});
|
||||
|
||||
it('renders a label for each filter group', () => {
|
||||
filterManager.setGroups([
|
||||
appliedFilterStore.setGroups([
|
||||
{ id: 'cat', label: 'Categories', properties: [] },
|
||||
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||
]);
|
||||
@@ -38,7 +38,7 @@ describe('Filters', () => {
|
||||
});
|
||||
|
||||
it('renders filter properties within groups', () => {
|
||||
filterManager.setGroups([
|
||||
appliedFilterStore.setGroups([
|
||||
{
|
||||
id: 'cat',
|
||||
label: 'Category',
|
||||
@@ -54,7 +54,7 @@ describe('Filters', () => {
|
||||
});
|
||||
|
||||
it('renders multiple groups with their properties', () => {
|
||||
filterManager.setGroups([
|
||||
appliedFilterStore.setGroups([
|
||||
{
|
||||
id: 'cat',
|
||||
label: 'Category',
|
||||
|
||||
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via filterManager. Requires responsive context — wrap with Providers.',
|
||||
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via appliedFilterStore. Requires responsive context — wrap with Providers.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
|
||||
@@ -4,19 +4,15 @@
|
||||
Sits below the filter list, separated by a top border.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { fontStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
filterManager,
|
||||
appliedFilterStore,
|
||||
sortStore,
|
||||
} from '../../model';
|
||||
|
||||
@@ -31,21 +27,16 @@ const {
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const apiSort = sortStore.apiValue;
|
||||
untrack(() => fontStore.setSort(apiSort));
|
||||
});
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||
|
||||
function handleReset() {
|
||||
filterManager.deselectAllGlobal();
|
||||
appliedFilterStore.deselectAllGlobal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
||||
'gap-1 md:gap-6',
|
||||
'pt-6 mt-6 md:pt-8 md:mt-8',
|
||||
@@ -77,7 +68,7 @@ function handleReset() {
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||
onclick={handleReset}
|
||||
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||
class={cn('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||
iconPosition="left"
|
||||
>
|
||||
{#snippet icon()}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Popover } from 'bits-ui';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -72,10 +72,11 @@ $effect(() => {
|
||||
|
||||
{#if !hidden}
|
||||
{#if responsive.isMobileOrTablet}
|
||||
<div class={className}>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button class={className} variant="primary" {...props}>
|
||||
<Button variant="primary" {...props}>
|
||||
{#snippet icon()}
|
||||
<Settings2Icon class="size-4" />
|
||||
{/snippet}
|
||||
@@ -88,7 +89,7 @@ $effect(() => {
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'z-50 w-72',
|
||||
'bg-surface dark:bg-dark-card',
|
||||
'border border-subtle',
|
||||
@@ -140,13 +141,14 @@ $effect(() => {
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class={clsx('w-full md:w-auto', className)}
|
||||
class={cn('w-full md:w-auto', className)}
|
||||
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||
'border border-subtle',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="103" height="87" viewBox="0 0 103 87" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M50.688 86.144C43.008 86.144 36.0533 85.248 29.824 83.456C23.68 81.664 18.3467 78.976 13.824 75.392C9.38667 71.808 5.97333 67.3707 3.584 62.08C1.19467 56.7893 0 50.688 0 43.776C0 36.7787 1.23733 30.592 3.712 25.216C6.272 19.7547 9.856 15.1467 14.464 11.392C19.1573 7.63733 24.704 4.82133 31.104 2.944C37.5893 0.981333 44.7573 0 52.608 0C61.9093 0 69.9307 1.32267 76.672 3.968C83.4133 6.528 88.704 10.1547 92.544 14.848C96.4693 19.5413 98.688 25.1307 99.2 31.616H82.816C81.7067 28.2027 79.872 25.2587 77.312 22.784C74.8373 20.224 71.552 18.2613 67.456 16.896C63.36 15.4453 58.4107 14.72 52.608 14.72C45.184 14.72 38.8267 15.9147 33.536 18.304C28.3307 20.6933 24.3627 24.064 21.632 28.416C18.9013 32.768 17.536 37.888 17.536 43.776C17.536 49.4933 18.7307 54.4427 21.12 58.624C23.5093 62.72 27.1787 65.8773 32.128 68.096C37.1627 70.3147 43.5627 71.424 51.328 71.424C57.3013 71.424 62.5493 70.656 67.072 69.12C71.68 67.4987 75.52 65.3653 78.592 62.72C81.664 59.9893 83.84 56.96 85.12 53.632L91.776 51.2C90.6667 62.208 86.4853 70.784 79.232 76.928C72.064 83.072 62.5493 86.144 50.688 86.144ZM87.424 84.48C87.424 81.8347 87.5947 78.8053 87.936 75.392C88.2773 71.8933 88.704 68.3947 89.216 64.896C89.728 61.312 90.1973 58.0267 90.624 55.04H52.736V44.16H102.144V84.48H87.424Z" fill="#FF3B30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
||||
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -258,12 +258,13 @@ export function createVirtualizer<T>(
|
||||
// Calculate initial offset ONCE
|
||||
const getElementOffset = () => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
const scrollY = typeof window !== 'undefined' ? window.scrollY : 0;
|
||||
return rect.top + scrollY;
|
||||
};
|
||||
|
||||
let cachedOffsetTop = 0;
|
||||
let rafId: number | null = null;
|
||||
containerHeight = window.innerHeight;
|
||||
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafId !== null) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export {
|
||||
export {
|
||||
buildQueryString,
|
||||
clampNumber,
|
||||
cn,
|
||||
debounce,
|
||||
getDecimalPlaces,
|
||||
roundToStepPrecision,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
correctly via the HTML element's class attribute.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type {
|
||||
Component,
|
||||
Snippet,
|
||||
@@ -32,7 +32,7 @@ let { icon: Icon, class: className, attrs = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if Icon}
|
||||
{@const __iconClass__ = clsx('size-4', className)}
|
||||
{@const __iconClass__ = cn('size-4', className)}
|
||||
<!-- Render icon component dynamically with class prop -->
|
||||
<Icon
|
||||
class={__iconClass__}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { cn } from './cn';
|
||||
|
||||
describe('cn utility', () => {
|
||||
it('should merge classes with clsx', () => {
|
||||
expect(cn('class1', 'class2')).toBe('class1 class2');
|
||||
expect(cn('class1', { class2: true, class3: false })).toBe('class1 class2');
|
||||
});
|
||||
|
||||
it('should resolve tailwind specificity conflicts', () => {
|
||||
// text-neutral-400 vs text-brand (text-brand should win)
|
||||
expect(cn('text-neutral-400', 'text-brand')).toBe('text-brand');
|
||||
|
||||
// p-4 vs p-2
|
||||
expect(cn('p-4', 'p-2')).toBe('p-2');
|
||||
|
||||
// dark mode classes should be handled correctly too
|
||||
expect(cn('text-neutral-400 dark:text-neutral-400', 'text-brand dark:text-brand')).toBe(
|
||||
'text-brand dark:text-brand',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined and null inputs', () => {
|
||||
expect(cn('class1', undefined, null, 'class2')).toBe('class1 class2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
type ClassValue,
|
||||
clsx,
|
||||
} from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility for merging Tailwind classes with clsx and tailwind-merge.
|
||||
* This resolves specificity conflicts between Tailwind classes.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
type QueryParamValue,
|
||||
} from './buildQueryString/buildQueryString';
|
||||
export { clampNumber } from './clampNumber/clampNumber';
|
||||
export { cn } from './cn';
|
||||
export { debounce } from './debounce/debounce';
|
||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
Pill badge with border and optional status dot.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
type LabelSize,
|
||||
labelSizeConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -64,7 +64,7 @@ let {
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 border rounded-full',
|
||||
'font-mono uppercase tracking-wide',
|
||||
labelSizeConfig[size],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
design-system button. Uppercase, zero border-radius, Space Grotesk.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
import type {
|
||||
@@ -71,7 +71,7 @@ let {
|
||||
const isIconOnly = $derived(!!icon && !children);
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: clsx(
|
||||
primary: cn(
|
||||
'bg-swiss-red text-white',
|
||||
'hover:bg-swiss-red/90',
|
||||
'active:bg-swiss-red/80',
|
||||
@@ -87,7 +87,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:cursor-not-allowed',
|
||||
'disabled:transform-none',
|
||||
),
|
||||
secondary: clsx(
|
||||
secondary: cn(
|
||||
'bg-surface dark:bg-paper',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/10 dark:border-white/10',
|
||||
@@ -98,7 +98,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
outline: clsx(
|
||||
outline: cn(
|
||||
'bg-transparent',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/20 dark:border-white/20',
|
||||
@@ -109,7 +109,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
ghost: clsx(
|
||||
ghost: cn(
|
||||
'bg-transparent',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
@@ -119,7 +119,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
icon: clsx(
|
||||
icon: cn(
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
@@ -130,8 +130,8 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
tertiary: clsx(
|
||||
// Font override — must come after base in clsx() to win via tailwind-merge
|
||||
tertiary: cn(
|
||||
// Font override — must come after base in cn() to win via tailwind-merge
|
||||
'font-secondary font-medium normal-case tracking-normal',
|
||||
// Inactive state
|
||||
'bg-transparent',
|
||||
@@ -168,14 +168,13 @@ const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
|
||||
const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
||||
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
|
||||
tertiary:
|
||||
'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
|
||||
tertiary: 'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-brand dark:text-brand',
|
||||
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
|
||||
outline: 'bg-surface dark:bg-paper border-brand',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
||||
};
|
||||
|
||||
const classes = $derived(clsx(
|
||||
const classes = $derived(cn(
|
||||
// Base
|
||||
'inline-flex items-center justify-center',
|
||||
'font-primary font-bold tracking-tight uppercase',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Use for segmented controls, view toggles, or any mutually exclusive button set.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -23,7 +23,7 @@ let { children, class: className, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex items-center gap-1 p-1',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'border border-subtle',
|
||||
|
||||
@@ -106,7 +106,7 @@ let selected = $state(false);
|
||||
<div class="flex items-center gap-4">
|
||||
<ToggleButton
|
||||
{...args}
|
||||
selected={selected}
|
||||
{selected}
|
||||
onclick={() => {
|
||||
selected = !selected;
|
||||
}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Slider } from '$shared/ui';
|
||||
import { Button } from '$shared/ui/Button';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { Popover } from 'bits-ui';
|
||||
import clsx from 'clsx';
|
||||
import TechText from '../TechText/TechText.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -78,7 +78,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
-->
|
||||
{#if reduced}
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex gap-4 items-end w-full',
|
||||
className,
|
||||
)}
|
||||
@@ -98,7 +98,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
<div class={clsx('flex items-center px-1 relative', className)}>
|
||||
<div class={cn('flex items-center px-1 relative', className)}>
|
||||
<!-- Decrease button -->
|
||||
<Button
|
||||
variant="icon"
|
||||
@@ -119,7 +119,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex flex-col items-center justify-center w-14 py-1',
|
||||
'select-none rounded-none transition-all duration-150',
|
||||
'border border-transparent',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Labeled container for form controls
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
const { label, children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class={cn('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class="flex justify-between items-center text-xs font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
1px separator line, horizontal or vertical.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ let {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'bg-black/10 dark:bg-white/10',
|
||||
orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full',
|
||||
className,
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
import clsx from 'clsx';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
draw,
|
||||
@@ -68,7 +68,7 @@ $effect(() => {
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class={clsx('flex flex-col', className)}>
|
||||
<div class={cn('flex flex-col', className)}>
|
||||
<Label
|
||||
variant="default"
|
||||
size="sm"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Provides classes for styling footnotes
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -26,14 +26,14 @@ const { children, class: className, render }: Props = $props();
|
||||
|
||||
{#if render}
|
||||
{@render render({
|
||||
class: clsx(
|
||||
class: cn(
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
),
|
||||
})}
|
||||
})}
|
||||
{:else if children}
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
design-system input. Zero border-radius, Space Grotesk, precise states.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
@@ -90,7 +90,7 @@ const hasRightSlot = $derived(!!rightIcon || showClearButton);
|
||||
const cfg = $derived(inputSizeConfig[size]);
|
||||
const styles = $derived(inputVariantConfig[variant]);
|
||||
|
||||
const inputClasses = $derived(clsx(
|
||||
const inputClasses = $derived(cn(
|
||||
'font-primary rounded-none outline-none transition-all duration-200',
|
||||
'text-neutral-900 dark:text-neutral-100',
|
||||
'placeholder:text-neutral-400 dark:placeholder:text-neutral-600',
|
||||
@@ -107,8 +107,8 @@ const inputClasses = $derived(clsx(
|
||||
));
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||
<div class={clsx('relative group', fullWidth && 'w-full')}>
|
||||
<div class={cn('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||
<div class={cn('relative group', fullWidth && 'w-full')}>
|
||||
<!-- Left icon slot -->
|
||||
{#if leftIcon}
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-600 pointer-events-none z-10 flex items-center">
|
||||
@@ -147,7 +147,7 @@ const inputClasses = $derived(clsx(
|
||||
<!-- Helper / error text -->
|
||||
{#if helperText}
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'text-2xs font-mono tracking-wide px-1',
|
||||
error ? 'text-brand ' : 'text-secondary',
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Inline monospace label. The base primitive for all micrographic text.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type LabelFont,
|
||||
@@ -72,7 +72,7 @@ let {
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'font-mono tracking-widest leading-none',
|
||||
'inline-flex items-center gap-1.5',
|
||||
font === 'primary' && 'font-primary tracking-tight',
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script module lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Link from './Link.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/Link',
|
||||
component: Link,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Styled link component based on the footer link design. Supports optional icon snippet and standard anchor attributes.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
href: {
|
||||
control: 'text',
|
||||
description: 'Link URL',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
href: 'https://fonts.google.com',
|
||||
target: '_blank',
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof Link>)}
|
||||
<Link {...args}>
|
||||
<span>Google Fonts</span>
|
||||
</Link>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="With Icon"
|
||||
args={{
|
||||
href: 'https://fonts.google.com',
|
||||
target: '_blank',
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof Link>)}
|
||||
<Link {...args}>
|
||||
<span>Google Fonts</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Multiple Links">
|
||||
{#snippet template()}
|
||||
<div class="flex gap-4 p-8 bg-neutral-100 dark:bg-neutral-900 rounded-lg">
|
||||
<Link href="https://fonts.google.com" target="_blank">
|
||||
<span>Google Fonts</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
<Link href="https://www.fontshare.com" target="_blank">
|
||||
<span>Fontshare</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
<Link href="https://github.com" target="_blank">
|
||||
<span>GitHub</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Component: Link
|
||||
A styled link component based on the footer link design.
|
||||
Supports optional icon snippet and standard anchor attributes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLAnchorAttributes {
|
||||
/**
|
||||
* Link content
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* Optional icon snippet
|
||||
*/
|
||||
icon?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
icon,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
class={cn(
|
||||
'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono',
|
||||
'text-neutral-400 hover:text-brand transition-colors',
|
||||
'bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 pointer-events-auto',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render icon?.()}
|
||||
</a>
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import Link from './Link.svelte';
|
||||
|
||||
/**
|
||||
* Helper to create a plain text snippet
|
||||
*/
|
||||
function textSnippet(text: string) {
|
||||
return createRawSnippet(() => ({
|
||||
render: () => `<span>${text}</span>`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an icon snippet
|
||||
*/
|
||||
function iconSnippet() {
|
||||
return createRawSnippet(() => ({
|
||||
render: () => `<svg class="lucide-arrow-up-right"></svg>`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('Link', () => {
|
||||
const defaultProps = {
|
||||
href: 'https://fonts.google.com',
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders text content via children snippet', () => {
|
||||
render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
children: textSnippet('Google Fonts'),
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('Google Fonts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an anchor element with correct href', () => {
|
||||
render(Link, { props: defaultProps });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://fonts.google.com');
|
||||
});
|
||||
|
||||
it('renders the icon when provided via snippet', () => {
|
||||
const { container } = render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
children: textSnippet('Google Fonts'),
|
||||
icon: iconSnippet(),
|
||||
},
|
||||
});
|
||||
const icon = container.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass('lucide-arrow-up-right');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attributes', () => {
|
||||
it('applies custom CSS classes', () => {
|
||||
render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
class: 'custom-class',
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('link')).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('spreads additional anchor attributes', () => {
|
||||
render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@
|
||||
Project logo with apropriate styles
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Badge } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ const { class: className }: Props = $props();
|
||||
const title = 'GLYPHDIFF';
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-2 md:gap-3 select-none', className)}>
|
||||
<div class={cn('flex items-center gap-2 md:gap-3 select-none', className)}>
|
||||
<h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { PerspectiveManager } from '$shared/lib';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -73,7 +73,7 @@ const isVisible = $derived(manager.isFront);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx('will-change-transform', className)}
|
||||
class={cn('will-change-transform', className)}
|
||||
style:transform-style="preserve-3d"
|
||||
style:transform={style?.transform}
|
||||
style:filter={style?.filter}
|
||||
|
||||
@@ -93,7 +93,7 @@ const flyParams: FlyParams = {
|
||||
>
|
||||
<div>
|
||||
{#if headerTitle}
|
||||
<SectionHeader title={headerTitle} subtitle={headerSubtitle} index={index} />
|
||||
<SectionHeader title={headerTitle} subtitle={headerSubtitle} {index} />
|
||||
{/if}
|
||||
<SectionTitle text={title} />
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Numbered section heading with optional subtitle and pulse dot.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Label } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ let {
|
||||
const indexStr = $derived(String(index).padStart(2, '0'));
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-3 md:gap-4 mb-2', className)}>
|
||||
<div class={cn('flex items-center gap-3 md:gap-4 mb-2', className)}>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if pulse}
|
||||
<span class="w-1.5 h-1.5 bg-brand rounded-full animate-pulse"></span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
A horizontal separator line used to visually separate sections within a page.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -15,4 +15,4 @@ interface Props {
|
||||
const { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('w-full h-px bg-swiss-black/5 dark:bg-white/10 my-8 md:my-12', className)}></div>
|
||||
<div class={cn('w-full h-px bg-swiss-black/5 dark:bg-white/10 my-8 md:my-12', className)}></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
@@ -79,7 +79,7 @@ function close() {
|
||||
The inner div stays w-80 so Sidebar layout never reflows mid-animation.
|
||||
-->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'shrink-0 z-30 h-full relative',
|
||||
'overflow-hidden',
|
||||
'will-change-[width]',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Generic loading placeholder with shimmer animation.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
@@ -18,7 +18,7 @@ let { class: className, animate = true, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'rounded-md bg-background-subtle/50 backdrop-blur-sm',
|
||||
animate && 'animate-pulse',
|
||||
className,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
A single key:value pair in Space Mono. Optional trailing divider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Label } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
|
||||
@@ -36,7 +36,7 @@ let {
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-1', className)}>
|
||||
<div class={cn('flex items-center gap-1', className)}>
|
||||
<Label variant="muted" size="xs">{label}:</Label>
|
||||
<Label {variant} size="xs" bold>{value}</Label>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Renders multiple Stat components in a row with auto-separators.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Stat } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>> {
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
let { stats, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-4', className)}>
|
||||
<div class={cn('flex items-center gap-4', className)}>
|
||||
{#each stats as stat, i}
|
||||
<Stat
|
||||
label={stat.label}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
Monospace <code> element for technical values, measurements, identifiers.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
type LabelSize,
|
||||
type LabelVariant,
|
||||
labelSizeConfig,
|
||||
labelVariantConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -42,7 +42,7 @@ let {
|
||||
</script>
|
||||
|
||||
<code
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'font-mono tracking-tight tabular-nums',
|
||||
labelSizeConfig[size],
|
||||
labelVariantConfig[variant],
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
-->
|
||||
<script lang="ts" generics="T">
|
||||
import { createVirtualizer } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -293,7 +293,7 @@ $effect(() => {
|
||||
isFullyVisible: row.isFullyVisible,
|
||||
isPartiallyVisible: row.isPartiallyVisible,
|
||||
proximity: row.proximity,
|
||||
})}
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -305,7 +305,7 @@ $effect(() => {
|
||||
isFullyVisible: row.isFullyVisible,
|
||||
isPartiallyVisible: row.isPartiallyVisible,
|
||||
proximity: row.proximity,
|
||||
})}
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -324,13 +324,13 @@ $effect(() => {
|
||||
{/snippet}
|
||||
|
||||
{#if useWindowScroll}
|
||||
<div class={clsx('relative w-full', className)} bind:this={viewportRef} {...rest}>
|
||||
<div class={cn('relative w-full', className)} bind:this={viewportRef} {...rest}>
|
||||
{@render content()}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={viewportRef}
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'relative overflow-y-auto overflow-x-hidden',
|
||||
'rounded-md bg-background',
|
||||
'w-full',
|
||||
|
||||
@@ -70,6 +70,12 @@ export {
|
||||
*/
|
||||
default as Label,
|
||||
} from './Label/Label.svelte';
|
||||
export {
|
||||
/**
|
||||
* Styled link with optional icon
|
||||
*/
|
||||
default as Link,
|
||||
} from './Link/Link.svelte';
|
||||
export {
|
||||
/**
|
||||
* Full-page or component-level progress spinner
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './utils/dotTransition';
|
||||
export * from './utils/getPretextFontString';
|
||||
export * from './utils/ensureCanvasFonts/ensureCanvasFonts';
|
||||
export * from './utils/getPretextFontString/getPretextFontString';
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
|
||||
import { ensureCanvasFonts } from './ensureCanvasFonts';
|
||||
|
||||
const FALLBACK_FAMILY = '__glyphdiff_no_such_font_42__';
|
||||
const fallbackFont = (sizePx: number) => getPretextFontString(400, sizePx, FALLBACK_FAMILY);
|
||||
|
||||
/**
|
||||
* Fake Canvas2D context that returns a scripted width per font string.
|
||||
* Tracks how many times measureText was called so tests can assert polling
|
||||
* behavior without depending on wall-clock time.
|
||||
*/
|
||||
function createFakeCtx() {
|
||||
const widthsByFont = new Map<string, number | (() => number)>();
|
||||
const measureCalls: Array<{ font: string; text: string }> = [];
|
||||
const ctx = {
|
||||
font: '',
|
||||
measureText(text: string) {
|
||||
measureCalls.push({ font: ctx.font, text });
|
||||
const entry = widthsByFont.get(ctx.font);
|
||||
const width = typeof entry === 'function' ? entry() : entry ?? 0;
|
||||
return { width };
|
||||
},
|
||||
};
|
||||
return {
|
||||
ctx: ctx as unknown as CanvasRenderingContext2D,
|
||||
widthsByFont,
|
||||
measureCalls,
|
||||
};
|
||||
}
|
||||
|
||||
interface MockGlobals {
|
||||
fontsLoad: ReturnType<typeof vi.fn>;
|
||||
rafCalls: number;
|
||||
nowValues: number[];
|
||||
nowIndex: { current: number };
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
function installGlobals(opts: {
|
||||
/** Sequence of values returned by performance.now(); last value repeats. */
|
||||
nowSequence: number[];
|
||||
/** If true, OffscreenCanvas is defined and getContext returns the fake ctx. */
|
||||
useOffscreenCanvas: boolean;
|
||||
ctx: CanvasRenderingContext2D | null;
|
||||
}): MockGlobals {
|
||||
const fontsLoad = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const originals: Array<[string, PropertyDescriptor | undefined]> = [];
|
||||
const setGlobal = (key: string, value: unknown) => {
|
||||
originals.push([key, Object.getOwnPropertyDescriptor(globalThis, key)]);
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
setGlobal('document', {
|
||||
fonts: { load: fontsLoad },
|
||||
createElement: vi.fn(() => ({
|
||||
getContext: vi.fn(() => opts.ctx),
|
||||
})),
|
||||
});
|
||||
|
||||
if (opts.useOffscreenCanvas) {
|
||||
class FakeOffscreenCanvas {
|
||||
getContext() {
|
||||
return opts.ctx;
|
||||
}
|
||||
}
|
||||
setGlobal('OffscreenCanvas', FakeOffscreenCanvas);
|
||||
} else {
|
||||
setGlobal('OffscreenCanvas', undefined);
|
||||
}
|
||||
|
||||
const nowIndex = { current: 0 };
|
||||
setGlobal('performance', {
|
||||
now: () => opts.nowSequence[Math.min(nowIndex.current++, opts.nowSequence.length - 1)],
|
||||
});
|
||||
|
||||
let rafCount = 0;
|
||||
const rafState = { count: 0 };
|
||||
setGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
rafState.count++;
|
||||
rafCount++;
|
||||
Promise.resolve().then(() => cb(0));
|
||||
return rafCount;
|
||||
});
|
||||
|
||||
return {
|
||||
fontsLoad,
|
||||
get rafCalls() {
|
||||
return rafState.count;
|
||||
},
|
||||
nowValues: opts.nowSequence,
|
||||
nowIndex,
|
||||
restore() {
|
||||
for (const [key, desc] of originals) {
|
||||
if (desc) {
|
||||
Object.defineProperty(globalThis, key, desc);
|
||||
} else {
|
||||
delete (globalThis as any)[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
} as MockGlobals;
|
||||
}
|
||||
|
||||
describe('ensureCanvasFonts', () => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
it('awaits document.fonts.load for every font string', async () => {
|
||||
const { ctx, widthsByFont } = createFakeCtx();
|
||||
const fontA = getPretextFontString(400, 36, 'Roboto');
|
||||
const fontB = getPretextFontString(400, 36, 'Smooch Sans');
|
||||
// Real font width clearly differs from the unknown-family fallback width.
|
||||
widthsByFont.set(fontA, 200);
|
||||
widthsByFont.set(fontB, 130);
|
||||
// The fallback probe uses the same size with an unknown family.
|
||||
widthsByFont.set(fallbackFont(36), 280);
|
||||
|
||||
const mocks = installGlobals({
|
||||
nowSequence: [0, 5],
|
||||
useOffscreenCanvas: true,
|
||||
ctx,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await ensureCanvasFonts([fontA, fontB]);
|
||||
|
||||
expect(mocks.fontsLoad).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.fontsLoad).toHaveBeenCalledWith(fontA);
|
||||
expect(mocks.fontsLoad).toHaveBeenCalledWith(fontB);
|
||||
});
|
||||
|
||||
it('returns without polling when fonts already measure as non-fallback', async () => {
|
||||
const { ctx, widthsByFont } = createFakeCtx();
|
||||
const font = getPretextFontString(400, 36, 'Roboto');
|
||||
widthsByFont.set(font, 200);
|
||||
widthsByFont.set(fallbackFont(36), 280);
|
||||
|
||||
const mocks = installGlobals({
|
||||
nowSequence: [0, 5],
|
||||
useOffscreenCanvas: true,
|
||||
ctx,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await ensureCanvasFonts([font]);
|
||||
|
||||
// First iteration succeeds → no rAF needed
|
||||
expect(mocks.rafCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('polls via requestAnimationFrame until measurement diverges from fallback', async () => {
|
||||
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
|
||||
const font = getPretextFontString(400, 36, 'Roboto');
|
||||
widthsByFont.set(fallbackFont(36), 280);
|
||||
// Roboto reports the fallback width for the first two reads, then resolves.
|
||||
let robotoReads = 0;
|
||||
widthsByFont.set(font, () => {
|
||||
robotoReads++;
|
||||
return robotoReads <= 2 ? 280 : 200;
|
||||
});
|
||||
|
||||
// Provide enough now() values for: initial fallback measurement +
|
||||
// multiple loop iterations within the deadline.
|
||||
const mocks = installGlobals({
|
||||
nowSequence: [0, 10, 20, 30, 40, 50],
|
||||
useOffscreenCanvas: true,
|
||||
ctx,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await ensureCanvasFonts([font]);
|
||||
|
||||
// Two iterations failed → two rAF awaits before success on the third.
|
||||
expect(mocks.rafCalls).toBe(2);
|
||||
// Measurement was called once for the fallback probe + three poll attempts.
|
||||
const robotoCalls = measureCalls.filter(c => c.font === font).length;
|
||||
expect(robotoCalls).toBe(3);
|
||||
});
|
||||
|
||||
it('exits when performance.now passes the 1s deadline even if fonts never load', async () => {
|
||||
const { ctx, widthsByFont } = createFakeCtx();
|
||||
const font = getPretextFontString(400, 36, 'NeverLoads');
|
||||
widthsByFont.set(fallbackFont(36), 280);
|
||||
// Always returns fallback width → poll never finds a divergence.
|
||||
widthsByFont.set(font, 280);
|
||||
|
||||
const mocks = installGlobals({
|
||||
// Start at 0, then the next check jumps past the 1000ms deadline.
|
||||
nowSequence: [0, 0, 1001],
|
||||
useOffscreenCanvas: true,
|
||||
ctx,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await expect(ensureCanvasFonts([font])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns early when no canvas context is available', async () => {
|
||||
const mocks = installGlobals({
|
||||
nowSequence: [0],
|
||||
useOffscreenCanvas: false,
|
||||
ctx: null,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await expect(
|
||||
ensureCanvasFonts([getPretextFontString(400, 16, 'X')]),
|
||||
).resolves.toBeUndefined();
|
||||
// fonts.load still ran; just no canvas polling.
|
||||
expect(mocks.fontsLoad).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rafCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to a DOM canvas when OffscreenCanvas is unavailable', async () => {
|
||||
const { ctx, widthsByFont } = createFakeCtx();
|
||||
const font = getPretextFontString(400, 36, 'Roboto');
|
||||
widthsByFont.set(font, 200);
|
||||
widthsByFont.set(fallbackFont(36), 280);
|
||||
|
||||
const mocks = installGlobals({
|
||||
nowSequence: [0, 5],
|
||||
useOffscreenCanvas: false,
|
||||
ctx,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await ensureCanvasFonts([font]);
|
||||
|
||||
expect((globalThis as any).document.createElement).toHaveBeenCalledWith('canvas');
|
||||
});
|
||||
|
||||
it('uses the font size from each font string for the fallback probe', async () => {
|
||||
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
|
||||
const fontA = getPretextFontString(400, 24, 'FontA');
|
||||
const fontB = getPretextFontString(700, 48, 'FontB');
|
||||
widthsByFont.set(fallbackFont(24), 150);
|
||||
widthsByFont.set(fallbackFont(48), 360);
|
||||
widthsByFont.set(fontA, 100);
|
||||
widthsByFont.set(fontB, 200);
|
||||
|
||||
const mocks = installGlobals({
|
||||
nowSequence: [0, 5],
|
||||
useOffscreenCanvas: true,
|
||||
ctx,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await ensureCanvasFonts([fontA, fontB]);
|
||||
|
||||
const fallbackFonts = measureCalls
|
||||
.map(c => c.font)
|
||||
.filter(f => f.includes(FALLBACK_FAMILY));
|
||||
expect(fallbackFonts).toContain(fallbackFont(24));
|
||||
expect(fallbackFonts).toContain(fallbackFont(48));
|
||||
});
|
||||
|
||||
it('removes a font from the pending set as soon as it diverges, leaving others to poll', async () => {
|
||||
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
|
||||
const fontA = getPretextFontString(400, 36, 'A');
|
||||
const fontB = getPretextFontString(400, 36, 'B');
|
||||
widthsByFont.set(fallbackFont(36), 280);
|
||||
// A loads immediately; B takes one extra frame.
|
||||
widthsByFont.set(fontA, 200);
|
||||
let bReads = 0;
|
||||
widthsByFont.set(fontB, () => {
|
||||
bReads++;
|
||||
return bReads === 1 ? 280 : 150;
|
||||
});
|
||||
|
||||
const mocks = installGlobals({
|
||||
nowSequence: [0, 10, 20, 30, 40],
|
||||
useOffscreenCanvas: true,
|
||||
ctx,
|
||||
});
|
||||
cleanup = mocks.restore;
|
||||
|
||||
await ensureCanvasFonts([fontA, fontB]);
|
||||
|
||||
// A measured once (resolved iter 1). B measured twice (iter 1 fallback, iter 2 real).
|
||||
const aCalls = measureCalls.filter(c => c.font === fontA).length;
|
||||
const bCalls = measureCalls.filter(c => c.font === fontB).length;
|
||||
expect(aCalls).toBe(1);
|
||||
expect(bCalls).toBe(2);
|
||||
expect(mocks.rafCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
|
||||
*
|
||||
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
|
||||
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
|
||||
* after that — `measureText` keeps returning a fallback width for some frames
|
||||
* even though `document.fonts.check()` reports the font as loaded.
|
||||
*
|
||||
* Pretext caches measurements per font string forever, so a single fallback
|
||||
* measurement during initial mount permanently poisons the cache and the
|
||||
* comparison morph boundary drifts visibly from the thumb. This helper polls
|
||||
* canvas measurement until each font reports a width that differs from the
|
||||
* "unknown font family" fallback, guaranteeing the next `measureText` call
|
||||
* sees the real glyph metrics.
|
||||
*/
|
||||
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
|
||||
|
||||
const PROBE_TEXT = 'mmmmmmmmmm';
|
||||
const MAX_WAIT_MS = 1000;
|
||||
const DEFAULT_PROBE_SIZE_PX = 16;
|
||||
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
|
||||
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
|
||||
|
||||
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
|
||||
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
|
||||
|
||||
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
|
||||
// registration timing, so we MUST poll using the same canvas type pretext does.
|
||||
const ctx = typeof OffscreenCanvas !== 'undefined'
|
||||
? new OffscreenCanvas(1, 1).getContext('2d')
|
||||
: document.createElement('canvas').getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure each font's "unknown font" fallback width (different per browser, per OS).
|
||||
// Canvas uses this same fallback for any font family it can't resolve, so when the
|
||||
// requested font finally registers, measureText will return a non-fallback width.
|
||||
const fallbackWidths = new Map<string, number>();
|
||||
for (const font of fontStrings) {
|
||||
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
|
||||
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
|
||||
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
|
||||
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
|
||||
}
|
||||
|
||||
const deadline = performance.now() + MAX_WAIT_MS;
|
||||
const pending = new Set(fontStrings);
|
||||
while (pending.size > 0 && performance.now() < deadline) {
|
||||
for (const font of Array.from(pending)) {
|
||||
ctx.font = font;
|
||||
const w = ctx.measureText(PROBE_TEXT).width;
|
||||
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
|
||||
pending.delete(font);
|
||||
}
|
||||
}
|
||||
if (pending.size === 0) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
|
||||
vi.mock('$entities/Font', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||
const { BatchFontStore } = await import(
|
||||
'$entities/Font/model/store/batchFontStore.svelte'
|
||||
'$entities/Font/model/store/batchFontStore/batchFontStore.svelte'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
interface Props {
|
||||
@@ -53,7 +53,7 @@ $effect(() => {
|
||||
>
|
||||
{#each [0, 1] as s (s)}
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'char-inner',
|
||||
'transition-colors duration-300',
|
||||
isPast
|
||||
|
||||
@@ -32,7 +32,7 @@ $effect(() => {
|
||||
|
||||
<NavigationWrapper index={0} title="Comparison">
|
||||
{#snippet content(action)}
|
||||
<div class="flex h-screen w-full overflow-hidden bg-surface dark:bg-background">
|
||||
<div class="flex h-dvh w-full overflow-hidden bg-surface dark:bg-background">
|
||||
<!-- Sidebar -->
|
||||
<SidebarContainer bind:isOpen={isSidebarOpen}>
|
||||
{#snippet sidebar()}
|
||||
|
||||
@@ -101,6 +101,7 @@ function isFontReady(font: UnifiedFont): boolean {
|
||||
data-font-list
|
||||
weight={DEFAULT_FONT_WEIGHT}
|
||||
itemHeight={44}
|
||||
gap={2}
|
||||
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
||||
>
|
||||
{#snippet skeleton()}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<script lang="ts">
|
||||
import { ThemeSwitch } from '$features/ChangeAppTheme';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Badge,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '$shared/ui';
|
||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
@@ -49,7 +48,7 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
|
||||
</script>
|
||||
|
||||
<header
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex items-center justify-between',
|
||||
'px-4 md:px-8 py-4 md:py-6',
|
||||
'h-16 md:h-20 z-20',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<!--
|
||||
Component: Search
|
||||
Typeface search input for the comparison view.
|
||||
Updates the global filterManager query to filter the font list.
|
||||
Writes through appliedFilterStore; the global bridge in $features/GetFonts
|
||||
propagates the value into fontStore.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { filterManager } from '$features/GetFonts';
|
||||
import { appliedFilterStore } from '$features/GetFonts';
|
||||
import { SearchBar } from '$shared/ui';
|
||||
</script>
|
||||
|
||||
@@ -14,7 +15,7 @@ import { SearchBar } from '$shared/ui';
|
||||
class="w-full"
|
||||
placeholder="Typeface Search"
|
||||
aria-label="Search typefaces"
|
||||
bind:value={filterManager.queryValue}
|
||||
bind:value={appliedFilterStore.queryValue}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { filterManager } from '$features/GetFonts';
|
||||
import { appliedFilterStore } from '$features/GetFonts';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@@ -7,7 +7,7 @@ import Search from './Search.svelte';
|
||||
|
||||
describe('Search', () => {
|
||||
beforeEach(() => {
|
||||
filterManager.queryValue = '';
|
||||
appliedFilterStore.queryValue = '';
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -23,8 +23,8 @@ describe('Search', () => {
|
||||
});
|
||||
|
||||
describe('Value binding', () => {
|
||||
it('reflects filterManager.queryValue as initial value', () => {
|
||||
filterManager.queryValue = 'Inter';
|
||||
it('reflects appliedFilterStore.queryValue as initial value', () => {
|
||||
appliedFilterStore.queryValue = 'Inter';
|
||||
render(Search);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Inter');
|
||||
});
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
Content (font list, controls) is injected via snippets.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
ButtonGroup,
|
||||
Label,
|
||||
ToggleButton,
|
||||
} from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type Side,
|
||||
@@ -40,7 +40,7 @@ let {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex flex-col h-full',
|
||||
'w-80',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
|
||||
@@ -14,15 +14,18 @@ import {
|
||||
type ResponsiveManager,
|
||||
debounce,
|
||||
} from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
CharacterComparisonEngine,
|
||||
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||
import { Loader } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getPretextFontString } from '../../lib';
|
||||
import {
|
||||
ensureCanvasFonts,
|
||||
getPretextFontString,
|
||||
} from '../../lib';
|
||||
import { comparisonStore } from '../../model';
|
||||
import Character from '../Character/Character.svelte';
|
||||
import Line from '../Line/Line.svelte';
|
||||
@@ -61,6 +64,26 @@ const comparisonEngine = new CharacterComparisonEngine();
|
||||
|
||||
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
|
||||
|
||||
// Track container width changes (window resize, sidebar toggle, etc.)
|
||||
$effect(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
// Use borderBoxSize if available, fallback to contentRect
|
||||
const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
||||
if (width > 0) {
|
||||
containerWidth = width;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.7,
|
||||
@@ -124,25 +147,35 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Layout effect — depends on content, settings AND containerWidth.
|
||||
// Awaits font loading into the canvas measurement context before invoking
|
||||
// the engine; otherwise pretext caches fallback-font widths globally per
|
||||
// font string, and the morph boundary drifts from the thumb visually.
|
||||
$effect(() => {
|
||||
const _text = comparisonStore.text;
|
||||
const _weight = typography.weight;
|
||||
const _size = typography.renderedSize;
|
||||
const _height = typography.height;
|
||||
const _spacing = typography.spacing;
|
||||
const _width = containerWidth;
|
||||
const _isMobile = isMobile;
|
||||
|
||||
if (!container || !fontA || !fontB || _width <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (container && fontA && fontB) {
|
||||
// PRETEXT API strings: "weight sizepx family"
|
||||
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
|
||||
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
|
||||
|
||||
// Use offsetWidth to avoid transform scaling issues
|
||||
const width = container.offsetWidth;
|
||||
const padding = isMobile ? 48 : 96;
|
||||
const availableWidth = width - padding;
|
||||
const padding = _isMobile ? 48 : 96;
|
||||
const availableWidth = Math.max(0, _width - padding);
|
||||
const lineHeight = _size * _height;
|
||||
|
||||
containerWidth = width;
|
||||
let cancelled = false;
|
||||
ensureCanvasFonts([fontAStr, fontBStr]).then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
layoutResult = comparisonEngine.layout(
|
||||
_text,
|
||||
fontAStr,
|
||||
@@ -152,31 +185,11 @@ $effect(() => {
|
||||
_spacing,
|
||||
_size,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handleResize = () => {
|
||||
if (container && fontA && fontB) {
|
||||
const width = container.offsetWidth;
|
||||
const padding = isMobile ? 48 : 96;
|
||||
containerWidth = width;
|
||||
layoutResult = comparisonEngine.layout(
|
||||
comparisonStore.text,
|
||||
getPretextFontString(typography.weight, typography.renderedSize, fontA.name),
|
||||
getPretextFontString(typography.weight, typography.renderedSize, fontB.name),
|
||||
width - padding,
|
||||
typography.renderedSize * typography.height,
|
||||
typography.spacing,
|
||||
typography.renderedSize,
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
// Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
|
||||
@@ -198,10 +211,10 @@ const scaleClass = $derived(
|
||||
Outer flex container — fills parent.
|
||||
The paper div inside scales down when the sidebar opens on desktop.
|
||||
-->
|
||||
<div class={clsx('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
||||
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
||||
<!-- Paper surface -->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'w-full h-full flex flex-col items-center justify-center relative',
|
||||
'bg-paper dark:bg-dark-card',
|
||||
'shadow-2xl shadow-black/5 dark:shadow-black/20',
|
||||
@@ -270,11 +283,11 @@ const scaleClass = $derived(
|
||||
|
||||
<TypographyMenu
|
||||
bind:open={isTypographyMenuOpen}
|
||||
class={clsx(
|
||||
'absolute z-50',
|
||||
class={cn(
|
||||
'absolute z-10',
|
||||
responsive.isMobileOrTablet
|
||||
? 'bottom-4 right-4 -translate-1/2'
|
||||
: 'bottom-5 left-1/2 right-[unset] -translate-x-1/2',
|
||||
? 'bottom-0 right-0 -translate-1/2'
|
||||
: 'bottom-2.5 left-1/2 -translate-x-1/2',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
1px red vertical rule with square handles at top and bottom.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -31,7 +31,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
>
|
||||
<!-- Top handle -->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'w-5 h-5 md:w-6 md:h-6',
|
||||
'-ml-2.5 md:-ml-3',
|
||||
'mt-2 md:mt-4',
|
||||
@@ -47,7 +47,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
|
||||
<!-- Bottom handle -->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'w-5 h-5 md:w-6 md:h-6',
|
||||
'-ml-2.5 md:-ml-3',
|
||||
'mb-2 md:mb-4',
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
Provides a search input and filtration for fonts
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { fontStore } from '$entities/Font';
|
||||
import {
|
||||
FilterControls,
|
||||
Filters,
|
||||
filterManager,
|
||||
mapManagerToParams,
|
||||
appliedFilterStore,
|
||||
} from '$features/GetFonts';
|
||||
import { springySlideFade } from '$shared/lib';
|
||||
import {
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
SearchBar,
|
||||
} from '$shared/ui';
|
||||
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import { untrack } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
Tween,
|
||||
@@ -34,11 +31,6 @@ interface Props {
|
||||
|
||||
let { showFilters = $bindable(true) }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const params = mapManagerToParams(filterManager);
|
||||
untrack(() => fontStore.setParams(params));
|
||||
});
|
||||
|
||||
const transform = new Tween(
|
||||
{ scale: 1, rotate: 0 },
|
||||
{ duration: 250, easing: cubicOut },
|
||||
@@ -66,7 +58,7 @@ function toggleFilters() {
|
||||
class="w-full"
|
||||
placeholder="Typeface Search"
|
||||
aria-label="Search typefaces"
|
||||
bind:value={filterManager.queryValue}
|
||||
bind:value={appliedFilterStore.queryValue}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<script lang="ts">
|
||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Section } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
@@ -38,7 +38,7 @@ $effect(() => {
|
||||
headerAction={registerAction}
|
||||
>
|
||||
{#snippet content({ className })}
|
||||
<div class={clsx(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Footer } from './ui/Footer/Footer.svelte';
|
||||
@@ -0,0 +1,47 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Footer from './Footer.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Widgets/Footer',
|
||||
component: Footer,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Application footer with project information and portfolio link. Visible only on desktop screens.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Desktop View"
|
||||
parameters={{
|
||||
viewport: { defaultViewport: 'desktop' },
|
||||
}}
|
||||
>
|
||||
{#snippet template()}
|
||||
<div class="h-[200px] relative bg-neutral-50 dark:bg-neutral-900">
|
||||
<Footer />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Mobile View (Hidden)"
|
||||
parameters={{
|
||||
viewport: { defaultViewport: 'mobile1' },
|
||||
}}
|
||||
>
|
||||
{#snippet template()}
|
||||
<div class="h-[200px] relative bg-neutral-50 dark:bg-neutral-900">
|
||||
<p class="p-4 text-sm text-neutral-500 italic">Footer should be hidden on mobile.</p>
|
||||
<Footer />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
Widget: Footer
|
||||
Application footer with project information and portfolio link.
|
||||
Visible only on desktop screens.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { ResponsiveManager } from '$shared/lib/helpers';
|
||||
import { getContext } from 'svelte';
|
||||
import FooterLink from '../FooterLink/FooterLink.svelte';
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const isVertical = $derived(responsive?.isDesktop || responsive?.isDesktopLarge);
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<footer
|
||||
class={cn(
|
||||
'fixed z-10 flex flex-row items-end gap-1 pointer-events-none',
|
||||
isVertical ? 'bottom-2.5 right-2.5 [writing-mode:vertical-rl] rotate-180' : 'bottom-4 left-4',
|
||||
)}
|
||||
>
|
||||
<!-- Project Name (Horizontal) -->
|
||||
{#if isVertical}
|
||||
<div class="flex flex-row pointer-events-auto items-center gap-2 bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 border border-subtle">
|
||||
<div class="w-1.5 h-1.5 bg-brand"></div>
|
||||
<span class="text-2xs font-mono uppercase tracking-wider-mono text-neutral-500 dark:text-neutral-400">
|
||||
GlyphDiff © 2025 — {currentYear}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Portfolio Link (Vertical) -->
|
||||
<div class="pointer-events-auto">
|
||||
<FooterLink
|
||||
text="allmy.work"
|
||||
href="https://allmy.work/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={cn('border border-subtle', isVertical ? 'text-2xs' : 'text-4xs')}
|
||||
iconClass={isVertical ? 'rotate-90' : ''}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { setContext } from 'svelte';
|
||||
import Footer from './Footer.svelte';
|
||||
|
||||
// Mock component to provide context
|
||||
import ContextWrapper from '$shared/lib/providers/ResponsiveProvider/ResponsiveProvider.svelte';
|
||||
|
||||
describe('Footer', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
it('renders on desktop', () => {
|
||||
// Mock responsive context
|
||||
const mockResponsive = {
|
||||
isDesktop: true,
|
||||
isDesktopLarge: false,
|
||||
};
|
||||
|
||||
const { container } = render(Footer, {
|
||||
context: new Map([['responsive', mockResponsive]]),
|
||||
});
|
||||
|
||||
expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('allmy.work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders on large desktop', () => {
|
||||
const mockResponsive = {
|
||||
isDesktop: false,
|
||||
isDesktopLarge: true,
|
||||
};
|
||||
|
||||
render(Footer, {
|
||||
context: new Map([['responsive', mockResponsive]]),
|
||||
});
|
||||
|
||||
expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render on mobile or tablet', () => {
|
||||
const mockResponsive = {
|
||||
isDesktop: false,
|
||||
isDesktopLarge: false,
|
||||
};
|
||||
|
||||
render(Footer, {
|
||||
context: new Map([['responsive', mockResponsive]]),
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/GlyphDiff/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<!--
|
||||
Component: FooterLink
|
||||
Specific footer link implementation that uses the generic Link component
|
||||
and adds the default arrow icon.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Link } from '$shared/ui';
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLAnchorAttributes {
|
||||
/**
|
||||
* Link text
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* CSS classes for the default icon
|
||||
*/
|
||||
iconClass?: string;
|
||||
/**
|
||||
* Link URL
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
iconClass,
|
||||
href,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Link
|
||||
{href}
|
||||
class={className}
|
||||
{...rest}
|
||||
>
|
||||
<span>{text}</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class={cn(
|
||||
'fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200',
|
||||
iconClass,
|
||||
)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
@@ -6,11 +6,11 @@
|
||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||
import { fontStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Label,
|
||||
Section,
|
||||
} from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { layoutManager } from '../../model';
|
||||
import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte';
|
||||
@@ -50,7 +50,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
||||
{/snippet}
|
||||
|
||||
{#snippet content({ className })}
|
||||
<div class={clsx(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<SampleList />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
+1
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
@@ -22,7 +23,6 @@
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"$lib/*": ["./src/lib/*"],
|
||||
"$app/*": ["./src/app/*"],
|
||||
|
||||
@@ -12,6 +12,10 @@ export default defineConfig({
|
||||
restoreMocks: true,
|
||||
setupFiles: ['./vitest.setup.component.ts', './vitest.setup.jsdom.ts'],
|
||||
globals: true,
|
||||
testTimeout: 15000,
|
||||
pool: 'forks',
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user